agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
199 lines • 10.2 kB
JavaScript
/**
* buildMessageApiChart — PROOF of the locked "messageAPI merge-tree" shape
* (MENTAL_MODEL.md ★ LOCKED DESIGN), LLM-only (no tools subflow yet).
*
* This is Step 1 of the agreed build order: prove the Context-selector →
* slot subflows → messageAPI stage → Call-LLM tree works + renders, BEFORE
* bringing it to the Agent (Step 2 adds the tools subflow + the loop).
*
* Chart shape (LLM-only):
*
* Seed
* → Context (SELECTOR stage — picks which slots to engineer)
* ├─ sf-system-prompt ┐ (selected branches run in parallel)
* └─ sf-messages ──────┴─→ messageAPI stage (the join point)
* → Call-LLM
*
* WHY a selector (not a plain fork): "Context = Selector stage" — it RETURNS
* the list of slot branch ids to engineer this iteration, and `select()`
* captures evidence (which slots + why). That is what will unify Static and
* Dynamic agent in ONE chart later: Static picks only `messages` per loop;
* Dynamic also picks `system-prompt` (and `tools`) when they re-engineer.
* The picked-set IS the lit/unlit-pill signal. For this LLM-only proof the
* selector picks BOTH slots (a one-shot call engineers everything once).
*
* WHY messageAPI is a REAL stage: it assembles the LLM request bulk that the
* agent's `callLLM` builds invisibly today (`buildCallLLMStage`,
* callLLM.ts · buildCallLLMStage) — `systemPrompt`
* (separate field) + `messages` (the conversation, incl. tool-results) → the
* message-API payload. Making it a stage makes that assembly visible +
* inspectable in Lens/Trace. (Tools is a separate field added at Call-LLM —
* it joins in Step 2.)
*
* Slots are REAL subflows (reused verbatim: buildSystemPromptSlot /
* buildMessagesSlot) writing the convention INJECTION_KEYS, so ContextRecorder
* emits context.injected and Lens renders them — no bespoke collapser.
*/
import { flowChartSelector, select } from 'footprintjs';
import { messageApiReceipt } from './messageApiReceipt.js';
import { SUBFLOW_IDS, STAGE_IDS, milestoneTagsFor, RECEIPT_KEY } from '../../lib/conventions.js';
import { typedEmit } from '../../recorders/core/typedEmit.js';
import { resilienceHooks } from '../../recorders/core/resilienceHooks.js';
import { callUnder } from '../../lib/abort.js';
import { buildSystemPromptSlot } from '../slots/buildSystemPromptSlot.js';
import { buildMessagesSlot } from '../slots/buildMessagesSlot.js';
import { joinSystemPrompt, stripFrameworkFields } from './composeRequest.js';
/**
* Build the LLM-only messageAPI merge-tree chart.
*
* **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
* });
* ```
*/
export function buildMessageApiChart(deps) {
const { provider, model, systemPrompt } = deps;
// ── Context: the ROOT SELECTOR. It runs FIRST — initialising the per-call
// state from the run input (the old "seed" work, now folded in: there is no
// separate seed stage), then returning which slot branches to engineer.
//
// select() collects one `then` PER matching rule, so multi-select = one rule
// per slot (a single `then` array would be coerced to one bogus id). For a
// one-shot LLM call both slots match → both branches run. select() captures
// evidence so a consumer sees WHICH slots were chosen + why (the lit/unlit-
// pill source that later distinguishes Static vs Dynamic). ──
const contextSelector = (scope) => {
// Init (formerly the seed stage) — Context is the chart's first node.
const args = scope.$getArgs();
scope.userMessage = args.message;
scope.history = [{ role: 'user', content: args.message }];
scope.iteration = 1;
scope.systemPromptInjections = [];
scope.messagesInjections = [];
scope.assembledSystem = '';
scope.assembledMessages = [];
scope.answer = '';
return select(scope, [
{ when: () => true, then: SUBFLOW_IDS.SYSTEM_PROMPT, label: 'engineer system-prompt' },
{ when: () => true, then: SUBFLOW_IDS.MESSAGES, label: 'engineer messages' },
]);
};
// ── messageAPI: assemble the request bulk (system + messages). This is
// the assembly callLLM does invisibly today, surfaced as a real stage. ──
const messageApiStage = (scope) => {
const sysInjections = (scope.systemPromptInjections ?? []);
scope.assembledSystem = joinSystemPrompt(sysInjections);
// The conversation (incl. any tool-result messages) IS the message
// stream — read from history directly (same as the agent's callLLM).
// Materialise a plain array (history is a reactive proxy ref).
scope.assembledMessages = [...(scope.history ?? [])];
typedEmit(scope, 'agentfootprint.context.slot_composed', {
slot: 'messages',
iteration: scope.iteration,
budget: { cap: 0, used: 0, headroomChars: 0 },
sourceBreakdown: {},
droppedCount: 0,
droppedSummaries: [],
});
};
// ── Call-LLM: send the assembled payload, write the answer. ──
const callLLM = async (scope) => {
const system = scope.assembledSystem;
const messages = stripFrameworkFields((scope.assembledMessages ?? []));
typedEmit(scope, 'agentfootprint.stream.llm_start', {
iteration: scope.iteration,
provider: provider.name,
model,
systemPromptChars: system.length,
messagesCount: messages.length,
toolsCount: 0,
});
const startMs = Date.now();
// ONE request object, sent and fingerprinted — see the receipt below.
const request = {
...(system.length > 0 && { systemPrompt: system }),
messages,
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 = messageApiReceipt({
runId: deps.getRunId?.(),
epoch: scope.iteration,
model,
provider: provider.name,
systemText: system,
systemPieces: (scope.systemPromptInjections ?? []),
messages,
tools: [],
request,
});
if (receipt !== undefined)
scope[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 callUnder(request, scope.$getEnv?.()?.signal, (wired) => provider.complete(wired,
// Resilience-report channel — see buildAgentMessageApiChart.
resilienceHooks(scope, receipt !== undefined)));
scope.answer = response.content;
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,
});
};
// ── Build the tree — Context is the ROOT selector (no seed stage). ──
const builder = flowChartSelector('Context', contextSelector, 'context', {
...(deps.structureRecorders !== undefined && {
structureRecorders: [...deps.structureRecorders],
}),
// 'LLMCall:' taxonomy marker → Lens renders this as an LLM group.
description: 'LLMCall: messageAPI merge-tree',
})
.addSubFlowChartBranch(SUBFLOW_IDS.SYSTEM_PROMPT, buildSystemPromptSlot({ prompt: systemPrompt, reason: 'messageAPI proof' }), 'System Prompt', {
tags: milestoneTagsFor(SUBFLOW_IDS.SYSTEM_PROMPT),
inputMapper: (parent) => ({
userMessage: parent.userMessage,
iteration: parent.iteration,
}),
outputMapper: (sf) => ({
systemPromptInjections: sf.systemPromptInjections,
}),
})
.addSubFlowChartBranch(SUBFLOW_IDS.MESSAGES, buildMessagesSlot(), 'Messages', {
tags: milestoneTagsFor(SUBFLOW_IDS.MESSAGES),
inputMapper: (parent) => ({
messages: parent.history,
iteration: parent.iteration,
}),
outputMapper: (sf) => ({ messagesInjections: sf.messagesInjections }),
})
.end()
// Join point — runs after the selected slot branches converge.
.addFunction('messageAPI', messageApiStage, 'message-api', 'Assemble system + messages into the LLM request')
.addFunction('CallLLM', callLLM, 'call-llm', 'Send the assembled request to the LLM')
// Declared milestone (9.90.0): the LLM turn (the slot branches above
// declare theirs in their mount options).
.tag(...milestoneTagsFor(STAGE_IDS.CALL_LLM));
return builder.build();
}
//# sourceMappingURL=buildMessageApiChart.js.map