agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
175 lines • 9.48 kB
JavaScript
/**
* toolDispatch — the run's own tool dispatch, delivered as `ctx.tools`.
*
* Pattern: a deps-closure factory beside `toolArtifacts` / `toolProgress` in
* the tool-calls handler; this module holds the PURE half (the
* dispatch object over a lookup + an inner-context maker) so the
* handler wires closures and nothing else.
* Role: core/agent — composition over the registry (the runbookAsTool
* substrate; any tool may consume it).
* Emits: nothing itself. Inner executes flow through the tool's own
* channels (`ctx.progress`, its result); they do NOT fire
* `tool_start`/`tool_end` — an inner call is the OUTER call's work,
* not a turn of the model's, and synthesizing model-facing events
* for it would put calls in the record the model never made.
*
* What an inner call is NOT (phase 1, refused loudly, never silently):
* - it cannot pause — a `checkIn` tool or a credential that needs
* interactive consent refuses by name (a pause the inner tool itself
* RAISES, `requestInput` or `pauseHere`, is not refused: it passes up as
* the OUTER call's pause, and an `absence` it declares is filed under the
* outer call — `semantics/coverage/README.md` § 4);
* - it cannot redeem artifact refs — a `wants` tool refuses by name
* (dispatch-time resolution belongs to the model-facing loop);
* - it does not see ToolProvider-delivered tools — there is no build-time
* list of those (the 9.72.0 caveat, carried forward honestly);
* - it does not carry `ctx.tools` itself — composition depth stops at one,
* the same bound the runbook grammar declares for sub-runbooks;
* - it does not apply argument rules — a tool that declares `askOrAssume`
* refuses an inner call that leaves a ruled argument out (the inputs
* layer fills and records only the model's own calls). A declared
* `period`'s forms are ALTERNATIVES: the window is owed once, and a call
* that gives one form whole (a look-back, or both bounds) is not refused
* for the other forms' arguments.
*/
import { formatToolArgIssues, validateToolArgs } from '../../tools/toolArgsValidation.js';
import { isMissing, isRefused, periodFormsOf, rulesOf, sentFormOf, untakenBesides, } from '../../tools/declare.js';
import { formArguments } from '../../time/periodForm.js';
/**
* Build the `ctx.tools` dispatch for one outer tool call.
*
* `call` resolves the inner tool's declared `needs` through the inner
* context's own credential provider (fail-closed — the provider throws its
* teaching refusal when none is attached), refuses the shapes an inner call
* cannot honor (see the module header), validates the declared input schema
* before creating an inner context, executes, and returns the result exactly
* as the tool returned it. Policy about what a result MEANS — an
* absence that should short-circuit, a coverage ledger that should fold —
* belongs to the consumer wrapping this dispatch, never here.
*/
export function agentToolDispatch(deps) {
let seq = 0;
return {
has(name) {
return deps.lookup(name) !== undefined;
},
async call(name, args, opts) {
const tool = deps.lookup(name);
if (tool === undefined) {
throw new Error(`ctx.tools.call('${name}'): no tool of that name is in this agent's dispatch map ` +
`(static .tool() registrations plus skill-carried tools). Tools delivered by a ` +
`ToolProvider are not visible to inner dispatch — there is no build-time list of ` +
`them. Register the tool statically, or check ctx.tools.has() first.`);
}
if (tool.checkIn !== undefined) {
throw new Error(`ctx.tools.call('${name}'): that tool declares a human check-in, and an inner ` +
`dispatch call cannot pause — running it here would silently skip a consent ` +
`gate somebody declared. Call it as a top-level tool, where the check-in rail ` +
`holds.`);
}
if (tool.wants !== undefined) {
throw new Error(`ctx.tools.call('${name}'): that tool declares artifact arguments (wants), and ` +
`inner dispatch does not resolve refs — the tool would run believing the ` +
`framework delivered data it did not. Call it as a top-level tool, or pass the ` +
`data through a tool that takes it directly.`);
}
refuseUnaccountedRuledArguments(name, tool, args);
const validation = validateToolArgs(args, tool.schema.inputSchema);
if (!validation.ok) {
throw new Error(formatToolArgIssues(name, validation.issues));
}
seq += 1;
const base = deps.innerContext(name, seq);
const ctx = {
...base,
...(opts?.signal !== undefined && { signal: opts.signal }),
...(tool.needs !== undefined && {
credential: await resolveInnerCredential(name, tool, base),
}),
};
return await tool.execute(args, ctx);
},
};
}
/**
* An inner call to a tool that declares argument rules (honesty layer 2) must
* give every ruled argument itself: inner dispatch fills nothing and files no
* row — the values are the composing tool's code, and the OUTER call is the
* accounted unit — so running a ruled tool with a ruled value missing would
* run it on a value nobody chose, with nothing on the record. Refused by name,
* like a `checkIn` or `wants` tool here. A rule that cannot be read refuses
* too; a tool that declares nothing is never asked.
*
* A window is owed ONCE: a period's forms are alternatives, so the form the
* call sent its window in (`declare.ts` · `sentFormOf`) is the window and the
* other forms' arguments are not owed; a period with several forms left out,
* or given in part, is named once, as its forms. One form keeps the
* per-argument sentence it always had.
*/
function refuseUnaccountedRuledArguments(name, tool, args) {
const rules = rulesOf(tool);
if (rules === undefined)
return;
if (isRefused(rules)) {
throw new Error(`ctx.tools.call('${name}'): that tool's argument rules could not be read ` +
`(${rules.refused}) — inner dispatch refuses it rather than run it unruled.`);
}
const given = args !== null && typeof args === 'object' && !Array.isArray(args)
? args
: {};
const forms = periodFormsOf(rules.period);
const several = forms.length > 1;
const sent = several ? sentFormOf(forms, given) : undefined;
const untaken = sent !== undefined ? untakenBesides(forms, sent) : new Set();
const missing = rules.ruled
.filter((r) => !untaken.has(r.argument) && isMissing(given, r.argument))
.map((r) => r.argument);
if (missing.length === 0)
return;
const window = several ? windowArgumentNames(forms) : new Set();
const named = missing.filter((m) => !window.has(m)).map((m) => `'${m}'`);
if (missing.some((m) => window.has(m)))
named.push(`one window — ${formsSpelled(forms)} —`);
throw new Error(`ctx.tools.call('${name}'): that tool declares argument rules (askOrAssume) and the call ` +
`leaves ${named.join(', ')} out — inner dispatch fills nothing and ` +
`files no row, so running it would run on a value nobody chose, off the record. Pass ` +
`every ruled argument, or call the tool as a top-level tool, where the inputs layer ` +
`applies its rules.`);
}
/** Every form's window arguments (the zone aside — a zone is owed as itself). */
function windowArgumentNames(forms) {
return new Set(forms.flatMap((f) => formArguments(f)
.filter((a) => a.role !== 'zone')
.map((a) => a.argument)));
}
/** The forms, spelled for a sentence: `'window', or 'start' and 'stop'`. */
function formsSpelled(forms) {
return forms
.map((f) => formArguments(f)
.filter((a) => a.role !== 'zone')
.map((a) => `'${a.argument}'`)
.join(' and '))
.join(', or ');
}
/**
* Resolve a declared `needs` for an inner call — the simple, non-interactive
* path only. Fail-closed: anything short of an issued credential refuses by
* name, because the consent flow the outer loop would ride (pause, URL,
* resume) has no seat inside a dispatch call.
*/
async function resolveInnerCredential(name, tool, ctx) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const need = tool.needs;
const result = await ctx.credentials.getCredential({
service: need.credential,
...(need.scopes && { scopes: need.scopes }),
...(need.mode && { mode: need.mode }),
});
if (result.status === 'issued')
return result.credential;
throw new Error(`ctx.tools.call('${name}'): the tool's declared credential '${need.credential}' did not ` +
`resolve to an issued credential (status '${result.status}'). An inner dispatch call ` +
`cannot pause for consent — complete the authorization first, or call the tool at the ` +
`top level where the consent rail can hold the run.`);
}
//# sourceMappingURL=toolDispatch.js.map