agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
224 lines • 12.7 kB
JavaScript
/**
* conversation — the refusals that guard a runner's per-instance state.
*
* Pattern: teaching refusal at a boundary (the shape `runInput` uses for a
* caller's message, applied to a caller's TIMING).
* Role: core/ layer. An `Agent` keeps the last run's executor, run context,
* answer and pause on ITSELF — that is what makes `checkpoint()`,
* `getLastSnapshot()` and `followUp()` possible at all. Those fields
* have exactly one owner at a time, and until 9.2.0 nothing said so.
* Emits: N/A — these fire before a run starts.
*
* ## Why these are refusals and not warnings
*
* Both shapes below used to SUCCEED, which is the entire problem. Two `run()`
* calls overlapping on one Agent both resolved with plausible answers, and the
* per-instance state afterwards belonged to whichever finished last: the
* conversation `checkpoint()` handed back was the other run's, the snapshot
* `getLastSnapshot()` served was the other run's, and every event carried the
* other run's meta. That is not concurrency, it is corruption — and nothing in
* the recording said so, because each run's own trace looked perfect.
*
* A message sent while a person still owes the agent an answer used to start a
* fresh run and silently abandon the pending question. A consent gate that can
* be walked around by sending another message is not a consent gate.
*
* Neither refusal is new policy. `standingAgent` has refused both since it
* existed (it serializes runs globally and calls that "a correctness
* requirement rather than a tuning choice", and answers a message that arrives
* mid-question with `AwaitingDecisionError`), and `recordedChat.send` refuses
* an overlapping turn in the same words. 9.2.0 moves the guarantee from the
* compositions down to the primitive, so it holds however you drive it.
*
* The names are deliberately NOT the hosting ones. `hosting/errors.ts` already
* owns `ConcurrentRunError` and `AwaitingDecisionError`, both of which carry a
* `sessionId` and speak about a session; core has no sessions, and two classes
* sharing one name across two doors is the duplicate-type hazard this codebase
* has fixed before.
*/
/**
* Thrown when `run()` / `resume()` is called on a runner that is already
* running.
*
* One instance answers one turn at a time. To run two turns at once, build two
* agents — a chart is built once per instance and instances are cheap — or put
* the turns behind `standingAgent({ onConcurrentInvoke: 'enqueue' })`, which
* queues them.
*
* @example
* ```ts
* // Refused: both would write the same instance's last-run state.
* await Promise.all([agent.run({ message: 'a' }), agent.run({ message: 'b' })]);
*
* // Fine: two instances, two sets of state.
* await Promise.all([agentA.run({ message: 'a' }), agentB.run({ message: 'b' })]);
* ```
*/
export class RunInFlightError extends Error {
code = 'ERR_RUN_IN_FLIGHT';
/** The runner that is busy, by its configured id. */
agentId;
/** The run already in flight, so a log line can be joined to its trace. */
activeRunId;
constructor(door, agentId, activeRunId) {
super(`${door}: this agent is already running (run '${activeRunId}'). One instance answers one ` +
`turn at a time — its last executor, run context, answer and pause all live on the ` +
`instance, so two overlapping runs would each finish having overwritten the other's. ` +
`Both would return a plausible answer and checkpoint() would then hand back whichever ` +
`finished last, which is why this is refused rather than left to look like it worked. ` +
`Await the run in flight, build a second Agent for the second turn (charts are ` +
`built per instance and instances are cheap), or serve the agent through ` +
`standingAgent({ onConcurrentInvoke: 'enqueue' }) to queue turns behind each other.`);
this.name = 'RunInFlightError';
this.agentId = agentId;
this.activeRunId = activeRunId;
}
}
/**
* Thrown when a new message is sent to an agent whose last run PAUSED to ask a
* person something, and that question has not been answered.
*
* The pause is not a failure and not a stale flag — it is unfinished work with
* a person on the other end. Answer it with `resume(checkpoint, decision)`, or
* say plainly that it is being dropped with `abandonPause()`; both are visible
* in the record, and silently starting a fresh run was not.
*/
export class PendingQuestionError extends Error {
code = 'ERR_PENDING_QUESTION';
/** The tool that asked, when the pause named one. */
toolName;
/** The id of the tool call that asked, for joining back to the trace. */
toolCallId;
constructor(door, pending) {
const asked = pending.toolName !== undefined
? `'${pending.toolName}'${pending.toolCallId ? ` (call ${pending.toolCallId})` : ''}`
: 'a tool';
const quoted = pending.question !== undefined ? ` It asked: "${pending.question}"` : '';
super(`${door}: this agent's last run paused to ask a person something and is still waiting. ` +
`${asked} raised the question and nothing has answered it.${quoted} Answer it with ` +
`agent.resume(outcome.checkpoint, decision) — that continues the paused run from where ` +
`it stopped, with no earlier tool re-executed. If the question really is being dropped, ` +
`call agent.abandonPause() first and then run() again: a pending question that a later ` +
`message silently discards is a consent gate anyone can walk around.`);
this.name = 'PendingQuestionError';
if (pending.toolName !== undefined)
this.toolName = pending.toolName;
if (pending.toolCallId !== undefined)
this.toolCallId = pending.toolCallId;
}
}
/**
* Thrown by `resume(checkpoint, input, options)` when the run would carry two
* identities — refused before anything runs:
* - `options.identity` names an identity other than the one the run's memory
* namespace and credentials are restored with (`scope.runIdentity`, whatever
* its source) — including an OWNERLESS pause (the per-run default or the
* session rung) resumed by a named person;
* - the checkpoint's own fields disagree (a session-rung marker on an
* identity that carries a person).
*
* The ownerless case is the fail-closed reading of an open owner question; a
* later release may relax it behind an explicit opt-in, never by default.
*
* One run never carries two identities. The resumed run keeps the paused run's
* memory namespace and credential identity (`scope.runIdentity`, restored from
* the checkpoint — a resume never re-seeds), so honouring a different caller
* would give the run one person's memory and vault and another person's
* `ctx.identity`, `EventMeta.principal` and stored ownership. Ownership is
* derived from the run and never moved; the same law, and the same `code`, as
* the hosting door's `SessionOwnershipConflictError`.
*
* Neither identity is named in the message: an error is read by whoever
* provoked it. Nothing has run and no state has moved when it is thrown.
*/
export class ResumeIdentityConflictError extends Error {
code = 'ERR_SESSION_OWNERSHIP_CONFLICT';
constructor() {
super('Agent.resume: the identity passed to resume() is not the one the paused run was for. ' +
"A resumed run keeps the paused run's memory namespace and credentials, so it cannot " +
'also run as somebody else. Resume with the identity the paused run was started with ' +
"(or none, and the checkpoint's is used), or start a new run for the other person. " +
'Neither identity appears in this message on purpose.');
this.name = 'ResumeIdentityConflictError';
}
}
/**
* Thrown by `resume(checkpoint, input, options)` when the paused run's
* redaction cannot be carried into the resumed leg — refused before anything
* runs, so a leg its policy could not keep covered never starts:
* - `'unreadable'` — the checkpoint's `runRedaction` (the policy the paused
* run was covered by, as plain data) is not one this library wrote;
* - `'missing'` — the checkpoint says its run kept values out of its records
* (footprintjs's `redactionMarks`: the names it masked) but carries no
* policy. Every leg of a covered run writes its policy into its checkpoint,
* so this one was altered; a `redact` passed to the resume cannot stand in
* for the policy the run was covered by, so it is refused all the same;
* - `'unknown-pattern'` — the carried policy names a pattern this side does
* not hold. A pattern is never compiled from a checkpoint (one built to hang
* the matcher could come back from storage someone else controls): a
* carried pattern is a reference to one the agent declares, the library's
* vocabulary holds, or the resume names — pass the run's `redact` to
* `resume(checkpoint, input, { redact })` when the run was handed one.
*
* Every way, the resumed leg would write what the paused leg kept out into
* every record of its own. No policy, pattern, name or value appears in the
* message.
*/
export class ResumeRedactionError extends Error {
code = 'ERR_RESUME_REDACTION';
reason;
constructor(reason) {
super(reason === 'unreadable'
? "Agent.resume: the checkpoint's redaction policy (`runRedaction`) is not one this library " +
'wrote, so the resumed run could not keep its records covered. Resume with the checkpoint ' +
'as it was written.'
: reason === 'missing'
? 'Agent.resume: the checkpoint says its run kept values out of its records but carries no ' +
'redaction policy — every covered run writes its policy into its checkpoint, so this one ' +
'was altered. Resume with the checkpoint as it was written.'
: "Agent.resume: the checkpoint's redaction policy names a pattern this agent does not " +
'declare and the resume does not name — a pattern is never compiled from a checkpoint. ' +
'Pass the redaction policy the run was handed: resume(checkpoint, input, { redact }).');
this.name = 'ResumeRedactionError';
this.reason = reason;
}
}
/**
* Thrown by `followUp()` when there is no conversation to follow up on.
*
* `followUp()` continues THIS agent's own last completed run. Before the first
* one there is nothing to continue, and a "follow-up" that quietly became a
* first turn would be the very confusion the door exists to remove.
* `reason` says which of those it was, so a caller can branch on it.
*/
export class NoConversationError extends Error {
code = 'ERR_NO_CONVERSATION';
reason;
constructor(door, reason) {
super(noConversationSentence(door, reason));
this.name = 'NoConversationError';
this.reason = reason;
}
}
function noConversationSentence(door, reason) {
switch (reason) {
case 'never-run':
return (`${door}: this agent has not completed a run, so there is no conversation to ` +
`continue. Start it with agent.run({ message }) — the first turn is a run; every ` +
`turn after it is a followUp(). To continue a conversation this PROCESS did not ` +
`have (a stored one, or one from another instance), pass it explicitly: ` +
`agent.run({ message, continueFrom: storedConversation }).`);
case 'last-run-unfinished':
return (`${door}: this agent's last run did not finish with an answer, so there is no ` +
`conversation to continue yet. A failed run's conversation is carried by ` +
`RunCheckpointError.checkpoint — catch it and pass that to ` +
`run({ message, continueFrom }) or resumeOnError(checkpoint).`);
case 'last-input-refused':
return (`${door}: input middleware refused this agent's last message, so that run ` +
`admitted no conversation to continue. Continue from your earlier accepted ` +
`checkpoint — the one agent.checkpoint() returned after the last accepted run — ` +
`with agent.run({ message, continueFrom: earlierCheckpoint }), or start a new ` +
`conversation with agent.run({ message }).`);
}
}
//# sourceMappingURL=conversation.js.map