/** * Injection Engine — subflow builder. * * Pattern: Subflow Builder (returns a FlowChart mountable via * `addSubFlowChartNext`). Each subflow stands alone. * Role: Layer-3 context-engineering primitive. Sits BEFORE the * three slot subflows in any primitive (Agent, LLMCall) that * uses Injections. Evaluates every Injection's trigger once * per iteration. * * Four small, readable stages (was one monolithic `evaluate`): * 1. Gather — snapshot the turn's inputs (iteration, history size, * last tool, LLM-activated count). Observability only. * 2. Evaluate — run every trigger → `activeInjections` (the REAL output * the slot subflows read). Logic is UNCHANGED from the old * single stage: `activeInjections` is byte-identical, so the * slots are 100% unaffected. (Safety invariant.) * 3. Route — partition `activeInjections` into per-slot buckets * (`activeByslot`), mirroring how the slots filter. Pure * annotation — the slots still do their own filtering. * 4. Delta — diff this turn's buckets vs last turn's (`slotDelta`): * per slot, what activated / deactivated / stayed. The * explainability win ("tools +skill X, system-prompt * unchanged"). Reads last turn via `priorActiveByslot` * carried by the mount's input/output mappers. * * Nothing here SKIPS a slot — Route/Delta only annotate. See * docs (injection-algorithm blog) + memory agentfootprint_slot_plan_review * for why per-turn skip was deferred. * * Emits: `agentfootprint.context.evaluated` at the Evaluate stage, with * aggregate metadata. The per-slot route/delta ride visible stage * STATE (`activeByslot` / `slotDelta`) so the lens reads them from * the commit log without a new event-type contract. * * Mount with: * builder.addSubFlowChartNext( * SUBFLOW_IDS.INJECTION_ENGINE, * buildInjectionEngineSubflow({ injections }), * 'Injection Engine', * { * inputMapper: (parent) => ({ * iteration: parent.iteration, * userMessage: parent.userMessage, * history: parent.history, * lastToolResult: parent.lastToolResult, * toolResults: parent.toolResults, // the whole batch, in call order * activatedInjectionIds: parent.activatedInjectionIds ?? [], * priorActiveByslot: parent.activeByslot ?? EMPTY_ACTIVE_BY_SLOT, * }), * outputMapper: (sf) => ({ * activeInjections: sf.activeInjections, * activeByslot: sf.activeByslot, // carried so next turn's Delta can diff * }), * }, * ) */ import type { FlowChart } from 'footprintjs'; import { type ActiveInjection, type Injection, type InjectionContext } from './types.js'; import { type CursorMove } from './skillGraph.js'; import { type StepPlanFor } from './skillSteps.js'; import type { EngagementPlan } from '../../maps/engagement/types.js'; export interface InjectionEngineConfig { /** * The Injection list. Frozen at build time. To change at runtime, * rebuild the agent / chart — the primitive is intentionally * declarative. */ readonly injections: readonly Injection[]; /** * The skill-graph CURSOR resolver (`graph.nextSkill`), present only when the * agent was built with `.skillGraph()`. The Evaluate stage advances the cursor * with the SAME `ctx` the triggers gate on, so trigger ↔ cursor never diverge * (the keystone). Absent → `currentSkillId` is never written (no graph routing). */ readonly nextSkill?: (ctx: InjectionContext) => string | undefined; /** * The same resolver, reporting the clause that WON (`graph.explainNextSkill`, * 8.5.0). Preferred over `nextSkill` when present — the stage takes the cursor * from `.to`, so the graph is still consulted exactly once per iteration, and * stamps the cause on `context.evaluated` as `cursorMove`. * * Optional for forward-compat with graphs built before it existed; without it * the stage falls back to `nextSkill` and emits no `cursorMove` (an observer * then sees exactly what it saw in 8.4.0). */ readonly explainNextSkill?: (ctx: InjectionContext) => CursorMove; /** * The graph's suppression reporter (`graph.supersededEntries`, 8.15.0) — the * conditional entries whose own `when` matched this iteration while the cursor was * elsewhere, so the cursor law kept them off the wire. Stamped on * `context.evaluated` as `supersededIds`. * * Optional for forward-compat with graphs built before it existed; absent → the * field is omitted and an observer sees exactly what it saw in 8.14.0. */ readonly supersededEntries?: (ctx: InjectionContext) => readonly string[]; /** * The gate's admissible set, for the record (9.50.0) — given the cursor a * move landed on, the skill ids the `read_skill` gate will admit this * iteration (declared hops out of that cursor plus the open skills). The * Agent composes it from the SAME two resolvers the `read_skill` offer and * the refusal messages use, so the recorded set can never drift from the * verdicts. Stamped on `context.evaluated.cursorMove` as `reachable`. * * Optional for forward-compat with graphs built before `reachableSkills` * existed; absent → the field is omitted and an observer sees exactly what * it saw in 9.49.0. */ readonly reachableSkills?: (currentSkillId?: string) => readonly string[]; /** * The frozen step plans, keyed by skill id (9.18.0) — present only when * ≥1 registered skill declares `steps`. The Evaluate stage owns the * pointer's TENURE RE-KEY with it, at the same stage the cursor truth * lives: tenant changed → fresh pointer (or cleared, when the new tenant * has no steps); unchanged → pass-through. Absent → no pointer key is * ever written (zero-cost-when-unused). */ readonly stepPlanFor?: StepPlanFor; /** * The mount kernel's plan (9.58.0) — present only when the agent was built * with `.maps()`. The Evaluate stage advances every mounted map's * engagement with the SAME ctx the triggers gate on, and hands the parked * maps' member ids to the evaluator as the one framework-tier suppression * (`ctx.parkedIds`, the `leaseActiveIds` admission's mirror). Absent → * nothing here runs and the evaluation is byte-identical. */ readonly engagement?: EngagementPlan; } /** One routed entry per (active injection × slot it contributes to). */ export interface RoutedInjection { readonly id: string; readonly source: ActiveInjection['flavor']; readonly reason: string; } /** Active injections partitioned by the slot they contribute to. */ export interface ActiveBySlot { readonly systemPrompt: readonly RoutedInjection[]; readonly messages: readonly RoutedInjection[]; readonly tools: readonly RoutedInjection[]; } /** Per-slot change since last turn. */ export interface SlotDeltaEntry { readonly added: readonly string[]; readonly removed: readonly string[]; readonly kept: readonly string[]; } /** Per-slot delta across the whole context. */ export interface SlotDelta { readonly systemPrompt: SlotDeltaEntry; readonly messages: SlotDeltaEntry; readonly tools: SlotDeltaEntry; } /** Empty buckets — turn-1 prior, and the safe default for the mappers. */ export declare const EMPTY_ACTIVE_BY_SLOT: ActiveBySlot; /** * Build the Injection Engine subflow — Gather → Evaluate → Route → Delta. */ export declare function buildInjectionEngineSubflow(config: InjectionEngineConfig): FlowChart; /** Partition active injections by the slot(s) each contributes to. Mirrors * the slot subflows' own filters so this view matches what they compose. * Pure — exported for unit tests + reuse (e.g. the lens). */ export declare function routeActiveInjections(active: readonly ActiveInjection[]): ActiveBySlot; /** Diff two per-slot snapshots into a per-slot delta. Pure — exported for * unit tests + reuse. */ export declare function diffActiveBySlot(prior: ActiveBySlot, current: ActiveBySlot): SlotDelta;