agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
433 lines • 21 kB
JavaScript
/**
* request — an AG-UI request (`RunAgentInput`) read as UNTRUSTED browser input,
* and the one turn it may ask for.
*
* Three steps. The first is the app's to replace; the other two are the CORE,
* and run whichever reader produced the request — so a reader can map another
* body onto a turn, and never weaken a rule:
*
* the READER a body → an {@link AgUiTurnRequest}. The default takes
* the body as an AG-UI `RunAgentInput`; a gateway that
* sends its own body shape passes its own
* ({@link AgUiRequestReader}). Mapping only — it decides
* nothing below;
* readAgUiRequest what a custom reader produced, copied as plain JSON (so
* a getter or a proxy cannot answer the check one way and
* the plan another; the default reader's body is parsed
* JSON already and is read as it is), then
* readAgUiRunInput its SHAPE, field by field, and
* the ids' FORMAT and length — nothing here is trusted,
* nothing the server holds is read;
* planAgUiTurn the turn, from the request AND what the server holds
* for the bound conversation.
*
* The core's steps refuse by NAME (a `WireRequestRefusal`: its own status and
* code; the field named, the value never quoted back).
*
* WHAT A REQUEST MAY DECIDE, and what it may not:
* - the newest USER message is the run's message. Nothing else the browser
* sends is ever conversation: its assistant and tool messages are its own
* copy of what the server said, and the server's copy is the one kept
* (`sessions`, or the agent's memory under the bound session). A system or
* developer message is refused — the agent's instructions are its own;
* - an ANSWER is taken only for the pause the server is keeping, matched to
* the ids that pause handed out (`AgUiSuspension`): a resume entry naming
* its interrupt, or the tool message answering the frontend tool call it is
* waiting on. Any other tool result is refused — never folded in. A
* decision answering a consent gate's interrupt is named for the request
* that interrupt stands for (`answeringRequest`) — the page never sees the
* request id, and the matched interrupt is what binds its answer;
* - a frontend tool runs only when the app allow-listed its NAME — with the
* app's own description and schema, never the page's (a description the
* model reads is an instruction) — and only on a host that keeps the paused
* run until the page answers;
* - `state` and `context` reach the agent only through the app's validators;
* without one, a request carrying either is refused;
* - identity, credentials and redaction never come from a request: the host
* binds the thread to the caller the app authenticates (`ThreadBinder`).
* `forwardedProps` is never read.
*/
import { looksLikeDecision } from '../core/checkin.js';
import { WireRequestRefusal } from '../hosting/errors.js';
/** The longest thread or run id read — longer is refused, never truncated. */
export const AG_UI_MAX_ID_LENGTH = 128;
/**
* The thread and run id alphabet: letters, digits and `._:-`, starting with a
* letter or digit — every id an AG-UI SDK mints (a UUID, a ULID) and nothing
* that could carry markup, a path or a control character into a log line.
*/
const ID_FORMAT = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
/** The longest message, tool-call or interrupt id read. */
const MAX_ITEM_ID_LENGTH = 400;
/** A tool name the page may declare — the provider-portable alphabet. */
const TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
const ROLES = new Set([
'user',
'assistant',
'tool',
'system',
'developer',
'reasoning',
'activity',
]);
/** A refusal of the request's shape — names the field, never its value. */
const malformed = (what) => new WireRequestRefusal('ERR_INVALID_RUN_INPUT', `AG-UI: ${what}`, 400);
/** A refusal of what this agent does not accept from a page — its own code. */
const refused = (code, what, status = 400) => new WireRequestRefusal(code, `AG-UI: ${what}`, status);
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
/** A thread or run id: present, bounded, and in the id alphabet. */
function runId(value, field) {
if (typeof value !== 'string' || value.length === 0)
throw malformed(`\`${field}\` must be a non-empty string.`);
if (value.length > AG_UI_MAX_ID_LENGTH)
throw malformed(`\`${field}\` is longer than ${AG_UI_MAX_ID_LENGTH} characters.`);
if (!ID_FORMAT.test(value))
throw malformed(`\`${field}\` must be letters, digits and . _ : - only, starting with a letter or digit.`);
return value;
}
/** A message, call or interrupt id: present and bounded. */
function itemId(value, field) {
if (typeof value !== 'string' || value.length === 0)
throw malformed(`\`${field}\` must be a non-empty string.`);
if (value.length > MAX_ITEM_ID_LENGTH)
throw malformed(`\`${field}\` is longer than ${MAX_ITEM_ID_LENGTH} characters.`);
return value;
}
function list(value, field) {
if (value === undefined || value === null)
return [];
if (!Array.isArray(value))
throw malformed(`\`${field}\` must be an array.`);
return value;
}
function content(value, at, optional = false) {
if (value === undefined && optional)
return;
if (typeof value === 'string')
return;
if (!Array.isArray(value))
throw malformed(`\`${at}.content\` must be a string or a list of parts.`);
for (const part of value) {
if (!isRecord(part) || typeof part.type !== 'string')
throw malformed(`every part of \`${at}.content\` must have a \`type\`.`);
if (part.type === 'text' && typeof part.text !== 'string')
throw malformed(`a text part of \`${at}.content\` must carry \`text\`.`);
}
}
function message(value, i) {
const at = `messages[${i}]`;
if (!isRecord(value))
throw malformed(`\`${at}\` must be an object.`);
itemId(value.id, `${at}.id`);
if (typeof value.role !== 'string' || !ROLES.has(value.role))
throw malformed(`\`${at}.role\` is not an AG-UI role.`);
switch (value.role) {
case 'user':
case 'tool':
content(value.content, at);
if (value.role === 'tool')
itemId(value.toolCallId, `${at}.toolCallId`);
break;
case 'assistant':
content(value.content, at, true);
list(value.toolCalls, `${at}.toolCalls`).forEach((call, j) => {
const where = `${at}.toolCalls[${j}]`;
if (!isRecord(call) || !isRecord(call.function))
throw malformed(`\`${where}\` must be a function call.`);
itemId(call.id, `${where}.id`);
if (typeof call.function.name !== 'string' || typeof call.function.arguments !== 'string')
throw malformed(`\`${where}.function\` must carry \`name\` and \`arguments\` strings.`);
});
break;
default:
break;
}
return value;
}
/**
* Read one AG-UI request body — its shape and its ids — refusing what the
* protocol does not allow by the field's NAME, never by its value.
*
* @throws WireRequestRefusal (400, `ERR_INVALID_RUN_INPUT`)
*/
export function readAgUiRunInput(body) {
if (!isRecord(body))
throw malformed('the body must be a RunAgentInput object.');
runId(body.threadId, 'threadId');
runId(body.runId, 'runId');
if (body.parentRunId !== undefined)
runId(body.parentRunId, 'parentRunId');
if (!Array.isArray(body.messages))
throw malformed('`messages` must be an array.');
const messages = body.messages.map(message);
const tools = list(body.tools, 'tools');
const named = new Set();
tools.forEach((tool, i) => {
if (!isRecord(tool) || typeof tool.name !== 'string' || !TOOL_NAME.test(tool.name))
throw malformed(`\`tools[${i}].name\` must be a tool name (letters, digits, _ and -).`);
if (named.has(tool.name))
throw malformed(`\`tools[${i}].name\` repeats a tool already declared.`);
named.add(tool.name);
});
const context = list(body.context, 'context');
context.forEach((entry, i) => {
if (!isRecord(entry) ||
typeof entry.description !== 'string' ||
typeof entry.value !== 'string')
throw malformed(`\`context[${i}]\` must carry \`description\` and \`value\` strings.`);
});
const resume = list(body.resume, 'resume');
resume.forEach((entry, i) => {
if (!isRecord(entry))
throw malformed(`\`resume[${i}]\` must be an object.`);
itemId(entry.interruptId, `resume[${i}].interruptId`);
if (entry.status !== 'resolved' && entry.status !== 'cancelled')
throw malformed(`\`resume[${i}].status\` must be 'resolved' or 'cancelled'.`);
});
return {
...body,
messages,
tools: tools,
context: context,
...(resume.length > 0 && { resume: resume }),
};
}
/** The default reader: the body IS an AG-UI `RunAgentInput`. */
export const runAgentInputReader = ({ body }) => body;
/**
* The core's read of one request: `reader` maps the body, and what it produced
* is checked by {@link readAgUiRunInput} — whichever reader it was. A custom
* reader's answer is copied as plain JSON first; the default reader's is the
* parsed body itself, read exactly as before readers were replaceable.
*
* @throws WireRequestRefusal — the reader's own refusal, or the core's by name
* (400, `ERR_INVALID_RUN_INPUT`).
*/
export function readAgUiRequest(reader, request) {
if (reader === runAgentInputReader)
return readAgUiRunInput(request.body);
let answer;
let produced;
try {
answer = reader(request);
if (!isThenable(answer))
produced = plainJson(answer);
}
catch (err) {
if (err instanceof WireRequestRefusal)
throw err;
// Never the reader's message: it may quote the body it could not read.
throw malformed('the request reader could not read the body.');
}
// An app's mistake, never the caller's: a reader answers at once. (A
// Promise copied as JSON would be `{}`, refused as the CALLER's fault.)
if (isThenable(answer))
throw new TypeError('agUi(): reader must return the turn itself, not a Promise.');
return readAgUiRunInput(produced);
}
const isThenable = (value) => typeof value?.then === 'function';
/**
* A custom reader's answer as plain JSON — every value read ONCE, here: a
* getter, a proxy or a `toJSON` cannot answer the core's check one way and its
* plan another. JSON's own rules apply (a non-finite number becomes `null`); a
* value JSON cannot hold (a cycle, a BigInt, nesting too deep to write) is
* refused as a malformed request.
*/
function plainJson(value) {
const text = JSON.stringify(value);
return text === undefined ? undefined : JSON.parse(text);
}
/**
* Plan one turn: the request (read by {@link readAgUiRunInput}) against what
* the server holds for its bound conversation.
*
* @throws WireRequestRefusal — by name, for everything a page may not decide.
*/
export function planAgUiTurn(input, held, facts, rules) {
input.messages.forEach((m, i) => {
if (m.role === 'system' || m.role === 'developer')
throw refused('ERR_INSTRUCTIONS_NOT_CARRIED', `messages[${i}] is a ${m.role} message. The agent's instructions are its own; a page ` +
`cannot send them.`);
});
const tools = toolsOf(input, facts, rules);
const accepted = {};
const state = validated('state', input.state, rules.accept.state, facts);
if (state !== undefined)
accepted.state = state;
const context = validated('context', input.context.length === 0 ? undefined : input.context, rules.accept.context, facts);
if (context !== undefined)
accepted.context = context;
return {
turn: turnOf(input, held, facts),
...(tools.length > 0 && { tools }),
...(Object.keys(accepted).length > 0 && { accepted }),
};
}
/** The frontend tools this request enables: allow-listed by name AND declared by the page. */
function toolsOf(input, facts, rules) {
const tools = [];
input.tools.forEach((declared, i) => {
const build = rules.frontendTools.get(declared.name);
if (build === undefined)
throw refused('ERR_TOOL_NOT_ALLOWED', `tools[${i}] names a tool this agent does not let the page run. A frontend tool runs ` +
`only when the app allow-lists it: agUi({ frontendTools }).`);
tools.push(build());
});
if (tools.length > 0 && !facts.keepsPauses)
throw refused('ERR_CLIENT_TOOLS_NOT_CARRIED', `the page offers frontend tools, and their results arrive in the page's NEXT request — ` +
`this host keeps no paused run to continue. Serve it with threads and sessions.`);
return tools;
}
/** A page value the app's validator accepted, or `undefined` for an empty one. */
function validated(name, value, validator, facts) {
if (isEmpty(value))
return undefined;
const upper = name.toUpperCase();
if (validator === undefined)
throw refused(`ERR_${upper}_NOT_CARRIED`, `the request carries \`${name}\`, and this agent accepts none from a page. To accept it, ` +
`validate it: agUi({ accept: { ${name} } }).`);
try {
return validator(value, {
...(facts.thread !== undefined && { thread: facts.thread }),
headers: facts.headers,
});
}
catch (err) {
if (err instanceof WireRequestRefusal)
throw err;
// Never the validator's message: it may describe the value it refused.
throw refused(`ERR_${upper}_REFUSED`, `the app's validator refused \`${name}\`.`);
}
}
const isEmpty = (value) => value === undefined ||
value === null ||
(Array.isArray(value) && value.length === 0) ||
(isRecord(value) && Object.keys(value).length === 0);
/** The turn the request asks for, against what the server holds. */
function turnOf(input, held, facts) {
const resume = input.resume ?? [];
const last = input.messages[input.messages.length - 1];
const awaiting = held.awaiting;
if (awaiting === undefined) {
if (resume.length > 0)
throw refused('ERR_NO_PENDING_ASK', facts.keepsPauses
? 'the request answers an interrupt, and this thread is waiting on none.'
: 'the request answers an interrupt, and this host keeps no paused run to continue.', 409);
if (last?.role === 'tool')
throw refused('ERR_UNKNOWN_TOOL_CALL', `messages[${input.messages.length - 1}] answers a tool call this thread is not ` +
`waiting on. A tool result is taken only for a frontend tool the run is paused on.`);
return { kind: 'message', message: newestMessage(input, facts) };
}
const suspension = readAgUiSuspension(awaiting.suspension);
if (suspension === undefined)
throw refused('ERR_AWAITING_DECISION', 'this thread is waiting on an answer another door asked for; answer it there.', 409);
const call = suspension.call;
if (call?.client === true)
return { kind: 'answer', answer: clientResult(input, call) };
if (resume.length === 0)
throw refused('ERR_AWAITING_DECISION', 'this thread is waiting on the answer to its interrupt; send it in `resume`.', 409);
const known = resume.findIndex((entry) => entry.interruptId !== suspension.interruptId);
if (known >= 0 || resume.length !== 1)
throw refused('ERR_UNKNOWN_INTERRUPT', `resume[${Math.max(known, 0)}] does not answer the interrupt this thread is waiting on.`);
const entry = resume[0];
if (entry.status === 'cancelled')
throw refused('ERR_CANCEL_NOT_CARRIED', 'the request cancels the interrupt, and this agent continues a paused run only with an ' +
'answer. Answer it — a decline is an answer.');
return { kind: 'answer', answer: answeringRequest(entry.payload, awaiting.pending) };
}
/**
* A page's decision, named for the consent request its interrupt stands for.
*
* A consent gate's decision must name the request it answers
* (`core/consentRequest.ts`). An AG-UI page never receives that id — its
* interrupt carries the question, not the evidence or the request — and it
* needs none: the interrupt it answered was matched above to the pause this
* server keeps, and one pause asks one request. So a decision WITHOUT a
* `requestId` is named here, for that pause's request. A decision that names
* one keeps it, and the resume holds it to the paused request like any other.
* Anything that is not a decision passes through unchanged and is refused by
* the resume as not being one.
*/
function answeringRequest(payload, pending) {
const requestId = pending.checkIn?.id ?? pending.ask?.id;
if (typeof requestId !== 'string' || !looksLikeDecision(payload))
return payload;
if (payload.requestId !== undefined)
return payload;
return { ...payload, requestId };
}
/** The page's result for the frontend tool call the run is paused on — the newest message. */
function clientResult(input, call) {
const at = input.messages.length - 1;
const last = input.messages[at];
if (last?.role === 'tool' && last.toolCallId === call.toolCallId)
return textOf(last.content, at);
if (last?.role === 'tool')
throw refused('ERR_UNKNOWN_TOOL_CALL', `messages[${at}] answers a tool call this thread is not waiting on.`);
throw refused('ERR_AWAITING_DECISION', 'this thread is waiting on the result of a tool the page runs; send it as the newest ' +
'message, a `tool` message for that call.', 409);
}
/** The newest message — the person's — as the run's message. */
function newestMessage(input, facts) {
const at = input.messages.length - 1;
const last = input.messages[at];
if (last === undefined || last.role !== 'user')
throw refused('ERR_NO_USER_TURN', 'the newest message must be the person’s (`role: user`) — it is what the agent answers.');
if (!facts.keepsConversation && at > 0)
throw refused('ERR_CONVERSATION_NOT_KEPT', 'the request continues a conversation, and this host keeps none: the page’s copy is ' +
'never taken as what was said. Serve it with threads (and sessions, or an agent ' +
'with memory) to continue one.');
const text = textOf(last.content, at);
if (text.trim().length === 0)
throw refused('ERR_NO_USER_TURN', `messages[${at}] says nothing.`);
return text;
}
/** A message body as text — a media part refused by name, never dropped. */
function textOf(value, at) {
if (value === undefined)
return '';
if (typeof value === 'string')
return value;
let out = '';
for (const part of value) {
if (part.type !== 'text')
throw refused('ERR_MEDIA_NOT_CARRIED', `messages[${at}] carries a ${part.type} part; this agent is given text only.`);
out += part.text;
}
return out;
}
const isText = (value) => typeof value === 'string';
const texts = (value) => Array.isArray(value) && value.every(isText);
/**
* A stored suspension record, checked — the store holds bytes; a record that
* is not one this adapter wrote is `undefined`, and nothing is matched to it.
*/
export function readAgUiSuspension(value) {
if (!isRecord(value) || value.version !== 1 || !isText(value.runId))
return undefined;
if (value.interruptId !== undefined && !isText(value.interruptId))
return undefined;
if (typeof value.rootAgent !== 'boolean' || !Array.isArray(value.scopes))
return undefined;
const call = value.call;
if (call !== undefined &&
(!isRecord(call) ||
!isText(call.toolCallId) ||
typeof call.client !== 'boolean' ||
!texts(call.mount)))
return undefined;
for (const scope of value.scopes) {
if (!isRecord(scope) || !texts(scope.mount) || !texts(scope.steps))
return undefined;
if (!Array.isArray(scope.calls))
return undefined;
for (const open of scope.calls)
if (!isRecord(open) || !isText(open.toolCallId) || typeof open.client !== 'boolean')
return undefined;
for (const field of ['subagentRunId', 'name', 'parentSubagentRunId', 'messageId'])
if (scope[field] !== undefined && !isText(scope[field]))
return undefined;
}
return value;
}
//# sourceMappingURL=request.js.map