agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
178 lines • 9.19 kB
TypeScript
/**
* 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;
/**
* Which skills the caller's ROLE may not see (`skill_read`), asked once per
* iteration — present only when the agent's permission checker governs
* `skill_read`. The Gather stage resolves it FIRST, before anything on
* this pass decides, and publishes it as `hiddenSkillIds`: the Evaluate
* stage hands it to the cursor resolver and the evaluator
* (`ctx.hiddenSkillIds`, read through `isHiddenSkill`), and the mount
* carries it to the parent for every model-facing composer after it.
* Absent → nothing is resolved and the evaluation is byte-identical.
*/
readonly hiddenSkillIds?: (scope: object) => Promise<readonly string[]> | readonly string[];
}
/** 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;
//# sourceMappingURL=buildInjectionEngineSubflow.d.ts.map