UNPKG

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
"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