UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

908 lines • 42.6 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 { CONSUMER_SCOPE_RUN_ID } from '../bridge/eventMeta.js'; import { LLM_ERROR_SENTENCES } from '../lib/humanizeLLMError.js'; import { toWireJson, withWireErrors } from '../lib/wireJson.js'; import { agUiIdMinter } from './ids.js'; import { AG_UI_PROTOCOL_VERSION, } from './types.js'; /** The listed fields of `payload`, where present. */ const pick = (payload, fields) => { const out = {}; for (const field of fields) if (payload[field] !== undefined) out[field] = payload[field]; return out; }; /** * 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 const AG_UI_CUSTOM_EVENTS = Object.freeze({ // Which call and which iteration — never the tool author's `payload`, which // is whatever the tool chose to report (list it to send it). 'agentfootprint.stream.tool_progress': (payload, at) => ({ ...(at.toolCallId !== undefined && { toolCallId: at.toolCallId }), ...pick(payload, ['iteration']), }), 'agentfootprint.cost.tick': (payload) => pick(payload, [ 'scope', 'model', 'provider', 'tokensInput', 'tokensOutput', 'estimatedUsd', 'cumulative', ]), 'agentfootprint.agent.route_decided': (payload) => pick(payload, ['iterIndex', 'chosen']), 'agentfootprint.answer.assessed': (payload) => pick(payload, ['standing', 'assessment', 'reasons', 'checked', 'turn', 'iteration']), 'agentfootprint.error.retried': (payload) => pick(payload, ['attempt', 'maxAttempts', 'backoffMs', 'statedWaitMs']), 'agentfootprint.error.recovered': (payload) => pick(payload, ['attempt', 'totalDurationMs']), }); /** Said by `TOOL_CALL_RESULT` for a failed call, unless `toolErrors: 'served'`. */ export 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 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 const AG_UI_ANSWER_WITHHELD = "The answer was withheld: it did not pass the app's answer checks."; /** Is `said` one of the record's fixed failure sentences — the only `error.fatal` words a `RUN_ERROR` repeats? */ const isFixedFailure = (said) => Object.values(LLM_ERROR_SENTENCES).includes(said); /** Said by `SUBAGENT_ERROR` for a sub-agent whose turn ended without an answer. */ export 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 const AG_UI_SUBAGENT_CANCELLED = 'The run was cancelled before this sub-agent finished.'; const newScope = (mount, subagentRunId, name, parentSubagentRunId) => ({ mount, subagentRunId, ...(name !== undefined && { name }), ...(parentSubagentRunId !== undefined && { parentSubagentRunId }), llmAddress: undefined, isAgent: false, text: undefined, callMessageId: undefined, streamed: false, reasoning: undefined, steps: [], calls: [], }); /** Does `prefix` lead `path`, segment by segment? */ function leads(prefix, path) { if (prefix.length > path.length) return false; for (let i = 0; i < prefix.length; i++) if (prefix[i] !== path[i]) return false; return true; } const sameMount = (a, b) => a.length === b.length && leads(a, b); const record = (value) => typeof value === 'object' && value !== null ? value : {}; const text = (value) => typeof value === 'string' ? value : undefined; /** A tool result as AG-UI text: a string as it is, anything else through the wire rule. */ const resultText = (value) => typeof value === 'string' ? value : value === undefined ? '' : toWireJson(value); /** * 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 function agUiSerializer(options) { const { threadId, runId } = options; if (typeof threadId !== 'string' || threadId.length === 0) { throw new TypeError('AG-UI: `threadId` must be a non-empty string.'); } const ids = agUiIdMinter(runId); const custom = options.custom ?? AG_UI_CUSTOM_EVENTS; const reasoning = options.reasoning === true; const owns = options.owns; const toolErrors = options.toolErrors ?? 'withheld'; const root = newScope([], undefined); /** Open scopes — the root always, then each nested agent turn in open order. */ const scopes = [root]; let started = false; let settled = false; /** * The run the events name, once one did — how a translation without `owns` * keeps to one run. Never written: the library's run ids count every run the * PROCESS made, and a browser must not learn how many other callers ran. */ let recordRunId; /** The record's own words for a failure — the last served `error.fatal`. */ let fatal; let pause; /** The root agent's answer as the record serves it — `RUN_FINISHED.result`. */ let rootResult; /** What a continuation needs — set when the run settles paused. */ let suspended; const scopeOf = (path) => { let best = root; for (const scope of scopes) { if (scope.mount.length > best.mount.length && leads(scope.mount, path)) best = scope; } return best; }; /** The record's address of `event`, as AG-UI metadata. */ const addressOf = (event, extra) => ({ agentfootprint: { runtimeStageId: event.meta.runtimeStageId, subflowPath: [...event.meta.subflowPath], ...extra, }, }); /** The fields every event of `scope` carries. */ const owned = (scope, event) => ({ ...(event !== undefined && { timestamp: event.meta.wallClockMs }), ...(scope.subagentRunId !== undefined && { subagentRunId: scope.subagentRunId }), }); // ── closers — each leaves its entity closed and writes the events that say so ── const closeText = (scope, out) => { if (scope.text === undefined) return; out.push({ type: 'TEXT_MESSAGE_END', messageId: scope.text.id, ...owned(scope) }); scope.text = undefined; }; const closeReasoning = (scope, out) => { if (scope.reasoning === undefined) return; const { id } = scope.reasoning; out.push({ type: 'REASONING_MESSAGE_END', messageId: id, ...owned(scope) }); out.push({ type: 'REASONING_END', messageId: id, ...owned(scope) }); scope.reasoning = undefined; }; const closeSteps = (scope, out) => { while (scope.steps.length > 0) { const stepName = scope.steps.pop(); out.push({ type: 'STEP_FINISHED', stepName, ...owned(scope) }); } }; /** Close what is open INSIDE a scope — the scope itself stays. */ const closeContents = (scope, out) => { closeText(scope, out); closeReasoning(scope, out); closeSteps(scope, out); }; /** Close one nested scope: what is open inside it, then its own terminal event. */ const closeScope = (scope, out, terminal) => { closeContents(scope, out); const at = scopes.indexOf(scope); if (at > 0) scopes.splice(at, 1); if (scope.subagentRunId !== undefined) out.push(terminal(scope.subagentRunId)); }; /** The open nested scopes mounted strictly under `mount`, deepest first. */ const scopesUnder = (mount) => scopes .filter((s) => s !== root && s.mount.length > mount.length && leads(mount, s.mount)) .reverse() .sort((a, b) => b.mount.length - a.mount.length); /** End a nested scope: every turn still open under it first (they never answered), then it. */ const endScope = (scope, out, terminal) => { for (const inner of scopesUnder(scope.mount)) closeScope(inner, out, unfinished); closeScope(scope, out, terminal); }; const unfinished = (subagentRunId) => ({ type: 'SUBAGENT_ERROR', subagentRunId, message: AG_UI_SUBAGENT_UNFINISHED, }); // ── openers ── const openStep = (scope, stepName, event, out) => { const at = scope.steps.indexOf(stepName); if (at >= 0) { // The same name still open in this scope: the record never closed it. scope.steps.splice(at, 1); out.push({ type: 'STEP_FINISHED', stepName, ...owned(scope) }); } scope.steps.push(stepName); out.push({ type: 'STEP_STARTED', stepName, ...owned(scope, event), metadata: addressOf(event), }); }; const finishStep = (scope, stepName, event, out) => { const at = scope.steps.indexOf(stepName); if (at < 0) return; scope.steps.splice(at, 1); out.push({ type: 'STEP_FINISHED', stepName, ...owned(scope, event) }); }; /** A whole text message in one go — an answer the record did not stream. */ const wholeText = (scope, content, event, out) => { const { id } = ids.next('text', event.meta.runtimeStageId); out.push({ type: 'TEXT_MESSAGE_START', messageId: id, role: 'assistant', ...owned(scope, event), metadata: addressOf(event), }); out.push({ type: 'TEXT_MESSAGE_CONTENT', messageId: id, delta: content, ...owned(scope, event), }); out.push({ type: 'TEXT_MESSAGE_END', messageId: id, ...owned(scope, event) }); scope.callMessageId = id; }; // ── the mapping ── const translate = (event, out) => { const path = event.meta.subflowPath; const address = event.meta.runtimeStageId; const payload = record(event.payload); switch (event.type) { case 'agentfootprint.agent.turn_start': { if (path.length === 0) { root.isAgent = true; return; } const previous = scopes.find((s) => s !== root && sameMount(s.mount, path)); if (previous !== undefined) endScope(previous, out, unfinished); const parent = scopeOf(path); const scope = newScope(path, ids.next('agent', address).id, path.join('/'), parent.subagentRunId); scope.isAgent = true; scopes.push(scope); out.push({ type: 'SUBAGENT_STARTED', subagentRunId: scope.subagentRunId, name: scope.name, ...(parent.subagentRunId !== undefined && { parentSubagentRunId: parent.subagentRunId }), timestamp: event.meta.wallClockMs, metadata: addressOf(event), }); return; } case 'agentfootprint.agent.turn_end': { const scope = scopeOf(path); if (!scope.isAgent) { // A turn this translation never saw open (it subscribed late): its // answer has no scope to end, and the root's must not be guessed. pushCustom(event, scope, out); return; } closeText(scope, out); closeReasoning(scope, out); const refused = payload.refused !== undefined; const answer = refused ? undefined : text(payload.finalContent); if (!scope.streamed && answer !== undefined && answer.length > 0) wholeText(scope, answer, event, out); if (scope === root) { rootResult = answer; closeSteps(scope, out); return; } endScope(scope, out, (subagentRunId) => ({ type: 'SUBAGENT_FINISHED', subagentRunId, ...(answer !== undefined && { result: answer }), timestamp: event.meta.wallClockMs, })); return; } case 'agentfootprint.agent.iteration_start': openStep(scopeOf(path), `iteration ${String(payload.iterIndex)}`, event, out); return; case 'agentfootprint.agent.iteration_end': finishStep(scopeOf(path), `iteration ${String(payload.iterIndex)}`, event, out); return; case 'agentfootprint.composition.enter': openStep(scopeOf(path), compositionStep(payload.kind, path), event, out); return; case 'agentfootprint.composition.exit': { // A turn under this composition that is still open never answered — a // branch a tolerant merge let fail. Said, never left dangling. for (const inner of scopesUnder(path)) closeScope(inner, out, unfinished); finishStep(scopeOf(path), compositionStep(payload.kind, path), event, out); return; } case 'agentfootprint.stream.llm_start': { const scope = scopeOf(path); closeText(scope, out); closeReasoning(scope, out); scope.streamed = false; scope.callMessageId = undefined; scope.llmAddress = address; return; } case 'agentfootprint.stream.token': { const content = text(payload.content); if (content === undefined) return; const scope = scopeOf(path); const index = typeof payload.tokenIndex === 'number' ? payload.tokenIndex : Number.NaN; const open = scope.text; if (open === undefined || open.address !== address || !(index > open.lastTokenIndex)) { // A new message: the first token of a call, another stage's tokens (an // answer released after validation), or a stream that restarted. closeText(scope, out); const { id } = ids.next('text', address); scope.text = { id, address, lastTokenIndex: Number.isNaN(index) ? -1 : index }; scope.callMessageId = id; out.push({ type: 'TEXT_MESSAGE_START', messageId: id, role: 'assistant', ...owned(scope, event), metadata: addressOf(event), }); } else { open.lastTokenIndex = index; } scope.streamed = true; out.push({ type: 'TEXT_MESSAGE_CONTENT', messageId: scope.text.id, delta: content, ...owned(scope, event), }); return; } case 'agentfootprint.stream.llm_end': { const scope = scopeOf(path); closeText(scope, out); // Inside an agent's turn the content is a draft; the turn's answer comes // at turn_end. With no agent around (an LLMCall, a merge by LLM) it IS // the answer — written whole when it streamed nothing. const content = text(payload.content); if (!scope.isAgent && !scope.streamed && payload.contentWithheld !== true && content) wholeText(scope, content, event, out); return; } case 'agentfootprint.stream.thinking_delta': { const content = text(payload.content); if (!reasoning || content === undefined) return; const scope = scopeOf(path); if (scope.reasoning === undefined || scope.reasoning.iteration !== payload.iteration) { closeReasoning(scope, out); const { id } = ids.next('reasoning', address); scope.reasoning = { id, iteration: payload.iteration }; out.push({ type: 'REASONING_START', messageId: id, ...owned(scope, event), metadata: addressOf(event), }); out.push({ type: 'REASONING_MESSAGE_START', messageId: id, role: 'reasoning', ...owned(scope, event), }); } out.push({ type: 'REASONING_MESSAGE_CONTENT', messageId: scope.reasoning.id, delta: content, ...owned(scope, event), }); return; } case 'agentfootprint.stream.thinking_end': { if (!reasoning) return; const scope = scopeOf(path); if (scope.reasoning !== undefined && scope.reasoning.iteration === payload.iteration) { closeReasoning(scope, out); return; } // Nothing streamed for this call: the blocks are the reasoning, one // message each, in one span. A redacted block carries no text to show. const blocks = Array.isArray(payload.blocks) ? payload.blocks : []; const thoughts = blocks .map((block) => record(block)) .filter((block) => block.type === 'thinking') .map((block) => text(block.content)) .filter((content) => content !== undefined && content.length > 0); if (thoughts.length === 0) return; closeReasoning(scope, out); const messageIds = thoughts.map(() => ids.next('reasoning', address).id); const span = messageIds[0]; out.push({ type: 'REASONING_START', messageId: span, ...owned(scope, event), metadata: addressOf(event), }); thoughts.forEach((content, i) => { const messageId = messageIds[i]; out.push({ type: 'REASONING_MESSAGE_START', messageId, role: 'reasoning', ...owned(scope, event), }); out.push({ type: 'REASONING_MESSAGE_CONTENT', messageId, delta: content, ...owned(scope, event), }); out.push({ type: 'REASONING_MESSAGE_END', messageId, ...owned(scope, event) }); }); out.push({ type: 'REASONING_END', messageId: span, ...owned(scope, event) }); return; } case 'agentfootprint.stream.tool_start': { const scope = scopeOf(path); // The call's arguments are complete before it runs: a model's text // message for this call is over, and so is its reasoning. closeText(scope, out); closeReasoning(scope, out); // Every call of one LLM response belongs to ONE assistant message: the // call's text message when it wrote any, else a message minted at the // LLM call's own address for the first call and held for the rest. if (scope.callMessageId === undefined) scope.callMessageId = ids.next('text', scope.llmAddress ?? address).id; const { id, n } = ids.next('tool', address); scope.calls.push({ id, n, address, servedId: payload.toolCallId }); const toolCallName = text(payload.toolName) ?? ''; out.push({ type: 'TOOL_CALL_START', toolCallId: id, toolCallName, parentMessageId: scope.callMessageId, ...owned(scope, event), metadata: addressOf(event, { toolCallId: text(payload.toolCallId) }), }); out.push({ type: 'TOOL_CALL_ARGS', toolCallId: id, delta: toWireJson(payload.args ?? {}), ...owned(scope, event), }); out.push({ type: 'TOOL_CALL_END', toolCallId: id, ...owned(scope, event) }); return; } case 'agentfootprint.stream.tool_end': { const scope = scopeOf(path); const call = takeCall(scope, address, payload.toolCallId); if (call === undefined) { // A result the record cannot tie to a call this run opened — never // attached to a call it may not belong to (CUSTOM only if listed). pushCustom(event, scope, out); return; } // A frontend tool's result is the one the client sent: it holds it already. if (call.client === true) return; // A FAILED call's result is the thrown text — a connection string, a // stack line, a provider's body. A person reads the fixed words unless // the app chose to serve the record's own. // Any `error` but an explicit `false` counts — under a policy that // selects the key, the flag itself is served as the placeholder. const failed = payload.error !== undefined && payload.error !== false && toolErrors !== 'served'; out.push({ type: 'TOOL_CALL_RESULT', messageId: call.n < 0 ? ids.next('result', address).id : ids.at('result', call.n, call.address), toolCallId: call.id, content: failed ? AG_UI_TOOL_FAILED : resultText(payload.result), role: 'tool', ...owned(scope, event), }); return; } case 'agentfootprint.pause.request': pause = { event, scope: scopeOf(path) }; return; case 'agentfootprint.error.fatal': { // Only a FIXED sentence goes to a browser. The record's own fallback // quotes the raw thrown message ("The AI call failed: …"), which may // hold anything a provider or a tool put in it. const said = text(payload.error); if (said !== undefined && isFixedFailure(said)) fatal = said; return; } default: pushCustom(event, scopeOf(path), out); } }; /** * The open call a `tool_end` closes. One agent runs its calls one at a time, * so the open call at the result's stage execution is the one it answers; * where the record still names the call (no redaction policy), the id * decides among several. */ function takeCall(scope, address, servedId) { let at = -1; for (let i = scope.calls.length - 1; i >= 0; i--) { const call = scope.calls[i]; if (call.address !== address) continue; if (at < 0) at = i; if (typeof servedId === 'string' && call.servedId === servedId) { at = i; break; } } // The call a paused run left open is answered by the first result in its // scope: the continuation's stage executions are new, so no address matches. if (at < 0) at = scope.calls.findIndex((call) => call.resumed === true); return at < 0 ? undefined : scope.calls.splice(at, 1)[0]; } /** * Re-open what the paused run left open — the scopes (same `subagentRunId`), * their steps and the call it stopped in — and return the events that say so, * for `start` to write after `RUN_STARTED`. */ function restore(from) { const events = []; root.isAgent = from.rootAgent; for (const kept of from.scopes) { let scope = root; if (kept.subagentRunId !== undefined) { scope = newScope(kept.mount, kept.subagentRunId, kept.name, kept.parentSubagentRunId); scope.isAgent = true; scopes.push(scope); events.push({ type: 'SUBAGENT_STARTED', subagentRunId: kept.subagentRunId, name: kept.name ?? kept.mount.join('/'), ...(kept.parentSubagentRunId !== undefined && { parentSubagentRunId: kept.parentSubagentRunId, }), }); } for (const stepName of kept.steps) { scope.steps.push(stepName); events.push({ type: 'STEP_STARTED', stepName, ...owned(scope) }); } for (const call of kept.calls) { scope.calls.push({ id: call.toolCallId, n: -1, address: '', servedId: undefined, resumed: true, client: call.client, }); } if (kept.messageId !== undefined) scope.callMessageId = kept.messageId; } return events; } /** The record a continuation resumes from: every scope open now, with its steps. */ function suspensionOf(interruptId, call) { const kept = scopes.map((scope) => ({ mount: [...scope.mount], ...(scope.subagentRunId !== undefined && { subagentRunId: scope.subagentRunId }), ...(scope.name !== undefined && { name: scope.name }), ...(scope.parentSubagentRunId !== undefined && { parentSubagentRunId: scope.parentSubagentRunId, }), steps: [...scope.steps], calls: scope.calls.map((open) => ({ toolCallId: open.id, client: open.id === call?.toolCallId ? call.client : open.client === true, })), ...(scope.callMessageId !== undefined && { messageId: scope.callMessageId }), })); return { version: 1, runId, ...(interruptId !== undefined && { interruptId }), ...(call !== undefined && { call }), rootAgent: root.isAgent, scopes: kept, }; } /** * `event` as CUSTOM — only when its type is on the allow-list, carrying only * the fields its projection picks from the served payload, through the wire * rule. An unlisted event writes nothing. */ function pushCustom(event, scope, out) { const project = Object.prototype.hasOwnProperty.call(custom, event.type) ? custom[event.type] : undefined; if (typeof project !== 'function') return; const call = scope.calls[scope.calls.length - 1]; const value = project(record(event.payload), call === undefined ? {} : { toolCallId: call.id }); out.push({ type: 'CUSTOM', name: event.type, value: withWireErrors(value ?? null), ...owned(scope, event), metadata: addressOf(event), }); } /** * What a pause leaves for its continuation, as of now: on whom it waits * decides whether the open call is the client's (a frontend tool) or the * interrupt's. Pure apart from minting the interrupt once. */ const pausedRecord = (awaiting) => { const at = pause?.scope ?? root; const open = at.calls[at.calls.length - 1]; if (awaiting === 'caller' && open !== undefined) { return { record: suspensionOf(undefined, { toolCallId: open.id, mount: [...at.mount], client: true, }), clientCall: open.id, }; } return { record: suspensionOf(interruptOf().id, open === undefined ? undefined : { toolCallId: open.id, mount: [...at.mount], client: false }), }; }; /** What `RUN_STARTED` is followed by in a continuation: the scopes and steps it re-opens. */ const reopened = options.resume === undefined ? [] : restore(options.resume); const start = (out) => { if (started) return; started = true; out.push({ type: 'RUN_STARTED', threadId, runId, protocolVersion: AG_UI_PROTOCOL_VERSION, }); out.push(...reopened); }; /** The interrupt a person-awaited pause hands out — minted once, so a record kept before settling names it. */ let interrupt; const interruptOf = () => (interrupt ??= mintInterrupt()); const mintInterrupt = () => { if (pause === undefined) return { id: ids.next('interrupt', 'run').id, reason: 'paused' }; const { event, scope } = pause; const payload = record(event.payload); // The question a person is asked, as the record serves it — and nothing // else of the pause's payload (a check-in's evidence pack, a typed ask's // context), which a browser has no reason to receive. const message = questionOf(record(payload.questionPayload)); const call = scope.calls[scope.calls.length - 1]; return { id: ids.next('interrupt', event.meta.runtimeStageId).id, reason: text(payload.reason) ?? 'paused', ...(message !== undefined && { message }), ...(call !== undefined && { toolCallId: call.id }), ...(scope.subagentRunId !== undefined && { subagentRunId: scope.subagentRunId }), metadata: addressOf(event), }; }; /** Close every open nested scope, deepest first, each with the terminal `end` names for it. */ const endAllNested = (out, end) => { for (const scope of scopesUnder(root.mount)) closeScope(scope, out, end(scope)); }; return { event(event) { if (settled) return []; if (owns !== undefined && !owns(event)) return []; const named = event.meta?.runId; const namesRun = typeof named === 'string' && named !== CONSUMER_SCOPE_RUN_ID; if (namesRun) { // Without `owns`, the first run an event names is this run's, and // another run's event is never this run's to translate. if (recordRunId === undefined) recordRunId = named; else if (owns === undefined && named !== recordRunId) return []; } const body = []; translate(event, body); // `RUN_STARTED` waits for the run to name itself (or to say something): // a continuation opens with the resume's own bookkeeping, which names no // run, and the run's id belongs on its first event. if (!started && !namesRun && body.length === 0) return []; const out = []; start(out); out.push(...body); return out; }, settle(settlement) { if (settled) return []; settled = true; const out = []; start(out); switch (settlement.kind) { case 'failed': // A refusal by its code, in fixed words; otherwise the record's own // words for the failure — never the thrown value. out.push(settlement.refusal === 'ERR_ANSWER_VALIDATION' ? { type: 'RUN_ERROR', message: AG_UI_ANSWER_WITHHELD, code: settlement.refusal } : { type: 'RUN_ERROR', message: fatal ?? AG_UI_RUN_FAILED }); return out; case 'paused': { const { record, clientCall } = pausedRecord(settlement.awaiting); suspended = record; if (clientCall !== undefined) { // A tool the client runs: the run COMPLETED this segment and left // the call for the application to answer in its next input. endAllNested(out, () => (subagentRunId) => ({ type: 'SUBAGENT_FINISHED', subagentRunId, outcome: { type: 'suspended' }, })); closeContents(root, out); out.push({ type: 'RUN_FINISHED', threadId, runId, outcome: { type: 'success', pendingToolCallIds: [clientCall] }, }); return out; } const interrupt = interruptOf(); endAllNested(out, (scope) => (subagentRunId) => ({ type: 'SUBAGENT_FINISHED', subagentRunId, outcome: { type: 'suspended', ...(pause?.scope === scope && { interruptIds: [interrupt.id] }), }, })); closeContents(root, out); out.push({ type: 'RUN_FINISHED', threadId, runId, outcome: { type: 'interrupt', interrupts: [interrupt] }, }); return out; } case 'cancelled': endAllNested(out, () => (subagentRunId) => ({ type: 'SUBAGENT_ERROR', subagentRunId, message: AG_UI_SUBAGENT_CANCELLED, code: 'cancelled', })); closeContents(root, out); out.push({ type: 'RUN_FINISHED', threadId, runId, outcome: { type: 'cancelled' }, }); return out; case 'finished': endAllNested(out, () => unfinished); closeContents(root, out); out.push({ type: 'RUN_FINISHED', threadId, runId, ...(rootResult !== undefined && { result: rootResult }), }); return out; } }, suspension: (awaiting) => { if (settled) return suspended; return pausedRecord(awaiting).record; }, }; } /** * The question a pause put to a person, from whichever home its kind keeps it * in: a plain ask (`askHuman` / `pauseHere` / an input request) at the top, a * middleware's ask on `ask.question`, a check-in as the plain-words claim of * what the tool will do (`checkIn.evidence.willDo`). */ function questionOf(payload) { return (text(payload.question) ?? text(record(payload.ask).question) ?? text(record(record(payload.checkIn).evidence).willDo)); } /** A composition's step name: its kind, and where it is mounted when it is not the run itself. */ function compositionStep(kind, path) { const name = typeof kind === 'string' && kind.length > 0 ? kind : 'Composition'; return path.length === 0 ? name : `${name}:${path.join('/')}`; } //# sourceMappingURL=serializer.js.map