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
JavaScript
/**
* 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