agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
302 lines (301 loc) • 17.5 kB
TypeScript
/**
* hosting/answeredPause — a stored pause whose resume failed and may have used
* its decision: answered, with the outcome not known.
*
* Role: the one owner of that state, for EVERY door that keeps a paused run
* (`standingAgent`, `streamHandler`). Once a resume's decision may have
* reached the ledger the call may have run, so a failure — in the run,
* or in storing its result — must stop the pause being offered for an
* answer: a retry, a rebuilt instance, a restart or another host on the
* same store would answer it again. The conversation must still be able
* to go on.
*
* readStoredSession the one reading of a stored session: an
* answered pause is a conversation to
* continue, never a pause to answer
* listedFormatOf the same reading for a session listing
* answeredRefusal what an answer to it is refused with
* watchAnswer opened before a door hands an answer over
* afterFailedAnswer the one step after that answer's resume failed
* unanswerablePause a pause no answer can reach any more, and
* closedConversation the conversation it goes on from
*
* ── MARKED WHEN ITS DECISION MAY HAVE BEEN USED ─────────────────────────────
* The call may have run only if the decision reached a ledger, and
* `spendDecision` is the one place it does — so a door asks the watch it
* opened before handing the answer over (`core/consentRequest.ts` ·
* `watchDecisionUse`). Spent, refused as spent already, still unanswered, or a
* failed spend its ledger cannot say never landed: marked. Taken up by this
* library and never taken to a ledger — refused, or aborted before the spend:
* the pause stays answerable. Never taken up at all: marked, because that
* cannot be told apart from a use. What an error calls itself proves nothing,
* and nothing is read from the decision object for it, which a door may copy
* or wrap on its way to the resume.
*
* ── A PAUSE NO ANSWER CAN REACH IS CLOSED, NOT HELD ─────────────────────────
* A consent pause that recorded no request (stored before requests were
* named), whose call changed after the ask, or an ask made under rules the
* resuming agent no longer runs (a redeploy) can never be answered — every
* decision is refused. Held, it would hold the session for good. So a door
* closes it: the next message continues the conversation with the calls it
* left open settled, and a decision refused for one of those reasons — having
* used nothing — closes it too, when its question names the request it asked
* (one stored before requests were named names none: the next message closes
* that one).
*
* ── "DID NOT RUN" ONLY ON PROOF ─────────────────────────────────────────────
* A pause that still says it is waiting is no proof its call never ran: a
* resume can spend the decision, run the call, fail, and fail again to mark
* the pause answered — and a redeploy can then make it a pause no answer can
* reach. So a closed pause's open calls are settled as not run
* ({@link NOT_RUN_UNANSWERABLE}) only when the ledger the request was minted
* under — the serving agent's, and only that one — spends the request itself
* (`consentRequest.ts` · `proveNeverRan`): atomic, and it consumes the request
* so no later resume can run the call. Otherwise, and whenever that proof
* cannot be had, as possibly run ({@link OUTCOME_UNKNOWN_UNANSWERABLE}: do not
* run it again, ask the person). A false "may have run" is the safe
* direction: a model told a refund did not run may issue it again.
*
* Reads: the stored session — never the runner's own state, which on a
* shared agent may be another session's run, or a run that never
* started.
* Writes: the marked pause, through the door's session store.
*
* ── WHY THE OPEN CALLS ARE SETTLED ─────────────────────────────────────────
* A conversation whose last assistant turn holds a tool call with no result
* cannot be continued: a provider refuses the next request. So every call left
* open gets the host's notice ({@link OUTCOME_NOT_RECORDED}) — never a result,
* because the host kept none and whether the call ran is not known. When the
* run kept this session's conversation while it ran (a durable writer), that
* is what goes on, its recorded results included.
*/
import { type DecisionUseWatch, type UnanswerableReason } from '../core/consentRequest.js';
import { ResumeDecisionError } from '../core/pause.js';
import type { AgentRunCheckpoint } from '../core/runCheckpoint.js';
import type { LLMMessage } from '../provider-contract/types.js';
import type { CheckpointEnvelope, PausedRun, SessionLifecycle } from './types.js';
/** What a stored session holds, for a door that continues it ({@link readStoredSession}). */
export interface StoredSession {
/** The conversation the next message continues — a stored one, or an answered pause's. */
readonly prior?: AgentRunCheckpoint;
/** The pause still waiting on a person: the only one a door may offer for an answer. */
readonly waiting?: PausedRun;
/** A pause whose decision a resume already used — an answer to it is refused. */
readonly answered?: PausedRun;
}
/**
* Read a stored session the way every door must: a pause still waiting is
* offered for an answer; an ANSWERED pause is the conversation the next
* message continues and is never offered again; a conversation is a
* conversation. Throws as the envelope readers do on a format this runtime
* cannot read.
*/
export declare function readStoredSession(stored: CheckpointEnvelope): StoredSession;
/**
* The format a session LISTING reports for a stored envelope
* (`SessionSummary.format`): `'flowchart-v1'` only for a pause still waiting
* on somebody's answer — the one a door would offer for an answer — and
* `'conversation-v1'` for a conversation, an answered pause
* (`PausedRun.answered`) and a pause no answer can reach any more
* ({@link unanswerablePause}, as far as the stored checkpoint can tell).
*
* The one reading for every store that lists, so a sidebar never badges a
* pause the doors would not offer for an answer as "awaiting you". A store
* that keeps the format in a column or a field writes this when it persists,
* and reads a row that still says `'flowchart-v1'` again when it lists
* ({@link listedFormatOfRow}); one that reads its envelopes at list time calls
* it there. The envelope itself is unchanged: `hydrate` hands back what was
* stored.
*
* Lenient, as a listing must be — it never throws. A value that is not an
* envelope lists as `'unknown'`, and a pause the readers would refuse lists
* as stored, so `hydrate` refuses it by name. An ask whose later rules a
* redeploy changed is told by the serving agent, not by the stored pause: it
* lists as waiting until a door closes it.
*
* @example
* // A store's listByUser — or its persist, when the format is a column:
* const format = listedFormatOf(envelope);
*/
export declare function listedFormatOf(envelope: unknown): string;
/**
* The listed format of a row that KEEPS one beside its envelope (a SQLite
* column, a Firestore field): what the row says — except a stored
* `'flowchart-v1'`, which is read again from the envelope ({@link listedFormatOf}).
*
* A row written before the kept format was the listed one (an earlier
* release, or an older replica still writing) holds the envelope's own
* format, so a pause stored then says `'flowchart-v1'` whether or not anyone
* can still answer it. Only that value can be stale: a row that says
* anything else is not a pause still waiting, and nothing makes it one
* without a write. Never throws — text this runtime cannot read keeps what
* the row says (`hydrate` refuses it by name).
*
* @param stored the format the row keeps.
* @param envelopeJson the envelope stored beside it, as JSON text.
*/
export declare function listedFormatOfRow(stored: string, envelopeJson: unknown): string;
/**
* What an answer to an answered pause is refused with: `'already-answered'`,
* whose message says the earlier resume's outcome is not known here.
*/
export declare function answeredRefusal(answered: PausedRun): ResumeDecisionError;
/**
* The watch a door opens BEFORE it hands an answer to its runner, and releases
* once {@link afterFailedAnswer} has decided — `undefined` for a pause no
* decision answers. Keyed by the pause's request, never by the answer object.
*/
export declare function watchAnswer(paused: PausedRun): DecisionUseWatch | undefined;
/**
* What {@link afterFailedAnswer} did to the stored pause: marked it answered,
* closed it (no answer could ever reach it), or kept it as it was.
*/
export type FailedAnswerOutcome = 'marked' | 'closed' | 'kept';
/**
* After a resume of a consent gate — or what followed it, storing its result
* included — FAILED, at any door that keeps the pause: when the watch says
* its decision may have been used (`DecisionUseWatch.mayHaveBeenUsed`),
* persist the pause marked answered; otherwise nothing of it was used and the
* pause stays answerable.
*
* Read first, to leave alone what must not be overwritten — but only when the
* read SUCCEEDS: a newer pause the run stored before the failure (this pause
* is gone, and a decision for it is refused as another request's) or a mark
* already there. A store that cannot be read, or holds what this runtime
* cannot read, is marked anyway: failing to read is no proof the decision was
* unused. A store that holds NOTHING is left so — the session expired or was
* forgotten while the answer ran, and a mark would bring it back. The
* conversation the mark goes on from is what the run kept for this session (a
* durable writer's conversation) when the read shows one, else the
* conversation as of the pause — never the runner's own state, which on a
* shared agent may be another session's run.
*
* A declined decision never runs its call — but a decline is recorded only
* when it is what the ledger spent: when the answer that failed was a decline
* AND the watch saw the request spent for a decline, with no approval taken
* to a ledger (`DecisionUseWatch.spentAsDecline`), the paused call is
* settled as declined. A decline refused because the request was already
* spent — an approval, here or in another process, may have run the call —
* is settled as unknown, like any other.
*
* Unused, and refused for a reason no decision can change (`error` is a
* `'request-not-recorded'`, `'call-changed'`, `'chain-changed'` or
* `'ledger-changed'` refusal):
* the pause is CLOSED instead — it could only ever hold the session. Read
* from the refusal's own code, which is safe only because THIS resume used
* nothing. An earlier one may have — a resume that ran the call and then
* could not mark the pause answered — so the calls it leaves open are
* recorded as not run only on proof ({@link closedConversation}, asked of
* `runner`'s ledger), else as not known.
*
* Only a failure of the WRITE itself is swallowed — the run's failure is the
* one the caller is told, and the ledger still refuses the decision in this
* process — and it is reported on the console, never silent.
*
* @param watch the door's {@link watchAnswer}, opened before the resume.
* @param answer the answer as the door handed it over — read only to tell a
* decline, and only from its own data properties.
* @param settle writes still in flight for this session (a durable writer),
* drained first so none lands on top of the mark.
* @param runner the runner the answer was handed to — whose ledger can prove
* that a closed pause's calls never ran.
*/
export declare function afterFailedAnswer(input: {
readonly store: SessionLifecycle;
readonly sessionId: string;
readonly paused: PausedRun;
readonly watch: DecisionUseWatch | undefined;
readonly answer?: unknown;
/** What the resume — or what followed it — threw. */
readonly error?: unknown;
readonly settle?: () => Promise<void> | undefined;
readonly runner?: unknown;
}): Promise<FailedAnswerOutcome>;
/**
* What the model reads for a call whose question no answer can reach any more
* — its pause was closed, and the call PROVABLY did not run: the ledger its
* request was minted under spent the request itself, so no decision for it
* had been handed over and none ever can be (`consentRequest.ts` ·
* `proveNeverRan`).
*/
export declare const NOT_RUN_UNANSWERABLE = "That call did not run: the question it waited on could no longer be answered.";
/**
* What the model reads for each call a pause no answer can reach left open,
* when it cannot be proven that the call never ran: a decision for the pause
* may have been used — by a resume whose failure the host could not record —
* so the call may have run. Never "did not run" without proof, and never an
* invitation to try again: a model told a refund did not run may issue it
* again. The same words for every open call — the paused one and a sibling
* that never needed an approval alike — so none of them names an approval.
*/
export declare const OUTCOME_UNKNOWN_UNANSWERABLE = "This call may have run; no result was kept. Do not run it again. Tell the person it may already have happened and ask them to check.";
/**
* Why the stored pause can never be answered by `runner` — `undefined` when it
* can, or when that cannot be told (`core/consentRequest.ts` ·
* `unanswerableReason`). Without a runner, or with one whose rules and
* ledger a host cannot see (a composition, an app's own), an ask's chain and
* the ledger binding (`'ledger-changed'`) are not checked.
*/
export declare function unanswerablePause(paused: PausedRun, runner?: unknown): UnanswerableReason | undefined;
/** The door closing a pause: who serves the session, and where it keeps its pauses. */
export interface ClosingDoor {
/** The runner serving the session — an agent this library built names its ledger. */
readonly runner?: unknown;
/** The door's session store — this process's memory or not (`processLocalStore.ts`). */
readonly store?: unknown;
}
/**
* The conversation a pause no answer can reach goes on from: every call it
* left open settled by the host's notice — none of them can run from here.
*
* "Did not run" ({@link NOT_RUN_UNANSWERABLE}) ONLY when non-execution is
* proven: the ledger the request was minted under — the serving agent's,
* and only when it is the same one — spends the request itself, atomically
* (`consentRequest.ts` · `proveNeverRan`). That both proves no decision had
* reached it and makes sure none ever will: closing CONSUMES the request. The
* record alone cannot say it — a resume may have run the call and then failed
* to mark the stored pause answered, before a redeploy made the pause one no
* answer can reach. Otherwise, and whenever the proof cannot be had, every
* open call is recorded as possibly run ({@link OUTCOME_UNKNOWN_UNANSWERABLE}).
* Every notice is marked as the library's (`toolResultOrigin`).
*/
export declare function closedConversation(paused: PausedRun, door?: ClosingDoor): Promise<AgentRunCheckpoint>;
/** The paused call a person declined — that call never ran. */
export interface DeclinedCall {
readonly toolCallId: string;
/** The person's note, when the decline carried one. */
readonly note?: string;
}
/**
* What the model reads for a call the run left open: a past fact about that
* call, and a notice rather than its result.
*/
export declare const OUTCOME_NOT_RECORDED: string;
/**
* The stored pause, marked answered: a decision for it is refused from here
* on, and the conversation it goes on from has every call left open settled
* as unknown — the call a person declined, as declined: it did not run.
*
* @param progressed this session's conversation as the run kept it, or
* `undefined` to go on from the conversation as of the pause.
* @param declined the paused call, when the answer declined it.
*/
export declare function markAnswered(paused: PausedRun, progressed: AgentRunCheckpoint | undefined, now?: number, declined?: DeclinedCall): PausedRun;
/**
* The tool calls in `history` that no tool message answered, in the order the
* model made them.
*/
export declare function openToolCalls(history: readonly LLMMessage[]): readonly {
readonly id: string;
readonly name: string;
}[];
/**
* `history` with every open tool call settled by the host's notice — the same
* history when none is open. Each notice is marked as the library's
* (`toolResultOrigin`: `'outcome-unknown'`, or `'not-entered'` for a declined
* call), whether or not the run served findings.
*
* @param declined the call a person declined, settled as declined — the
* result the dispatch loop records for a decline — instead of as unknown.
*/
export declare function settleOpenCalls(history: readonly LLMMessage[], declined?: DeclinedCall): LLMMessage[];