agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
187 lines (186 loc) • 10.8 kB
TypeScript
/**
* serializer — one run's served agentfootprint events → AG-UI events.
*
* Pattern: a per-run state machine (`RunTranslation`), pure — no I/O, no clock,
* no import of `@ag-ui/*`. Frames are the framing strategy's job
* (`framing.ts`); reading a request is the adapter's (`adapter.ts`).
* Role: the Lens over a run for an AG-UI reader. It composes what that reader
* is served, from the RECORD only: every value it writes comes from a
* served event (the placeholder wherever a redaction policy selected a
* value), from the record's address (`meta.runtimeStageId`,
* `meta.subflowPath` — never selected by a policy), or from the
* AG-UI request's own ids. It never reads the run's live values, so
* nothing a policy hides can reach an AG-UI event. The record's own
* run id is never written: it counts every run the process made.
*
* THE MAPPING
* agent.turn_start (nested) → SUBAGENT_STARTED (a sub-agent's turn)
* agent.turn_end → SUBAGENT_FINISHED / the root answer
* agent.iteration_start/_end → STEP_STARTED/_FINISHED `iteration <n>`
* composition.enter/exit → STEP_STARTED/_FINISHED `<Kind>[:<mount>]`
* stream.token → TEXT_MESSAGE_START/CONTENT/END
* stream.llm_end (no agent around) → one whole TEXT message (an LLM call that streams nothing)
* stream.thinking_delta/_end → REASONING_START … REASONING_END (only with `reasoning: true`)
* stream.tool_start → TOOL_CALL_START + TOOL_CALL_ARGS + TOOL_CALL_END
* stream.tool_end → TOOL_CALL_RESULT
* pause.request + settle('paused') → RUN_FINISHED { outcome: interrupt }
* settle('paused', awaiting caller) → RUN_FINISHED { outcome: success, pendingToolCallIds }
* (a tool the client runs: its call is left to answer)
* error.fatal + settle('failed') → RUN_ERROR — the served error.fatal's words when they
* are a fixed sentence, else "The run failed."
* an allow-listed event → CUSTOM { name: <event type>, value: <its listed fields> }
* anything else → nothing
*
* WHAT LEAVES THE PROCESS. An AG-UI stream goes to a browser, so nothing goes
* out by default that a person is not meant to read. Every value comes from a
* served payload; `CUSTOM` carries only the event types on an allow-list, each
* with the fields its projection picks ({@link AG_UI_CUSTOM_EVENTS} — the whole
* record, such as `context.injected`'s assembled prompt or `error.retried`'s
* raw error, stays off the wire unless the app lists it); the model's
* reasoning goes out only when the app says so; and a failure is described by
* the record, never by the thrown value. Identity on the meta (`principal`,
* `tenant`, `sessionId`) is never written.
*
* WHO OWNS AN EVENT. Each agent's turn opens a SCOPE at its mount — the
* subflow path its `turn_start` fired at (`[]` for the root agent, `step-p/x`
* for an agent in a parallel branch). An event belongs to the deepest open scope
* whose mount prefixes its own subflow path, segment by segment, and carries
* that scope's `subagentRunId`. Parallel branches have different mounts, so
* their interleaved events can never be attributed to each other.
*
* A RUN THAT CONTINUES A PAUSE. A paused run hands back `suspension()` — the
* ids it gave the client (the interrupt, the open call) and every scope and
* step open at the pause. The run that continues it passes that as `resume`:
* the same sub-agents re-open (AG-UI: "a later run may continue the same
* invocation"), their steps re-open, and the first result in the paused call's
* scope answers that call under its own id — or, for a tool the client ran, is
* not written at all, because the client holds the result it sent.
*
* TEXT. The reply is what the record streamed (`stream.token`) — the channel
* `toSSE({ format: 'text' })` and a hosted reply read. An agent turn whose
* answer streamed no token (a provider without `stream()`) is written whole
* from `turn_end.finalContent`, unless the record marks it refused. An
* agent's `llm_end.content` is a DRAFT the library may be holding back
* (answer validation, an output policy) and is never written.
*/
import type { AgentfootprintEvent } from '../events/registry.js';
import type { RunTranslation } from '../hosting/protocolAdapter.js';
import { type AgUiEvent, type AgUiSuspension } from './types.js';
/** What a CUSTOM projection is told beside the payload: where the event landed. */
export interface AgUiCustomContext {
/** The AG-UI id of the tool call open where the event happened, when one is. */
readonly toolCallId?: string;
}
/**
* One allow-listed event's `CUSTOM` value: the fields of its SERVED payload
* that go out (the payload is the one every listener receives — the
* placeholder wherever a redaction policy selected a value). Return only what
* a person using the frontend is meant to read.
*/
export type AgUiCustomProjection = (payload: Readonly<Record<string, unknown>>, at: AgUiCustomContext) => unknown;
/**
* The agentfootprint events that go out as AG-UI `CUSTOM` by default, each with
* the fields it carries: progress (which call, which iteration), spend, the
* loop's route, the answer's standing and a provider's retries — counts and the
* library's own words. Content (a tool's own progress `payload`,
* `context.injected`'s assembled prompt, `error.retried`'s raw error, memory,
* permission and credential facts) stays off the wire unless an app lists it:
* `agUiSerializer({ ..., custom: { ...AG_UI_CUSTOM_EVENTS, [type]: projection } })`.
*/
export declare const AG_UI_CUSTOM_EVENTS: Readonly<Record<string, AgUiCustomProjection>>;
/** Options for {@link agUiSerializer}. */
export interface AgUiSerializerOptions {
/** The conversation this run belongs to — `RunAgentInput.threadId`. */
readonly threadId: string;
/** This run's AG-UI id — `RunAgentInput.runId`. Every id the run mints is under it. */
readonly runId: string;
/**
* The events with no AG-UI equivalent that go out as `CUSTOM`, keyed by event
* type, each with the projection that picks its fields. Default
* {@link AG_UI_CUSTOM_EVENTS}; `{}` sends none. An event not listed sends
* nothing. Events the mapping consumes (tokens, tool calls, turns,
* iterations, compositions, the pause and the fatal error) are never CUSTOM.
*/
readonly custom?: Readonly<Record<string, AgUiCustomProjection>>;
/**
* Write the model's reasoning — `REASONING_*` events from the served
* `stream.thinking_*` events. Default `false`: a model's chain of thought is
* shown to a person only when the app decides it is.
*/
readonly reasoning?: boolean;
/**
* What a FAILED tool call's result says. Default `'withheld'`: the fixed words
* "The tool failed." — the record's result for a failed call is the thrown
* text, which may hold a connection string, a path or a provider's response.
* `'served'` sends the record's own result (still the served value: the
* placeholder wherever a redaction policy selected one).
*/
readonly toolErrors?: 'withheld' | 'served';
/**
* Which served events are this run's. Default: the events of the first run
* an event names (plus events that name no run). Pass it when the listener
* can hear more than one run — an instance several callers share — so the
* translation never binds to whichever run spoke first.
*/
readonly owns?: (event: AgentfootprintEvent) => boolean;
/**
* The paused run this one continues — what that run's translation returned
* from `suspension()` after it settled `'paused'`. Its suspended sub-agents
* and open steps re-open under the same ids, and the call it stopped in is
* answered under its own id (or, for a frontend tool, not at all: the client
* already holds the result it sent).
*/
readonly resume?: AgUiSuspension;
}
/** One run's AG-UI translation — and, once it pauses, what its continuation needs. */
export interface AgUiTranslation extends RunTranslation<AgUiEvent> {
/**
* The record to `resume` from. Before settling, what a `'paused'` settlement
* with the same `awaiting` will hand out (keep it first, then settle); after
* `settle({ kind: 'paused' })`, what it handed out; after any other ending,
* `undefined`.
*/
suspension(awaiting?: 'person' | 'caller'): AgUiSuspension | undefined;
}
/** Said by `TOOL_CALL_RESULT` for a failed call, unless `toolErrors: 'served'`. */
export declare const AG_UI_TOOL_FAILED = "The tool failed.";
/** Said by `RUN_ERROR` when the run's `error.fatal` is not one of the record's fixed sentences. */
export declare const AG_UI_RUN_FAILED = "The run failed.";
/**
* Said by `RUN_ERROR` (with `code: 'ERR_ANSWER_VALIDATION'`) when an enforcing
* `.answerValidation()` withheld the answer: a refusal, not a crash.
*/
export declare const AG_UI_ANSWER_WITHHELD = "The answer was withheld: it did not pass the app's answer checks.";
/** Said by `SUBAGENT_ERROR` for a sub-agent whose turn ended without an answer. */
export declare const AG_UI_SUBAGENT_UNFINISHED = "The sub-agent's turn ended without an answer.";
/** Said by `SUBAGENT_ERROR` for a sub-agent still running when its run was cancelled. */
export declare const AG_UI_SUBAGENT_CANCELLED = "The run was cancelled before this sub-agent finished.";
/**
* The AG-UI serializer for ONE run: feed it every served agentfootprint event
* of the run, in order, then settle it once. `RUN_STARTED` comes before the
* first event it returns; `RUN_FINISHED` or `RUN_ERROR` is the last thing
* `settle` returns, with everything still open closed before it.
*
* @example
* ```ts
* const ag = agUiSerializer({ threadId, runId });
* const off = agent.on('*', (event) => send(ag.event(event)));
* try {
* const out = await agent.run({ message });
* send(ag.settle({ kind: isPaused(out) ? 'paused' : 'finished' }));
* } catch (error) {
* // A withheld answer is a refusal, named by its code (`RUN_ERROR.code`);
* // anything else is described by the record, never by the thrown value.
* send(
* ag.settle(
* error instanceof AnswerValidationError
* ? { kind: 'failed', refusal: 'ERR_ANSWER_VALIDATION' }
* : { kind: 'failed' },
* ),
* );
* } finally {
* off();
* }
* ```
*/
export declare function agUiSerializer(options: AgUiSerializerOptions): AgUiTranslation;