agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
186 lines • 8.68 kB
JavaScript
;
/**
* mcp/elicitation — a typed ask carried as an MCP elicitation, and the
* elicitation's answer carried back (time design § 6.1).
*
* Pattern: Adapter, pure, both directions in one file so they cannot drift.
* `elicitationOf` maps a pending `AwaitingInput` onto the MCP
* elicitation request (`message` + a flat `requestedSchema` of
* primitive properties, the MCP specification revision of
* 2025-06-18); `answerFromElicitation` maps the client's `content`
* back to the `InputResponse` `agent.resume` takes. Nothing is
* checked here that the resume door checks: the answer goes through
* `tools/inputRequest.ts` · `applyInputResponse` like any other, and a
* refused time answer comes back as a re-ask to elicit again.
* Role: mcp (the transport). Reads `tools/inputRequest.ts` types only.
* Emits: N/A.
*
* ## The mapping
*
* MCP's elicitation schema holds strings (with a `format` of `date-time`
* among a few), numbers, booleans and string choices (`enum` + `enumNames`).
* It has no range, so a time field becomes the properties a person can fill:
*
* | Field | Properties |
* |-------|------------|
* | `string` / `number` / `boolean` | `<id>`, as typed; a string's `enum` with its `labels` as `enumNames` |
* | `format: 'instant'` | `<id>`: `{ type: 'string', format: 'date-time' }` |
* | `format: 'zone'` | `<id>`: a string |
* | `format: 'time-range'` | `<id>.from` and `<id>.to`: two `date-time` strings, joined back as `from/to` |
* | a `format` field with choices | `<id>`: the choices, labelled; unless `strict`, ALSO the free-entry properties above (`<id>.other` for an instant or a zone) — answer one or the other |
*
* Only the fields still open are asked (a supplied value is known). A
* property is `required` only when it is the one way to answer a missing
* field. A number or boolean with `enum` has no MCP spelling and is refused,
* never widened; so is a field id that collides with a generated name.
*
* @example
* ```ts
* const out = await agent.run({ message });
* if (isInputPause(out)) {
* const request = elicitationOf(out.awaitingInput);
* const reply = await mcpClientSession.elicit(request); // { action: 'accept', content }
* if (reply.action === 'accept')
* await agent.resume(out.checkpoint, answerFromElicitation(out.awaitingInput, reply.content));
* }
* ```
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.answerFromElicitation = exports.elicitationOf = void 0;
const inputRequest_js_1 = require("../tools/inputRequest.js");
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
function fail(reason) {
throw new inputRequest_js_1.InputRequestError(reason);
}
function carriageOf(field) {
const id = field.id;
if (field.enum === undefined) {
return { entry: field.format === 'time-range' ? [`${id}.from`, `${id}.to`] : [id] };
}
if (field.format === undefined || field.strict === true)
return { choice: id, entry: [] };
// A time field's choices keep free entry open (time design § 6.1): both ways travel.
return {
choice: id,
entry: field.format === 'time-range' ? [`${id}.from`, `${id}.to`] : [`${id}.other`],
};
}
function entryProperty(field) {
const description = field.description !== undefined ? { description: field.description } : {};
if (field.type === 'number')
return { type: 'number', ...description };
if (field.type === 'boolean')
return { type: 'boolean', ...description };
if (field.format === 'instant' || field.format === 'time-range')
return { type: 'string', format: 'date-time', ...description };
return { type: 'string', ...description };
}
function choiceProperty(field) {
if (field.type !== 'string') {
fail(`field '${field.id}': an MCP elicitation carries choices of strings only`);
}
return {
type: 'string',
...(field.description !== undefined && { description: field.description }),
enum: (field.enum ?? []).map(String),
...(field.labels !== undefined && { enumNames: [...field.labels] }),
};
}
/** The fields still open — a supplied value is known and not asked again. */
const openFields = (awaiting) => awaiting.fields.filter((f) => !own(awaiting.supplied, f.id));
/**
* The MCP elicitation for a pending typed ask: its question (after the
* refusal's reason, on a re-ask) and one property per way to answer each open
* field. Throws `InputRequestError` on a field MCP cannot carry.
*/
function elicitationOf(awaiting) {
const properties = {};
const required = [];
const ids = new Set(awaiting.fields.map((f) => f.id));
const place = (name, property, owner) => {
if (own(properties, name) || (name !== owner && ids.has(name))) {
fail(`field '${owner}': the elicitation property '${name}' collides with another field`);
}
properties[name] = property;
};
for (const field of openFields(awaiting)) {
const carriage = carriageOf(field);
if (carriage.choice !== undefined)
place(carriage.choice, choiceProperty(field), field.id);
for (const name of carriage.entry)
place(name, entryProperty(field), field.id);
// Required only when there is ONE way to answer: a choice with free entry is either-or.
if (awaiting.missing.includes(field.id) &&
(carriage.choice === undefined) !== (carriage.entry.length === 0)) {
required.push(...(carriage.choice !== undefined ? [carriage.choice] : carriage.entry));
}
}
const message = awaiting.refused !== undefined
? `${awaiting.refused.reason}\n\n${awaiting.question}`
: awaiting.question;
return {
message,
requestedSchema: {
type: 'object',
properties,
...(required.length > 0 && { required }),
},
};
}
exports.elicitationOf = elicitationOf;
function primitive(name, value) {
if (typeof value === 'string' || typeof value === 'boolean')
return value;
if (typeof value === 'number' && Number.isFinite(value))
return value;
return fail(`the elicitation's '${name}' is not a string, number or boolean`);
}
/**
* The `InputResponse` an accepted elicitation's `content` names — each open
* field's value, a time range's two properties joined as `from/to`. Throws
* `InputRequestError` on a property the elicitation did not ask, on a choice
* AND a free entry for one field, and on half a range. The values are judged
* by `agent.resume`, as any answer is.
*/
function answerFromElicitation(awaiting, content) {
if (typeof content !== 'object' || content === null || Array.isArray(content)) {
fail("the elicitation's content must be an object");
}
const given = content;
const asked = new Set();
const values = {};
for (const field of openFields(awaiting)) {
const carriage = carriageOf(field);
const names = [...(carriage.choice !== undefined ? [carriage.choice] : []), ...carriage.entry];
names.forEach((n) => asked.add(n));
const chosen = carriage.choice !== undefined && own(given, carriage.choice);
const entered = carriage.entry.filter((n) => own(given, n));
if (chosen && entered.length > 0) {
fail(`field '${field.id}': answer with a choice or your own value, not both`);
}
let value;
if (chosen)
value = primitive(carriage.choice, given[carriage.choice]);
else if (field.format === 'time-range' && entered.length > 0) {
if (entered.length !== 2)
fail(`field '${field.id}': a time range needs its start and its end`);
value = `${primitive(`${field.id}.from`, given[`${field.id}.from`])}/${primitive(`${field.id}.to`, given[`${field.id}.to`])}`;
}
else if (entered.length === 1)
value = primitive(entered[0], given[entered[0]]);
if (value !== undefined) {
Object.defineProperty(values, field.id, {
value,
enumerable: true,
writable: true,
configurable: true,
});
}
}
const unasked = Object.keys(given).filter((k) => !asked.has(k));
if (unasked.length > 0)
fail(`the elicitation's content names properties it did not ask: ${unasked.join(', ')}`);
return { requestId: awaiting.requestId, values };
}
exports.answerFromElicitation = answerFromElicitation;
//# sourceMappingURL=elicitation.js.map