agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
349 lines • 17.7 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.renderStatusLine = exports.selectStatus = exports.progressMessageOf = exports.PROGRESS_MESSAGE_LIMIT = exports.defaultStatusTemplates = void 0;
// ── Defaults ───────────────────────────────────────────────────────
/**
* Bundled English defaults. Override in the agent config via
* `.thinkingTemplates({...})`. Per-tool overrides go via
* `tool.<toolName>` keys.
*/
exports.defaultStatusTemplates = {
idle: 'Thinking…',
streaming: '{{partial}}',
tool: 'Working on `{{toolName}}`…',
paused: 'Waiting on you: {{question}}',
// Mid-call reports (9.54.0). `tool.progress` mirrors `streaming` — the
// template is the author's own sentence and nothing else, because a line
// this library wrapped around it would be this library talking over a tool
// that already said what it is doing.
'tool.progress': '{{progressMessage}}',
// …and when it said nothing sayable, the honest line is the fact that it
// reported, plus how often. No payload, ever.
'tool.progress.generic': '`{{toolName}}` reported progress ({{progressCount}} so far)…',
};
/**
* 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.
*/
exports.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.
*/
function progressMessageOf(payload) {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
return null;
const raw = payload.message;
if (typeof raw !== 'string')
return null;
const text = raw.trim();
if (text.length === 0)
return null;
if (text.length <= exports.PROGRESS_MESSAGE_LIMIT)
return text;
const cut = text.length - exports.PROGRESS_MESSAGE_LIMIT;
return `${text.slice(0, exports.PROGRESS_MESSAGE_LIMIT)}… (+${String(cut)} more)`;
}
exports.progressMessageOf = progressMessageOf;
// ── Selector ───────────────────────────────────────────────────────
/**
* 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.
*/
function selectStatus(events) {
let activePause = null;
let activeLlmStartIdx = -1; // -1 = no active LLM call
const inFlight = new Map();
for (let i = 0; i < events.length; i++) {
const e = events[i];
switch (e.type) {
case 'agentfootprint.pause.request': {
const p = e.payload;
activePause = {
question: p.reason ?? 'input required',
...(p.toolCallId ? { toolCallId: p.toolCallId } : {}),
};
break;
}
case 'agentfootprint.pause.resume':
activePause = null;
break;
case 'agentfootprint.stream.tool_start': {
const p = e.payload;
// A call the paused batch never dispatched (9.113.0, `notDispatched`
// on the bracket) is not a tool at work: it never joins the in-flight
// map, so the bubble never says "Working on" a call that did not run.
if (p.notDispatched !== undefined)
break;
// A call with no id still gets a slot — an emitter simpler than this
// library's own (a hand-built stream, an older recording) must still
// move the bubble. The synthetic key just cannot be correlated.
inFlight.set(p.toolCallId ?? `__anon#${String(i)}`, {
toolName: p.toolName,
...(p.toolCallId ? { toolCallId: p.toolCallId } : {}),
startSeq: i,
progressCount: 0,
lastProgressSeq: -1,
lastMessage: null,
});
break;
}
case 'agentfootprint.stream.tool_progress': {
// 9.54.0 — the live middle of a call. Correlate STRICTLY by id: the
// framework stamps it, so a real run always has one. Off the wire it
// may be missing, and then the only safe attribution is a sole
// in-flight call; with two running, a guess would put one call's words
// under the other one's name.
const p = e.payload;
const key = p.toolCallId !== undefined && inFlight.has(p.toolCallId)
? p.toolCallId
: p.toolCallId === undefined && inFlight.size === 1
? [...inFlight.keys()][0]
: undefined;
if (key === undefined)
break; // a report for a call that already ended
const call = inFlight.get(key);
if (call === undefined)
break;
call.progressCount += 1;
call.lastProgressSeq = i;
call.lastMessage = progressMessageOf(p.payload);
break;
}
case 'agentfootprint.stream.tool_end': {
// Match by toolCallId so parallel tools don't clobber each
// other. If no toolCallId is present (older event), clear
// unconditionally — backward-compat with simpler emitters.
const p = e.payload;
if (p.toolCallId === undefined)
inFlight.clear();
else
inFlight.delete(p.toolCallId);
break;
}
case 'agentfootprint.stream.llm_start':
activeLlmStartIdx = i;
break;
case 'agentfootprint.stream.llm_end':
activeLlmStartIdx = -1;
break;
}
}
// Priority resolution.
if (activePause) {
return {
state: 'paused',
vars: {
question: activePause.question,
...(activePause.toolCallId ? { toolCallId: activePause.toolCallId } : {}),
},
};
}
const calls = [...inFlight.values()];
if (calls.length > 0) {
const reported = calls.filter((c) => c.lastProgressSeq >= 0);
const focus = reported.length > 0
? reported.reduce((a, b) => (b.lastProgressSeq > a.lastProgressSeq ? b : a))
: calls.reduce((a, b) => (b.startSeq > a.startSeq ? b : a));
return {
state: 'tool',
toolName: focus.toolName,
vars: {
toolName: focus.toolName,
...(focus.toolCallId ? { toolCallId: focus.toolCallId } : {}),
// Absent field → absent clause: a call that has not reported carries
// no progress vars at all, so the renderer resolves exactly the ladder
// it resolved before this feature existed.
...(focus.progressCount > 0 ? { progressCount: String(focus.progressCount) } : {}),
...(focus.lastMessage !== null ? { progressMessage: focus.lastMessage } : {}),
},
};
}
if (activeLlmStartIdx >= 0) {
// Concatenate any tokens emitted after the active llm.start.
let partial = '';
for (let j = activeLlmStartIdx + 1; j < events.length; j++) {
if (events[j].type === 'agentfootprint.stream.token') {
const tok = events[j].payload;
partial += tok.content;
}
}
return partial.length > 0
? { state: 'streaming', vars: { partial } }
: { state: 'idle', vars: {} };
}
return null;
}
exports.selectStatus = selectStatus;
// ── Renderer ───────────────────────────────────────────────────────
/**
* 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).
*/
function renderStatusLine(state, ctx, templates = exports.defaultStatusTemplates) {
if (!state)
return null;
let template;
for (const key of templateLadder(state)) {
template = templates[key];
if (template !== undefined)
break;
}
if (template === undefined)
return null;
const vars = { appName: ctx.appName, ...state.vars };
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => vars[name] ?? '');
}
exports.renderStatusLine = renderStatusLine;
/** Candidate template keys for a state, most specific first. */
function templateLadder(state) {
if (state.state !== 'tool')
return [state.state];
const generic = state.toolName !== undefined ? [`tool.${state.toolName}`, 'tool'] : ['tool'];
const perTool = (suffix) => state.toolName !== undefined ? [`tool.${state.toolName}.${suffix}`] : [];
if (state.vars.progressMessage !== undefined) {
return [...perTool('progress'), 'tool.progress', ...generic];
}
if (state.vars.progressCount !== undefined) {
return [...perTool('progress.generic'), 'tool.progress.generic', ...generic];
}
return generic;
}
//# sourceMappingURL=statusTemplates.js.map