agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
362 lines • 15.2 kB
JavaScript
;
/**
* checkin — the evidence-carrying human-consent primitive.
*
* "OpenWorker-class agents check in; agentfootprint checks in WITH THE
* RECEIPTS." A tool declares `checkIn` (see `Tool.checkIn` in `tools/tools.ts`); when
* it trips, the tool-dispatch loop pauses BEFORE executing and surfaces a
* {@link CheckInRequest} — a typed ask that carries an EVIDENCE PACK
* ({@link CheckInEvidence}): what the tool will do, what context the run
* consumed, which context drove the choice, and a compact run-so-far trail.
* A human answers with {@link checkInApproved} / {@link checkInDeclined};
* the {@link CheckInDecision} lands as a typed record.
*
* This is the LEAF that owns every check-in TYPE + the pure assembly logic.
* It has one runtime import, the `lib/frozenCopy.ts` leaf (which reads only
* footprintjs's dev-mode flag); the rest are type-only imports of the message
* shape + the influence-core attribution unit — so it stays cheap to import
* and trivial to unit-test. The pause plumbing lives in
* `core/agent/stages/toolCalls.ts`; the events in `events/`; the store in
* `recorders/core/CheckInRecorder.ts`.
*
* NOT a policy engine (the `PermissionChecker` in `provider-contract/types.ts` is
* untouched, and runs BEFORE this gate) and NOT UI. This asks a person to
* CONSENT, with the receipts attached.
*
* Pattern: Strategy (pluggable evidence assembler + driver scorer) + pure
* value types. Every value here survives `structuredClone` + JSON
* (checkpoint discipline) — no functions, no class instances.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveCheckInConfig = exports.minimalEvidenceAssembler = exports.standardEvidenceAssembler = exports.unitsFromHistory = exports.lexicalDriverScorer = exports.shouldCheckIn = exports.declinedResult = exports.looksLikeDecision = exports.isCheckInDecision = exports.checkInDeclined = exports.checkInApproved = void 0;
const frozenCopy_js_1 = require("../lib/frozenCopy.js");
/**
* Approve a pending check-in — the paused tool executes normally on resume.
*
* The decision names the request it answers (`request.id`), so it answers that
* request once and no other: a resume refuses it on a different pause, on a
* call that changed after the ask, or a second time (`ResumeDecisionError`).
*
* @throws TypeError when `request` is not a paused request (it has no `id`).
*
* @example
* const decision = checkInApproved({
* request: outcome.checkIn,
* by: 'alice@ops',
* note: 'verified with customer',
* });
* const final = await agent.resume(outcome.checkpoint, decision);
*/
function checkInApproved(input) {
return decide(true, input, 'checkInApproved');
}
exports.checkInApproved = checkInApproved;
/**
* Decline a pending check-in — the tool is NOT executed; the model receives
* a `"declined by human: <note>"` tool result and adapts in-loop.
*
* A decline is an answer too: it names its request and spends it, so an
* approval presented for the same request afterwards is refused.
*
* @throws TypeError when `request` is not a paused request (it has no `id`).
*
* @example
* const decision = checkInDeclined({
* request: outcome.checkIn,
* by: 'alice@ops',
* note: 'amount too high',
* });
* const final = await agent.resume(outcome.checkpoint, decision);
*/
function checkInDeclined(input) {
return decide(false, input, 'checkInDeclined');
}
exports.checkInDeclined = checkInDeclined;
function decide(approved, input, door) {
const requestId = input?.request?.id;
if (typeof requestId !== 'string' || requestId.length === 0) {
throw new TypeError(`${door}: \`request\` must be the paused request this decision answers — ` +
`outcome.checkIn or outcome.ask (PendingAsk.checkIn / .ask on a hosted door). A ` +
`decision names its request, so it can answer that request once and no other.`);
}
return {
requestId,
approved,
by: input.by,
at: Date.now(),
...(input.note && { note: input.note }),
// Carried on a DECLINE too: "none of these" is an answer with coverage —
// what they searched and did not find — not the absence of one.
...(input.value && { value: input.value }),
};
}
/**
* Type guard — is this resume input a {@link CheckInDecision}? Distinguishes
* a check-in answer from a plain `askHuman` tool-result value. A decision
* names its request: an object without a `requestId` is not one.
*/
function isCheckInDecision(x) {
return (looksLikeDecision(x) &&
typeof x.requestId === 'string' &&
x.requestId.length > 0);
}
exports.isCheckInDecision = isCheckInDecision;
/**
* The SHAPE of a decision before it names a request — `approved` and `by`.
* What tells "a decision that names no request" (refused by name, with the
* migration) apart from "not a decision at all" (`DecisionRequiredError`).
*
* @internal
*/
function looksLikeDecision(x) {
return (typeof x === 'object' &&
x !== null &&
typeof x.approved === 'boolean' &&
typeof x.by === 'string');
}
exports.looksLikeDecision = looksLikeDecision;
/**
* What the model reads for a call a person declined: the result the dispatch
* loop records, and the host's notice for a declined call a failed run left
* open (`hosting/answeredPause.ts`). Either way the call did not run.
*
* @internal
*/
function declinedResult(note) {
return note ? `declined by human: ${note}` : 'declined by human';
}
exports.declinedResult = declinedResult;
/**
* Decide whether a tool's declared demand trips for this call. Returns false
* when the tool declared no `checkIn` (backward-compatible: byte-identical
* behavior for tools without the field).
*
* The ONE place a predicate is asked, at every door (the loop's gate, an
* approved ask's resume). It is handed its OWN frozen copy of the arguments
* (`lib/frozenCopy.ts`), never the object the tool runs with: a predicate
* that writes into its argument — after a person approved the call, or after
* answering "no check-in needed" for it — must not change what runs. The
* write throws in strict code (and a predicate that throws trips) or is
* ignored in sloppy code (in dev mode, one warning names the cause). A copy
* the caller already shares with the call's other callbacks is handed on as
* it is (`frozenView`). Arguments no copy can be made of trip the gate.
*/
function shouldCheckIn(demand, args, ctx) {
if (demand === undefined)
return false;
if (demand === 'always')
return true;
const own = (0, frozenCopy_js_1.frozenView)(args);
if (own === undefined)
return true;
try {
return demand(own, ctx) === true;
}
catch (err) {
(0, frozenCopy_js_1.warnFrozenWrite)('checkIn predicate', err, 'the gate tripped, so the call asks a person');
// A buggy predicate must not let a consequential action slip through
// unreviewed — fail toward asking the human.
return true;
}
}
exports.shouldCheckIn = shouldCheckIn;
const WORD_RE = /[a-z0-9]+/g;
const STOP = new Set([
'the',
'a',
'an',
'and',
'or',
'to',
'of',
'in',
'on',
'for',
'with',
'is',
'are',
'this',
'that',
'it',
'as',
'at',
'by',
'be',
'you',
'your',
'i',
]);
function tokenize(text) {
const out = new Set();
const lower = text.toLowerCase();
let m;
while ((m = WORD_RE.exec(lower))) {
if (m[0].length > 1 && !STOP.has(m[0]))
out.add(m[0]);
}
WORD_RE.lastIndex = 0;
return out;
}
/**
* The default drivers scorer: deterministic Jaccard token overlap between
* the tool text and each context unit. Zero LLM, zero network, structuredClone
* -safe output. Ties keep input order (stable sort).
*/
const lexicalDriverScorer = ({ tool, units }) => {
const toolTokens = tokenize(tool.text);
const scored = units.map((u) => {
const unitTokens = tokenize(u.text);
let shared = 0;
for (const t of unitTokens)
if (toolTokens.has(t))
shared++;
const union = toolTokens.size + unitTokens.size - shared;
const score = union === 0 ? 0 : shared / union;
return { id: u.id, channel: u.channel, text: u.text, score };
});
// Stable descending sort — Array.prototype.sort is stable in modern engines.
return scored.sort((a, b) => b.score - a.score);
};
exports.lexicalDriverScorer = lexicalDriverScorer;
const MAX_SUMMARY = 160;
function truncate(s, max = MAX_SUMMARY) {
const flat = s.replace(/\s+/g, ' ').trim();
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
}
/** Render args as a compact `k=v, k=v` string (values truncated, clone-safe). */
function renderArgs(args) {
const parts = Object.entries(args).map(([k, v]) => {
const val = typeof v === 'string' ? v : v === null || v === undefined ? String(v) : JSON.stringify(v);
return `${k}=${truncate(val, 60)}`;
});
return parts.join(', ');
}
/** Plain-words "what will happen" claim. */
function buildWillDo(tool, args) {
const desc = tool.description.trim() || tool.name;
const rendered = renderArgs(args);
return rendered ? `${desc} — with ${rendered}` : desc;
}
/**
* Derive attribution units from the run-so-far conversation: system rules
* (split into sentences), the user task, and prior tool results. These feed
* BOTH `read` (as frames) and `drivers` (scored). Ids are unique + stable.
*/
function unitsFromHistory(history) {
const units = [];
let sysN = 0;
let taskN = 0;
let resN = 0;
for (const m of history) {
const content = (m.content ?? '').trim();
if (!content)
continue;
if (m.role === 'system') {
// Split the system prompt into rule-sized units so a single rule can be
// cited. Sentence-ish split; keep non-empty pieces.
for (const piece of content.split(/(?<=[.!?])\s+|\n+/)) {
const t = piece.trim();
if (t)
units.push({ id: `system-${++sysN}`, channel: 'system', text: t });
}
}
else if (m.role === 'user') {
units.push({ id: `task-${++taskN}`, channel: 'task', text: content });
}
else if (m.role === 'tool') {
units.push({
id: `result-${++resN}`,
channel: 'result',
text: m.toolName ? `${m.toolName}: ${content}` : content,
});
}
}
return units;
}
exports.unitsFromHistory = unitsFromHistory;
function framesFromUnits(units) {
return units.map((u) => ({ channel: u.channel, summary: truncate(u.text) }));
}
function buildTrail(history, iteration) {
const toolCalls = [];
for (const m of history) {
// A call the batch settlement answered was never dispatched (9.113.0) —
// not a completed call, so not in the receipts at all. Read off the
// marker, never the sentence. Its sentence stays in `read`: the model
// did read it (`unitsFromHistory`).
if (m.role === 'tool' && m.toolName && m.notDispatched === undefined) {
// A tool result whose content advertises an error is marked not-ok. The
// synthetic strings the loop writes ("[permission denied…]", "declined by
// human…", "credential error…") all read as failures here.
const c = (m.content ?? '').toLowerCase();
const ok = !(c.startsWith('[permission denied') ||
c.startsWith('declined by human') ||
c.startsWith('credential error') ||
c.startsWith('authorization required'));
toolCalls.push({ name: m.toolName, ok });
}
}
const summary = toolCalls.length === 0
? `no tools run yet (iteration ${iteration})`
: `${toolCalls.length} tool${toolCalls.length === 1 ? '' : 's'} run over ${iteration} iteration${iteration === 1 ? '' : 's'}`;
return { iteration, toolCalls, summary };
}
/**
* The `'standard'` assembler — fills all four evidence fields. The `drivers`
* ranking runs the configured scorer over the run-so-far context units; the
* default scorer is deterministic and makes zero LLM/network calls.
*/
const standardEvidenceAssembler = async (input) => {
const { tool, args, iteration, history, scorer, signal } = input;
const units = unitsFromHistory(history);
const willDo = buildWillDo(tool, args);
const read = framesFromUnits(units);
const trail = buildTrail(history, iteration);
let drivers = [];
if (units.length > 0) {
const toolText = `${tool.name} ${tool.description} ${renderArgs(args)}`.trim();
drivers = await scorer({
tool: { name: tool.name, text: toolText },
units,
...(signal && { signal }),
});
}
return { willDo, read, drivers, trail };
};
exports.standardEvidenceAssembler = standardEvidenceAssembler;
/** The `'minimal'` assembler — only `willDo`. Zero cost; no scorer call. */
const minimalEvidenceAssembler = (input) => ({
willDo: buildWillDo(input.tool, input.args),
});
exports.minimalEvidenceAssembler = minimalEvidenceAssembler;
/**
* Resolve builder options (or nothing) into a runtime config. The default —
* `standard` evidence + `lexicalDriverScorer` — makes a tool that declares
* `checkIn` work even when the builder never called `.checkIn()`.
*/
function resolveCheckInConfig(opts) {
// 8.13.0 — a scorer that will never be called is refused, not resolved.
// `minimalEvidenceAssembler` builds only `willDo`; the scorer's whole job is
// ranking `drivers`, which the minimal pack does not have. Configured and
// inert looks exactly like configured and working.
//
// Only the literal `'minimal'` PRESET is refused. A custom assembler is handed
// the scorer in its input and may legitimately call it, so the library cannot
// judge that pairing — and refusing what it cannot judge would be a guess.
if (opts?.scorer !== undefined && opts.evidence === 'minimal') {
throw new Error("AgentBuilder.checkIn: `scorer` has no effect with `evidence: 'minimal'`. The scorer " +
'ranks the `drivers` field — which context drove this tool choice — and the minimal ' +
'assembler builds only `willDo`, so the scorer is resolved and then never called. Use ' +
"`evidence: 'standard'` to get the drivers, or drop the `scorer`.");
}
const scorer = opts?.scorer ?? exports.lexicalDriverScorer;
const evidence = opts?.evidence ?? 'standard';
const assembler = evidence === 'standard'
? exports.standardEvidenceAssembler
: evidence === 'minimal'
? exports.minimalEvidenceAssembler
: evidence;
return { assembler, scorer };
}
exports.resolveCheckInConfig = resolveCheckInConfig;
//# sourceMappingURL=checkin.js.map