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
TypeScript
/**
* 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 {};