UNPKG

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
"use strict"; /** * 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