UNPKG

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
/** * 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;