UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

169 lines (168 loc) • 11.5 kB
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>;