agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
180 lines • 8.25 kB
TypeScript
/**
* ToolProvider — abstraction over tool dispatch.
*
* v2.4 shipped tools as a flat array on the agent (registered via
* `agent.tool(t)` / `agent.tools(arr)`). That model breaks down once
* production agents need:
* 1. Permission gating per-tool, per-caller (read-only roles, etc.)
* 2. Per-skill tool gating (only show the active skill's tools to
* the LLM each turn)
* 3. Composable filters (a `withReadonly` decorator over a `withSkill`
* decorator over the base tool list)
*
* `ToolProvider` is the answer: a chainable abstraction over "what
* tools does the LLM see right now?". The agent asks the provider
* each iteration; the provider returns the visible tool set computed
* from whatever predicates / role gates / skill filters the consumer
* composed.
*
* Pattern: Strategy (GoF) — each ToolProvider is a strategy for
* "compute the visible tool list given current context".
* Decorator (GoF) — `gatedTools(inner, predicate)` wraps any
* provider with an additional filter, mirroring how `withRetry`
* / `withFallback` decorate `LLMProvider`.
* Role: tools/ layer (L7) dispatch primitive. Agent calls `provider.list(ctx)`
* each iteration to compute the visible tool set.
* Emits: N/A (pure compute; permission denials emit elsewhere via the
* permission subsystem).
*
* @example Static tool list (90% case — what `.tools(arr)` does today)
* const provider = staticTools([weather, lookupOrder]);
*
* @example Read-only enforcement (role-based gate)
* const readOnlyProvider = gatedTools(
* staticTools([weather, lookupOrder, processRefund]),
* (toolName) => policy.isAllowed(toolName),
* );
*
* @example Skill-gated dispatch (only active skill's tools visible)
* const skillGated = gatedTools(
* staticTools(allTools),
* (toolName, ctx) => ctx.activeSkillId
* ? skillsToolMap[ctx.activeSkillId].includes(toolName)
* : alwaysVisible.includes(toolName),
* );
*
* @example Stack: read-only over skill-gated
* const provider = gatedTools(
* gatedTools(staticTools(allTools), readOnlyPredicate),
* skillGatePredicate,
* );
*/
import type { Tool } from './tools.js';
/**
* Read-only context the provider receives each iteration. Pure data
* — providers MUST NOT mutate. Used by gating predicates to inspect
* the current activation state.
*/
export interface ToolDispatchContext {
/** Current ReAct iteration (1-based). */
readonly iteration: number;
/**
* The id of the currently-activated Skill, if any.
* Set by `read_skill(id)` activation; cleared between turns.
* Used by `autoActivate`-driven per-skill tool gating.
*
* **This is the last `read_skill` activation, not the graph's position.** It is
* filled from the tail of `activatedInjectionIds`, which only `read_skill` ever
* appends to. For "which skills are actually loaded right now", including the ones
* an entry rule or a `skillGraph()` edge activated, read {@link activeSkillIds}.
*/
readonly activeSkillId?: string;
/**
* **Every skill active on this iteration** — the real active set, however each one
* got there: a `read_skill` activation, an entry rule, a `skillGraph()` route, a
* decision-tree leaf. Added in 8.7.0.
*
* `activeSkillId` answers "what did the model most recently open"; this answers
* "what is loaded". A provider that wants to follow the graph's POSITION — which
* `skillScopedTools`' own docs said was impossible — reads this one:
*
* ```ts
* const graphScoped = (id: string, tools: Tool[]): ToolProvider => ({
* id: `graph-scoped:${id}`,
* list: (ctx) => (ctx.activeSkillIds?.includes(id) ? tools : []),
* });
* ```
*
* Optional for forward-compat: a provider written before 8.7.0 sees `undefined` and
* behaves exactly as it always did.
*/
readonly activeSkillIds?: readonly string[];
/**
* Caller identity tuple — passed through from `agent.run({ identity })`.
* Permission predicates can role-check based on `identity.principal`
* or `identity.tenant`.
*/
readonly identity?: {
readonly tenant?: string;
readonly principal?: string;
readonly conversationId: string;
};
/**
* The identity the CALLER named in `run({ identity })` — the same fact a
* tool's `ToolExecutionContext.identity` carries. **Absent when the run's
* identity was derived** (from a `sessionId`, or the per-run default), which
* {@link identity} is not: it falls back to `{ conversationId: sessionId }` or
* `{ conversationId: runId }`, and a derived tuple is not an authenticated
* identity — a session id is caller data.
*
* A provider that keys anything live on the caller (a client per
* conversation, `mcpClientPool`'s provider) reads only this one.
*/
readonly callerIdentity?: {
readonly tenant?: string;
readonly principal?: string;
readonly conversationId: string;
};
/**
* Optional abort signal propagated from the agent's `run({ env })` /
* AbortController. Async providers (network discovery, MCP catalog
* fetch, registry pull) MUST honor this — abandon the in-flight
* request when the agent is cancelled mid-discovery, otherwise the
* provider holds the run open past abort. Sync providers can ignore
* it.
*/
readonly signal?: AbortSignal;
}
/**
* The provider interface. A `ToolProvider` answers ONE question per
* iteration: "what tools should the LLM see right now?"
*
* Implementations are PURE — given the same context, return the same
* tool list (no observable mutation; reentrant; safe under concurrent
* calls).
*
* **Sync vs async.** Most providers (`staticTools`, `gatedTools`,
* `skillScopedTools`) compute the answer synchronously and return
* `readonly Tool[]` — the agent's hot path skips the await microtask
* entirely via a runtime `instanceof Promise` check. Discovery-style
* providers (MCP catalog fetch, registry pull, dynamic skill resolution)
* may return `Promise<readonly Tool[]>`; the agent awaits only when
* the value is actually a Promise. Sync providers pay zero overhead.
*
* **Caching.** The agent calls `list(ctx)` once per iteration. For
* expensive lookups (network calls, hub queries), the provider is
* responsible for caching — typically TTL- or iteration-keyed. The
* framework deliberately does NOT cache for you because the cache
* key depends on which fields of `ctx` matter to your provider
* (e.g., per-skill vs per-tenant vs per-iteration).
*
* **Errors.** A throwing or rejecting provider emits
* `agentfootprint.tools.discovery_failed` and aborts the iteration —
* the run continues only if a configured `reliability` rule routes
* the error (`fail-fast`, `retry`, etc.). Discovery failure is loud
* by design; silently dropping tools mid-conversation produces
* non-deterministic agent behavior that's harder to debug than a
* crash.
*/
export interface ToolProvider {
/**
* Return the tool list visible to the LLM for the current iteration.
* Sync return is the fast path; Promise return is supported for
* discovery-style providers. The returned array MUST be a NEW
* reference each call (the agent compares for change detection).
* Order is preserved — the LLM may use position as a hint when tool
* descriptions are ambiguous.
*/
list(ctx: ToolDispatchContext): readonly Tool[] | Promise<readonly Tool[]>;
/**
* Optional: stable id for observability / debugging. Defaults to
* `'static'` for `staticTools`, `'gated'` for `gatedTools`. Custom
* implementations should set their own id — surfaces on
* `agentfootprint.tools.discovery_failed.providerId` so consumers
* can route alerts to the right hub adapter.
*/
readonly id?: string;
}
/** Predicate for `gatedTools` — runs per tool, per iteration. */
export type ToolGatePredicate = (toolName: string, ctx: ToolDispatchContext) => boolean;
//# sourceMappingURL=toolProvider.d.ts.map