UNPKG

agentfootprint

Version:

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

297 lines (296 loc) • 17.8 kB
/** * buildToolRegistry — pure function that composes the agent's * augmented tool registry from three sources: * * 1. **Static registry** — tools registered via `.tool()`. Always * visible to the LLM; always executable. * 2. **`read_skill`** — auto-attached when ≥1 Skill is registered. * Activation tool for LLM-guided Skills. * 3. **Skill-supplied tools** (`Skill.inject.tools[]`) — visible * only when the Skill is active (filtered by tools-slot subflow); * MUST always be in the executor registry so when the LLM calls * one, the tool-calls handler can dispatch. * * Tool-name uniqueness is enforced across all three sources at build * time. The LLM only sees `tool.schema.name` (no ids), so names ARE * the runtime dispatch key — collisions break the LLM's ability to * call the right tool. Throw early instead of subtly shadowing. * * **Block C runtime — `autoActivate: 'currentSkill'` semantics:** * When a skill's `defineSkill({ autoActivate: 'currentSkill' })` is * set, its tools are EXCLUDED from the static registry. They flow * into the LLM's tool list ONLY through `dynamicSchemas` (the * buildToolsSlot path that reads activeInjections), which means * they're visible ONLY on iterations after the skill is activated by * `read_skill('id')`. Without this, the LLM sees every skill's tools * on every iteration and the per-skill-narrowing autoActivate * promised in `defineSkill` doesn't actually narrow anything. Skills * WITHOUT autoActivate keep the v2.4 behavior (tools always visible) * for back-compat. * * **autoActivate dispatch invariant:** autoActivate skill tools live * OUTSIDE the LLM-visible registry (so they don't pollute the * per-iteration tool list before the skill activates), but they MUST * still be findable by the dispatch handler — the LLM calls them by * name once the skill is active, and dispatch looks up by name. We * add them to the dispatch map (`registryByName`) so `lookupTool` * resolves correctly. * * ── THE CAPABILITY LAW, EPOCH-SCOPED ────────────────────────────────── * * An EPOCH is one composed request plus the dispatch of the tool calls * that request comes back with. For one epoch E: * * every offered capability resolves to a dispatchable implementation * with stable identity. Attention may alter the offer. Omission from * the offer must NOT be presented as proof of permanent capability loss. * * Clause one is what the two maps returned from here are FOR, and it is * scoped to THE TOOLS THIS FILE ROUTES. `augmentedRegistry` (plus the * per-iteration narrowing the tools slot applies downstream) is this file's * CONTRIBUTION to the offer; `registryByName` is DISPATCH. It is not the * whole offer: the wire list is assembled one layer out, as * `[static, provider, skill, step]` merged first-occurrence-wins * (`buildToolsSlot`), and PROVIDER schemas are a source these maps never * contain. Within the scoped set, a name in the offer that is missing from * dispatch is an `Unknown tool` the model was invited to call — which is why * the autoActivate invariant above exists, and why the name-uniqueness throws * below are throws: an identity that is not stable is one the model cannot * address. Where a provider schema reaches the wire ahead of a skill tool of * the same name, the identity half WAS the SHADOW SEAM illustrated below — * closed in 9.92.0 by making dispatch follow the offer. * * `with stable identity` is kept rather than dropped, and the reason is the * event. A law reading only "every offered name resolves to something" would * be true everywhere and would leave `agentfootprint.tools.shadowed` reporting * a deviation from nothing — the framework spends an event on that seam * precisely because reading one contract and calling another implementation is * a violation of an expectation worth naming. Since 9.92.0 the clause HOLDS * for every name on the wire (dispatch follows the offer); the event now names * the collision that would have broken it, and its companion * `agentfootprint.tools.claim_swallowed` names the party the collision silenced. * * Clause two is the whole point of the narrowing dials — `autoActivate`, * `skillGraph({ scopeTools })`, `.toolsFromActiveSkill()`, a parked map, * an open step tenure. Every one of them subtracts from the OFFER. * * Clause three is a rule about SENTENCES, and nothing in this file can * enforce it; the surfaces that write them are inventoried by * `test/modelFacingSurfaces.test.ts` and judged by * `test/helpers/modelFacingClaims.ts`. It is stated HERE because this is * where a reader learns what an offer is, and therefore where the wrong * inference is cheapest to draw: a tool absent from this iteration's list * is a tool the attention policy did not put there, never a capability * that has been taken away. * * ── WHAT THE LAW DELIBERATELY DOES NOT SAY ──────────────────────────── * * Three drafts of it read "position governs the offer, never dispatch". * That generalisation is FALSE across the framework, and it is false in a * direction that matters: it would license telling a model that anything * it once saw is still callable. It holds for the tools THIS file routes — * a scoped skill tool leaves `augmentedRegistry` and stays in * `registryByName`, which is exactly the invariant above. It does not hold * where the implementation never lived in these maps, or where the SCHEMA * the model read never did. * * ── WHERE IT DOES NOT HOLD IS NOT WRITTEN DOWN HERE ─────────────────── * * It used to be: a list, in this comment, that called itself complete. It was * wrong three times in three rounds — round 1 missed provider tools vanishing * cross-epoch, round 2 the same-epoch provider/skill shadow, round 3 an * INACTIVE skill shadowing silently and a provider able to claim `skip_step`. * Each round stated the list more precisely and each round an independent * check found one more. A hand-maintained enumeration that claims completeness * is the exact defect this library exists to fix, one level up, so this one is * no longer maintained by hand: * * THE ENUMERATION IS `test/core/agent/toolDivergenceWalk.test.ts`. * * It walks the configuration space — every source that can put a name on the * wire or answer to one, crossed with the narrowings that change resolution — * drives a REAL run per configuration, and records per epoch what name was * offered, whose contract was on the wire, whose implementation answered, and * whether `agentfootprint.tools.shadowed` fired and what it claimed. Its * committed baseline is the list this comment used to be, kept by observation * instead of by memory: a divergence missing from it fails, and one that stops * appearing fails too. Read it for the SET, and for the reason each member is * tolerated. * * What follows is ILLUSTRATION — the seams a reader of THIS file meets first, * so the two maps below make sense. It is not a boundary, and nothing here * should be read as "and no others": * * • PROVIDER-DELIVERED tools (`ToolProvider`). Dispatch resolves those * from `providerToolCache.current`, which the Tools slot OVERWRITES * with this iteration's `provider.list(ctx)` (buildToolsSlot.ts). So * `skillScopedTools(id, …)`, which answers `[]` whenever * `ctx.activeSkillId` is not its skill — and that field reports only a * `read_skill` activation — drops the tool out of the offer and out of * dispatch on the SAME epoch. * Same-epoch offer implies same-epoch dispatch; the cross-epoch half is * simply not true there. * • `read_skill` RECOVERY is cursor-gated, so it is not the escape hatch * that would make the cross-epoch claim harmless anyway. * `makeReachableSkills` filters the cursor out of its own successor set, * and under `.tree()` the reachable set is `() => []` outright * (skillGraph.ts) — a tree routes by predicate and has no cursor to move. * • The SHADOW SEAM — the SAME-EPOCH divergence this file's two maps used * to produce, CLOSED in 9.92.0 and kept here because a reader meeting * the maps has to know why dispatch no longer reads them first. A * `ToolProvider` and a skill can declare the same tool name. The wire * merge puts provider schemas ahead of skill injections * first-occurrence-wins, so the model reads the PROVIDER's contract; until * 9.92.0 `lookupTool` checked `registryByName` first, where every skill * tool lives and no provider tool does, so the SKILL's `execute` ran — * the contract offered and the implementation that answered were * different tools, inside ONE epoch, and an INACTIVE skill could answer * a call it was never offered. Now DISPATCH FOLLOWS THE OFFER: the tools * slot records which party put each name on the wire (`ServedToolParties`, * buildToolsSlot.ts) and `lookupTool` resolves a name on the wire to THAT * party's implementation; `registryByName` is the fallback for a name * that is not on this epoch's wire at all (the held-out, parked and * restored-transcript cases the capability law keeps dispatchable) — * reached only for the party the model LAST read the name under, or the * name's only holder when it was never served, and put on the record by * `agentfootprint.tools.answered_off_wire`; a name whose last-served * party can no longer answer is refused (`toolCalls.ts` · * `notServedResult`), never handed to a party the model was not shown * under that name. The * collision itself is still REPORTED rather than refused, because nothing * here can see it coming: the provider's list is resolved per iteration * (`list(ctx)`), so there is no build-time moment at which the pair is * knowable. The pair this file CAN see — a static `.tool()` against a * skill tool — is refused upstream (`validators.ts` · * `validateToolNameUniqueness`), which is the better answer whenever the * answer is available that early. `agentfootprint.tools.shadowed` names * the wire's party (now the same party in both of its halves) every * iteration two contracts compete, and `agentfootprint.tools.claim_swallowed` * names every party whose claim is dead that iteration — the loser of a * competition, and the claimant that never even reached the merge (a * provider tool whose name a registry holder owns, a skill tool named * after a framework auto-attach). `toolClaimants` below is what makes the * second event possible: the list of who claimed a name, kept instead of * collapsed to a winner. Pinned by `test/toolShadowing.test.ts`, * `test/core/tools/offer-and-answer.test.ts` and epoch-laws 1(g). * * MCP does not add a seam of its OWN, and it is worth saying so because it * looks like it does: `mcpServe` builds `listing` and `byName` ONCE at * construction from the same array, and every server instance installs * those same two captures, so the offer and the dispatch map there cannot * diverge at all. What it does add is a mount: an MCP source reaches an agent * as `staticTools(await client.tools())` — a `ToolProvider` — so it inherits * every provider seam above unchanged. The walk drives both and gets the same * rows for each, which is the only way that claim is worth making. * * Pinned by `test/core/agent/epoch-laws.test.ts` (the law) and * `test/core/agent/toolDivergenceWalk.test.ts` (the set). */ import type { Injection } from '../../injection-engine/types.js'; import type { LLMToolSchema } from '../../provider-contract/types.js'; import type { Tool, ToolRegistryEntry } from '../../tools/tools.js'; import type { ToolNameChannel } from '../../events/payloads.js'; /** * One party's claim to a tool name, as declared at build (9.92.0). * * `channel` is the vocabulary `tools.shadowed` / `tools.claim_swallowed` name * parties in; `id` is the skill id for a skill's claim (a static `.tool()` and * a framework auto-attach carry none). `tool` is the implementation that * answers when THIS claim is the one on the wire. */ export interface ToolClaim { readonly channel: Exclude<ToolNameChannel, 'provider'>; readonly id?: string; readonly tool: Tool; } export interface ToolRegistryArtifacts { /** All tools the LLM sees in the static portion of its tool list * (registry + read_skill + non-autoActivate skill tools). */ readonly augmentedRegistry: readonly ToolRegistryEntry[]; /** Dispatch map by name — used by the tool-calls handler at run * time to resolve a tool the LLM called. INCLUDES autoActivate * skill tools (which aren't in `augmentedRegistry`) so dispatch * works once the skill is active. */ readonly registryByName: ReadonlyMap<string, Tool>; /** Static tool schemas for the LLM call. Mirrors `augmentedRegistry` * shape; passed to `buildToolsSlot` + the seed stage as the * per-iteration default before the dynamic tools slot has run. */ readonly toolSchemas: readonly LLMToolSchema[]; /** * WHICH SKILLS DECLARE EACH TOOL NAME (9.86.0) — tool name → the ids of the * skills whose `inject.tools` carry it, in declaration order. * * This walk already happened here; it was thrown away, and the one consumer * that needed it — the unknown-tool roster, which must not name a tool * belonging to a skill the caller's policy hides — had no way to ask. A * consumer re-deriving it from `Agent.injections` would be a second walk of * the same arrays, which is the shape this release exists to remove. * * Shared references are kept whole: a Tool the same reference of which two * skills carry is listed under BOTH ids, so a reader can apply the * sole-owner rule (hide the name only when every declaring skill is hidden) * rather than guessing at ownership. * * EMPTY when no registered skill declares a tool. Static `.tool()` * registrations, `read_skill`, `present` and `skip_step` are never in it: * they are the framework's or the app's, not a skill's. */ readonly toolDeclaringSkills: ReadonlyMap<string, readonly string[]>; /** * EVERY BUILD-TIME CLAIMANT PER TOOL NAME (9.92.0) — a list, not a winner. * * `registryByName` holds one implementation per name and still does: it is * the map dispatch falls back to for a name that is NOT on this epoch's * wire (a held-out step tool, a parked map's tool, a scoped tool named from * a restored transcript — the capability law's "a narrowing takes a name * off the wire, never out of dispatch", qualified since the 9.92.0 review * by "to the party the model last read it under"). What it cannot say is who ELSE * claimed the name, and that is what the tools slot needs to report a * dead claim (`tools.claim_swallowed`) and to name the wire's party * truthfully (`tools.shadowed`). Provider claims are not here — a * `ToolProvider` resolves per iteration and is a runtime fact. * * Order per name: the `augmentedRegistry` holder first (a static `.tool()`, * the framework's `read_skill`/`present`, an always-visible skill's tool), * then every other skill that declares the name (a shared reference is * listed under each declaring skill), then the framework's `skip_step` when * any skill declares steps. Build time decides nothing here: the wire * decides, per epoch, in `buildToolsSlot`. */ readonly toolClaimants: ReadonlyMap<string, readonly ToolClaim[]>; } /** The build facts that gate auto-attached tools beyond skills (9.22.0). */ export interface BuildToolRegistryOptions { /** * Is an artifact store attached to this agent? Gates the `present` * auto-attach (the `read_skill` seam): true reserves the name and adds the * tool; false — the default — changes not one byte, so a storeless agent * may keep its own `present`. */ readonly hasArtifactStore?: boolean; /** * THE FINDINGS LEDGER IS ARMED (9.101.0, `.findings()`) — present only * then, only ever `true`. Arms `assertReservedArgument`: a REGISTRY schema * that already declares the reserved `_findings` argument is refused at * build, naming the tool. Absent — the default — and not one line runs. */ readonly findings?: true; /** * DECLARED SOURCES ARE ARMED (honesty layer 2 — `.inputsLayer({ * argumentSources: true })` or `.findings({ argumentSources: true })`) — * present only then, only ever `true`. Arms `assertReservedArgument` over * every RULED tool of the dispatch map (`tools/declare.ts` · * `carriesRules`): the tools the wire plants `_findings.from` on, a skill's * scoped tools included, whichever door armed it — without `.findings()`, * this is the only thing that arms the refusal. An UNRULED tool is never * decorated by this arm, so it keeps its own `_findings`. Absent — and not * one line runs. */ readonly argumentSources?: true; } /** * Compose the augmented tool registry from the static `.tool()` * registry + the agent's injections (skills only). Throws on tool- * name collisions across sources. */ export declare function buildToolRegistry(registry: readonly ToolRegistryEntry[], injections: readonly Injection[], options?: BuildToolRegistryOptions): ToolRegistryArtifacts;