agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
199 lines (198 loc) • 10.5 kB
TypeScript
import type { ToolAbsence } from '../semantics/coverage/types.js';
import { type TimeAskMessages, type TimeFormat } from '../time/ask.js';
import type { ZoneName } from '../time/zone.js';
/** Typed missing-input values. Collection is distinct from permission or consent. */
export type InputValue = string | number | boolean;
export interface InputField {
readonly id: string;
readonly type: 'string' | 'number' | 'boolean';
readonly required?: boolean;
readonly description?: string;
readonly enum?: readonly InputValue[];
/**
* A TIME field (time design § 6.1): the library checks the answer before the
* app sees it. `'instant'` — an ISO 8601 date-time with its offset
* (`2026-10-09T08:00-07:00`); `'time-range'` — an ISO 8601 interval
* `from/to` of two such instants, `from` before `to`; `'zone'` — an IANA
* zone name. The value stays a string on the wire, so refused unless
* `type: 'string'`. An answer that fails the check is not taken: the resume
* door asks again with `refused: { answer, reason }` (the reason a catalog
* sentence, `defaultTimeAskMessages`) and `repeat: { count }`, and nothing
* runs. A choice or a supplied value that fails it is refused at definition.
*/
readonly format?: TimeFormat;
/**
* One label per `enum` choice, in its order — what a person reads beside
* the value (`'Fri, Oct 9, 2026, 8:00 – 8:40 AM PDT'`); the value is what the
* answer carries. Needs `enum`.
*/
readonly labels?: readonly string[];
/**
* A time field's choices are the only answers. Without it a `format` field
* with `enum` keeps free entry open: any answer the format check takes is
* taken. Needs `format` and `enum`.
*/
readonly strict?: boolean;
}
export interface InputRequestDeclaration {
readonly id: string;
readonly question: string;
readonly fields: readonly InputField[];
/** Values the collection tool already knows; never labelled as a person's answer. */
readonly supplied?: Readonly<Record<string, InputValue>>;
/** Opaque JSON authored by the collecting tool, never editable by the reply. */
readonly context?: Readonly<Record<string, unknown>>;
/**
* What the tool LOOKED AT before it asked (9.114.0): the envelope `absent()`
* returns, when a lookup found nothing and raises this request about the
* miss. Recognized at raise time by the one recognizer
* (`../semantics/coverage/absent.ts` · `readAbsence`) — anything it does not read
* is refused, and `null` is the field omitted — and filed by the dispatch
* door at the raise, before the
* checkpoint is returned: the same `tools.absent` event and
* `coverageDeclared` row a RETURNED absence files
* (`core/agent/stages/toolCalls.ts` · `declareRaisedAbsence`). Data for the
* record, not for the ask: it never rides the awaiting-input shape or the
* paused call's served result, and nothing on resume reads it, so the
* pending question says the miss only if `question` says it in words.
* Under `.limitsTravelWithTheAnswer()` the final answer's limits block
* carries it, as it does a returned miss.
*/
readonly absence?: ToolAbsence;
/**
* The previous answer to this ask was REFUSED, and why: the app
* validated what the person gave, turned it down, and asks again. Carried
* on the awaiting-input shape the person receives — the checkpoint's
* `pauseData`, the pause outcome, the `pause.request` event — so a UI can
* say "Your answer '…' was not accepted: <reason>" instead of repeating
* the same question in silence. The reason is the APP'S words — with one
* exception the app arms itself: a time field's answer the library's check
* refused (`InputField.format`), whose reason is a catalog sentence the app
* can override (`defaultTimeAskMessages`, `.time({ messages })`). `answer`
* is optional and judged against `fields` like any answer; `null` is the
* field omitted.
*/
readonly refused?: InputRefusal;
}
/** An app's refusal of the previous answer to the same ask — see `InputRequestDeclaration.refused`. */
export interface InputRefusal {
/** The refused values, field id → value, as the person gave them. */
readonly answer?: Readonly<Record<string, InputValue>>;
/** Why it was refused, in the app's own words — or, for a time field the library checked, its catalog's (non-blank, at most 4096 characters). */
readonly reason: string;
}
/**
* The runtime's mark on a RE-ASK: the same ask (same declaration
* `id`) raised again in the same turn after the person answered it. Facts
* only — never a reason. Stamped by the runtime, never declarable.
*/
export interface InputRepeat {
/** How many times the person has already answered this ask in this turn. */
readonly count: number;
/**
* The person's previous answer (the fields they supplied) as the RECORD
* holds it — after the tool-result rules, redaction first among them, ran
* on it. Absent when the record no longer holds it in that shape (a rule
* replaced the result, placement moved it), never reconstructed.
*/
readonly previousAnswer?: Readonly<Record<string, InputValue>>;
}
/** The stamped request as the person, the model and the durable pause read it — never the `absence`. */
export interface AwaitingInput extends Omit<InputRequestDeclaration, 'absence'> {
readonly status: 'awaiting_input';
/** Runtime-stamped token, distinct from the author's reusable declaration id. */
readonly requestId: string;
readonly supplied: Readonly<Record<string, InputValue>>;
readonly origins: Readonly<Record<string, 'declaration' | 'response'>>;
readonly missing: readonly string[];
/** Present only on a re-ask — see `InputRepeat`. */
readonly repeat?: InputRepeat;
readonly origin: {
readonly originalRequest: string;
/**
* The call that raised the request. For the inputs layer's own batch ask
* (`context.agentfootprint.ask === 'arguments'`, honesty layer 2) no single
* call raised it — the library asked before anything in the batch ran — so
* this names the batch's FIRST asked call, and `context.agentfootprint.fields`
* lists every call each field is for.
*/
readonly toolCallId: string;
readonly skillId?: string;
readonly offeredSkillIds?: readonly string[];
};
}
export interface InputResponse {
readonly requestId: string;
readonly values: Readonly<Record<string, InputValue>>;
}
export interface InputCancellation {
readonly requestId: string;
readonly cancel: true;
}
/** The dedicated collecting tool's result; it contains inputs, not observations. */
export interface InputResponseResult {
readonly status: 'input_received';
readonly requestId: string;
readonly values: Readonly<Record<string, InputValue>>;
readonly origins: AwaitingInput['origins'];
readonly context?: InputRequestDeclaration['context'];
readonly origin: AwaitingInput['origin'];
}
/** A malformed or stale data reply; no value was consumed and the ask remains live. */
export declare class InputRequestError extends TypeError {
readonly code: "ERR_INPUT_REQUEST_INVALID";
constructor(reason: string);
}
/**
* A value a typed ask can carry in a field — as a supplied value, a choice or
* an answer: a non-blank string of at most 4096 characters, a finite number,
* or a boolean. The one rule, shared by this module's validation and by the
* declarations that build a typed ask (`tools/declare.ts` judges an
* `ask` rule's choices with it at definition).
*/
export declare function isInputFieldValue(value: unknown): value is InputValue;
/** One validation owner, shared by the declaration and durable-pause readers. */
export declare function validateInputDeclaration(raw: unknown): InputRequestDeclaration;
/** Where the same ask was last answered in this turn — what the runtime knows at a re-ask. */
export interface AnsweredAsk {
/** The declaration `id` that was answered. */
readonly id: string;
/** The answered request's runtime token — how its landed result is found in the record. */
readonly requestId: string;
/** How many times this ask has been answered in this turn, this answer included. */
readonly count: number;
}
/**
* The re-ask mark for a declaration, or `undefined` when this is not a re-ask.
* The previous answer is READ FROM THE RECORD — the
* `input_received` result that landed for `answered.requestId`, after the
* tool-result rules ran on it — so it is redacted exactly as the answer is.
* Only the fields the PERSON supplied (origin `'response'`) are kept; when
* the record holds no such result, or its values no longer satisfy the
* fields, the mark carries the count alone.
*/
export declare function repeatOf(declaration: InputRequestDeclaration, answered: AnsweredAsk | undefined, history: readonly {
readonly role: string;
readonly content: unknown;
}[]): InputRepeat | undefined;
/** Runtime-only stamping: a model cannot choose its saved skill or original request. */
export declare function stampInputRequest(declaration: InputRequestDeclaration, requestId: string, origin: AwaitingInput['origin'], repeat?: InputRepeat): AwaitingInput;
/** Read only an explicitly declared, well-shaped input pause; never assistant prose. */
export declare function readAwaitingInput(pauseData: unknown): AwaitingInput | undefined;
/**
* What the resume door knows when it checks a time field's answer: the
* person's zone (the run clock's, under `.time()` — it arms the DST-gap check)
* and the app's catalog overrides (`.time({ messages })`).
*/
export interface TimeAnswerContext {
readonly zone?: ZoneName;
readonly messages?: Partial<TimeAskMessages>;
}
/**
* Accept typed fields, or explicit cancellation, without coercing free text.
* A time field's answer (`InputField.format`) is checked here too; one the
* check refuses comes back as the same ask with `refused` and `repeat`, its
* field `missing` again — the door that keeps a partial answer asks again
* without running anything.
*/
export declare function applyInputResponse(waiting: AwaitingInput, raw: unknown, time?: TimeAnswerContext): AwaitingInput | InputCancellation;