agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
186 lines (185 loc) • 10.2 kB
TypeScript
/**
* Thinking templates — chat-bubble surface (separate from commentary).
*
* Audience split:
* • COMMENTARY (`commentaryTemplates`) — third-person, every moment,
* shown in Lens panel. Audience:
* developer / observer.
* • THINKING (this file) — first-person, mid-call only,
* shown in chat bubble. Audience:
* end user chatting with the agent.
*
* The thinking surface is a tiny finite state machine driven purely by
* the event log:
*
* ┌──────────┐ llm.start, no tools yet
* ────┤ idle ├────────────────────────────► "Thinking…"
* └──────────┘
*
* ┌──────────┐ stream.token chunks accumulate
* ────┤streaming ├────────────────────────────► "{{partial}}"
* └──────────┘
*
* ┌──────────┐ tool.start, no tool.end yet
* ────┤ tool ├────────────────────────────► "Working on `weather`…"
* └──────────┘ (or per-tool override)
*
* ┌──────────┐ tool_progress, still no tool.end
* ────┤ tool ├────────────────────────────► "Looking at your screen…"
* └──────────┘ (same state, newer line) (or the generic report)
*
* ┌──────────┐ pause.request, no resume yet
* ────┤ paused ├────────────────────────────► "Waiting on you: …"
* └──────────┘
*
* (null) run done / between calls → bubble hidden
*
* The selector returns the CURRENT state by walking the event log;
* the renderer maps state → final string by looking up the template.
*
* Per-tool templates: consumers can ship `tool.<toolName>` keys
* (e.g. `tool.weather: 'Looking up the weather…'`) which the renderer
* prefers over the generic `tool` template. Lets each tool have its
* own first-person status without per-tool plumbing.
*
* MID-CALL REPORTS (9.54.0). `ctx.progress(payload)` files
* `stream.tool_progress` from inside a still-running tool call. Through
* 9.53.0 that event reached the record and stopped there: nothing
* projected it onto the surface a person watches, so consumers hand-rolled
* a side channel for the live middle of a long call. The `tool` state now
* consumes it — same state, newer line — and the DISPLAY CONTRACT is
* deliberately narrow, because `payload` is author-defined `unknown`:
*
* · a top-level string field named `message` is shown VERBATIM
* (trimmed; cut at {@link PROGRESS_MESSAGE_LIMIT} characters, and the
* cut is always stated). `message` is the word MCP's own progress
* notification uses, so a tool already speaking that protocol needs no
* second vocabulary.
* · anything else — no `message`, a non-string one, an empty one — gets
* the generic line: the tool's name, that it reported, and how many
* times. NEVER a pretty-printed dump of the author's payload: a status
* line is prose, and prose that quotes a JSON blob is the field-dump
* law broken in public.
*
* The structured payload rides on to the record untouched either way. One
* `ctx.progress` call, two honest faces.
*/
import type { AgentfootprintEvent } from '../../../events/registry.js';
/** The four mid-call states a chat bubble might render. */
export type StatusKind = 'idle' | 'tool' | 'streaming' | 'paused';
/**
* What the selector returns. The chat-bubble consumer feeds this into
* the renderer to get the final string.
*/
export interface StatusState {
readonly state: StatusKind;
/** Vars for `{{name}}` substitution in the matched template. */
readonly vars: Readonly<Record<string, string>>;
/** When `state === 'tool'`, the resolving toolName. The renderer
* uses this to look up `tool.<toolName>` before the generic `tool`. */
readonly toolName?: string;
}
/** Flat template map. Keys: state kinds + per-tool overrides. */
export type StatusTemplates = Readonly<Record<string, string>>;
/** Render context — what the consumer's app config injects. */
export interface StatusContext {
/** Active actor's name. Substituted as `{{appName}}` in templates. */
readonly appName: string;
}
/**
* Bundled English defaults. Override in the agent config via
* `.thinkingTemplates({...})`. Per-tool overrides go via
* `tool.<toolName>` keys.
*/
export declare const defaultStatusTemplates: StatusTemplates;
/**
* How much of a tool author's `message` one status line carries before it is
* cut. A status line lives in a chat bubble; past ~120 characters it stops
* being a status and starts being a paragraph.
*
* Truncation is always STATED, never silent — the line says how much it cut,
* the same rule the analyst-view humanizer keeps. A message that legitimately
* ends in an ellipsis must stay distinguishable from one we shortened.
*/
export declare const PROGRESS_MESSAGE_LIMIT = 120;
/**
* The tool author's `message`, ready to show — or `null` when this report had
* nothing sayable and the generic line should stand instead.
*
* Deliberately strict, and the strictness is the contract:
*
* · the payload must be a plain object with a top-level `message` STRING.
* A bare string payload is not a `message` — `ctx.progress('…')` is a
* valid report, but it is the author's data, not a sentence they asked us
* to show a person. Wrapping it (`ctx.progress({ message: '…' })`) is the
* one-word opt-in, and it keeps a single rule to document.
* · an empty or whitespace-only `message` is not a sentence. It falls to the
* generic line rather than blanking the bubble mid-call.
*
* Nothing here reads any other field. Guessing at `done`/`total`/`percent`
* would put words in the tool's mouth: one tool's `total` is hops, the next
* one's is bytes, and a status line that says "3 of 12" about the wrong unit
* is worse than one that says nothing.
*/
export declare function progressMessageOf(payload: unknown): string | null;
/**
* Derive the current thinking state from the event log.
*
* Single forward walk that tracks "active" state for each domain:
* • pause — set on pause.request, cleared on pause.resume
* • tool calls — a call joins the in-flight map on tool.start and leaves
* it on ITS OWN tool.end (matched by `toolCallId`), so two
* parallel calls cannot close each other. Each in-flight
* call also carries its own progress tally (9.54.0).
* • llm — set on llm.start, cleared on llm.end
*
* Priority order (highest first):
*
* 1. ACTIVE PAUSE wins. When the agent is waiting on the human,
* that's what the chat should show — not the underlying tool
* that triggered the pause.
* 2. AN IN-FLIGHT TOOL CALL — the LLM said "use a tool" and the tool is
* running. Show "Working on `<toolName>`…", or, once that call has
* reported, what it reported.
* 3. ACTIVE LLM — call in flight. Show streaming tokens if any
* arrived, otherwise "Thinking…".
* 4. Otherwise null (bubble hidden).
*
* WHICH CALL THE LINE IS ABOUT, when several run in parallel: the one with
* the most recent `tool_progress`, and only if none of them has reported, the
* most recently STARTED. A report is the newest evidence of what is actually
* happening, and a surface that showed a silent call while a reporting one
* had something to say would be choosing the less informative truth. Two
* chatty parallel calls therefore alternate on the line, each verbatim, in the
* order their reports landed — which is what a person watching them wants.
*
* Pure projection. Forward walk is O(n); a closing event correctly
* cancels its matching opener so a completed tool.start/tool.end
* pair leaves the state quiescent.
*/
export declare function selectStatus(events: readonly AgentfootprintEvent[]): StatusState | null;
/**
* Resolve the matched template + substitute vars.
*
* Each state resolves a LADDER of candidate keys, most specific first, and
* takes the first one the merged template map defines:
*
* | the state | ladder |
* |------------------------------------|---------------------------------------------------------------------|
* | tool, reported with a `message` | `tool.<name>.progress` → `tool.progress` → `tool.<name>` → `tool` |
* | tool, reported without one | `tool.<name>.progress.generic` → `tool.progress.generic` → `tool.<name>` → `tool` |
* | tool, no report yet | `tool.<name>` → `tool` |
* | anything else | the state's own name |
*
* The ladder falls THROUGH to the plain tool keys on purpose. A consumer who
* shipped a curated `tool.weather` line and does not want it interrupted
* deletes `tool.progress.generic` from their map and keeps it; a consumer who
* ships a partial map that predates 9.54.0 keeps rendering exactly what it
* rendered before, mid-call reports and all. Nothing here can blank a bubble
* that used to have a line in it.
*
* Missing template keys return null rather than the empty string —
* keeps the contract honest (consumer can detect "no template" and
* fall back to its own default).
*/
export declare function renderStatusLine(state: StatusState | null, ctx: StatusContext, templates?: StatusTemplates): string | null;