UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

174 lines • 9.31 kB
"use strict"; /** * Messages slot subflow builder * * Pattern: Builder (returns a FlowChart mountable via addSubFlowChartNext). * Role: Layer-3 context engineering. Produces InjectionRecord[] from * the current conversation history. For LLMCall, that's one * user message. For Agent, it's user + assistant + tool-result * messages accumulated over iterations. * Emits: None directly; ContextRecorder sees the writes. * * ── What this slot is, and is not ─────────────────────────────────── * It is the OBSERVABILITY PROJECTION of the conversation: one * InjectionRecord per message, with the source, reason, role and * recency a consumer needs to see what the turn was made of. It is not * the wire. The request's message list comes from `scope.history` * directly (`stages/callLLM.ts`), because the projection flattens away * the LLM-protocol shape a provider needs — an assistant turn's * `toolCalls[]`, a tool turn's `toolCallId`. * * Nothing enters the conversation HERE, and that is the point. Two stages * upstream edit `scope.history` before this one reads it: the window * strategy takes messages out (`stages/window.ts`, 7.16–7.17) and the * delivery stage lets declared ones in (`stages/deliver.ts`, 7.21). Both run * at the loop head, before the slots, so the projection and the wire always * describe the same past. * * That is why this file no longer walks `activeInjections` looking for * `inject.messages`. Until 7.19.1 it did, and it produced a record — an * emitted `context.injected` — for content the request never carried, which * is the exact bug the wire-truth test now guards. A delivered injection is * IN the window by the time this runs, so it is projected like any other * message and attributed to its injection by the `injectedBy` marker the * delivery stage stamped. One wire message, one record, one name. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.buildMessagesSlot = void 0; const footprintjs_1 = require("footprintjs"); const conventions_js_1 = require("../../lib/conventions.js"); const types_js_1 = require("../../recorders/core/types.js"); // Writes the context recorder derives its events from — relayed under a policy. const runRedaction_js_1 = require("../../redaction/runRedaction.js"); const helpers_js_1 = require("./helpers.js"); /** * Build the Messages slot subflow. * * Mount with: * builder.addSubFlowChartNext(SUBFLOW_IDS.MESSAGES, buildMessagesSlot(cfg), 'Messages', { * inputMapper: (parent) => ({ messages: parent.messages, iteration: parent.iteration }), * outputMapper: (sf) => ({ messagesInjections: sf.messagesInjections }), * }) */ function buildMessagesSlot(config = {}) { const budgetCap = config.budgetCap ?? 10000; // Dedup latch for the human-facing overflow warning (see buildToolsSlot). let warnedOverflow = false; return (0, footprintjs_1.flowChart)('Compose', (scope) => { const args = scope.$getArgs(); const messages = args.messages ?? []; const iteration = args.iteration ?? 1; // ONE record per message on the wire. Every message in the window gets // exactly one, and the only question is who to credit: a message the // Deliver stage let in carries `injectedBy`, so it is attributed to its // injection (flavor / id / description); everything else is baseline // conversation flow, attributed by role. // // The content hash is the SAME formula for both, deliberately. The // window stage reports an eviction under `${role}:${index}:${content}`, // so an injected message and the eviction that later removes it name // one piece of context by one name — which they would not if a // delivered message got its own hash scheme. const injections = messages.map((m, i) => { assertComposable(m, i, messages.length); const by = m.injectedBy; return { contentSummary: (0, helpers_js_1.truncate)(m.content, 80), contentHash: (0, helpers_js_1.fnv1a)(`${m.role}:${i}:${m.content}`), slot: 'messages', source: by ? by.flavor : inferSource(m.role), reason: by ? by.reason ?? `${by.flavor} '${by.injectionId}' delivered at iteration ${by.iteration}` : `conversation history [${i}]`, rawContent: m.content, asRole: m.role, asRecency: (i === messages.length - 1 ? 'latest' : 'earlier'), position: i, ...(by !== undefined && { sourceId: by.injectionId }), ...(by === undefined && m.toolCallId !== undefined && { sourceId: m.toolCallId }), }; }); (0, runRedaction_js_1.setEventSource)(scope, conventions_js_1.INJECTION_KEYS.MESSAGES, injections); const composition = (0, helpers_js_1.composeSlot)('messages', iteration, injections, budgetCap, 'history-order'); (0, runRedaction_js_1.setEventSource)(scope, types_js_1.COMPOSITION_KEYS.SLOT_COMPOSED, composition); // Overflow is LOUD — nothing here truncates (see buildToolsSlot). const pressure = (0, helpers_js_1.slotOverflow)(composition); if (pressure) { (0, runRedaction_js_1.setEventSource)(scope, types_js_1.COMPOSITION_KEYS.BUDGET_PRESSURE, [pressure]); if (!warnedOverflow) { warnedOverflow = true; console.warn((0, helpers_js_1.formatOverflowWarning)({ pressure, itemCount: injections.length, itemNoun: 'message', contentNoun: 'messages', remedy: 'Raise contextBudget.messages on the agent, or trim the conversation history.', })); } } }, 'compose', { description: 'Compose messages slot' }).build(); } exports.buildMessagesSlot = buildMessagesSlot; /** * The backstop: a turn that cannot be composed is NAMED here (8.18.0). * * Every way a message enters the conversation now normalizes or refuses at * its own source — the run input, a tool's return value, a resumed pause, a * middleware's rewrite, a declared injection, a restored checkpoint. This is * the net under all of them, and it exists because this line is where the * failure used to appear: `truncate(m.content, 80)` on a missing content threw * `TypeError: Cannot read properties of undefined (reading 'length')` from * inside a subflow, naming no message, no role and no origin. * * It does NOT coerce. Substituting `''` here would hand the model a turn * nobody said and hide the source seam that let it through — the next one of * these should be findable in one read, which is what naming the position, the * role and the likely origin is for. */ function assertComposable(m, index, total) { if (typeof m?.content === 'string') return; const role = m?.role ?? 'unknown'; const origin = m?.injectedBy !== undefined ? `injection '${m.injectedBy.injectionId}' (${m.injectedBy.flavor})` : m?.toolCallId !== undefined ? `the result of tool call '${m.toolCallId}'${m.toolName ? ` (${m.toolName})` : ''}` : role === 'user' && index === 0 ? "the run's input message" : 'the conversation history'; throw new TypeError(`[messages slot] message ${index + 1} of ${total} (role '${role}') has no text: ` + `\`content\` is ${m?.content === undefined ? 'missing' : `a ${m.content === null ? 'null' : typeof m.content}`}. It came from ${origin}. A turn with no content cannot be sent or recorded, and it is ` + `not composed as an empty one — that would put words nobody said into the conversation. ` + `Every entry point normalizes or refuses on its own (run input, tool results, resumed ` + `pauses, middleware rewrites, declared injections, restored checkpoints), so a message ` + `arriving here without text means one of them was bypassed.`); } /** * Map a ROLE to a baseline source tag. These represent regular LLM-API * conversation flow — NOT context engineering: * * - `user` → source 'user' (the user's message, current or history replay) * - `tool` → source 'tool-result' (tool return, current or history replay) * - `assistant` → source 'assistant' (prior LLM output replayed as history) * - `system` → source 'base' (static system-prompt content replayed) * * If a memory strategy / RAG retriever re-injects a message with * engineered intent, it must set its OWN source (`'memory'` / `'rag'` * / etc.) at the injection site — NOT rely on role inference here. * Role-based inference is the baseline fallback. */ function inferSource(role) { switch (role) { case 'user': return 'user'; case 'tool': return 'tool-result'; case 'assistant': return 'assistant'; case 'system': return 'base'; default: return 'custom'; } } //# sourceMappingURL=buildMessagesSlot.js.map