agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
155 lines (154 loc) • 8.36 kB
TypeScript
/**
* 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 type { HeldConversation, PlanFacts, ProtocolPlan, ProtocolRequestFacts, ThreadBinding } from '../hosting/protocolAdapter.js';
import type { Tool } from '../tools/tools.js';
import type { AgUiMessage, AgUiResumeEntry, AgUiRunInput, AgUiSuspension } from './types.js';
/** The longest thread or run id read — longer is refused, never truncated. */
export declare const AG_UI_MAX_ID_LENGTH = 128;
/**
* 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 declare function readAgUiRunInput(body: unknown): AgUiRunInput;
/**
* What a request reader hands the core: the one turn a request asks for, in
* AG-UI's terms. `AgUiRunInput` is one (a `RunAgentInput` as sent); a reader
* for another body builds one from it — `tools` and `context` may be left out.
*
* It carries no identity, credential or redaction setting, and the core reads
* nothing else a reader adds: the caller is bound by the host's `ThreadBinder`,
* a credential reaches a run only through the host's `sealRequest`.
*/
export interface AgUiTurnRequest {
/** The conversation the request NAMES — format-checked, never a key. */
readonly threadId: string;
/** The run id the stream names this run by — format-checked. */
readonly runId: string;
readonly parentRunId?: string;
/**
* The messages, newest last. Only the newest is read — a user message (the
* run's message), or the `tool` message answering the frontend tool call a
* paused run waits on. A system or developer message anywhere is refused.
*/
readonly messages: readonly AgUiMessage[];
/** The frontend tools the caller offers — run only when the app allow-listed the name. */
readonly tools?: AgUiRunInput['tools'];
/** Reaches the agent only through the app's `accept.context` validator. */
readonly context?: AgUiRunInput['context'];
/** Reaches the agent only through the app's `accept.state` validator. */
readonly state?: unknown;
/** Answers to the interrupt a paused run waits on — matched to the server's own record. */
readonly resume?: readonly AgUiResumeEntry[];
}
/**
* What a request reader is given: the request as the host read it — UNTRUSTED.
* The host port's own `ProtocolRequestFacts` (the parsed body, the headers).
*/
export type AgUiRequestFacts = ProtocolRequestFacts;
/**
* The request-reading strategy: a body → the turn it asks for. Mapping only;
* every rule runs after it, in the core. Refuse a body it cannot read by
* throwing a `WireRequestRefusal` (its status and code reach the caller);
* anything else it throws is refused as a malformed request, in fixed words.
*
* @example A gateway that sends `{ conversationId, requestId, text }`
* ```ts
* const reader: AgUiRequestReader = ({ body }) => {
* const b = body as { conversationId?: unknown; requestId?: unknown; text?: unknown };
* return {
* threadId: String(b.conversationId),
* runId: String(b.requestId),
* messages: [{ id: 'gateway-turn', role: 'user', content: String(b.text) }],
* };
* };
* ```
*/
export type AgUiRequestReader = (request: AgUiRequestFacts) => AgUiTurnRequest;
/** The default reader: the body IS an AG-UI `RunAgentInput`. */
export declare const runAgentInputReader: AgUiRequestReader;
/**
* 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 declare function readAgUiRequest(reader: AgUiRequestReader, request: AgUiRequestFacts): AgUiRunInput;
/** What a page's `state` or `context` is checked by before the agent may see it. */
export type AgUiValidator = (value: unknown, at: {
/** The conversation the request was bound to — whose state this claims to be. */
readonly thread?: ThreadBinding;
readonly headers: Readonly<Record<string, string>>;
}) => unknown;
/** The rules one adapter plans by — built from `agUi()`'s options. */
export interface AgUiTurnRules {
/** The frontend tools the app allow-listed, by name: each builds the tool the agent is given. */
readonly frontendTools: ReadonlyMap<string, () => Tool>;
readonly accept: {
readonly state?: AgUiValidator;
readonly context?: AgUiValidator;
};
}
/**
* 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 declare function planAgUiTurn(input: AgUiRunInput, held: HeldConversation, facts: PlanFacts, rules: AgUiTurnRules): ProtocolPlan;
/**
* 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 declare function readAgUiSuspension(value: unknown): AgUiSuspension | undefined;