agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
613 lines • 33.7 kB
JavaScript
"use strict";
/**
* conventions — subflow + stage ID constants (builder↔recorder protocol).
*
* Pattern: Single Source of Truth constants (Ward Cunningham's SSOT).
* Role: Contract between `core/` builders and `recorders/core/` observers.
* Builders mount subflows with these IDs; recorders pattern-match
* on the IDs to emit grouped domain events.
* Emits: N/A (constants only).
*
* Rename any ID here → both builders and recorders stay in sync.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.RECEIPT_KEY = exports.isInjectionKey = exports.injectionKeyForSlot = exports.INJECTION_KEYS = exports.milestoneFromTags = exports.milestoneTagsFor = exports.milestoneTags = exports.milestoneTag = exports.MILESTONE_LABEL_TAG_PREFIX = exports.MILESTONE_TAG_PREFIX = exports.milestoneFor = exports.MILESTONE_KINDS = exports.stageRole = exports.isKnownStage = exports.isKnownSubflow = exports.slotFromRuntimeStageId = exports.slotFromSubflowId = exports.isSlotSubflow = exports.STAGE_IDS = exports.assertUnreservedSubflowSegment = exports.isReservedSubflowSegment = exports.RESERVED_SUBFLOW_PREFIX = exports.SUBFLOW_IDS = void 0;
const foottrace_1 = require("foottrace");
/** Subflow IDs — mounted by builders, observed by recorders. */
exports.SUBFLOW_IDS = {
/** Injection Engine subflow. Evaluates every Injection's trigger
* and writes activeInjections[] for the slot subflows to consume. */
INJECTION_ENGINE: 'sf-injection-engine',
/** Inner subflow inside LLMCall that wraps the invocation
* (seed + slots + call-llm + optional thinking + extract-final).
* Mounted by LLMCall's outer `client` chart. */
LLM_CALL: 'sf-llm-call',
/** System-prompt slot subflow. Observed by ContextRecorder. */
SYSTEM_PROMPT: 'sf-system-prompt',
/** Messages slot subflow. */
MESSAGES: 'sf-messages',
/** Tools slot subflow. */
TOOLS: 'sf-tools',
/** ReAct router subflow (inside Agent). */
ROUTE: 'sf-route',
/** Tool-call execution subflow (inside Agent loop). */
TOOL_CALLS: 'sf-tool-calls',
/** Merge step inside Parallel. */
MERGE: 'sf-merge',
/** Final-answer composition inside Agent. Mounted via
* `addSubFlowChartBranch('final', ...)` so the subflow id is the
* Route decider's branch key — `'final'`, no `sf-` prefix. The
* decider returns `'final'` as a routing value AND the same string
* becomes the subflow's id. */
FINAL: 'final',
/** Cache subflow (v2.14). Wraps the whole per-turn cache machinery —
* decide markers → CacheGate decider → apply/skip — as ONE collapsible
* boundary in the chart. Provider-independent decision layer; the
* attached provider's CacheStrategy turns markers into wire format.
* UpdateSkillHistory stays OUTSIDE (in the main loop) so the rolling
* skillHistory window persists across iterations without round-tripping
* through this subflow. */
CACHE: 'sf-cache',
/** Cache decision subflow (v2.6). Walks activeInjections, emits
* agnostic CacheMarker[]. Provider-independent. Standalone building
* block; the agent now uses the `decideCacheMarkers` stage inside
* `sf-cache` instead of mounting this directly. */
CACHE_DECISION: 'sf-cache-decision',
/** Thinking-normalization mount (v2.14). Wraps the consumer's
* ThinkingHandler.normalize() in a real footprintjs subflow so it
* has its own runtimeStageId for tracing. The result lands on the
* parent LLMCall's `thinkingBlocks` payload, so this subflow is
* pure plumbing from the agent step's POV — never a user-facing
* step in the StepGraph. */
THINKING: 'sf-thinking',
/** The inputs layer (honesty layer 2) — mounted between the LLM call and
* Route ONLY when armed (a registered tool declares `askOrAssume`, or the
* agent was built with `.inputsLayer()`). Runs once per batch: Declare →
* Verify → Record → Resolve, filing one `argument` row per ruled argument
* per call and handing ToolCalls the values the library fills. Absent on
* every other agent — the chart is byte-identical. */
INPUTS: 'sf-inputs',
/** The results layer (honesty layer 3, step 7b) — mounted at the LOOP HEAD
* (before `Compact`, which it precedes as the loop target) ONLY when armed
* (a registered tool declares a `ToolPeriod`, or the agent was built with
* `.resultsLayer()`). Reads the batch ToolCalls just ran — Declare → Verify
* → Record → Resolve — and files one `period` row per call that declared a
* period or whose tool declares a `ToolPeriod`. Absent on every other agent
* — the chart is byte-identical. */
RESULTS: 'sf-results',
};
// ─── The reserved subflow namespace ──────────────────────────────────
/**
* The subflow-id prefix the framework reserves for its OWN composition
* segments. Every id in {@link SUBFLOW_IDS} carries it except
* {@link SUBFLOW_IDS.FINAL} (a decider BRANCH KEY that doubles as a mount id,
* not a name we chose), and the set is deliberately open-ended — builders
* generate more of them per feature and per release (`sf-router-llm`,
* `sf-memory-read-<id>`, `sf-memory-write-<id>`, …).
*
* Reserved rather than merely conventional because every reader downstream
* tells LIBRARY PLUMBING from CONSUMER STRUCTURE by this prefix and nothing
* else:
*
* • commentary skips `sf-*` segments while walking `meta.subflowPath` back
* to a user-facing agent name (`commentary/commentaryTemplates.ts`);
* • `BoundaryRecorder` hides them from the StepGraph so they never show up
* as steps a person has to scrub past;
* • the OTel bridge drops a slot-fork selection whose members are all
* `sf-*` (`adapters/observability/otel.ts`);
* • {@link stageRole} and the pattern/fingerprint readers classify by the
* same convention.
*
* So a consumer branch named `sf-billing` does NOT fail like a name clash —
* it is silently read as framework plumbing and vanishes from the very views
* it was built to appear in. That is two different facts (framework segment /
* consumer segment) sharing one namespace with no law, which is why the doors
* that accept a consumer-chosen segment name refuse this prefix outright.
*/
exports.RESERVED_SUBFLOW_PREFIX = 'sf-';
/** True when a subflow-path segment falls inside the reserved namespace. */
function isReservedSubflowSegment(segment) {
return segment.startsWith(exports.RESERVED_SUBFLOW_PREFIX);
}
exports.isReservedSubflowSegment = isReservedSubflowSegment;
/**
* Refuse a consumer-chosen name that would become a reserved subflow-path
* segment — at DECLARATION time, where the name was typed, rather than at run
* time where the damage is invisible.
*
* Refuse-by-domain: a name that cannot be declared cannot collide. There is no
* escape hatch and no rename-behind-the-scenes, because a silently rewritten
* id would break the one thing the consumer wanted (finding their own segment
* in the trace) in a second, quieter way.
*
* @param door the API that accepted the name, e.g. `Parallel.branch()`
* @param noun what the name IS at that door, e.g. `branch id`
* @param id the consumer-supplied name
*/
function assertUnreservedSubflowSegment(door, noun, id) {
if (!isReservedSubflowSegment(id))
return;
throw new Error(`${door}: ${noun} '${id}' starts with '${exports.RESERVED_SUBFLOW_PREFIX}', which is reserved — ` +
`agentfootprint names its own subflows '${exports.RESERVED_SUBFLOW_PREFIX}…' ` +
`(sf-llm-call, sf-tools, sf-cache, and more each release), and every reader downstream ` +
`(step graphs, commentary, trace fingerprints) tells library plumbing from your structure ` +
`by that prefix alone. Named this way, '${id}' would be filtered out as plumbing instead ` +
`of colliding loudly. Pick a name that does not start with '${exports.RESERVED_SUBFLOW_PREFIX}'.`);
}
exports.assertUnreservedSubflowSegment = assertUnreservedSubflowSegment;
/** Stage IDs — plain function stages that builders mount. */
exports.STAGE_IDS = {
SEED: 'seed',
/** Relevance entry router (`entryByRelevance`). A once-per-turn function stage
* mounted between Initialize and InjectionEngine (off the ReAct loop) that
* picks the starting skill by embedding similarity → sets `currentSkillId`. */
PICK_ENTRY: 'pick-entry',
/** Context-assembly selector stage. Runs AFTER InjectionEngine and
* fans the 3 slot subflows (system-prompt / messages / tools) out in
* PARALLEL (selector picks all 3 every iteration; failFast so a
* required slot's throw aborts the turn). They converge before
* CacheDecision. Shared by buildAgentChart + buildDynamicAgentChart;
* the flat viz proof chart uses the same id as its root selector. */
CONTEXT: 'context',
/** Outer "client" stage in LLMCall's wrapped chart. Receives args on
* the first visit, $break()s on the second (post-loop) visit with
* the LLM answer as TraversalResult. This is the lens-friendly
* affordance — the User pill maps to this stage. */
CLIENT: 'client',
CALL_LLM: 'call-llm',
/** Final-response extraction stage that runs after CallLLM (and
* optional sf-thinking). For LLMCall this is mostly symmetric with
* Agent's `sf-final` branch — gives lens a "Final" node and a
* clear commit boundary marking "we have the answer." */
EXTRACT_FINAL: 'extract-final',
FINAL: 'final',
/** The Route decider's third branch (7.26), mounted ONLY on an agent built
* with `.outputSchema(parser, { retries })`. It writes the corrective turn
* and loops back — a re-ask is an ordinary iteration, not a mode. */
OUTPUT_RETRY: 'output-retry',
/** The Route decider's unfinished-steps branch (9.18.0), mounted ONLY on an
* agent with ≥1 stepped skill. When a would-be-final answer leaves declared
* steps unrun, ONE teaching nudge goes back and the loop turns once more —
* the SchemaRetry mechanism verbatim (an ordinary iteration, not a mode).
* At most once per turn; never a forced continue. */
STEP_NUDGE: 'step-nudge',
/** The Route decider's evidence branch (9.35.0), mounted ONLY on an agent
* built with `.namesAndNumbersFromEvidence({ posture: 'guard' | 'rails' })`.
* When a would-be-final answer states names or numbers that appear in no
* tool result, they are named back to the model and the loop turns once
* more — the SchemaRetry mechanism verbatim (an ordinary iteration, not a
* mode). At most once per turn. */
EVIDENCE_RECHECK: 'evidence-recheck',
NEGATIVE_RECHECK: 'negative-recheck',
/** The Route decider's out-of-budget branch (9.56.0), mounted on any agent
* that can call tools unless `wrapUpAtMaxIterations: false` turned it off.
* When `maxIterations` runs out while the model is still asking for tools,
* it appends one instruction and the loop turns ONCE more with the tools
* withheld — the SchemaRetry mechanism verbatim (an ordinary iteration, not
* a mode). At most once per turn, and that last call cannot loop: with no
* tools on the wire there is nothing for the model to ask for. */
WRAP_UP: 'wrap-up',
/** The final branch's first stage: the turn's answer is captured — and, with `.answerValidation()`, checked — here (9.98.1: a milestone, so a scrub can stop on the answer). */
PREPARE_FINAL: 'prepare-final',
FORMAT_MERGE: 'format-merge',
MERGE_LLM: 'merge-llm',
EXTRACT_MERGE: 'extract-merge',
/** Window-strategy stage (7.16 as compaction; any strategy since 7.17).
* Mounted ONLY when `.window()` / `.compaction()` is configured, and when
* it is, it BECOMES the ReAct loop target — so it runs once per iteration
* boundary and everything downstream in the turn (injection triggers, all
* 3 slots, the wire) sees one window. Without a strategy the stage does
* not exist and the loop target is unchanged. The id stays `compact` for
* every strategy: it is the protocol name every lens and matcher already
* binds to, and which strategy ran is on the record. */
COMPACT: 'compact',
/** Messages-slot delivery stage (7.21). Mounted ONLY when the agent has
* something that could target the messages slot (a registered injection
* that declares `inject.messages`, or any `.memory()` whose recall might).
* Runs right after the InjectionEngine and before the slots, so the
* delivered message is part of the window every downstream reader sees.
* Absent otherwise — an agent with nothing to deliver runs the chart it
* always ran. */
DELIVER: 'deliver',
/** Updates the rolling skill-history window before CacheGate
* evaluates skill-churn (v2.6). */
UPDATE_SKILL_HISTORY: 'update-skill-history',
/** CacheGate decider stage — routes to apply-markers / no-markers
* based on kill switch / hit rate / skill churn (v2.6). */
CACHE_GATE: 'cache-gate',
/** CacheGate branch (routing key) when markers SHOULD be applied
* this iteration. Pass-through stage; markers stay in scope. (v2.6) */
APPLY_MARKERS: 'apply-markers',
/** CacheGate branch (routing key) when markers should be SKIPPED
* this iteration. Stage clears scope.cacheMarkers. (v2.6) */
SKIP_CACHING: 'no-markers',
/** BuildLLMRequest stage — calls strategy.prepareRequest to apply
* markers to the wire request (v2.6). */
BUILD_LLM_REQUEST: 'build-llm-request',
/** The inputs layer's four stages, inside `sf-inputs` (honesty layer 2):
* which calls carry rules and which ruled values are missing … */
DECLARE_ARGUMENTS: 'declare-arguments',
/** … where each ruled value came from … */
VERIFY_ARGUMENTS: 'verify-arguments',
/** … one `argument` row per ruled argument, and its event … */
RECORD_ARGUMENTS: 'record-arguments',
/** … and the fills and refusals ToolCalls applies. */
RESOLVE_ARGUMENTS: 'resolve-arguments',
/** The results layer's four stages, inside `sf-results` (honesty layer 3):
* which calls of the batch just run declared a period, or come from a tool
* that declares a `ToolPeriod` … */
DECLARE_RESULTS: 'declare-results',
/** … the verdict on each one's period … */
VERIFY_RESULTS: 'verify-results',
/** … one `period` row per call, and its event … */
RECORD_RESULTS: 'record-results',
/** … and what the layer does about them: flag (the rows are the flags). */
RESOLVE_RESULTS: 'resolve-results',
/** The answer layer (honesty layer 4) — mounted as the FIRST stage of the
* final branch ONLY when armed (`.answerLayer()`): the answer's standing,
* folded from the committed record, filed as data for PrepareFinal and
* announced once (`agentfootprint.answer.assessed`). Absent on every other
* agent — the final branch starts at PrepareFinal, byte-identical. */
ASSESS_ANSWER: 'assess-answer',
};
// ─── Type guards ─────────────────────────────────────────────────────
/** True when a subflow id corresponds to one of the 3 context slots. */
function isSlotSubflow(id) {
return (id === exports.SUBFLOW_IDS.SYSTEM_PROMPT || id === exports.SUBFLOW_IDS.MESSAGES || id === exports.SUBFLOW_IDS.TOOLS);
}
exports.isSlotSubflow = isSlotSubflow;
/** Map a slot subflow id to its ContextSlot type. Undefined for non-slot ids. */
function slotFromSubflowId(id) {
// Footprintjs prefixes nested subflow IDs with the parent's path
// (e.g., 'llm-call-internals/sf-system-prompt' when a slot subflow
// is mounted inside a wrapper subflow). Match the LAST segment so
// the convention works at any nesting depth.
const { localStageId } = (0, foottrace_1.splitStageId)(id);
switch (localStageId) {
case exports.SUBFLOW_IDS.SYSTEM_PROMPT:
return 'system-prompt';
case exports.SUBFLOW_IDS.MESSAGES:
return 'messages';
case exports.SUBFLOW_IDS.TOOLS:
return 'tools';
default:
return undefined;
}
}
exports.slotFromSubflowId = slotFromSubflowId;
/**
* Resolve the context slot a scope write belongs to FROM THE WRITE'S OWN
* `runtimeStageId` — not from a "currently-open slot" stack.
*
* Why: once the 3 slot subflows run in PARALLEL (selector fan-out), their
* entry/write/exit events INTERLEAVE — a stack top is unreliable, so a write
* inside `sf-messages` could be attributed to (or dropped against)
* `sf-tools`. The write's `runtimeStageId` (`[subflowPath/]stageId#index`)
* always encodes which slot subflow enclosed it; we scan the path segments
* innermost-first for a slot id. Matches the sequential result exactly
* (the write is still inside its own slot), so it is behavior-preserving.
*/
function slotFromRuntimeStageId(runtimeStageId) {
// Strip the `#index` suffix, then walk `[subflowPath/]stageId` segments.
const path = runtimeStageId.split('#', 1)[0];
const segments = path.split('/');
for (let i = segments.length - 1; i >= 0; i--) {
const slot = slotFromSubflowId(segments[i]);
if (slot)
return slot;
}
return undefined;
}
exports.slotFromRuntimeStageId = slotFromRuntimeStageId;
/** True when an id is any of the library's known subflow IDs. */
function isKnownSubflow(id) {
return Object.values(exports.SUBFLOW_IDS).includes(id);
}
exports.isKnownSubflow = isKnownSubflow;
/** True when an id is any of the library's known stage IDs. */
function isKnownStage(id) {
return Object.values(exports.STAGE_IDS).includes(id);
}
exports.isKnownStage = isKnownStage;
/** Mechanism stages — present so the run works, not what the user reads. */
const PLUMBING_LOCAL_IDS = new Set([
exports.SUBFLOW_IDS.INJECTION_ENGINE,
exports.SUBFLOW_IDS.LLM_CALL, // wrapper; the hero is the `call-llm` stage INSIDE it
exports.SUBFLOW_IDS.ROUTE,
exports.SUBFLOW_IDS.MERGE,
exports.SUBFLOW_IDS.CACHE,
exports.SUBFLOW_IDS.CACHE_DECISION,
exports.SUBFLOW_IDS.THINKING,
exports.STAGE_IDS.CONTEXT, // the selector fan-out point; its 3 slot children are the heroes
exports.STAGE_IDS.CLIENT,
exports.STAGE_IDS.EXTRACT_FINAL,
exports.STAGE_IDS.FORMAT_MERGE,
exports.STAGE_IDS.EXTRACT_MERGE,
exports.STAGE_IDS.UPDATE_SKILL_HISTORY,
exports.STAGE_IDS.CACHE_GATE,
exports.STAGE_IDS.APPLY_MARKERS,
exports.STAGE_IDS.SKIP_CACHING,
exports.STAGE_IDS.BUILD_LLM_REQUEST,
// The inputs layer (honesty layer 2): its verdicts ride the ledger rows and
// `agentfootprint.findings.argument`; the mount and its stages are mechanism.
exports.SUBFLOW_IDS.INPUTS,
exports.STAGE_IDS.DECLARE_ARGUMENTS,
exports.STAGE_IDS.VERIFY_ARGUMENTS,
exports.STAGE_IDS.RECORD_ARGUMENTS,
exports.STAGE_IDS.RESOLVE_ARGUMENTS,
// The results layer (honesty layer 3): its verdicts ride the ledger's
// `period` rows and `agentfootprint.findings.period`; the mount and its
// stages are mechanism.
exports.SUBFLOW_IDS.RESULTS,
exports.STAGE_IDS.DECLARE_RESULTS,
exports.STAGE_IDS.VERIFY_RESULTS,
exports.STAGE_IDS.RECORD_RESULTS,
exports.STAGE_IDS.RESOLVE_RESULTS,
// The answer layer (honesty layer 4): its verdict rides `turn_end` and
// `agentfootprint.answer.assessed`; the stage that folds it is mechanism —
// the answer itself is PrepareFinal's milestone, one stage later.
exports.STAGE_IDS.ASSESS_ANSWER,
]);
/** Neutral chart boundaries — entry/exit, rendered normally (not muted). */
const BOUNDARY_LOCAL_IDS = new Set([
exports.STAGE_IDS.SEED, // 'Initialize' — chart root / Agent boundary
exports.STAGE_IDS.FINAL, // 'final' (=== SUBFLOW_IDS.FINAL)
// The schema re-ask is a boundary, not plumbing: it is the run deciding the
// answer was not good enough, which is exactly the kind of thing a reader
// came to see. Muting it would hide the reason a run took three turns.
exports.STAGE_IDS.OUTPUT_RETRY,
// Same reasoning for the unfinished-steps nudge (9.18.0): the run telling
// the model its declared procedure is not done is a stop worth reading.
exports.STAGE_IDS.STEP_NUDGE,
// And for the evidence recheck (9.35.0): the run telling the model it made
// a value up is the single most interesting stop a reader can find.
exports.STAGE_IDS.EVIDENCE_RECHECK,
exports.STAGE_IDS.NEGATIVE_RECHECK,
// And for the wrap-up (9.56.0): the run telling the model its action budget
// is gone is exactly why the answer that follows reads like a summary
// instead of the next step. Muting it would hide the reason.
exports.STAGE_IDS.WRAP_UP,
]);
/**
* Classify a stage id into its {@link StageRole}. Accepts a path-qualified id
* (`sf-llm-call/call-llm`) — only the LOCAL segment matters, so it works at
* any nesting depth. Built entirely from the id constants above, so adding a
* stage to the chart only requires listing it here.
*/
function stageRole(id) {
const { localStageId } = (0, foottrace_1.splitStageId)(id);
if (isSlotSubflow(localStageId))
return 'hero-slot';
if (localStageId === exports.STAGE_IDS.CALL_LLM || localStageId === exports.STAGE_IDS.MERGE_LLM)
return 'hero-llm';
// Tool execution mounts under the bare branch key 'tool-calls' in shipped
// charts; SUBFLOW_IDS.TOOL_CALLS is the reserved prefixed form.
if (localStageId === 'tool-calls' || localStageId === exports.SUBFLOW_IDS.TOOL_CALLS)
return 'hero-action';
if (BOUNDARY_LOCAL_IDS.has(localStageId))
return 'boundary';
if (PLUMBING_LOCAL_IDS.has(localStageId))
return 'plumbing';
return 'boundary'; // unknown → neutral (never silently muted)
}
exports.stageRole = stageRole;
/** Every {@link MilestoneKind}, for validating a kind read off a recording. */
exports.MILESTONE_KINDS = Object.freeze([
'iteration',
'slot',
'llm-turn',
'tool-call',
'decision',
]);
const milestone = (kind, label) => Object.freeze({ kind, label });
/**
* THE milestone table — local stage id → {@link Milestone}. One owner, two
* readers: {@link milestoneFor} classifies an id from a recording made BEFORE
* the chart declared tags (9.90.0), and {@link milestoneTagsFor} is what every
* chart-building site declares from, so the fact stamped on the commit bundle
* and the fallback derived from the id can never disagree.
*/
const MILESTONES = new Map([
// Loop entry — one per ReAct iteration. INJECTION_ENGINE is the flat loop
// target; LLM_CALL is the subflow-shape loop target.
[exports.SUBFLOW_IDS.INJECTION_ENGINE, milestone('iteration', 'Iteration')],
[exports.SUBFLOW_IDS.LLM_CALL, milestone('iteration', 'Iteration')],
// Context slots — one stop per slot that was engineered THIS iteration. In
// dynamic mode all three appear every turn; in classic mode only the slot
// that actually re-ran (Messages) appears after turn 1 — so scrubbing shows
// exactly "which slot got updated."
[exports.SUBFLOW_IDS.SYSTEM_PROMPT, milestone('slot', 'System prompt')],
[exports.SUBFLOW_IDS.MESSAGES, milestone('slot', 'Messages')],
[exports.SUBFLOW_IDS.TOOLS, milestone('slot', 'Tools')],
[exports.STAGE_IDS.CALL_LLM, milestone('llm-turn', 'LLM turn')],
[exports.STAGE_IDS.MERGE_LLM, milestone('llm-turn', 'LLM turn')],
// Tool execution mounts under the bare branch key 'tool-calls'.
['tool-calls', milestone('tool-call', 'Tool call')],
[exports.SUBFLOW_IDS.TOOL_CALLS, milestone('tool-call', 'Tool call')],
[exports.SUBFLOW_IDS.ROUTE, milestone('decision', 'Route')],
// The inputs layer (honesty layer 2), mounted only when armed: where the
// library filled a value the call left out — a stop a reader scrubs to when
// asking whose value a call ran with.
[exports.SUBFLOW_IDS.INPUTS, milestone('decision', 'Inputs')],
// The results layer (honesty layer 3), mounted only when armed at the loop
// head: where the record says what the batch just run covered in time — a
// stop a reader scrubs to when asking whether the data held the period.
[exports.SUBFLOW_IDS.RESULTS, milestone('decision', 'Results')],
// The answer failed its schema and the loop asked again — a stop worth
// scrubbing to, because everything after it is a second attempt.
[exports.STAGE_IDS.OUTPUT_RETRY, milestone('decision', 'Schema retry')],
// The answer left declared steps unrun and the one teaching nudge went
// back (9.18.0) — everything after it is the model finishing (or
// explaining) its procedure.
[exports.STAGE_IDS.STEP_NUDGE, milestone('decision', 'Step nudge')],
// The answer stated values no tool result carried and the one revision
// went back (9.35.0) — everything after it is the model's second try.
[exports.STAGE_IDS.EVIDENCE_RECHECK, milestone('decision', 'Evidence recheck')],
[exports.STAGE_IDS.NEGATIVE_RECHECK, milestone('decision', 'Negative citation correction')],
// The action budget ran out and the turn was wrapped up (9.56.0) —
// everything after it is the model summarizing with no tools to call.
[exports.STAGE_IDS.WRAP_UP, milestone('decision', 'Wrap up')],
// The answer itself (9.98.1). Every chart shape ends here; with
// `.answerValidation()` it is also where the answer is checked and
// delivered or withheld — a decision a reader scrubs to.
[exports.STAGE_IDS.PREPARE_FINAL, milestone('decision', 'Answer')],
// …and its MOUNT on the parent chart, the way `tool-calls` and
// SUBFLOW_IDS.TOOL_CALLS are twin rows: the parent's own axis stops on the
// answer without drilling, and the drilled log stops on the stage.
[exports.SUBFLOW_IDS.FINAL, milestone('decision', 'Answer')],
]);
/**
* Classify a stage id into a {@link Milestone}, or `null` when the stage is NOT
* a milestone boundary (its commits fold into the surrounding milestone's
* collection). This is the DOMAIN's declaration of which steps are scrub-worthy;
* the Lens consumes it to build the time-travel slider (see
* agentfootprint-lens `cursorPositionsAtDrill`).
*
* **Since 9.90.0 this is the FALLBACK, not the fact.** The charts declare the
* same milestones as tags at build time ({@link milestoneTagsFor}), footprintjs
* 9.21 stamps them on the stage's first commit bundle (`CommitBundle.tags`), and
* `milestoneStops` reads the bundle first — it derives from the id only for a
* bundle that carries no tags (a recording made before 9.90.0). Every milestone
* stage is declared, slot branch mounts included (footprintjs 9.21.1). Same
* table either way, so the two readings agree.
*
* Mirrors {@link stageRole}: accepts a runtimeStageId (`call-llm#17`), a
* path-qualified id (`sf-llm-call/call-llm`), or a bare local id — only the
* LOCAL stage segment matters, so it works at any nesting depth and for both
* commit ids and subflow-group ids.
*/
function milestoneFor(id) {
// Strip the `#executionIndex` suffix (runtimeStageId form) before decomposing
// the path prefix — splitStageId expects the segment before `#`.
const beforeHash = id.includes('#') ? id.slice(0, id.indexOf('#')) : id;
const { localStageId } = (0, foottrace_1.splitStageId)(beforeHash);
return MILESTONES.get(localStageId) ?? null;
}
exports.milestoneFor = milestoneFor;
// ─── Declared milestones — the tag vocabulary (9.90.0) ──────────────────
//
// footprintjs 9.21 lets a chart put NAMES on a stage at build time and stamps
// them on the stage's first commit bundle. footprintjs owns no vocabulary; this
// is agentfootprint's. Two names per milestone, produced by ONE function and
// read back by ONE function — no second string literal anywhere:
//
// 'milestone:<kind>' what a reader FILTERS on (tagStops(['milestone:llm-turn']))
// 'milestone-label:<label>' the human word the table gives that stage
//
// Law 3 of docs/design/2026-09-declared-tags.md (footprintjs): the tag is the
// fact; the derivation from the id is the fallback.
/** Prefix of the tag that names a milestone's KIND: `milestone:llm-turn`. */
exports.MILESTONE_TAG_PREFIX = 'milestone:';
/** Prefix of the tag that names a milestone's LABEL: `milestone-label:LLM turn`. */
exports.MILESTONE_LABEL_TAG_PREFIX = 'milestone-label:';
/** The kind tag for a {@link MilestoneKind} — `'milestone:' + kind`. */
function milestoneTag(kind) {
return exports.MILESTONE_TAG_PREFIX + kind;
}
exports.milestoneTag = milestoneTag;
/**
* The tags that DECLARE a {@link Milestone} on a stage: its kind tag and its
* label tag, in that order. What a declaration site spreads into `.tag(...)`
* or `{ tags }`; what {@link milestoneFromTags} reads back.
*/
function milestoneTags(milestone) {
return [milestoneTag(milestone.kind), exports.MILESTONE_LABEL_TAG_PREFIX + milestone.label];
}
exports.milestoneTags = milestoneTags;
/**
* The tags to declare on the stage with this LOCAL id, from the same table
* {@link milestoneFor} reads — so a chart-building site names the id it is
* mounting and never spells a kind or a label. Throws at build time for an id
* the table does not classify: a declaration site that is not a milestone is
* a wiring mistake, and a silent no-op would be a forgotten tag by another name.
*
* @example
* ```ts
* builder
* .addFunction('CallLLM', callLLM, STAGE_IDS.CALL_LLM, 'LLM invocation')
* .tag(...milestoneTagsFor(STAGE_IDS.CALL_LLM)); // 'milestone:llm-turn', 'milestone-label:LLM turn'
* ```
*/
function milestoneTagsFor(localStageId) {
const found = MILESTONES.get(localStageId);
if (!found) {
throw new Error(`milestoneTagsFor('${localStageId}'): not a milestone stage — add it to the MILESTONES table in ` +
'conventions.ts (the one owner) before declaring it on a chart.');
}
return milestoneTags(found);
}
exports.milestoneTagsFor = milestoneTagsFor;
/**
* Read a {@link Milestone} back off a commit bundle's declared `tags`, or
* `null` when they carry no milestone kind tag. The kind must be one of
* {@link MILESTONE_KINDS}; the label is the `milestone-label:` tag when one was
* declared, else `labelWhenUndeclared` (a reader passes the stop's own label —
* the chart's word for the stage), else the kind itself. A stored row is
* `unknown[]`-shaped until narrowed: non-strings are ignored, never a milestone.
*
* @example
* ```ts
* milestoneFromTags(['milestone:llm-turn', 'milestone-label:LLM turn']);
* // { kind: 'llm-turn', label: 'LLM turn' }
* milestoneFromTags(['audit']); // null — tagged, but not a milestone
* milestoneFromTags(undefined); // null — an untagged bundle
* ```
*/
function milestoneFromTags(tags, labelWhenUndeclared) {
if (!Array.isArray(tags))
return null;
const names = tags.filter((t) => typeof t === 'string');
// The first VALID kind wins; an unknown `milestone:` name (a foreign chart's
// vocabulary) is skipped rather than shadowing a real kind beside it.
const kinds = names
.filter((t) => t.startsWith(exports.MILESTONE_TAG_PREFIX))
.map((t) => t.slice(exports.MILESTONE_TAG_PREFIX.length))
.filter((k) => exports.MILESTONE_KINDS.includes(k));
const kind = kinds[0];
if (kind === undefined)
return null;
const labelTag = names.find((t) => t.startsWith(exports.MILESTONE_LABEL_TAG_PREFIX));
const declared = labelTag?.slice(exports.MILESTONE_LABEL_TAG_PREFIX.length);
const label = declared && declared.length > 0 ? declared : labelWhenUndeclared ?? kind;
return { kind: kind, label };
}
exports.milestoneFromTags = milestoneFromTags;
/**
* Scope-key convention for context injections.
*
* Each slot subflow writes its injections to a well-known scope key.
* ContextRecorder observes writes to these keys to emit context.injected
* events. Builders that mount slot subflows MUST write injections to the
* corresponding key; this is the data-level contract between builder and
* recorder.
*/
exports.INJECTION_KEYS = {
SYSTEM_PROMPT: 'systemPromptInjections',
MESSAGES: 'messagesInjections',
TOOLS: 'toolsInjections',
};
/** Map a slot to its injection scope key. */
function injectionKeyForSlot(slot) {
switch (slot) {
case 'system-prompt':
return exports.INJECTION_KEYS.SYSTEM_PROMPT;
case 'messages':
return exports.INJECTION_KEYS.MESSAGES;
case 'tools':
return exports.INJECTION_KEYS.TOOLS;
}
}
exports.injectionKeyForSlot = injectionKeyForSlot;
/** True when a scope key is any of the known injection keys. */
function isInjectionKey(key) {
return Object.values(exports.INJECTION_KEYS).includes(key);
}
exports.isInjectionKey = isInjectionKey;
/**
* The scope key the receipt is committed under. Written by `callLLM` and the
* message-API charts, read back by `time-travel/servedView.ts` and the replay
* hooks — a state-key name both sides agree on, so it lives here rather than
* beside the receipt's builder (`core/agent/receipt/receipt.ts`).
*/
exports.RECEIPT_KEY = 'receipt';
//# sourceMappingURL=conventions.js.map