agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
929 lines • 54.3 kB
JavaScript
"use strict";
/**
* Tools slot subflow builder
*
* Pattern: Builder (returns a FlowChart mountable via addSubFlowChartNext).
* Role: Layer-3 context engineering. Resolves the tools list the LLM
* sees on this iteration — one InjectionRecord per exposed tool.
* Emits: None directly; ContextRecorder sees the writes.
*
* Minimal scope for Phase 3e: static tool registry, all exposed every
* iteration. Full permission gating / skill activation / context-aware
* tool filtering arrives in Phase 5.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildToolsSlot = exports.mergeWire = void 0;
const footprintjs_1 = require("footprintjs");
const conventions_js_1 = require("../../lib/conventions.js");
const types_js_1 = require("../../recorders/core/types.js");
// Writes the context recorder derives its events from — relayed under a policy.
const runRedaction_js_1 = require("../../redaction/runRedaction.js");
const routingPolicy_js_1 = require("../../injection-engine/routingPolicy.js");
const check_js_1 = require("../../integrity/invariant-violation/check.js");
const types_js_2 = require("../../integrity/finding/types.js");
const skillTools_js_1 = require("../../injection-engine/skillTools.js");
const skillSteps_js_1 = require("../../injection-engine/skillSteps.js");
const typedEmit_js_1 = require("../../recorders/core/typedEmit.js");
const mcpClientUse_js_1 = require("../../tools/mcpClientUse.js");
const helpers_js_1 = require("./helpers.js");
const reserved_js_1 = require("../agent/findings/reserved.js");
const declare_js_1 = require("../../tools/declare.js");
const FRAMEWORK_PARTY = { channel: 'framework' };
const sameParty = (a, b) => a.channel === b.channel && a.id === b.id;
/**
* The same CONTRACT arriving twice — an always-visible skill's tool rides the
* static list AND, once the skill is active, the injection list. One claim,
* two routes; not a loser of itself.
*/
const sameContract = (a, b) => a.name === b.name &&
a.description === b.description &&
JSON.stringify(a.inputSchema ?? null) === JSON.stringify(b.inputSchema ?? null);
const partyOfClaim = (c) => ({
channel: c.channel,
...(c.id !== undefined && { id: c.id }),
});
/**
* THE WIRE MERGE (9.92.0) — first occurrence wins, and the record of who won
* and who lost is produced by the SAME pass that produces the list, so the two
* cannot disagree about a name.
*
* Order is the caller's: `[static, provider, skill, step]`. A `registry`
* candidate is the static list; a `provider` candidate the resolved
* `ToolProvider` list; a `skill` candidate an active skill's `inject.tools`;
* a `framework` candidate the step tool. Two candidates from one provider
* under one name (a composed provider serving a name twice) lose to each
* other by the same rule and are reported the same way. The SAME
* implementation put forward twice — an always-visible skill's tool rides the
* static list and the active-injection list; two skills may share one `Tool`
* reference — is one claim and draws nothing: identity is by `tool`, and only
* where no implementation is known (a chart without a claimant record) by
* party + contract.
*
* @example
* ```ts
* const { merged, winners, losers } = mergeWire([
* { schema: { name: 'x', description: 'a' }, party: { channel: 'provider', id: 'p' } },
* { schema: { name: 'x', description: 'b' }, party: { channel: 'skill', id: 's' } },
* ]);
* merged.map((t) => t.description); // ['a']
* winners.get('x'); // { channel: 'provider', id: 'p' }
* losers.get('x'); // [{ channel: 'skill', id: 's' }]
* ```
*/
function mergeWire(candidates) {
const merged = [];
const winners = new Map();
const won = new Map();
const losers = new Map();
const winningTools = new Map();
for (const candidate of candidates) {
const { schema, party } = candidate;
const first = won.get(schema.name);
if (first !== undefined) {
const sameImplementation = (candidate.tool !== undefined && candidate.tool === first.tool) ||
(sameParty(party, first.party) && sameContract(schema, first.schema));
if (sameImplementation)
continue;
const lost = losers.get(schema.name);
if (lost === undefined)
losers.set(schema.name, [party]);
else
lost.push(party);
continue;
}
won.set(schema.name, candidate);
winners.set(schema.name, party);
if (candidate.tool !== undefined)
winningTools.set(schema.name, candidate.tool);
merged.push(schema);
}
return { merged, winners, losers, winningTools };
}
exports.mergeWire = mergeWire;
/**
* The rules' serve options for a schema that will CARRY `_findings.from`
* under declared sources (`agent/arguments/serve.ts` · `ServeOptions`) — one
* frozen value, handed per schema.
*/
const SOURCES_SERVED = Object.freeze({ sources: true });
/**
* Declared sources WITHOUT the findings ledger: each RULED schema gains the
* reserved `_findings` argument carrying `from` alone
* (`findings/reserved.ts` · `withSourcesArgument`); every other schema is
* served as it was. The SAME list when nothing changed — a wire with no ruled
* tool commits the bytes it always did.
*/
function sourcesOnWire(served, isRuled) {
let changed = false;
const decorated = served.map((schema) => {
if (!isRuled(schema))
return schema;
const next = (0, reserved_js_1.withSourcesArgument)(schema);
if (next !== schema)
changed = true;
return next;
});
return changed ? decorated : served;
}
/** Shared empty hold-out — the no-step iterations never allocate. */
const EMPTY_NAME_SET = new Set();
/** Shared empty offer — an unarmed iteration, or the first call, never allocates. */
const EMPTY_OFFER = Object.freeze([]);
// LENS · tool-description + tool-list · request-ephemeral
// reads: hiddenSkillIds ← the mount's input, resolved by the Injection Engine this iteration
// (owner: src/injection-engine/buildInjectionEngineSubflow.ts · hiddenSkillIdsOf)
// the read_skill offer ← readSkillFor({currentSkillId, hiddenSkillIds, menu}) → buildReadSkillTool
// menu ← scope.turnRoute.offered while outstanding; step narrowing ← scope.stepPointer + stepPlan
// parked tools ← scope.parkedToolNames (owner: src/maps/engagement/lease.ts · advanceEngagement)
// law: may omit, never deny; every clause anchored to the call it was composed on.
/**
* Build the Tools slot subflow.
*
* Mount with:
* builder.addSubFlowChartNext(SUBFLOW_IDS.TOOLS, buildToolsSlot(cfg), 'Tools', {
* inputMapper: (parent) => ({ iteration: parent.iteration }),
* outputMapper: (sf) => ({ toolsInjections: sf.toolsInjections, toolSchemas: sf.toolSchemas }),
* })
*/
function buildToolsSlot(config) {
const budgetCap = config.budgetCap ?? 2000;
const staticTools = config.tools;
const readSkillFor = config.readSkillFor;
const toolProvider = config.toolProvider;
const providerToolCache = config.providerToolCache;
const stepPlanFor = config.stepPlanFor;
const toolClaimants = config.toolClaimants;
const servedTools = config.servedTools;
const toolChoice = config.toolChoice;
/** A STATIC-list schema's party and implementation: its first build-time claimant. */
const registryCandidate = (schema) => {
const first = toolClaimants?.get(schema.name)?.[0];
return first === undefined
? { schema, party: { channel: 'registry' } }
: { schema, party: partyOfClaim(first), tool: first.tool };
};
/** The framework's own `skip_step` instance, when any skill declares steps. */
const frameworkSkipStep = toolClaimants
?.get((0, skillTools_js_1.buildSkipStepTool)().schema.name)
?.find((c) => c.channel === 'framework')?.tool;
// The skip_step schema template — description is rebuilt per iteration
// (it names the current step), everything else is static.
const skipStepSchemaTemplate = stepPlanFor ? (0, skillTools_js_1.buildSkipStepTool)().schema : undefined;
// Read by Discover from the mount's input, read by Compose (sync).
//
// Chart-scoped, and the chart is built ONCE at Agent construction — so this
// outlives a run. It is never stale at READ time because Discover always runs
// before Compose in the same slot and overwrites it every iteration; and it
// cannot cross callers because one Agent runs one turn at a time (and
// `standingAgent({ agentFactory })` gives each session its own instance, and
// therefore its own chart and its own closure).
let hiddenSkillIds = [];
// Dedup latch for the human-facing warning, scoped to THIS built chart:
// a 20-iteration ReAct loop must not print 20 identical warnings. The
// typed `context.budget_pressure` event still fires every iteration and
// carries the per-iteration truth.
let warnedOverflow = false;
// Same latch discipline for the provider↔skill tool-name shadow warning (8.7.0):
// one console line per offending NAME per built chart. The typed event is unlatched.
const warnedShadow = new Set();
// Stage 1 — Discover: consult the external ToolProvider (if any) and
// resolve its Tool[] for this iteration. ALWAYS runs (even when no
// provider) so the trace shape is consistent across agents — the
// no-provider path early-returns in microseconds. When a provider IS
// set, this stage owns the entire async-discovery boundary:
//
// • own runtimeStageId (e.g., `sf-tools/discover#7`) so KeyedStore
// and SequenceStore can scope per-discovery latency / errors
// • own InOutRecorder boundary (entry/exit pair)
// • own narrative entry separating "I called the hub" from "I built
// the slot"
// • emits `tools.discovery_started`, `tools.discovery_completed` (or
// `tools.discovery_failed`) with timing + provider id
//
// Sync providers still pay zero microtask overhead — the dynamic
// `instanceof Promise` check skips await for non-Promise returns.
const discoverStage = async (scope) => {
// Per-role skill visibility (9.11.0). The Injection Engine resolved this
// iteration's ids BEFORE it decided anything and published them on scope
// (owner: `buildInjectionEngineSubflow.ts` · `hiddenSkillIdsOf`); this
// slot reads that one answer through its mount, so the menu it composes
// and the activation the engine refused were judged by the same policy
// call. Absent on agents without per-role visibility.
hiddenSkillIds =
scope.$getArgs().hiddenSkillIds ?? EMPTY_OFFER;
if (!toolProvider)
return; // No-op fast path: keeps trace shape consistent.
const args = scope.$getArgs();
const iteration = args.iteration ?? 1;
const env = scope.$getEnv();
const activatedIds = scope.$getValue('activatedInjectionIds') ?? [];
const identity = scope.$getValue('runIdentity');
// `activeSkillIds` is the REAL active set (8.7.0) — every skill the injection
// engine resolved for this iteration, whatever activated it. `activeSkillId`
// stays what it always was: the tail of `activatedInjectionIds`, which only
// `read_skill` writes. The two answer different questions and both are needed.
const activeSkillIds = (scope.$getValue('activeInjections') ?? [])
.filter((inj) => inj.flavor === 'skill')
.map((inj) => inj.id);
// The caller's own identity, never a derived one (`ToolDispatchContext.callerIdentity`).
const callerIdentity = config.callerIdentity?.(scope);
const ctx = {
iteration,
...(activatedIds.length > 0 && { activeSkillId: activatedIds[activatedIds.length - 1] }),
...(activeSkillIds.length > 0 && { activeSkillIds }),
...(identity && { identity }),
...(callerIdentity !== undefined && { callerIdentity }),
...(env.signal && { signal: env.signal }),
};
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.tools.discovery_started', {
providerId: toolProvider.id,
iteration,
});
const startMs = Date.now();
let visibleTools;
try {
// Dynamic check — sync providers skip the await microtask.
const result = toolProvider.list(ctx);
visibleTools = result instanceof Promise ? await result : result;
}
catch (err) {
// An MCP client pool's use of a client is recorded whether or not its
// listing succeeded (`tools/mcpClientUse.ts`).
(0, mcpClientUse_js_1.emitListUses)(scope, ctx);
// Discovery failure is loud by design. Emit the typed event
// with the providerId so consumers can route alerts; then
// re-throw so a configured `reliability` rule decides whether
// to retry / fall back / fail-fast. Silently dropping tools
// mid-conversation creates non-deterministic agent behavior
// harder to debug than a crash.
const errMessage = err instanceof Error ? err.message : String(err);
const errName = err instanceof Error ? err.name : 'Error';
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.tools.discovery_failed', {
providerId: toolProvider.id,
error: errMessage,
errorName: errName,
iteration,
durationMs: Date.now() - startMs,
});
throw err;
}
(0, mcpClientUse_js_1.emitListUses)(scope, ctx);
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.tools.discovery_completed', {
providerId: toolProvider.id,
iteration,
durationMs: Date.now() - startMs,
toolCount: visibleTools.length,
});
// Cache the resolved Tool[] in the closure-shared ProviderToolCache.
// The Compose stage reads providerSchemas from here; the toolCalls
// handler reads the executable Tool objects on dispatch. Both share
// ONE list() call per iteration. The cache lives outside scope
// because Tool objects carry `execute` functions that can't be
// `structuredClone`d into the transactional memory layer.
if (providerToolCache)
providerToolCache.current = visibleTools;
};
// Stage 2 — Compose: merges static + provider + per-skill schemas
// into the tool slot. Pure compute, sync, fast. Reads provider tools
// from `providerToolCache.current` populated by the Discover stage.
const composeStage = (scope) => {
const args = scope.$getArgs();
const iteration = args.iteration ?? 1;
// Active Injections — read once, up front: the step hold-out's ownership
// rule and the per-skill schema readmission below both consume it.
const activeInjections = scope.$getValue('activeInjections') ?? [];
// ── Per-step narrowing (9.18.0) ──────────────────────────────────
// The pointer arriving here is THIS iteration's: the Injection Engine
// re-keyed it and the mount mappers carry the fresh alias
// (`nextStepPointer ?? stepPointer` — the nextSkillCursor pattern), so
// the offer narrows on the very iteration a tenure begins. Absent
// plan/pointer, or a complete procedure → every variable below is
// undefined/empty and the compose path is byte-identical to today.
const stepPointer = stepPlanFor ? (0, skillSteps_js_1.pointerOf)(args.stepPointer) : undefined;
const stepPlan = stepPointer !== undefined ? stepPlanFor?.(stepPointer.skillId) : undefined;
const stepNow = stepPlan !== undefined && (0, skillSteps_js_1.stepInProgress)(stepPointer)
? (0, skillSteps_js_1.currentStepOf)(stepPointer, stepPlan)
: undefined;
// The hold-out: the stepped skill's own tools that are not the current
// step's tool — MINUS every name another ACTIVE source also carries.
// A shared name is somebody's escape hatch; the sequence owns only what
// the stepped skill alone brought (the escape-hatches-stay house law).
// The two live co-owners are OTHER active skills (the same Tool
// reference shared across `tools:[]` arrays) and provider tools; a
// baseline `.tool()` sharing a skill tool's name is REFUSED at Agent
// build (`validateToolNameUniqueness` — ambiguous dispatch), so it can
// never reach this rule.
let stepHoldOut = EMPTY_NAME_SET;
if (stepNow !== undefined && stepPointer !== undefined && stepPlan !== undefined) {
const ownedByOthers = new Set();
for (const inj of activeInjections) {
if (inj.id === stepPointer.skillId)
continue;
for (const t of inj.inject.tools ?? [])
ownedByOthers.add(t.schema.name);
}
if (toolProvider && providerToolCache) {
for (const t of providerToolCache.current)
ownedByOthers.add(t.schema.name);
}
const holdOut = new Set();
for (const name of stepPlan.toolNames) {
if (name !== stepNow.tool && !ownedByOthers.has(name))
holdOut.add(name);
}
stepHoldOut = holdOut;
}
// The banner — a rebuilt schema copy substituted BY NAME (the
// `read_skill` precedent), so dispatch is untouched and nothing else in
// the list moves.
const bannered = (schema) => stepNow !== undefined && schema.name === stepNow.tool
? {
...schema,
description: `${(0, skillSteps_js_1.stepBannerPrefix)(stepPointer, stepNow)}${schema.description}`,
}
: schema;
// The turn-start MENU (SG-C) — composed from the SAME verdict the record
// carries (`scope.turnRoute`, threaded by the mount mappers), and only
// while it is OUTSTANDING: once an accepted pick moves the cursor, the
// description stops offering a menu the turn has already resolved.
// `menuOutstanding` is the one shared implementation (routingPolicy.ts).
// Advisory relevance rides beside each candidate when the scorer ranked it.
const turnRoute = args.turnRoute;
const menu = turnRoute?.offered !== undefined && (0, routingPolicy_js_1.menuOutstanding)(turnRoute, args.currentSkillId)
? {
candidates: turnRoute.offered.map((id) => {
const rel = turnRoute.relevance?.find((r) => r.id === id)?.relevance;
return { id, ...(rel !== undefined && { relevance: rel }) };
}),
...(args.currentSkillId !== undefined && { cursorId: args.currentSkillId }),
...(turnRoute.stayOffered === true && { stay: true }),
}
: undefined;
// Per-iteration `read_skill` offer. The cursor arriving here is THIS iteration's:
// the Injection Engine already advanced it and its outputMapper wrote it to the
// parent before this slot mounts — the same value the read_skill gate will read
// when the model answers. Substituted by name so nothing else in the list moves.
const substituted = readSkillFor
? staticTools.flatMap((t) => {
if (t.name !== 'read_skill')
return [t];
// `undefined` = the offer is WITHHELD this iteration (see
// `readSkillFor`): the schema comes off the wire, the name stays in
// the dispatch map. Position governs the offer, never dispatch.
const offered = readSkillFor({
...(args.currentSkillId !== undefined && { currentSkillId: args.currentSkillId }),
...(hiddenSkillIds.length > 0 && { hiddenSkillIds }),
...(menu !== undefined && { menu }),
});
return offered === undefined ? [] : [offered];
})
: staticTools;
// Step narrowing over the STATIC list (9.18.0): hold out the stepped
// skill's other tools (sole-owner names only — see stepHoldOut), lead the
// current step's tool with the banner. No step in progress → the exact
// array from the line above, untouched.
const steppedTools = stepNow !== undefined
? substituted.filter((t) => !stepHoldOut.has(t.name)).map(bannered)
: substituted;
// ── PARK HOLD-OUT (9.59.0) — the mount kernel's own axis ────────────
// A parked map's tools come off the wire here, in the STATIC list, which
// is the only place they can be reached on the default posture.
//
// With `scopeTools` false (the default for flat graphs) a
// skill's tools are pre-loaded into the static registry and ride from
// iteration 1 whatever the cursor says. So suppressing the ACTIVE set —
// which is all parking used to do — stopped the prompt fragment and left
// all four tool schemas riding: the wire did not merely stay silent about
// the park, it contradicted it, showing the model tools for a skill whose
// instructions had just vanished.
//
// This is NOT a change to the `scopeTools` default. The two dials are orthogonal and always were: `scopeTools`
// answers "do this map's tools follow the CURSOR?", parking answers "is
// this map talking at all?" — the kernel's whole thesis is that those are
// different questions. Absent the kernel the set is empty and this line
// returns the same array it was given.
const parkedToolNames = scope.$getValue('parkedToolNames') ?? [];
const parkHoldOut = parkedToolNames.length > 0 ? new Set(parkedToolNames) : undefined;
const tools = parkHoldOut !== undefined
? steppedTools.filter((t) => !parkHoldOut.has(t.name))
: steppedTools;
const ownerOf = config.toolOwners ?? new Map();
// The tool NAME behind each injection record, by index (9.105.0): a
// record's `sourceId` is the owner or the skill, so the narrowing below
// needs its own list to know which record is which tool.
const injectionNames = tools.map((t) => t.name);
const injections = tools.map((t, i) => {
const summary = `${t.name}: ${t.description}`;
// `source: 'registry'` — tools configured at build time via
// `.tool(...)` are baseline API flow (the static tool list sent
// to the LLM), NOT context engineering. Skills / Instructions
// that gate tools dynamically tag their injections with their
// flavor below.
// A registration-time owner stamp (9.60.0) wins over the derived
// default: the record then names the OWNING subsystem instead of the
// tool's own name, which is what lets a checker ask "who owns X"
// without a slot pass having run.
const owner = ownerOf.get(t.name);
return {
contentSummary: (0, helpers_js_1.truncate)(summary, 80),
contentHash: (0, helpers_js_1.fnv1a)(`tool:${t.name}:${t.description}`),
slot: 'tools',
source: owner?.kind ?? 'registry',
...(owner !== undefined ? { sourceId: owner.id } : { sourceId: t.name }),
reason: owner !== undefined ? `owned by ${owner.kind} '${owner.id}'` : 'tool registry',
rawContent: summary,
position: i,
};
});
const providerSchemas = [];
const providerCandidates = [];
const providerParty = {
channel: 'provider',
...(toolProvider?.id !== undefined && { id: toolProvider.id }),
};
if (toolProvider && providerToolCache) {
for (const t of providerToolCache.current) {
// PARK HOLD-OUT, the same rule the registry list above and the
// dynamic list below already apply. A provider tool sharing a parked
// map's owned name used to merge here unfiltered and ride the wire,
// and the compose-seam backstop further down could only REPORT it —
// so parking, whose whole contract is that a parked map contributes
// nothing by any route, leaked through exactly one of the three.
//
// It has to be filtered here rather than by the provider itself:
// `ToolDispatchContext` carries the active skill but nothing about
// engagement standing, so a `ToolProvider` cannot see that a map was
// parked and this is the only layer that can.
//
// Not the same thing as the provider-wins-the-wire law on
// `reportShadowedTools` below. That governs two ACTIVE sources
// genuinely disagreeing about which schema should win. A parked map
// is not competing — it is not talking at all.
if (parkHoldOut?.has(t.schema.name) === true)
continue;
// Never held out by the STEP narrowing (a provider is an active
// owner, so its names are excluded from stepHoldOut by construction)
// — but a provider serving the CURRENT step's tool still gets the
// banner the model reads.
const schema = bannered(t.schema);
providerSchemas.push(schema);
providerCandidates.push({ schema, party: providerParty, tool: t });
const summary = `${schema.name}: ${schema.description}`;
injectionNames.push(schema.name);
injections.push({
contentSummary: (0, helpers_js_1.truncate)(summary, 80),
contentHash: (0, helpers_js_1.fnv1a)(`tool:provider:${schema.name}`),
slot: 'tools',
source: 'registry',
sourceId: schema.name,
reason: `tool provider${toolProvider.id ? ` '${toolProvider.id}'` : ''}`,
rawContent: summary,
position: tools.length + providerSchemas.length - 1,
});
}
}
// Active Injections targeting the tools slot (Skills with tools=[…]),
// read up top. An autoActivate STEPPED skill readmits through here, so
// the step hold-out filters this source too — by the same sole-owner
// rule (a name in stepHoldOut is provably the stepped skill's alone).
const dynamicSchemas = [];
const dynamicCandidates = [];
for (const inj of activeInjections) {
const injTools = inj.inject.tools;
if (!injTools || injTools.length === 0)
continue;
for (const tool of injTools) {
if (stepNow !== undefined && stepHoldOut.has(tool.schema.name))
continue;
// A parked map's tools never ride, by whichever route they arrive.
if (parkHoldOut?.has(tool.schema.name) === true)
continue;
const schema = bannered(tool.schema);
dynamicSchemas.push(schema);
// The implementation is the REGISTRY's reference for (name, skill), not
// the object on the active injection: `activeInjections` is committed
// state, and what comes back from scope is a copy. Identity has to be
// judged on the one reference `buildToolRegistry` kept.
const claimed = toolClaimants
?.get(schema.name)
?.find((c) => c.channel === 'skill' && c.id === inj.id)?.tool;
dynamicCandidates.push({
schema,
party: { channel: 'skill', id: inj.id },
tool: claimed ?? tool,
});
const summary = `${schema.name}: ${schema.description}`;
injectionNames.push(schema.name);
injections.push({
contentSummary: (0, helpers_js_1.truncate)(summary, 80),
contentHash: (0, helpers_js_1.fnv1a)(`tool:${inj.flavor}:${inj.id}:${schema.name}`),
slot: 'tools',
source: inj.flavor,
sourceId: inj.id,
reason: `${inj.flavor} '${inj.id}' unlocked tool '${schema.name}'`,
rawContent: summary,
position: tools.length + providerSchemas.length + dynamicSchemas.length - 1,
});
}
}
// ── skip_step: offered ONLY while a step is in progress (9.18.0) ──
// Dispatchable all along (buildToolRegistry auto-attached it), but its
// schema enters the request here, description naming the current step,
// the remaining count and the declared onSkip policy. No step → no
// schema, no record — an agent between procedures offers exactly what
// it always did.
const stepSchemas = [];
if (stepNow !== undefined && stepPointer !== undefined && stepPlan !== undefined) {
const skipSchema = {
...skipStepSchemaTemplate,
description: (0, skillSteps_js_1.skipStepDescription)(stepPointer, stepPlan),
};
stepSchemas.push(skipSchema);
const summary = `${skipSchema.name}: ${skipSchema.description}`;
injectionNames.push(skipSchema.name);
injections.push({
contentSummary: (0, helpers_js_1.truncate)(summary, 80),
contentHash: (0, helpers_js_1.fnv1a)(`tool:skill:${stepPointer.skillId}:${skipSchema.name}`),
slot: 'tools',
source: 'skill',
sourceId: stepPointer.skillId,
reason: `skill '${stepPointer.skillId}' step ${stepPointer.step} of ${stepPointer.total} ` +
`— skip_step offered (procedure integrity)`,
rawContent: summary,
position: tools.length + providerSchemas.length + dynamicSchemas.length,
});
}
// Merge schemas from all three sources, deduping by tool name.
// Order: static .tool() registry FIRST (auto-attached read_skill /
// list_skills land here when `.skills(registry)` is wired), then
// external `.toolProvider()` output, then per-skill inject.tools.
// First occurrence wins.
//
// Why dedupe matters: the host app wires `gatedTools(staticTools([listSkills,
// readSkill]), policy.isAllowed)` AND calls `.skills(registry)` —
// the framework auto-attaches its own `read_skill` from the skill
// registry, AND the consumer's toolProvider emits one too. Without
// dedupe both reach the LLM and Anthropic rejects the request:
// "tools: Tool names must be unique."
//
// Since 9.92.0 the same pass records WHO won each name and who lost it
// (`mergeWire`), because the record of the wire and the wire itself must
// come from one loop: dispatch resolves against the winners, and the
// report below is computed from the losers — never from a re-derivation.
const candidates = [
...tools.map(registryCandidate),
...providerCandidates,
...dynamicCandidates,
...stepSchemas.map((schema) => ({
schema,
party: FRAMEWORK_PARTY,
...(frameworkSkipStep !== undefined && { tool: frameworkSkipStep }),
})),
];
const { merged, winners, losers, winningTools } = mergeWire(candidates);
// ── THE COMMIT — everything after the merge, as one closure ─────────
// `served` is the list that goes on the record: the merged list itself
// (every agent without `.toolChoice({ serve: { top } })`), or the
// classifier's top-N plus the doors (9.105.0). The unarmed path calls it
// synchronously with `merged`, so every write below lands in the order
// it always did; the armed path awaits the pick first and then calls it
// with what the pick decided. `servedInjections` is the slot record of
// the SAME list — one `InjectionRecord` per tool actually served.
const commitWire = (served, servedInjections, narrowedTo, rules) => {
(0, runRedaction_js_1.setEventSource)(scope, conventions_js_1.INJECTION_KEYS.TOOLS, servedInjections);
// THE ONE DECORATION SITE (9.101.0). With `.findings()` armed, every
// schema on the committed list gains the reserved optional `_findings`
// property here — after the merge, so `sameContract` paired UNDECORATED
// candidates and an always-visible skill tool still pairs with itself;
// before the commit, so what the mount maps to `dynamicToolSchemas`, what
// `callLLM · registeredToolSchemas` reads, what `servedView` rebuilds and
// what `receipt.tools.schemaHashes` hashes are the SAME bytes by
// construction (no new served gap). A rebuilt copy per schema — never an
// edit of a registry reference `mcpServe` serves and `validateToolArgs`
// judges. `merged` itself stays undecorated for the names-only reads
// below. Unarmed: the merged list, byte for byte.
//
// Result IDs no longer change tool contracts. The late DATA composer
// alone reads the current offer; dispatch admission remains separate.
// THE INPUTS LAYER'S RULES (honesty layer 2) decorate FIRST, from the
// rules of the implementation that WINS each name — `served` itself,
// by reference, when the layer is not armed or no served tool is ruled.
// `rules` is loaded (below) exactly when the layer is armed. Under
// declared sources an `ask` rule's sentence names `_findings.from` only
// on a schema that will CARRY it: a tool whose author owns `_findings`
// is left undecorated by both planters below (`ownsReservedArgument`,
// the rule the dispatch peel asks), so it keeps the unarmed sentence —
// a REGISTERED one is refused at build (`buildToolRegistry` ·
// `assertReservedArgument`), so this is a ToolProvider's tool.
const sourcesOf = (s) => config.argumentSources === true && !(0, reserved_js_1.ownsReservedArgument)(s) ? SOURCES_SERVED : undefined;
const ruled = rules !== undefined && config.inputsLayer === true
? rules.rulesOnWire(served, winningTools, sourcesOf)
: served;
// THE TIME LINE (step T6b) is composed HERE, from the tools really served, and served LATE —
// `callLLM` appends it last to the request, never to history — because a sentence on a tool
// description sits far from the decision it is about (the step-7b finding: a conclusion
// served at the decision point is followed, raw facts early are not). Under the reader's arm
// only, written EVERY composition — the line, or '' — stamped with the iteration that
// composed it, so a slot that does not re-run (classic mode) never serves a stale line.
// THE TIME LIMITS (step T8) join the same line, after the windows: what the calls that ran
// READ against what they ASKED, the conclusion an answer states — composed from the record
// by the one owner the limits block also asks, so the model and the person read one text.
if ((config.timeWindows !== undefined || config.timeLimits === true) && rules !== undefined) {
const windows = config.timeWindows === undefined || args.timeWindows === undefined
? undefined
: rules.timeWindowsLine(ruled, winningTools, {
...args.timeWindows,
...(config.timeWindows.appZone !== undefined && {
appZone: config.timeWindows.appZone,
}),
});
const limits = config.timeLimits === true ? rules.timeLimitsLine(args.timeLimits) : undefined;
// THE RUN CLOCK (G16) opens the line on every request under `.time()`: the model reads
// the current date and time from the library, never from its training date.
const clock = config.timeWindows !== undefined ? rules.clockSentence(args.timeClock) : undefined;
// ONE line, opened ONCE with who says it (`agent/arguments/serve.ts` · `timeLine`), never per half.
const text = rules.timeLine([clock, windows, limits]) ?? '';
if (config.timeWindows?.reader === true || text.length > 0) {
scope.timeLine = { iteration, text };
}
}
// Declared sources (honesty layer 2): `_findings.from` on a RULED tool only —
// inside the ledger's decoration under `.findings()`, or as the reserved
// argument's only property without it (`withSourcesArgument`).
const isRuled = (s) => config.argumentSources === true && (0, declare_js_1.carriesRules)(winningTools.get(s.name));
const from = (s) => isRuled(s) ? { from: true } : undefined;
scope.toolSchemas =
config.findings === true
? ruled.map((s) => (0, reserved_js_1.withFindingsArgument)(s, from(s)))
: config.argumentSources === true
? sourcesOnWire(ruled, isRuled)
: ruled;
if (servedTools !== undefined) {
// Dispatch follows the OFFER: a name narrowed off this epoch's wire
// was not served, so it is not on `current` and takes the off-wire
// path (`toolCalls.ts · resolveTool`) — answered by the party the
// model last read it under, and recorded as `tools.answered_off_wire`.
const wireWinners = narrowedTo === undefined
? winners
: new Map([...winners].filter(([name]) => narrowedTo.has(name)));
servedTools.current = wireWinners;
// The run's memory of who served what (read by the off-wire fallback).
// Iteration 1 is a run's first epoch — `seed` sets it — so the memory
// of a previous run on this same built chart is dropped here.
if (iteration === 1)
servedTools.lastServed.clear();
for (const [name, party] of wireWinners)
servedTools.lastServed.set(name, party);
}
// ── Compose-seam integrity backstop (9.60.0) ──────────────────────
// Every list the merge reads is park-filtered — the registry and skill
// lists from 9.59.0, the provider list since the 9.60.0 fix pass (the
// comment at the provider loop above says why it has to be this layer).
// The backstop stays as the check that the MERGED wire agrees with the
// park, whatever route a name took: the final list is compared against
// every parked map's owned names. One finding per defect (identity
// dedup via a scope-held seen list, written only when something fires),
// filed as a typed event; the composition itself is never altered —
// detection converts a silent inconsistency into an attributed one.
if (config.mountedMaps !== undefined && parkHoldOut !== undefined) {
const servedNames = served.map((t) => t.name);
const seenIds = scope.$getValue('priorIntegrityFindingIds') ?? [];
const newIds = [...seenIds];
let comparedAnyMap = false;
let firedThisPass = false;
for (const map of config.mountedMaps) {
const parkedOwned = map.toolNames.filter((n) => parkHoldOut.has(n));
if (parkedOwned.length === 0)
continue;
comparedAnyMap = true;
const findings = (0, check_js_1.invariantViolationsOf)({
mapId: map.id,
standing: 'parked',
iteration,
ownedToolNames: parkedOwned,
}, { names: servedNames, provenance: 'tools slot (merged wire list)' });
if (findings.length > 0)
firedThisPass = true;
for (const f of findings) {
const id = (0, types_js_2.contextErrorIdentity)({ ...f, epoch: undefined });
if (newIds.includes(id))
continue;
newIds.push(id);
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.integrity.context_error', {
...f,
seam: 'compose',
iteration,
});
}
}
if (newIds.length > seenIds.length)
scope.$setValue('integrityFindingIds', newIds);
// The disposition (9.60.0): a pass with no parked map had nothing this
// check could violate — stated as not-applicable, never as silence.
config.integrityLedger?.current?.note('invariant-violation', 'compose', !comparedAnyMap ? 'not-applicable' : firedThisPass ? 'checked-fail' : 'checked-pass', firedThisPass ? Date.now() : undefined);
}
else if (config.mountedMaps !== undefined) {
// Maps mounted, nothing parked this pass: the registered check had no
// applicable work — stated, so the liveness theorem never mistakes a
// run that simply never parked for a dead checker.
config.integrityLedger?.current?.note('invariant-violation', 'compose', 'not-applicable');
}
reportShadowedTools(scope, {
iteration,
winners,
losers,
candidates,
...(toolClaimants !== undefined && { claimants: toolClaimants }),
warnedShadow,
});
const composition = (0, helpers_js_1.composeSlot)('tools', iteration, servedInjections, budgetCap, toolProvider ? 'registry+provider+injections' : 'registry+injections');
(0, runRedaction_js_1.setEventSource)(scope, types_js_1.COMPOSITION_KEYS.SLOT_COMPOSED, composition);
// Overflow is LOUD. Nothing here truncates — the full tool definitions
// always reach the LLM — so an over-budget slot is otherwise invisible
// (headroomChars clamps to 0, droppedCount stays 0). Write a FRESH
// single-record array: ContextRecorder re-dispatches every record in
// the written value on every write, so appending would re-fire prior
// iterations.
const pressure = (0, helpers_js_1.slotOverflow)(composition);
if (pressure) {
(0, runRedaction_js_1.setEventSource)(scope, types_js_1.COMPOSITION_KEYS.BUDGET_PRESSURE, [pressure]);
if (!warnedOverflow) {
warnedOverflow = true;
console.warn((0, helpers_js_1.formatOverflowWarning)({
pressure,
itemCount: servedInjections.length,
itemNoun: 'tool definition',
contentNoun: 'definitions',
remedy: 'Raise contextBudget.tools on the agent, or trim tool descriptions.',
}));
}
}
};
// ── THE INPUTS LAYER'S DECORATION (honesty layer 2) — loaded through
// `import()` only when the layer is armed (the optional-family law, the
// tool-choice tail's own precedent below); an unarmed slot commits
// synchronously, exactly as it always did. The same module composes the
// time line, so under `.time()` without the inputs layer it loads only on
// a turn that crosses windows to serve (the app's control window, G14).
const servesTime = config.timeWindows !== undefined &&
(args.timeWindows !== undefined || args.timeClock !== undefined);
const loadingRules = config.inputsLayer === true || servesTime
? Promise.resolve().then(() => __importStar(require('../agent/arguments/serve.js'))).then((m) => ({
rulesOnWire: m.rulesOnWire,
timeWindowsLine: m.timeWindowsLine,
timeLimitsLine: m.timeLimitsLine,
timeLine: m.timeLine,
clockSentence: m.clockSentence,
}))
: undefined;
if (toolChoice === undefined) {
if (loadingRules === undefined) {
commitWire(merged, injections, undefined, undefined);
return;
}
return loadingRules.then((rules) => commitWire(merged, injections, undefined, rules));
}
// ── TOOL CHOICE BY CLASSIFIER (9.105.0) — the armed tail ──────────────
// The pick is awaited HERE, after the merge and before the commit, so the
// row records the offer the classifier read and the list that was really
// served, and the narrowed list — when there is one — is what the mount
// maps to `dynamicToolSchemas`. The whole tail (`toolChoice/compose.ts`)
// is loaded through `import()`: the optional-family law of docs-next's
// site budget (`toolCalls.ts · judgeLanded`). The rows arrive as the
// frozen mount arg `priorToolChoices`; the writer is handed them
// explicitly and the mount's outputMapper carries the fresh list back.
return Promise.resolve().then(() => __importStar(require('../agent/toolChoice/compose.js'))).then(({ composeToolChoice }) => composeToolChoice({
scope: scope,
classifier: toolChoice.classifier,
...(toolChoice.top !== undefined && { top: toolChoice.top }),
...(toolChoice.alwaysServe !== undefined && { alwaysServe: toolChoice.alwaysServe }),
iteration,
merged,
injections,
injectionNames,
userMessage: args.userMessage ?? '',
...(args.currentSkillId !== undefined && { currentSkillId: args.currentSkillId }),
prior: args.priorToolChoices ?? [],
wrapUpAsked: args.wrapUpAsked === true,
toolsOff: args.toolsOffAfterRevision === true,
}).then(({ served, servedInjections, narrowedTo }) => loadingRules === undefined
? commitWire(served, servedInjections, narrowedTo, undefined)
: loadingRules.then((rules) => commitWire(served, servedInjections, narrowedTo, rules))));
};
return (0, footprintjs_1.flowChart)('Discover', discoverStage, 'discover', {
description: 'Discover provider tools',
})
.addFunction('Compose', composeStage, 'compose', 'Compose tools slot')
.build();
}
exports.buildToolsSlot = buildToolsSlot;
/** How a party is named in a sentence: `provider 'hub'`, `skill 'billing'`, `framework`. */
const describeParty = (p) => p.id === undefined ? p.channel : `${p.channel} '${p.id}'`;
/**
* Report every tool name more than one party claimed this epoch (8.7.0;
* re-subjected 9.92.0).
*
* The SUBJECT is the wire crossed with dispatch: for each name on the wire,
* the party whose contract won it — which, since dispatch follows the offer,
* is also the party that answers — against every other party that claimed
* the name. Two kinds of loser, two events:
*
* • a COMPETING loser put a contract forward this epoch and lost the merge
* (`losers`, from `mergeWire`). The name draws ONE
* `agentfootprint.tools.shadowed` naming the wire's party in both halves —
* the shipped event, kept truthful — and one `tools.claim_swallowed` per
* loser;
* • a RESERVED loser is a build-time claimant that never reached the merge
* (`claimants`, from `buildToolRegistry`): a scoped skill's tool while the
* skill is inactive, a held-out step tool, the framework's `skip_step`
* between tenures. Its name is held by somebody else this epoch and its
* implementation cannot be reached by any call, so it draws
* `tools.claim_swallowed` — and NOT `tools.shadowed`, because no contract of
* its competed for the wire.
*
* Until 9.92.0 this loop walked `activeInjections` against the pre-merge
* provider list and ASSERTED `schemaFrom: 'provider'`, which could not see an
* inactive skill, a static `.tool()` or a framework auto-attach, and named the
* provider on epochs whose wire carried the skill's contract (recorded-not-
* built, entries 1 and 2). Reading the merge's own record is what makes both
* sentences true by construction.
*
* The events fire every iteration — that is the channel that reaches
* production, where a dynamic provider can start a collision on iteration 9
* of a run nobody is watching. The console line is dev-mode only and latched
* per tool name per built chart, so a 20-iteration loop prints once.
*/
function reportShadowedTools(scope, input) {
const { iteration, winners, losers, candidates, claimants, warnedShadow } = input;
for (const [toolName, winner] of winners) {
const competing = losers.get(toolName) ?? [];
// Reserved: every build-time claimant of the name that is neither the
// winner nor one of this epoch's candidates for it.
const putForward = candidates.filter((c) => c.schema.name === toolName);
const winning = putForward.find((c) => sameParty(c.party, winner));
const reserved = (claimants?.get(toolName) ?? [])
.filter((c) =>
// The same implementation is the same claim: a second skill sharing
// the winner's `Tool` reference lost nothing.
c.tool !== winning?.tool &&
!putForward.some((p) => p.tool === c.tool) &&
!sameParty(partyOfClaim(c), winner) &&
!putForward.some((p) => sameParty(p.party, partyOfClaim(c))))
.map(partyOfClaim);
if (competing.length === 0 && reserved.length === 0)
continue;
if (competing.length > 0) {
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.tools.shadowed', {
toolName,
iteration,
schemaFrom: winner.channel,
...(winner.id !== undefined && { schemaFromId: winner.id }),
dispatchTo: winner.channel,
...(winner.id !== undefined && { dispatchToId: winner.id }),
});
}
for (const lost of [...competing, ...reserved]) {
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.tools.claim_swallowed', {
toolName,
iteration,
lostBy: lost.channel,
...(lost.id !== undefined && { lostById: lost.id }),
wonBy: winner.channel,
...(winner.id !== undefined && { wonById: winner.id }),
});
}
if (!(0, footprintjs_1.isDevMode)() || warnedShadow.has(toolName))
continue;
warnedShadow.add(toolName);
const others = [...competing, ...reserved].map(describeParty).join(', ');
// eslint-disable-next-line no-console
console.warn(`agentfootprint tools: '${toolName}' is claimed by ${describeParty(winner)} AND by ` +
`${others}. The model is shown ${describeParty(winner)}'s contract and ` +
`${describeParty(winner)}'s implementation answers; the other claim answers no call ` +
`while the name is held. Rename one of them, or drop the duplicate.`);
}
}
//# sourceMappingURL=buildToolsSlot.js.map