@sema-agent/core
Version:
Stateless, task-oriented AI agent core
169 lines (168 loc) • 11.5 kB
TypeScript
import type { AgentMessage } from "../internal/harness-types.js";
import type { ToolResultMessage } from "../internal/llm.js";
import type { Session } from "./session.js";
import type { ToolEffect } from "./types.js";
/**
* Wake/resume side-effect reconciliation.
*
* Problem (design/07 · C3): a task can be interrupted (crash, kill, timeout, lost instance) in the
* window between an assistant message with tool calls being persisted and that turn's tool results
* being flushed. The session event log then ends with one or more **orphan tool calls** — a tool
* call with no matching `toolResult`. On wake this is two bugs at once:
* 1. Most chat APIs reject a request whose assistant tool_call is not followed by a tool result.
* 2. The tool's `execute` may already have run (the side effect happened) — or may not have. The
* outcome is genuinely unknown.
*
* Contract enforced here:
* - **Never auto-replay a tool.** Reconstruction (`buildSessionContext`) already does not re-run
* tools; this guarantees we also never synthesize a "success" that hides an unknown outcome.
* - For each orphan an append can close (an id's last call site), append a synthetic `toolResult`
* (isError) that tells the model the call was interrupted and its outcome is unknown — making the
* transcript API-valid AND handing the decision to the model under its verification discipline.
* - Use the tool's {@link ToolEffect} to tune the message: `read` carries no risk at all; `idempotent`
* is replayable (re-issuing converges the state, but the interrupted attempt may have partially
* landed — RB-221); `write` (and unknown tools, conservatively) must be verified/confirmed
* before repeating.
*
* This runs on `acquire` of a resumed session, before the next turn starts. New/empty sessions and
* cleanly-finished sessions have no orphans and are left untouched.
*/
/** One broken half of a tool call/result pair in the active branch (see {@link OrphanToolCall.kind}). */
export interface OrphanToolCall {
toolCallId: string;
toolName: string;
/**
* RB-398-a part 2: which half of the pair is missing.
* - absent (the historical shape) = a **call-orphan**: an assistant `toolCall` with no matching
* `toolResult` — the reconcile-actionable kind ({@link reconcileInterruptedSession} closes it
* with a synthetic interrupted result).
* - `"result"` = a **result-orphan**: a `toolResult` with no matching assistant `toolCall` — a
* protocol-invalid context every mainstream chat API rejects. The oracle used to walk only
* assistant→result, so it reported a context in exactly this state as clean while the request
* builder dropped the result (the request view's tool-turn law leaves it out and announces it,
* `orphan_result`). NOT reconcile-actionable (appending another result would not repair it); consumers
* deciding on repair must filter on this field.
* Additive: absent = call-orphan, so pre-existing consumers and persisted expectations are
* byte-identical for the historical direction.
*/
kind?: "result";
}
/** The machine-readable discriminator on a reconcile-appended result — `details.errorKind` on the persisted
* message and `errorCode` on the synthetic `tool_end` frame, so a consumer never parses the text. Two-valued
* (a third value needs a cross-repo slip, see {@link reconcileInterruptedSession}). */
export type ReconciledErrorKind = "interrupted_never_started" | "interrupted_outcome_unknown";
/** One orphan closed by {@link reconcileInterruptedSession}, with everything the caller needs to mint the
* matching stream frame WITHOUT re-reading the session. */
export type RecoveredOrphan = OrphanToolCall & {
/** The persisted toolResult entry id (message-identity Phase 1). */
entryId: string;
/** The model-facing text that was persisted — the synthetic `tool_end`'s `output` body. Handed back
* because the caller's frame is otherwise BODY-LESS: a consumer rendering tool output from frames saw an
* empty result for every interrupted call, while the model-facing transcript carried the full
* `[INTERRUPTED]` explanation (the two faces disagreed about the same call). */
text: string;
/** Which of the two honest states this orphan was closed in — the frame's `errorCode`. */
errorKind: ReconciledErrorKind;
};
/** Outcome of reconciling a resumed session. */
export interface ReconcileReport {
/** Orphan calls that were closed with a synthetic interrupted result (empty when nothing to do). `entryId` =
* the persisted toolResult entry id — message-identity Phase 1: the caller emits a `message_committed` for each
* so a consumer's `entryId → message` map stays complete for these reconcile-appended results (they render a
* `tool_end`). They are never a compaction floor (`toolResult` is excluded from cut points), so this is map
* completeness, not `preserved_segment` correctness. */
recovered: RecoveredOrphan[];
}
/**
* Find assistant tool calls in `messages` that have no matching `toolResult`.
* Order follows first appearance in the transcript (orphans are normally only at the tail).
*
* `suspendedBatch` (design/45 §15.2 net-add #7): the set of tool-call ids belonging to a **deliberately
* suspended** batch (the pending + not-yet-started calls of a durable-checkpoint suspension). These are
* NOT orphans — they are waiting to be resumed via `runner.resume()`, not crash-interrupted. Wake-path
* reconcile must skip them, or it would append `[INTERRUPTED]` results and **destroy the suspended
* batch** (the bug this guard fixes: the wake/resume naming overload became real data destruction).
* `runner.resume()` uses a checkpoint-aware entry that bypasses this reconcile entirely for those calls.
*/
export declare function findOrphanToolCalls(messages: AgentMessage[], suspendedBatch?: ReadonlySet<string>): OrphanToolCall[];
/**
* backlog #389 伴生 (D-2) — CC 2.1.223's interruption markers, VERBATIM (`$U`/`CR` @ `CC:151120-151121`,
* minted as a USER message by `Jce` @ `CC:640141-640154`). CC mints one on every abort whose reason is
* outside `{"interrupt","refusal-fallback-edit"}` — and the interactive Esc / remote cancel, which is
* what `TaskStream.interrupt()` corresponds to, is precisely on the minting side (`CC:1033698-1033733`).
* The suppressed reason is the one case where the user's own replacement message is already the context.
*
* Taken verbatim rather than reworded: this string is an INPUT to later reasoning in CC (its own
* "interrupted then immediately retried the same action" rule reads it back), and the constitution's
* standing rule is that a question CC has answered is answered in CC's form.
*/
export declare const INTERRUPTED_BY_USER_MARKER = "[Request interrupted by user]";
/** backlog #389 伴生 — the tool-use variant (`CR`): the run was cut while a tool batch was in flight. */
export declare const INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER = "[Request interrupted by user for tool use]";
/**
* backlog #389 伴生 (D-2) — append the interruption marker so the SESSION records that a person stopped
* this run.
*
* Without it, an interrupt that lands on the model stream (no tool call in flight) leaves literally no
* trace: the orphan reconcile has nothing to close, and the empty aborted assistant is deliberately not
* persisted (`isEmptyFailureAssistant`). The next run on that session then reads a transcript in which
* the half-finished work simply stops, and continues as if it had ended by itself.
*
* MUST be called AFTER {@link reconcileInterruptedSession} on the same interruption: a user message
* placed between an assistant's tool calls and their results is exactly the invalid sequence the
* reconcile exists to prevent.
*
* Returns the persisted entry id so the caller can mint its `message_committed` frame.
*/
export declare function appendInterruptionMarker(session: Session, opts: {
toolUseInFlight: boolean;
}): Promise<string>;
/** One synthetic interrupted result: what a call with no recorded result is closed with. */
export interface InterruptedResult {
toolCallId: string;
toolName: string;
/** The model-facing text. */
text: string;
/** Which of the two honest states the call is closed in. */
errorKind: ReconciledErrorKind;
}
/**
* The ONE mint of interrupted-result content for a batch of calls that have no recorded result. Two readers: the
* persisted reconcile ({@link reconcileInterruptedSession}) and the request view's tool-turn law
* (`engine/llm/tool-turns.ts`, which closes a call on the wire only and never writes the transcript) — so a call
* closed either way reads the same to the model.
*
* `toolEffects` / `startedToolCallIds` as on {@link reconcileInterruptedSession}; absent ⇒ every call gets the
* conservative unknown-outcome wording. The first call of each class in the batch states it in full and the rest
* point back at it (scoped to this call, so a later batch always starts from the full wording again).
*/
export declare function interruptedResults(calls: readonly {
toolCallId: string;
toolName: string;
}[], toolEffects?: Map<string, ToolEffect>, startedToolCallIds?: ReadonlySet<string>): InterruptedResult[];
/** Is `message` an INTERRUPTED CLOSE — a result minted to stand in for one that was never recorded (this family's
* two-valued `details.errorKind`, written by the reconcile here and by the loop's turn-scoped interrupt)? A close
* says only "no result was recorded", so it is void beside a recorded result for the same call. Total: a tool's
* `details` is the tool's value, so an unreadable one reads as "not a close" (one guarded read per member). */
export declare function isInterruptedClose(message: {
details?: unknown;
}): boolean;
/** The `toolResult` message for one {@link InterruptedResult} — the persisted and the request-view closes are this one shape. */
export declare function interruptedResultMessage(result: InterruptedResult, timestamp: number): ToolResultMessage<{
errorKind: ReconciledErrorKind;
}>;
/**
* Reconcile a resumed session's active branch: close any orphan tool calls with a synthetic
* interrupted `toolResult` (never re-running the tool) — the ones an append can close
* ({@link closableByAppend}), so a second reconcile of the same branch appends nothing. Returns what
* was recovered.
*
* @param toolEffects optional name→effect map (from the task's tools). Unknown names are treated as
* `write` — the conservative default, so an unrecognized interrupted call is never said to be safe.
* @param startedToolCallIds optional set of tool-call ids whose execution START the caller observed
* during the run being reconciled (the live-abort caller has this knowledge; the wake/crash path
* does not — the previous process is gone). When provided, an orphan absent from the set provably
* never started: it is closed with a "never executed — safe to re-issue" result instead of the
* conservative unknown-outcome wording. When omitted, every orphan keeps the conservative texts.
*/
export declare function reconcileInterruptedSession(session: Session, toolEffects?: Map<string, ToolEffect>, suspendedBatch?: ReadonlySet<string>, startedToolCallIds?: ReadonlySet<string>): Promise<ReconcileReport>;