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
JavaScript
;
/**
* 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