UNPKG

agentfootprint

Version:

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

349 lines (348 loc) • 18.3 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 type { FlowChart } from 'footprintjs'; import type { LLMToolSchema } from '../../provider-contract/types.js'; import { type StepPlanFor } from '../../injection-engine/skillSteps.js'; import type { Tool } from '../../tools/tools.js'; import type { ToolClaim } from '../agent/buildToolRegistry.js'; import type { ToolNameChannel } from '../../events/payloads.js'; import type { ToolProvider } from '../../tools/toolProvider.js'; import type { Classifier } from '../../classify/types.js'; import type { ZoneName } from '../../time/zone.js'; /** * Mutable cache shared between `buildToolsSlot` (writer) and * `buildToolCallsHandler` (reader) within ONE run. The Tools slot * resolves the provider's tools each iteration and stashes the * Tool[] here; the toolCalls handler reads on dispatch — so async * providers pay the discovery cost once, not twice. Scoped to the * chart build so concurrent `agent.run()` calls each get their own * cache. */ export interface ProviderToolCache { current: readonly Tool[]; } /** * A party that put a tool name forward for the wire (9.92.0) — the vocabulary * `tools.shadowed` and `tools.claim_swallowed` speak. `id` is the provider id or * the skill id when the party has one; a static `.tool()` and a framework * auto-attach carry none. */ export interface ToolParty { readonly channel: ToolNameChannel; readonly id?: string; } /** * WHO PUT EACH NAME ON THIS EPOCH'S WIRE (9.92.0) — written by Compose from * the same merge that produces `scope.toolSchemas`, read by the tool-calls * handler's `lookupTool` so that DISPATCH FOLLOWS THE OFFER: a name the model * was served resolves to the implementation of the party whose contract it * read, never to whichever map happened to be consulted first. * * The `ProviderToolCache` pattern, and for the same reason: build-closure * plumbing shared by the slot (writer) and the handler (reader) within one * chart, never scope state — so a run with no collision commits the exact * bytes it always did. Overwritten every iteration; a name absent from it was * not on the wire this epoch and dispatch falls back to the build-time map, * which is the capability law's held-out clause unchanged. */ export interface ServedToolParties { current: ReadonlyMap<string, ToolParty>; /** * The party each name was LAST served under, across the epochs of one run * (cleared on iteration 1). The off-wire fallback reads it: a name that * left the wire may still be answered by the party the model last read it * under — never by a party the model was never shown under that name. */ readonly lastServed: Map<string, ToolParty>; } /** One schema put forward for the wire, by whom — and WHICH implementation. */ interface WireCandidate { readonly schema: LLMToolSchema; readonly party: ToolParty; /** * The implementation behind the contract. Identity is by implementation: * two skills sharing ONE `Tool` reference (documented-legal) are one claim * however many routes it arrives by, and draw nothing. Absent only on a * chart with no claimant record (the message-API charts). */ readonly tool?: Tool; } /** * 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 declare function mergeWire(candidates: readonly WireCandidate[]): { readonly merged: readonly LLMToolSchema[]; readonly winners: ReadonlyMap<string, ToolParty>; /** Every candidate that lost its name, in candidate order — competing losers. */ readonly losers: ReadonlyMap<string, readonly ToolParty[]>; /** * The winning IMPLEMENTATION per served name, where the candidate carried one * (honesty layer 2): the inputs layer decorates a served schema from the * rules of the tool that will answer it. A name with no known tool (a chart * with no claimant record) is absent — and is not decorated; the dispatch * re-read is its guard. */ readonly winningTools: ReadonlyMap<string, Tool>; }; export interface ToolsSlotConfig { /** Tool registry exposed to the LLM. Empty → empty slot (LLMCall case). */ readonly tools: readonly LLMToolSchema[]; /** * Registration-time owner stamps by tool name (9.60.0) — the identity * edges `Tool.owner` declared. The record then attributes a registry * tool to its OWNING subsystem instead of deriving `source:'registry'`. * Absent or unmatched → exactly today's bytes. */ readonly toolOwners?: ReadonlyMap<string, import('../../tools/tools.js').ToolOwner>; /** * The mount kernel's map cards (9.60.0) — id + owned tool names, for the * compose-seam integrity backstop below. Present only when `.maps()` is * mounted; absent → the backstop never runs, byte-identical. */ /** * The per-run disposition ledger, by REFERENCE (9.60.0) — the * ProviderToolCache pattern: build-closure plumbing, never scope state. * The compose backstop notes one disposition per pass here. */ readonly integrityLedger?: { current: import('../../integrity/disposition/ledger.js').DispositionLedger | undefined; }; readonly mountedMaps?: ReadonlyArray<{ readonly id: string; readonly toolNames: readonly string[]; }>; /** * Optional `ToolProvider` consulted PER-ITERATION (Block A5 follow-up). * When set, the slot calls `provider.list(ctx)` each iteration with * the current `{ iteration, activeSkillId, identity, signal }`. * Provider-supplied tool schemas are MERGED with the static `tools` * registry — both flow to the LLM. This is what makes Dynamic ReAct's * tool list reshape per iteration. */ readonly toolProvider?: ToolProvider; /** * The identity the CALLER named for the run this scope belongs to — the same * fact a tool's `ctx.identity` carries — or `undefined` when the run's * identity was derived. An ACCESSOR: the chart is built once and the caller * changes per run. Fills `ToolDispatchContext.callerIdentity`; absent → the * field is never set, byte-identical. */ readonly callerIdentity?: (scope: object) => { tenant?: string; principal?: string; conversationId: string; } | undefined; /** * Mutable cache the slot writes to after resolving `toolProvider.list(ctx)`. * The same cache reference is passed to `buildToolCallsHandler` so * dispatch reads from this iteration's resolved Tool[] instead of * calling `list()` a second time. Required when `toolProvider` is set. */ readonly providerToolCache?: ProviderToolCache; /** * Every build-time claimant per tool name (9.92.0) — `buildToolRegistry`'s * `toolClaimants`. Read for two things: the party a STATIC-list schema * belongs to (a `.tool()`, a framework auto-attach, an always-visible * skill's tool), and the claimants that never reached this epoch's merge * (a scoped skill's tool while the skill is inactive, a held-out step tool, * the framework's `skip_step` between tenures) so a dead claim can be named. * Absent — the message-API charts — and every static schema is `registry`. */ readonly toolClaimants?: ReadonlyMap<string, readonly ToolClaim[]>; /** * The record of who put each name on the wire (9.92.0), written here every * iteration and read by dispatch. Same closure-shared shape and lifetime as * `providerToolCache`. Absent → the record is not kept, and dispatch reads * its build-time map first as it did before 9.92.0. */ readonly servedTools?: ServedToolParties; /** * Rebuild `read_skill`'s SCHEMA for this iteration's cursor (8.5.0). * * Set only for a `.skillGraph()` agent in a per-iteration ReAct mode. The tool's * enum is the full catalog and never changes; what varies is the DESCRIPTION — * which ids the gate will actually grant from where the cursor stands. Without it * the menu advertised every registered skill on every iteration while the gate * admitted a subset, so the model was routinely offered ids it would be refused. * * Substituted by NAME in Compose, so dispatch is untouched: the tool-calls handler * resolves executables from `registryByName`, never from the schema list. */ readonly readSkillFor?: (args: { readonly currentSkillId?: string; readonly hiddenSkillIds?: readonly string[]; /** The turn-start MENU (SG-C), passed only while the turn's menu verdict * is outstanding — see the Compose stage. */ readonly menu?: { readonly candidates: ReadonlyArray<{ readonly id: string; readonly relevance?: number; }>; readonly cursorId?: string; readonly stay?: boolean; }; }) => LLMToolSchema | undefined; /** Budget cap (chars). Default: 2000. Set from the public door as * `contextBudget.tools` on `AgentOptions`. */ readonly budgetCap?: number; /** * The frozen step plans, keyed by skill id (9.18.0) — set only when ≥1 * registered skill declares `steps`. While a stepped tenure is active and * unfinished (`scope.stepPointer`, threaded by the mount mappers), Compose * narrows the OFFER to the current step: the stepped skill's own tools * that are not the current step's tool are held out of the request, the * current step's tool description leads with the `[Step k of n — <note>]` * banner (a rebuilt schema copy substituted BY NAME — the `read_skill` * precedent, so dispatch is untouched), and `skip_step` is appended. * * The escape hatches STAY OFFERED (house law): `read_skill`, * `list_skills`, every OTHER active skill's tools, the baseline `.tool()` * registry, provider tools. The hold-out applies only to names whose SOLE * active owner is the stepped skill — a name another active source also * carries (the same Tool reference shared across skills, a provider) is * somebody's escape hatch and is never pulled; a baseline `.tool()` can * never even collide (that overlap is refused at Agent build). Absent * plan / absent pointer / complete procedure → this path is * byte-identical to today. */ readonly stepPlanFor?: StepPlanFor; /** * THE FINDINGS LEDGER IS ARMED (9.101.0, `.findings()`) — present ONLY * then, and only ever `true`. When present, every schema on the committed * wire list gains the reserved optional `_findings` property * (`findings/reserved.ts · withFindingsArgument`) at the ONE decoration * site: after `mergeWire`, before `scope.toolSchemas` is written. Absent — * the default — and the committed list is the merged list, byte for byte. * * The decoration is stable: mutable result IDs are served in the call's * request-only DATA, outside this slot and its cached tool contract. */ readonly findings?: true; /** * THE INPUTS LAYER IS ARMED (honesty layer 2) — present ONLY then, only * ever `true`. Every served schema whose WINNING implementation declares * `askOrAssume` is rebuilt at the ONE decoration site, one step before * `withFindingsArgument` (`arguments/serve.ts` · `withArgumentRules`): each * `assume`-ruled argument leaves `required` and its property says the rule. * Absent, nothing is decorated — a ruled tool on an agent without the layer * is refused at dispatch, and a sentence promising a fill would be false. */ readonly inputsLayer?: true; /** * DECLARED SOURCES ARE ARMED (honesty layer 2, `.findings({ argumentSources: * true })` or `.inputsLayer({ argumentSources: true })`) — present ONLY * then, only ever `true`, and only beside `inputsLayer`. At the same * decoration site, a schema whose WINNING implementation carries argument * rules (`tools/declare.ts` · `carriesRules`) gets `from`: beside * `findings`, the ledger's `_findings` variant with `from` * (`findings/reserved.ts` · `withFindingsArgument`'s `from`); without it, * the reserved argument with `from` alone (`withSourcesArgument`). An `ask` * rule's sentence also names `_findings.from` (`arguments/serve.ts` · * `ASK_SOURCES_SENTENCE`). Every other schema keeps its decoration — the * ledger's base by reference, or none. Absent → the decoration it always was. */ readonly argumentSources?: true; /** * THE TIME LAYER IS ARMED (`.time()`) — present ONLY then. At the same * decoration site the slot composes the ONE * served time line from the tools it really serves — first the run clock * (`agent/arguments/serve.ts` · `clockSentence`, every request, G16; mount * arg `timeClock`), then (`timeWindowsLine`) each window the reader SETTLED this turn in * each period tool's own form, each quote still pending, and the window the * person set in the app's time control (`time.window`; on every request, * with or without a period tool served, and with or without `inputsLayer` * — a fact about the person's turn, G14) — and writes it to * `timeLine` as `{ iteration, text }`; the mount carries it to `callLLM`, * which appends it LAST to the request, never to history. The tool schemas * are not touched. Reads two mount args under this gate only, `timeClock` * (`agent/buildAgentChart.ts` · `timeClockArg`) and `timeWindows` * (`time/windows.ts` · `readerWindowsOf` — absent on a turn with none). * `appZone` is the app's `.time({ zone })`. `reader` — the reader is armed * (`.time({ reader })`, step T6b) beside `inputsLayer`: the line is written EVERY composition * (`''` when there is nothing to say), so a slot that does not re-run never * serves a stale one; without it only a line with something to say is * written (the control window alone). */ readonly timeWindows?: { readonly appZone?: ZoneName; readonly reader?: true; }; /** * THE TIME LAYER IS ARMED (`.time()`, step T8) — present ONLY then, and only * beside `inputsLayer`. The same site appends the turn's time limits * (`agent/arguments/serve.ts` · `timeLimitsLine`, which renders them) to the * ONE served time line, from one mount arg, `timeLimits` — unrendered facts * (`coverage/timeLimitFacts.ts` · `timeLimitFactsOf` — absent on a turn * whose reads match what was asked). * Without the reader the line is written only when it says something; the * iteration stamp keeps an older one from being served. */ readonly timeLimits?: true; /** * TOOL CHOICE BY CLASSIFIER IS ARMED (9.105.0, `.toolChoice()`) — present * ONLY then. Compose then asks `classifier` which of the merged wire's * tools (minus the doors) answers the current step, files the pick under * `toolChoices` BEFORE `scope.toolSchemas` is written, and — under `top` — * commits the top-N plus the doors as the served list, at the ONE * decoration site, so the receipt hashes and `servedView` rebuilds the * narrowed list by construction. The pick is awaited: the stage returns a * promise under this gate and stays synchronous without it. Reads three * mount args under this gate only: `userMessage`, `priorToolChoices` (the * parent's rows, a frozen input the slot never writes), `wrapUpAsked` and * `toolsOffAfterRevision`. * The module that asks is loaded through `import()` — an unarmed agent * never loads it. */ readonly toolChoice?: { readonly classifier: Classifier; /** Serve the top-N plus the doors; absent = advisory only (the full wire). */ readonly top?: number; readonly alwaysServe?: readonly string[]; }; } /** * 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 declare function buildToolsSlot(config: ToolsSlotConfig): FlowChart; export {};