agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
290 lines • 15.9 kB
JavaScript
;
/**
* buildAgentMessageApiChart — the Agent (ReAct) form of the messageAPI
* merge-tree, as ONE FLAT main chart (no inner LLM-call sub-box).
*
* The whole ReAct cycle lives directly in the single Agent chart:
*
* Context (ROOT selector — inits + picks which context slots to engineer)
* ├─ system-prompt ┐
* ├─ messages ─────┼─→ messageAPI → Call-LLM
* └─ tools ────────┘
* → Route (decider) → [ ToolCalls (execute) → loop ] / Final (response)
* loopTo(Context)
*
* WHY flat (the user's call): the entire agent — context engineering, the LLM
* call, routing, tool execution, the loop, and the final response — is ONE
* visible flowchart in ONE Agent box. No nested LLM-call box: Lens wraps the
* whole chart in the Agent main-box and renders the slots as pills. This is
* simpler than the composed (sf-llm-call subflow) shape and avoids box-in-box
* nesting entirely.
*
* The three context slots are DIRECT children of Context; all converge at
* messageAPI (which assembles system-prompt + messages); Call-LLM then sends
* the assembled payload plus the tool schemas. Route decides tool-calls (loop)
* vs final (terminate). The same chart serves Static and Dynamic agents — only
* which slots the Context selector lights per iteration differs.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildAgentMessageApiChart = void 0;
const footprintjs_1 = require("footprintjs");
const advanced_1 = require("footprintjs/advanced");
const messageApiReceipt_js_1 = require("./messageApiReceipt.js");
const receiptDigests_js_1 = require("./receipt/receiptDigests.js");
const conventions_js_1 = require("../../lib/conventions.js");
const typedEmit_js_1 = require("../../recorders/core/typedEmit.js");
const resilienceHooks_js_1 = require("../../recorders/core/resilienceHooks.js");
const abort_js_1 = require("../../lib/abort.js");
const buildSystemPromptSlot_js_1 = require("../slots/buildSystemPromptSlot.js");
const buildMessagesSlot_js_1 = require("../slots/buildMessagesSlot.js");
const buildToolsSlot_js_1 = require("../slots/buildToolsSlot.js");
const composeRequest_js_1 = require("./composeRequest.js");
/** Route branch ids. */
const ROUTE_TOOL_CALLS = 'tool-calls';
const ROUTE_FINAL = conventions_js_1.SUBFLOW_IDS.FINAL;
/**
* Build the Agent merge-tree chart as one flat ReAct flowchart.
*
* **Cancellation.** Every model call reads its signal from the run's `env`.
* For an abort or a deadline to cancel the provider's request, pass the signal
* there too: `env: { signal }`, and for a deadline `AbortSignal.timeout(ms)`. A
* bare `FlowChartExecutor` hands its own `signal` and `timeoutMs` to the
* traversal only. That stops the chart, but a call already in flight runs to
* completion, and you are billed for it.
*
* ```ts
* const controller = new AbortController();
* await new FlowChartExecutor(chart).run({
* input: { message: 'hi' },
* signal: controller.signal, // stops the traversal
* env: { signal: controller.signal }, // cancels the model call in flight
* });
* ```
*/
function buildAgentMessageApiChart(deps) {
const { provider, model, systemPrompt, tools } = deps;
const maxIterations = deps.maxIterations ?? 5;
// The receipt memo every call of a run mints through, so a loop hashes each
// message once per run (`receipt/receiptDigests.ts`). One per chart:
// it rebinds itself to each run id, so a chart reused across runs is safe.
const receiptDigests = (0, receiptDigests_js_1.createReceiptDigests)();
// ── Context: ROOT selector. Inits per-call state on the first turn (the
// folded-in seed — Context is the chart's first node); on ReAct loop re-entry
// it leaves state intact (the iteration was bumped by ToolCalls). Returns the
// three context slots to engineer. ──
const contextSelector = (scope) => {
if (scope.iteration === undefined) {
const args = scope.$getArgs();
scope.userMessage = args.message;
scope.history = [{ role: 'user', content: args.message }];
scope.iteration = 1;
scope.systemPromptInjections = [];
scope.messagesInjections = [];
scope.toolSchemas = [];
scope.assembledSystem = '';
scope.assembledMessages = [];
scope.answer = '';
scope.toolCalls = [];
scope.finalContent = '';
}
return (0, footprintjs_1.select)(scope, [
{ when: () => true, then: conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT, label: 'engineer system-prompt' },
{ when: () => true, then: conventions_js_1.SUBFLOW_IDS.MESSAGES, label: 'engineer messages' },
{ when: () => true, then: conventions_js_1.SUBFLOW_IDS.TOOLS, label: 'engineer tools' },
]);
};
// ── messageAPI: assemble system-prompt + messages (the join after the slots
// converge). tools is a separate field Call-LLM reads directly. ──
const messageApiStage = (scope) => {
const sysInjections = (scope.systemPromptInjections ?? []);
scope.assembledSystem = (0, composeRequest_js_1.joinSystemPrompt)(sysInjections);
scope.assembledMessages = [...(scope.history ?? [])];
};
// ── Call-LLM: send the assembled payload + the tool schemas. ──
const callLLM = async (scope) => {
const system = scope.assembledSystem;
const messages = (0, composeRequest_js_1.stripFrameworkFields)((scope.assembledMessages ?? []));
const toolSchemas = (scope.toolSchemas ?? []);
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.stream.llm_start', {
iteration: scope.iteration,
provider: provider.name,
model,
systemPromptChars: system.length,
messagesCount: messages.length,
toolsCount: toolSchemas.length,
...(toolSchemas.length > 0 && {
tools: toolSchemas.map((t) => ({
name: t.name,
...(t.description ? { description: t.description } : {}),
})),
}),
});
const startMs = Date.now();
// ONE request object, sent and fingerprinted — never assembled twice.
const request = {
...(system.length > 0 && { systemPrompt: system }),
messages,
...(toolSchemas.length > 0 && { tools: toolSchemas }),
model,
};
// ── THE RECEIPT (9.91.0) ───────────────────────────────────────────
// Minted immediately before the port is called, so it is committed in the
// call-llm bundle that already exists — and before rather than after on
// purpose: the stage commits on the error path too, so a call that throws
// still leaves the record of what it was about to send. No run id ⇒ no
// receipt; the rule and its reason live in `messageApiReceipt.ts`.
const receipt = (0, messageApiReceipt_js_1.messageApiReceipt)({
runId: deps.getRunId?.(),
epoch: scope.iteration,
model,
provider: provider.name,
systemText: system,
systemPieces: (scope.systemPromptInjections ?? []),
messages,
tools: toolSchemas,
request,
digests: receiptDigests,
});
if (receipt !== undefined)
scope[conventions_js_1.RECEIPT_KEY] = receipt;
// The run's signal (its deadline included) rides the request and bounds
// the wait; an answer that lands after it is refused before `llm_end`
// could count it (lib/abort.ts · callUnder).
const response = await (0, abort_js_1.callUnder)(request, scope.$getEnv?.()?.signal, (wired) => provider.complete(wired,
// Resilience-report channel: a decorated provider's fallback /
// retry / recovery becomes an in-run typed event here.
//
// Where that event GOES is entirely up to the recorders attached to
// the executor, and on a bare one the answer is NOWHERE.
// footprintjs's `ScopeFacade.emitEvent` dispatches only to
// recorders' `onEmit` — it never touches the transaction buffer, and
// it fast-returns outright when zero recorders are attached — so an
// emit can never become a `CommitBundle`. This chart has no runner
// of its own, so the caller must attach a recorder that implements
// `onEmit` for the report to be observable at all, and
// `resilienceRecorder` (exported from `agentfootprint/observe`)
// specifically for `agent.on(...)`. Pinned by the two bare-executor
// cases in
// test/resilience/integration/resilience-decorator-visibility.test.ts.
(0, resilienceHooks_js_1.resilienceHooks)(scope, receipt !== undefined)));
scope.answer = response.content;
scope.toolCalls = response.toolCalls;
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.stream.llm_end', {
iteration: scope.iteration,
content: response.content,
toolCallCount: response.toolCalls.length,
usage: response.usage,
stopReason: response.stopReason,
durationMs: Date.now() - startMs,
});
};
// ── Route: ReAct decider — tool-calls (loop) vs final (terminate). ──
const routeDecider = (scope) => {
const calls = (scope.toolCalls ?? []);
if (calls.length > 0 && scope.iteration < maxIterations)
return ROUTE_TOOL_CALLS;
return ROUTE_FINAL;
};
// ── ToolCalls: execute the LLM's requested tools, append results, bump the
// iteration, loop. ──
const toolExec = async (scope) => {
const calls = (scope.toolCalls ?? []);
const newHistory = [...(scope.history ?? [])];
for (const call of calls) {
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.stream.tool_start', {
toolCallId: call.id,
toolName: call.name,
args: call.args,
});
const result = `[${call.name} result]`; // demo executor — real agents wire a registry
newHistory.push({ role: 'tool', content: result, toolCallId: call.id });
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.stream.tool_end', {
toolCallId: call.id,
result,
durationMs: 0,
});
}
scope.history = newHistory;
scope.toolCalls = [];
scope.iteration = scope.iteration + 1;
};
// ── Final: the agent's response — terminate the loop, capture the answer. ──
const finalStage = (scope) => {
scope.finalContent = scope.answer;
scope.$break('agent reached final answer');
};
// ── Build ONE flat chart: Context(root) → 3 slots → messageAPI → Call-LLM
// → Route → [ToolCalls → loop] / Final. ──
return ((0, footprintjs_1.flowChartSelector)('Context', contextSelector, 'context', {
...(deps.structureRecorders !== undefined && {
structureRecorders: [...deps.structureRecorders],
}),
// 'Agent:' taxonomy marker → Lens renders this as an Agent group.
description: 'Agent: ReAct loop',
})
// Three DIRECT context slots — all converge at messageAPI.
.addSubFlowChartBranch(conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT, (0, buildSystemPromptSlot_js_1.buildSystemPromptSlot)({ prompt: systemPrompt, reason: 'agent messageAPI' }), 'System Prompt', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.SYSTEM_PROMPT),
inputMapper: (parent) => ({
userMessage: parent.userMessage,
iteration: parent.iteration,
}),
outputMapper: (sf) => ({
systemPromptInjections: sf.systemPromptInjections,
}),
})
.addSubFlowChartBranch(conventions_js_1.SUBFLOW_IDS.MESSAGES, (0, buildMessagesSlot_js_1.buildMessagesSlot)(), 'Messages', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.MESSAGES),
inputMapper: (parent) => ({
messages: parent.history,
iteration: parent.iteration,
}),
outputMapper: (sf) => ({ messagesInjections: sf.messagesInjections }),
})
.addSubFlowChartBranch(conventions_js_1.SUBFLOW_IDS.TOOLS, (0, buildToolsSlot_js_1.buildToolsSlot)({ tools }), 'Tools', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.TOOLS),
inputMapper: (parent) => ({ iteration: parent.iteration }),
outputMapper: (sf) => ({ toolSchemas: sf.toolSchemas }),
// REPLACE, never concatenate (9.92.0). footprintjs's default output
// mapping CONCATENATES a subflow's array output onto the parent's
// existing array, so turn 2 of this loop handed the model
// `['weather','weather']` — the same tool twice, and Anthropic rejects
// a request whose tool names repeat. The receipt and `servedAt` recorded
// the doubled list truthfully, which is how the defect became visible
// (9.91.0 follow-up). The agent charts already say this on their own
// tools mount; this chart had the same mapper without the same law.
arrayMerge: advanced_1.ArrayMergeMode.Replace,
// tools is a SEPARATE Anthropic wire field — it BYPASSES messageAPI
// (which assembles only system+messages) and pairs with its output at
// Call-LLM. `convergeAt` makes the structure edge `sf-tools → call-llm`
// instead of the default `sf-tools → message-api`, so Call-LLM reads as a
// true 2-parent merge {messageAPI, tools}. (toolSchemas already rides
// shared scope; this only makes the diagram faithful.)
convergeAt: 'call-llm',
})
.end()
// messageAPI assembles ONLY system+messages; Call-LLM then sends that
// payload + the tool schemas (the 2-parent merge above).
.addFunction('messageAPI', messageApiStage, 'message-api', 'Assemble system + messages into the LLM request')
.addFunction('CallLLM', callLLM, 'call-llm', 'Send the assembled request + tools to the LLM')
// Declared milestones (9.90.0), from the same table `milestoneFor` reads
// (the slot branches above declare theirs in their mount options).
.tag(...(0, conventions_js_1.milestoneTagsFor)(conventions_js_1.STAGE_IDS.CALL_LLM))
// Route → [ToolCalls → loop back to Context] / [Final → terminate].
.addDeciderFunction('Route', routeDecider, conventions_js_1.SUBFLOW_IDS.ROUTE, 'ReAct routing', {
tags: (0, conventions_js_1.milestoneTagsFor)(conventions_js_1.SUBFLOW_IDS.ROUTE),
})
.addFunctionBranch(ROUTE_TOOL_CALLS, 'ToolCalls', toolExec, 'Execute tool calls', {
tags: (0, conventions_js_1.milestoneTagsFor)(ROUTE_TOOL_CALLS),
})
// ReAct: the loop is sourced from the TOOL-CALLS branch (after executing
// tools, re-engineer context next turn) — NOT from the Route decider. Final
// terminates as a leaf (its $break is the terminal boundary signal).
.loopTo('context')
.addFunctionBranch(ROUTE_FINAL, 'Final', finalStage, 'Terminate the ReAct loop (response)', { tags: (0, conventions_js_1.milestoneTagsFor)(ROUTE_FINAL) })
.setDefault(ROUTE_FINAL)
.end()
.build());
}
exports.buildAgentMessageApiChart = buildAgentMessageApiChart;
//# sourceMappingURL=buildAgentMessageApiChart.js.map