agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
212 lines • 10.2 kB
TypeScript
/**
* hostContract — THE BOUNDARY between the skill-graph runtime and whatever
* runs it (9.34.0).
*
* The skill graph is a pure decision layer: given an `InjectionContext` it
* says which skill the cursor is in, which skills are reachable from there,
* and which injections are active. It does not call a model, does not own a
* loop, and does not know what a `Tool` is in any particular framework. This
* module is the small set of shapes that statement needs to be TRUE:
*
* • {@link SkillToolSchema} / {@link SkillTool} — the narrow shape a skill's
* tools must have for the graph to reason about them (a name, a
* description, an input schema, something to run). agentfootprint's own
* `Tool` structurally satisfies it; so would any other framework's.
* • {@link SkillToolDescriptor} — a PLAIN description of a tool the graph
* needs to exist (`read_skill` above all, which is how the model moves
* the cursor). The graph describes it; the host constructs it in its own
* tool type.
* • {@link SkillCachePolicy} — the cache directive that rides the
* `ActiveInjection` projection across the host boundary.
* • {@link SkillGraphHost} — the obligations a host must meet for the graph
* to route correctly. Documentation as a type.
*
* WHY STRUCTURAL COPIES. `SkillToolSchema` and `SkillCachePolicy` are
* field-for-field mirrors of `provider-contract/types.ts#LLMToolSchema` and
* `cache/types.ts#CachePolicy`. Mirroring them is what lets a foreign host
* satisfy the boundary without importing an adapter layer it does not use;
* TypeScript's structural typing then makes the two spellings mutually
* assignable, so nothing inside agentfootprint changed shape. The mirrors are
* PINNED against their originals by
* `test/injection-engine/skill-graph-fence.test.ts`, which fails on drift
* in either direction.
*
* Zone: PURE CORE. Zero imports, by construction.
*/
/**
* A tool's LLM-facing declaration — name, description, input schema.
*
* Structural mirror of `LLMToolSchema` (`src/provider-contract/types.ts`). Anything
* that satisfies one satisfies the other.
*/
export interface SkillToolSchema {
readonly name: string;
readonly description: string;
readonly inputSchema: Readonly<Record<string, unknown>>;
}
/**
* The NARROW tool shape a skill carries in `InjectionContent.tools`.
*
* Two required members — a schema and something to run — because those are
* the only two the graph, the contract checker and the vocabulary checker
* ever read. agentfootprint's `Tool` adds credentials, artifact wants,
* check-in demands, capabilities and result ceilings on top; every one of
* them is optional, so a `Tool` IS a `SkillTool` and a `SkillTool` is
* accepted everywhere a `Tool` is. That is the point: a host on another
* framework hands its own tool objects straight in.
*
* `execute`'s context is deliberately `unknown` here — it is the HOST's
* execution context, and the graph never calls the tool.
*/
export interface SkillTool<TArgs = Record<string, unknown>, TResult = unknown> {
readonly schema: SkillToolSchema;
execute(args: TArgs, ctx: unknown): Promise<TResult> | TResult;
}
/**
* A tool the graph DESCRIBES but does not construct.
*
* `read_skill` is the load-bearing one: it is how the model asks to move the
* cursor, so the graph has to be able to say what that tool looks like —
* which ids are on the enum, which are reachable from here, what the menu
* offers this turn, what the result says. What it must NOT do is build the
* host's tool object, because that would drag a framework's tool factory
* (and everything that factory pulls in) into a pure decision layer.
*
* So the graph emits this POJO and the host adapts it:
*
* @example agentfootprint's own adapter (`skillTools.ts`)
* const d = readSkillDescriptor(skills, offer);
* return d && defineTool({ name: d.name, description: d.description,
* inputSchema: d.inputSchema, execute: d.execute });
*
* @example another framework
* const d = readSkillDescriptor(skills);
* myFramework.tool(d.name, d.description, d.inputSchema, d.execute);
*/
export interface SkillToolDescriptor<TArgs = Record<string, unknown>, TResult = unknown> {
readonly name: string;
readonly description: string;
readonly inputSchema: Readonly<Record<string, unknown>>;
/** Pure: same args in, same string out. No host context is passed. */
execute(args: TArgs): TResult;
}
/**
* Read-only snapshot a {@link SkillCachePolicy} predicate inspects.
* Structural mirror of `CachePolicyContext` (`src/cache/types.ts`).
*/
export interface SkillCachePolicyContext {
readonly iteration: number;
readonly iterationsRemaining: number;
readonly userMessage: string;
readonly lastToolName?: string;
readonly cumulativeInputTokens: number;
}
/**
* Whether an injection's content may be marked as a cacheable prefix.
* Structural mirror of `CachePolicy` (`src/cache/types.ts`) — carried
* across the boundary by `ActiveInjection.cache`, where a host that has no
* prefix cache at all can simply ignore it.
*/
export type SkillCachePolicy = 'always' | 'never' | 'while-active' | {
readonly until: (ctx: SkillCachePolicyContext) => boolean;
};
/**
* The obligations a host must meet to run a skill graph CORRECTLY.
*
* This is documentation as a type, not a runtime abstraction. Nothing in
* agentfootprint constructs one, nothing consumes one, and implementing it
* does not start a run — the package has exactly one run door and this is
* not a second one. It exists so the contract that was previously spread
* across four call sites in three files can be READ in one place, and so a
* host on another framework can typecheck its own wiring against it.
*
* `buildInjectionEngineSubflow.ts` + `core/agent/stages/toolCalls.ts` are the
* reference implementation of every clause below.
*
* @example checking a foreign host against the contract
* const host: SkillGraphHost = {
* advanceCursor: (ctx) => graph.explainNextSkill(ctx).to,
* acceptSkillPick: (id, cursor) => graph.reachableSkills(cursor).includes(id),
* …
* };
*/
export interface SkillGraphHost {
/**
* **Obligation 1 — one cursor advance per iteration, off the SAME ctx.**
*
* Build the iteration's `InjectionContext` ONCE, ask the graph where the
* cursor goes with that exact object, and evaluate every trigger with that
* exact object. Route triggers are compiled as `nextSkill(ctx) === id`, so
* an active set derived from a different ctx than the stored cursor can
* disagree with it — the skill the loop thinks it is in and the body it
* actually injects come apart. This is the keystone; everything else here
* is bookkeeping around it.
*
* Reference: `buildInjectionEngineSubflow.ts` — `baseCtx` is built, handed
* to `explainNextSkill`, then handed to `evaluateInjections`.
*/
advanceCursor(ctx: SkillGraphIterationContext): string | undefined;
/**
* **Obligation 2 — enforce reachability at PICK time.**
*
* When the model calls `read_skill(id)`, the host must check `id` against
* `graph.reachableSkills(cursor)` BEFORE treating the pick as real, and
* refuse teachingly when it is not reachable. The graph cannot enforce
* this itself: it never sees the tool call. A host that skips the gate
* turns every declared edge into a suggestion.
*
* Reference: `toolCalls.ts` — `deps.allowedSkillIds(currentSkillId)`.
*/
acceptSkillPick(skillId: string, currentSkillId: string | undefined): boolean;
/**
* **Obligation 3 — publish an ACCEPTED pick, and only an accepted one.**
*
* `InjectionContext.pendingSkillPick` is the model's validated volunteer
* hop. Set it only after obligation 2 has said yes (a refused pick must
* never move the cursor), and clear it at the top of every iteration so it
* names a pick made just now rather than a stale one.
*
* Reference: `toolCalls.ts` — cleared once per iteration, then set only on
* the accepted-hop path.
*/
publishAcceptedPick(skillId: string | undefined): void;
/**
* **Obligation 4 — carry the cursor across iterations.**
*
* The value obligation 1 produced becomes the NEXT iteration's
* `currentSkillId`. One run's worth: a skill graph declares how one turn
* is routed, so a fresh run starts at the entry, not where the last one
* stopped. A host that wants continuity persists the id itself and starts
* the next turn's graph from it.
*
* Reference: `buildInjectionEngineSubflow.ts` writes the advanced cursor
* to a distinct output key; the mount mappers map it onto the parent's
* `currentSkillId` for the next iteration.
*/
carryCursor(nextSkillId: string | undefined): void;
/**
* **Obligation 5 — say what happened, out loud.**
*
* Every routing decision the graph makes is evidence, and evidence that is
* not emitted did not happen as far as any observer is concerned. A host
* should surface at minimum: a skill becoming active or inactive, a pick
* REFUSED by the gate, and a batch whose edges disagreed. agentfootprint
* spells these `agentfootprint.skill.activated` / `.deactivated` /
* `.rejected` / `.escalated` / `.route_conflict` / `.reroute_superseded` /
* `.turn_routed`; a foreign host may name them whatever it likes, but a
* refusal the operator cannot see is the one this contract cares about.
*/
emitSkillEvent(name: string, payload: Readonly<Record<string, unknown>>): void;
}
/**
* The per-iteration context obligation 1 reads. Kept structural (rather than
* naming `InjectionContext`) so this module stays a zero-import leaf; the
* real `InjectionContext` satisfies it.
*/
export interface SkillGraphIterationContext {
readonly iteration: number;
readonly userMessage: string;
readonly currentSkillId?: string;
readonly pendingSkillPick?: string;
}
//# sourceMappingURL=hostContract.d.ts.map