UNPKG

agentfootprint

Version:

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

901 lines • 52.4 kB
/**
 * 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.
 */
import { flowChart, isDevMode } from 'footprintjs';
import { INJECTION_KEYS } from '../../lib/conventions.js';
import { COMPOSITION_KEYS } from '../../recorders/core/types.js';
// Writes the context recorder derives its events from — relayed under a policy.
import { setEventSource } from '../../redaction/runRedaction.js';
import { menuOutstanding } from '../../injection-engine/routingPolicy.js';
import { invariantViolationsOf } from '../../integrity/invariant-violation/check.js';
import { contextErrorIdentity } from '../../integrity/finding/types.js';
import { buildSkipStepTool } from '../../injection-engine/skillTools.js';
import { currentStepOf, pointerOf, skipStepDescription, stepBannerPrefix, stepInProgress, } from '../../injection-engine/skillSteps.js';
import { typedEmit } from '../../recorders/core/typedEmit.js';
import { emitListUses } from '../../tools/mcpClientUse.js';
import { composeSlot, fnv1a, formatOverflowWarning, slotOverflow, truncate } from './helpers.js';
import { ownsReservedArgument, withFindingsArgument, withSourcesArgument, } from '../agent/findings/reserved.js';
import { carriesRules } from '../../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' }]
 * ```
 */
export 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 };
}
/**
 * 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 = 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 }),
 *   })
 */
export 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(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 ? 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 }),
        };
        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`).
            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';
            typedEmit(scope, 'agentfootprint.tools.discovery_failed', {
                providerId: toolProvider.id,
                error: errMessage,
                errorName: errName,
                iteration,
                durationMs: Date.now() - startMs,
            });
            throw err;
        }
        emitListUses(scope, ctx);
        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 ? pointerOf(args.stepPointer) : undefined;
        const stepPlan = stepPointer !== undefined ? stepPlanFor?.(stepPointer.skillId) : undefined;
        const stepNow = stepPlan !== undefined && stepInProgress(stepPointer)
            ? 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: `${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 && 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: truncate(summary, 80),
                contentHash: 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: truncate(summary, 80),
                    contentHash: 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: truncate(summary, 80),
                    contentHash: 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: skipStepDescription(stepPointer, stepPlan),
            };
            stepSchemas.push(skipSchema);
            const summary = `${skipSchema.name}: ${skipSchema.description}`;
            injectionNames.push(skipSchema.name);
            injections.push({
                contentSummary: truncate(summary, 80),
                contentHash: 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) => {
            setEventSource(scope, 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 && !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 && carriesRules(winningTools.get(s.name));
            const from = (s) => isRuled(s) ? { from: true } : undefined;
            scope.toolSchemas =
                config.findings === true
                    ? ruled.map((s) => 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 = 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 = contextErrorIdentity({ ...f, epoch: undefined });
                        if (newIds.includes(id))
                            continue;
                        newIds.push(id);
                        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 = composeSlot('tools', iteration, servedInjections, budgetCap, toolProvider ? 'registry+provider+injections' : 'registry+injections');
            setEventSource(scope, 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 = slotOverflow(composition);
            if (pressure) {
                setEventSource(scope, COMPOSITION_KEYS.BUDGET_PRESSURE, [pressure]);
                if (!warnedOverflow) {
                    warnedOverflow = true;
                    console.warn(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
            ? import('../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 import('../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 flowChart('Discover', discoverStage, 'discover', {
        description: 'Discover provider tools',
    })
        .addFunction('Compose', composeStage, 'compose', 'Compose tools slot')
        .build();
}
/** 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) {
            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]) {
            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 (!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