UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

602 lines • 35.4 kB
/** * 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 { flowChart } from 'footprintjs'; import { typedEmit } from '../recorders/core/typedEmit.js'; import { iterationsRemainingOf } from '../lib/iterationBudget.js'; import { evaluateInjections } from './evaluator.js'; import { isHiddenSkill, projectActiveInjection, toolResultsOf, } from './types.js'; import { SKILL_GRAPH_METADATA_KEY, } from './skillGraph.js'; import { menuOutstanding } from './routingPolicy.js'; import { pointerOf, rekeyStepPointer, } from './skillSteps.js'; import { activeLeaseIds, pruneLeases, tenantOf, } from './leases.js'; import { advanceEngagement } from '../maps/engagement/lease.js'; import { parkCard, PARK_CARD_ID } from '../maps/engagement/parkCard.js'; /** Empty buckets — turn-1 prior, and the safe default for the mappers. */ export const EMPTY_ACTIVE_BY_SLOT = { systemPrompt: [], messages: [], tools: [], }; /** * Build the Injection Engine subflow — Gather → Evaluate → Route → Delta. */ export function buildInjectionEngineSubflow(config) { const injections = config.injections; const resolveHidden = config.hiddenSkillIds; const gather = resolveHidden === undefined ? gatherStage : async (scope) => { gatherStage(scope); scope.$setValue('hiddenSkillIds', await hiddenSkillIdsOf(resolveHidden, scope)); }; return flowChart('Gather', gather, 'gather', { description: "Snapshot this turn's injection inputs (iteration, history, last tool, LLM-activated)", }) .addFunction('Evaluate', makeEvaluateStage(injections, config.nextSkill, config.explainNextSkill, config.supersededEntries, config.stepPlanFor, config.reachableSkills, config.engagement, resolveHidden !== undefined), 'evaluate', 'Evaluate every Injection trigger; produce activeInjections + metadata') .addFunction('Route', routeStage, 'route', 'Partition active injections into per-slot buckets (system-prompt / messages / tools)') .addFunction('Delta', deltaStage, 'delta', 'Per-slot delta vs last turn: what activated / deactivated / stayed') .build(); } // ── Stage 1: Gather ────────────────────────────────────────────────────── /** Observability-only: record what this turn is being evaluated against. */ function gatherStage(scope) { const args = scope.$getArgs(); scope.$setValue('injectionContextSummary', { iteration: args.iteration ?? 1, historyLength: args.history?.length ?? 0, lastToolName: args.lastToolResult?.toolName, activatedInjectionCount: args.activatedInjectionIds?.length ?? 0, }); } // FOLD · the one owner of scope.hiddenSkillIds — which skill ids this caller's role may not see, this iteration // consumers read this and never re-derive it: the Evaluate stage below (cursor + activation, via // `types.ts` · isHiddenSkill), and, through the mount's outputMapper, the tools slot's read_skill offer, // toolCalls.ts · dispatchRoster, the read_skill gate, the propose-transition refusal, callLLM's ontology piece. // detached: yes — a plain string[]. /** * Resolve the role-hidden skill ids for this pass. Resolved HERE, at the top * of the engine, because activation is decided on this pass and nothing may * decide before the answer is in. Its errors are NOT swallowed: the agent's * resolver already hides a skill whose check throws, and a resolver that * fails as a whole fails the iteration — nothing is composed or activated * from a policy that did not answer. */ async function hiddenSkillIdsOf(resolve, scope) { const asked = resolve(scope); return [...(asked instanceof Promise ? await asked : asked)]; } // ── Stage 2: Evaluate (logic identical to the old single stage) ────────── function makeEvaluateStage(injections, nextSkill, explainNextSkill, supersededEntries, stepPlanFor, reachableSkills, engagementPlan, /** Gather published `hiddenSkillIds` this pass. Read only then, so an agent * without per-role visibility records exactly the reads it always did. */ readsHidden = false) { return (scope) => { const args = scope.$getArgs(); // The accepted transition proposal (9.19.0), VALID only on the iteration // after its grant — one-shot by data, the same one-shot-ness the pick // gets from being rewritten, with zero clearing writes (an agent whose // tools never propose keeps its exact committed keys). const iteration = args.iteration ?? 1; const proposal = args.pendingToolTransition !== undefined && args.pendingToolTransition.iteration + 1 === iteration ? args.pendingToolTransition : undefined; const hidden = readsHidden ? scope.$getValue('hiddenSkillIds') : undefined; const baseCtx = { iteration, // The action budget, as ONE fact with two spellings (9.57.0). Written // together so the pairing invariant — both or neither — is a property // of this object literal rather than a rule somebody has to remember. ...(args.maxIterations !== undefined && { maxIterations: args.maxIterations, iterationsRemaining: iterationsRemainingOf(args.maxIterations, iteration), }), userMessage: args.userMessage ?? '', history: args.history ?? [], ...(args.lastToolResult && { lastToolResult: args.lastToolResult }), ...(args.toolResults && { toolResults: args.toolResults }), activatedInjectionIds: args.activatedInjectionIds ?? [], ...(args.currentSkillId !== undefined && { currentSkillId: args.currentSkillId }), ...(args.pendingSkillPick !== undefined && { pendingSkillPick: args.pendingSkillPick }), ...(proposal !== undefined && { pendingToolTransition: { targetSkillId: proposal.targetSkillId }, }), ...(args.entryScores !== undefined && { entryScores: args.entryScores }), ...(args.entryScorer !== undefined && { entryScorer: args.entryScorer }), ...(args.turnRoute !== undefined && { turnRoute: args.turnRoute }), // Role visibility for this pass, resolved by Gather — read by the cursor // resolver and the evaluator alike, so neither can activate a hidden skill. ...(hidden !== undefined && hidden.length > 0 && { hiddenSkillIds: hidden }), }; // KEYSTONE cursor advance — derive the next cursor from the SAME ctx the // route triggers gate on (`nextSkill(ctx) === id`), so the active set and the // stored cursor can never disagree. Written to a DISTINCT output key // (`nextSkillCursor`) because `currentSkillId` arrives as a readonly INPUT // here; the mount's outputMapper maps it onto the parent's mutable // `currentSkillId` for the next iteration. Skill-graph agents only. // ONE consultation of the graph per iteration: when the graph can explain // itself (8.5.0) the cursor comes out of `.to`, so asking for the cause costs // nothing extra and cannot answer a different destination than the routing did. const move = explainNextSkill ? explainNextSkill(baseCtx) : undefined; const routes = explainNextSkill !== undefined || nextSkill !== undefined; const cursor = move ? move.to : nextSkill ? nextSkill(baseCtx) : undefined; if (routes) { scope.$setValue('nextSkillCursor', cursor); } // ── The step pointer's TENURE RE-KEY (9.18.0) ───────────────────── // Same discipline as the cursor, one line down from it: `stepPointer` // arrives as a readonly INPUT, so the fresh value goes out under a // DISTINCT key (`nextStepPointer` — the currentSkillId/nextSkillCursor // alias, NOT the turn-constant turnRoute pattern) and the mount mappers // carry it back onto the parent. The tenant is the ADVANCED cursor on // graph agents, else the tail of `activatedInjectionIds` (the shipped // `activeSkillId` notion). Written on EVERY iteration the feature is on // (the `if (routes)` precedent) so a downstream reader never sees a // stale pointer; gated on `stepPlanFor` so agents without steps commit // exactly the keys they always did. // // Computed BEFORE `evaluateInjections`, and the FRESH pointer goes into // the ctx the triggers read — else `defineStepsHint`'s trigger would // judge the stale pointer and the hint would miss the tenure's first // iteration while the banner (tools slot, fresh value) was present. let ctx = baseCtx; if (stepPlanFor) { const activated = args.activatedInjectionIds ?? []; const tenant = routes ? cursor : activated[activated.length - 1]; const fresh = rekeyStepPointer({ prior: args.stepPointer, tenant, stepPlanFor }); scope.$setValue('nextStepPointer', fresh); const freshPointer = pointerOf(fresh); ctx = { ...baseCtx, ...(freshPointer !== undefined && { stepPointer: freshPointer }) }; } // ── Instruction leases (9.19.0) — the `require-instruction` push ─── // Validity is COMPUTED per pass against the ADVANCED tenure (a lease // dies the same pass its tenure ends): `'next-call'` serves exactly the // pass after its grant; `'until-skill-exit'` serves while the granting // tenant still holds. The served ids ride the ctx the evaluator reads, // which admits them into the active set beside their own triggers. // Zero-cost gate: no granted lease anywhere → not one line here runs. if (args.instructionLeases !== undefined) { const leaseTenant = tenantOf(routes ? cursor : undefined, args.activatedInjectionIds); if (args.instructionLeases.length > 0) { const leaseIds = activeLeaseIds(args.instructionLeases, ctx.iteration, leaseTenant); if (leaseIds.length > 0) { ctx = { ...ctx, leaseActiveIds: leaseIds }; } } // The TENURE SWEEP — what makes lease death PERMANENT. `skillId === // tenant` alone cannot tell "held the tenure all along" from // "re-entered the granting skill later" (a cyclic graph makes both // real), so the pass that sees a tenure end removes its dead leases // from the record — computed AFTER `activeLeaseIds` (this pass's // serving is untouched; the swept array feeds only future passes). // Same alias round trip as the cursor: `instructionLeases` is a // readonly boundary INPUT here, so the survivors go out under // `nextInstructionLeases` and the mount mappers carry them back onto // the parent key. Written on EVERY pass the key exists — even as [] — // so no boundary ever re-delivers a stale, pre-sweep array. scope.$setValue('nextInstructionLeases', pruneLeases(args.instructionLeases, ctx.iteration, leaseTenant)); } // A parallel batch whose results matched edges to DIFFERENT targets // (9.16.0). The resolver already decided the hop — first match in call // order won — and reported the suppression on the move; here it goes on // the record as its own typed event, stamped the iteration it happened. // Silent-drop was the pre-9.16.0 behavior this replaces: earlier calls of // a batch simply never routed. POJO copies, because event payloads must // be detached plain data. if (move?.conflict) { typedEmit(scope, 'agentfootprint.skill.route_conflict', { iteration: ctx.iteration, ...(ctx.currentSkillId !== undefined && { fromSkillId: ctx.currentSkillId }), winner: { ...move.conflict.winner }, losers: move.conflict.losers.map((l) => ({ ...l })), }); } // ── Map engagement (9.58.0) — the mount kernel's one pass ────────── // Advanced with the SAME facts the triggers gate on, BEFORE the // evaluator runs, so a re-engagement earned this pass serves this pass // and a park suppresses this pass — the record and the wire can never // disagree. The state rides the boundary as a readonly INPUT // (`mapEngagement`) and leaves under the DISTINCT key // `nextMapEngagement` (the cursor/lease alias discipline). The cursor // itself is untouched by every branch of this block: engagement is the // kernel's axis, position is the map's. let parkCardText; if (engagementPlan !== undefined) { const advance = advanceEngagement(engagementPlan, args.mapEngagement, { iteration: ctx.iteration, ...(cursor !== undefined && { currentNode: cursor }), ...(move?.by !== undefined && { moveBy: move.by }), ...(move?.witness?.text !== undefined && { witness: move.witness.text }), toolResults: toolResultsOf(ctx), ...(ctx.pendingSkillPick !== undefined && { pendingSkillPick: ctx.pendingSkillPick }), // PER-PASS picks (9.59.0), not the turn-cumulative activation list. acceptedSkillPicks: args.acceptedSkillPicks ?? [], // The idle test's SERVED clause (9.59.0) — what the engine actually // put on the wire last pass, carried under the kernel's own key. servedLastPass: args.servedInjectionIds ?? [], }); // Written on EVERY pass the feature is on (the `if (routes)` precedent) // so a boundary never re-delivers stale engagement state. scope.$setValue('nextMapEngagement', advance.next); for (const change of advance.changes) { if (change.kind === 'engaged') { typedEmit(scope, 'agentfootprint.map.engaged', { mapId: change.mapId, iteration: change.iteration, by: change.by, ...(change.witness !== undefined && { witness: change.witness }), ...(change.reengaged === true && { reengaged: true }), // A guess that real evidence confirmed, and the cause it was // FOUNDED on — so a reader of the record sees both halves of the // story instead of a `by` that silently rewrote itself (9.59.0). ...(change.upgraded === true && { upgraded: true }), // The cursor moved to a different member: eligibility was // re-derived from THIS member's evidence, not inherited. ...(change.tenantChanged === true && { tenantChanged: true }), ...(change.at !== undefined && { at: change.at }), ...(change.foundedBy !== undefined && { foundedBy: change.foundedBy }), }); } else { typedEmit(scope, 'agentfootprint.map.parked', { mapId: change.mapId, iteration: change.iteration, by: change.by, idleCalls: change.idleCalls, ...(change.witness !== undefined && { witness: change.witness }), }); } } // ── The MODEL-VISIBLE surface (9.59.0) ────────────────────────── // Everything above lands on the RECORD, which the model never reads. // The card is the one thing it does read: cursor and engagement shown // as SEPARATE fields, the reason, and the way back named as a concrete // call. Built from the state that was just advanced, so the card and // the suppression are two views of one decision. // Filtered by the role: the card never names a member skill this caller may not read. parkCardText = parkCard(engagementPlan, advance.next, (id) => !isHiddenSkill(ctx, id)); // Tools come off the wire on the ENGAGEMENT axis, independently of // `scopeTools` — see `EngagementAdvance.parkedToolNames`. scope.$setValue('parkedToolNames', advance.parkedToolNames); if (advance.parkedInjectionIds.length > 0) { ctx = { ...ctx, parkedIds: advance.parkedInjectionIds }; } } const evaluation = evaluateInjections(injections, ctx); // The suppression the cursor law performs (8.15.0). Asked ONCE, on the same ctx // the triggers gated on, so the reported suppression and the active set are two // views of one evaluation and cannot disagree. const supersededIds = supersededEntries ? [...supersededEntries(ctx)] : []; // The told-the-truth check. `read_skill` answered "Skill 'X' activated for the // next iteration" and the gate accepted the pick — so X must be in THIS pass's // active set. It normally is (the pick moved the cursor). The one case where it // isn't: a declared edge fired the same turn and won the cursor (`D1 > D2` — // the model emitted a domain tool AND read_skill in one message, and the domain // tool's result matched a route). Rather than let a promise quietly go unmet, // say so on the record. Checked against the REAL active set, not against which // clause won — the question is "did the promised skill load?", and only the // active set answers it. // // 8.15.0 widened WHEN that answer is "no", without changing the question. A // superseded pick onto a rules-form entry used to slip through: the pick lost the // cursor, but the entry's own rule kept it active anyway, so the promise looked // kept by accident. Conditional entries are cursor-gated now, so the same hop // reports itself. `supersededIds` below is a DIFFERENT fact — a continuous // suppression, nobody's broken promise — and deliberately does not come out here. const pick = ctx.pendingSkillPick; if (pick !== undefined && !evaluation.active.some((inj) => inj.id === pick)) { typedEmit(scope, 'agentfootprint.skill.reroute_superseded', { volunteeredId: pick, ...(cursor !== undefined && { wonId: cursor }), ...(ctx.currentSkillId !== undefined && { fromSkillId: ctx.currentSkillId }), iteration: ctx.iteration, }); } // The same told-the-truth check for an ACCEPTED transition proposal // (9.19.0): the effect was accepted (`tools.effect { outcome: // 'accepted' }` is on the record) but a declared edge fired on the same // batch and won the cursor (D1 outranks the proposal exactly as it // outranks a pick). Said out loud rather than left as a promise that // quietly went unmet. if (proposal !== undefined && cursor !== proposal.targetSkillId) { typedEmit(scope, 'agentfootprint.skill.reroute_superseded', { volunteeredId: proposal.targetSkillId, ...(cursor !== undefined && { wonId: cursor }), ...(ctx.currentSkillId !== undefined && { fromSkillId: ctx.currentSkillId }), iteration: ctx.iteration, source: 'tool-proposal', }); } // activeInjections — the REAL output the slot subflows read. POJO // projections (no trigger functions, no Tool execute functions) so they // survive footprintjs's transactional scope buffer (which clones on // write). Tool schemas are preserved + tagged by injectionId so the // Agent's closure-held registry can look up the executable. // The ctx goes in (9.57.0): this is the one moment the context and the // content are both in scope, so a templated instruction is rendered here // and every reader downstream sees the same plain text it always saw. const activePOJOs = evaluation.active.map((inj) => projectActiveInjection(inj, ctx)); // The park card rides as its own row — a reserved id, so it is one // identifiable line on the record (composition, budget, the context // ledger) instead of an anonymous string glued onto somebody's fragment. // Appended LAST: it is a framework aside about the map, and it should // read after the content it is commenting on. if (parkCardText !== undefined) { activePOJOs.push({ id: PARK_CARD_ID, flavor: 'steering', description: 'the mount kernel telling the model which maps are parked, and the way back', inject: { systemPrompt: parkCardText }, }); } scope.$setValue('activeInjections', activePOJOs); // The kernel's SERVED carrier (9.59.0) — this pass's answer, read as the // previous pass's on the next one. Written on EVERY pass the kernel is // mounted (even as []), so a boundary never re-delivers a stale set. if (engagementPlan !== undefined) { scope.$setValue('nextServedInjectionIds', activePOJOs.map((i) => i.id)); } const routing = routingEntriesOf(evaluation.active); // Structural copy (the events layer stays decoupled from `CursorMove`), and a // POJO so it survives the emit channel's clone. // // SG-C decoration: on the iteration a MODEL PICK resolves an outstanding // turn-start menu, the move carries the menu it resolved (`offered`) — and // `declinedOffer: true` when the accepted pick was reachable but NOT on it. // Under 'assist' the model's divergence from the menu is DATA, not a // refusal; without this stamp the record could not tell an on-menu pick // from an off-menu one. `menuOutstanding` is the same one implementation // the envelope and the 'guard' gate use, judged on the PRE-advance cursor // (this very move is what closes the menu). const resolvedMenu = move?.by === 'model-pick' && move.to !== undefined && menuOutstanding(ctx.turnRoute, ctx.currentSkillId) ? { offered: [...ctx.turnRoute.offered], ...(ctx.turnRoute.offered.includes(move.to) ? {} : { declinedOffer: true }), } : undefined; // The gate's admissible set from the LANDED cursor (9.50.0) — asked once, // beside the one resolver consultation, so the recorded set and this // iteration's read_skill offer/refusals come from the same answer. A POJO // copy (event payloads are detached plain data); `[]` is a fact (a dead // end), absence means the graph could not say. const reachable = move !== undefined && reachableSkills ? [...reachableSkills(cursor)] : undefined; const cursorMove = move ? { ...(move.from !== undefined && { from: move.from }), ...(move.to !== undefined && { to: move.to }), by: move.by, ...(reachable !== undefined && { reachable }), // Tier-1 DATA-matcher evidence (9.28.0) — what the message said that // routed this hop. Copied field-by-field: the event payload is a // structural shape, never the engine's own object. ...(move.witness !== undefined && { witness: { text: move.witness.text, ...(move.witness.keyword !== undefined && { keyword: move.witness.keyword }), }, }), // Data-guard evidence (9.51.0) — the taken hop's evaluation and the // refusals, copied field-by-field like the witness: the event // payload is a structural shape, never the engine's own object. ...(move.guard !== undefined && { guard: copyGuardEvaluation(move.guard) }), ...(move.guardsClosed !== undefined && move.guardsClosed.length > 0 && { guardsClosed: move.guardsClosed.map(copyGuardEvaluation), }), ...resolvedMenu, } : undefined; // Aggregate evaluation metadata is pure OBSERVABILITY — no flow stage // reads it — so it goes out the EMIT channel where a recorder/Lens can // observe "what was considered, what won, what was skipped and why". typedEmit(scope, 'agentfootprint.context.evaluated', { iteration: ctx.iteration, activeCount: evaluation.active.length, skippedCount: evaluation.skipped.length, evaluatedTotal: injections.length, activeIds: evaluation.active.map((i) => i.id), skippedDetails: evaluation.skipped, triggerKindCounts: countTriggerKinds(evaluation.active), // The Skill menu the LLM was offered (same text as the read_skill tool // description) — pair "offered" with "chosen" (activatedInjectionIds) to // debug a missed/wrong read_skill call. skillCatalog: skillCatalogOf(injections), // Routing PROVENANCE for active skill-graph injections — the decision path // / edge that reached each. Undefined when none came from a skillGraph(). // // NOTE the difference from `cursorMove` below, which is the whole reason the // latter exists: `routing[]` is BUILD-TIME provenance ("how is this skill // reachable at all"), stamped once by the compiler. It answers per SKILL, not // per HOP. Reading it as the cause of this turn's move is what made a model // pick get recorded under a declared edge's label. ...(routing && { routing }), // How the CURSOR actually moved this iteration, straight from the clause that // won inside the resolver (8.5.0). Skill-graph agents only; absent for a graph // built before `explainNextSkill` existed. ...(cursorMove && { cursorMove }), // Entries whose own `when` matched and which the cursor law kept OFF the wire // (8.15.0). A conditional entry is active exactly while the cursor is on it, // and a suppression the run cannot name is a silent drop. Omitted when nothing // was suppressed, so the common iteration is byte-identical to 8.14.0. ...(supersededIds.length > 0 && { supersededIds }), }); }; } /** One guard evaluation as a DETACHED POJO for the emit channel (9.51.0) — * field-by-field, conditions included: event payloads must be plain data, * and value arrays are re-sliced so no live reference rides the record. */ function copyGuardEvaluation(g) { return { from: g.from, to: g.to, toolName: g.toolName, ...(g.toolCallId !== undefined && { toolCallId: g.toolCallId }), verdict: g.verdict, conditions: g.conditions.map((c) => ({ key: c.key, op: c.op, value: Array.isArray(c.value) ? [...c.value] : c.value, actualSummary: c.actualSummary, passed: c.passed, })), }; } /** Per active skill-graph injection: its routing provenance + unlocked tools. * Returns undefined when no active injection carries skill-graph metadata, so * the emit payload omits `routing` entirely for non-skill-graph runs. */ function routingEntriesOf(active) { const entries = active.flatMap((inj) => { const routing = inj.metadata?.[SKILL_GRAPH_METADATA_KEY]; if (!routing) return []; return [ { injectionId: inj.id, flavor: inj.flavor, via: routing.via, ...(routing.path && { path: routing.path.map((s) => ({ label: s.label, branch: s.branch })), }), ...(routing.label && { label: routing.label }), ...(routing.from && { from: routing.from }), ...(routing.triggerKind && { triggerKind: routing.triggerKind }), tools: (inj.inject.tools ?? []).map((t) => t.schema.name), }, ]; }); return entries.length > 0 ? entries : undefined; } // ── Stage 3: Route ─────────────────────────────────────────────────────── /** 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 function routeActiveInjections(active) { const systemPrompt = []; const messages = []; const tools = []; for (const inj of active) { const entry = { id: inj.id, source: inj.flavor, reason: inj.description ?? `${inj.flavor} '${inj.id}' active`, }; // system-prompt: has prompt content AND not a tool-only Skill // (mirrors buildSystemPromptSlot's Block C suppression). if (inj.inject.systemPrompt && inj.inject.systemPrompt.length > 0 && !(inj.flavor === 'skill' && inj.surfaceMode === 'tool-only')) { systemPrompt.push(entry); } if (inj.inject.messages && inj.inject.messages.length > 0) messages.push(entry); if (inj.inject.tools && inj.inject.tools.length > 0) tools.push(entry); } return { systemPrompt, messages, tools }; } function routeStage(scope) { const active = scope.$getValue('activeInjections') ?? []; scope.$setValue('activeByslot', routeActiveInjections(active)); } // ── Stage 4: Delta ─────────────────────────────────────────────────────── /** Diff this turn's per-slot buckets against last turn's (carried via the * mount mappers as `priorActiveByslot`). Turn 1 / unwired → all "added". */ function deltaStage(scope) { const current = scope.$getValue('activeByslot') ?? EMPTY_ACTIVE_BY_SLOT; const prior = scope.$getArgs().priorActiveByslot ?? EMPTY_ACTIVE_BY_SLOT; scope.$setValue('slotDelta', diffActiveBySlot(prior, current)); } /** Diff two per-slot snapshots into a per-slot delta. Pure — exported for * unit tests + reuse. */ export function diffActiveBySlot(prior, current) { return { systemPrompt: diffSlot(prior.systemPrompt, current.systemPrompt), messages: diffSlot(prior.messages, current.messages), tools: diffSlot(prior.tools, current.tools), }; } /** added = now-not-before, removed = before-not-now, kept = both. */ function diffSlot(prior, current) { const priorIds = new Set(prior.map((e) => e.id)); const currentIds = new Set(current.map((e) => e.id)); return { added: [...currentIds].filter((id) => !priorIds.has(id)), removed: [...priorIds].filter((id) => !currentIds.has(id)), kept: [...currentIds].filter((id) => priorIds.has(id)), }; } /** The Skill catalog the LLM was offered — id + description for every Skill * injection, mirroring buildReadSkillTool's `(no description)` fallback. */ function skillCatalogOf(injections) { return injections .filter((i) => i.flavor === 'skill') .map((i) => ({ id: i.id, description: i.description ?? '(no description)' })); } /** Count active injections by trigger kind (observability metric). */ function countTriggerKinds(active) { const counts = {}; for (const inj of active) { counts[inj.trigger.kind] = (counts[inj.trigger.kind] ?? 0) + 1; } return counts; } //# sourceMappingURL=buildInjectionEngineSubflow.js.map