agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
127 lines • 5.66 kB
JavaScript
/**
* transcript — the conversation, read back out of the event stream.
*
* A maintainer opening a bug report reads the conversation first: what was
* asked, what the model said, which tools ran, what came back. All of that is
* already in the typed events — `agent.turn_start` carries the prompt,
* `stream.llm_end` the model's content, `stream.tool_start` / `tool_end` the
* call and its result, `agent.turn_end` the final answer — so `conversation.json`
* is DERIVED, never a second copy the reporter has to keep in sync.
*
* ## Derivable, and when it is not
*
* Turn markers come from the Agent loop. A bare `LLMCall`, a `Sequence` of
* them, a pattern — none of those fire `turn_start`, so the walk falls back to
* ONE synthetic turn holding the model and tool steps in order, and says so on
* the turn (`derived: 'no-turn-markers'`). A recording with no LLM and no tool
* events yields no transcript at all, and the manifest carries a note rather
* than an empty file that reads like an empty conversation.
*
* ## What it does NOT do
*
* It does not summarize, truncate or scrub. Whatever the events carry is what
* this writes — including tool arguments and results. Redaction is upstream:
* under an agent's `redact` every event was served before it was recorded (a
* selected key at any depth is the placeholder), and the manifest lists the
* keys that were scrubbed so the reporter can see it happened. The policy
* selects by name, never by content — free text under a name it does not
* select (a user's sentence, a model's answer) is written as it was.
*/
const asRecord = (value) => typeof value === 'object' && value !== null ? value : {};
const asString = (value) => typeof value === 'string' ? value : undefined;
/**
* Walk one conversation's events into turns.
*
* @returns the transcript, or `undefined` when nothing conversational
* happened — which the manifest reports as a note.
*/
export function deriveTranscript(events) {
const turns = [];
let open;
/** `tool_start` args, held until the matching `tool_end` names the result. */
const pendingTools = new Map();
/** Open a turn the Agent never announced — an LLMCall or a composition. */
const ensureTurn = () => {
if (open)
return open;
open = { index: turns.length, steps: [], derived: 'no-turn-markers' };
turns.push(open);
return open;
};
for (const raw of events) {
const event = raw;
const type = asString(event?.type);
if (!type)
continue;
const payload = asRecord(event.payload);
switch (type) {
case 'agentfootprint.agent.turn_start': {
open = {
index: typeof payload.turnIndex === 'number' ? payload.turnIndex : turns.length,
steps: [],
...(asString(payload.userPrompt) !== undefined && {
user: asString(payload.userPrompt),
}),
};
turns.push(open);
break;
}
case 'agentfootprint.agent.turn_end': {
const turn = ensureTurn();
const final = asString(payload.finalContent);
if (final !== undefined)
turn.final = final;
open = undefined;
break;
}
case 'agentfootprint.stream.llm_end': {
const turn = ensureTurn();
turn.steps.push({
kind: 'assistant',
content: asString(payload.content) ?? '',
...(asString(payload.stopReason) !== undefined && {
stopReason: asString(payload.stopReason),
}),
...(typeof payload.toolCallCount === 'number' && {
toolCallCount: payload.toolCallCount,
}),
});
break;
}
case 'agentfootprint.stream.tool_start': {
const id = asString(payload.toolCallId);
const name = asString(payload.toolName) ?? 'unknown-tool';
if (id)
pendingTools.set(id, { name, args: payload.args });
break;
}
case 'agentfootprint.stream.tool_end': {
const turn = ensureTurn();
const id = asString(payload.toolCallId);
const started = id ? pendingTools.get(id) : undefined;
if (id)
pendingTools.delete(id);
turn.steps.push({
kind: 'tool',
name: started?.name ?? 'unknown-tool',
...(id !== undefined && { toolCallId: id }),
...(started !== undefined && { args: started.args }),
...('result' in payload && { result: payload.result }),
...(payload.error === true && { error: true }),
...(payload.notDispatched !== undefined && { notDispatched: payload.notDispatched }),
});
break;
}
default:
break;
}
}
if (turns.length === 0)
return undefined;
// A single inferred turn with nothing in it is not a conversation.
if (turns.length === 1 && turns[0].steps.length === 0 && turns[0].user === undefined) {
return undefined;
}
return { turns: turns.map((turn) => ({ ...turn, steps: [...turn.steps] })) };
}
//# sourceMappingURL=transcript.js.map