UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

193 lines (192 loc) • 13.6 kB
/** * First-party agent loop (design/118 ④a — de-vendoring 4/6). * * Rewritten from the vendored openclaw distillation with the loop's control flow made EXPLICIT as a * state machine (the CC query.ts State/transition shape, sema deps injected): every "go around again" * is a named {@link LoopContinue} and every exit is a named {@link LoopTerminal}, so tests can assert * WHICH path ran (test/loop-transitions.test.ts) instead of inferring it from event order. ④a is * behavior-1:1 with the vendored loop — no new recovery chains; those land in ④b as new Continue * reasons on this skeleton. * * Load-bearing invariants carried over verbatim (the VENDORED EDIT provenance comments are kept at * their sites as history): * - A-1 layer ② abort-before-stream guard (a mid-batch abort must never reach the next provider * request) — {@link runSingleTurn} entry guard, Terminal `aborted_before_stream`. * - Durable double-execution gate (a deferred parallel tool closure must never run its side effect * after a mid-batch suspend/abort) — {@link executePreparedToolCall} entry guard. * - D4 post-hook posture (afterToolCall throw must not rewrite a committed side effect into a flat * failure) — {@link finalizeExecutedToolCall}. * - The TWO queue drain points (1.204 priority wiring基石): steer drains at every turn end * (Continue `steer_injected` / mid-work boundary), followUp drains only when the agent would * otherwise stop (Continue `follow_up_injected`). Never merge these into one queue. * - text_delta.partial reconstruction (upstream cherry-pick 6fcc945702). */ import type { AssistantMessage, ToolResultMessage } from "../llm/index.js"; import { type AgentCoreStreamRuntimeDeps } from "./runtime-deps.js"; import type { AfterToolCallResult, AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentToolResult, HaltRemainingDirective, StreamFn } from "./types.js"; /** Callback used by synchronous loop runners to publish agent lifecycle events. */ export type AgentEventSink = (event: AgentEvent) => Promise<void> | void; /** Named reasons the loop goes around again. Assertable in tests via the trace sink. */ export type LoopContinueReason = /** A turn finished with executable tool calls (or injected messages) still driving work. */ "next_turn" /** Drain point A (mid-work boundary): steering messages were injected after a turn. */ | "steer_injected" /** Drain point B (agent would otherwise stop): follow-up messages were injected. */ | "follow_up_injected" /** ④b recovery: a prompt-too-long failure was recovered (compacted context) and retried in-turn. */ | "reactive_compact_retry" /** ④b recovery: a `length`-stopped answer triggered a synthetic continue nudge for another turn. */ | "max_output_tokens_recovery" /** S2 recovery (design/119): a `toolUse` stop with ZERO parseable tool calls got a retry nudge. */ | "malformed_tool_use_retry" /** S2 recovery (design/119): a thinking-only turn (no visible text, no tools) got a retry nudge. */ | "thinking_only_retry" /** design/124 tier A: a partially-finalized "stop" (mid-stream cut promoted to content) got a * bounded continue nudge so an unattended run doesn't book the cut as an incomplete success. */ | "midstream_partial_recovery" /** T1-4 recovery: a degenerate-repetition cutoff got a bounded "different approach" continue. */ | "degenerate_output_recovery"; /** Named reasons the loop exits. Assertable in tests via the trace sink. */ export type LoopTerminalReason = /** Natural end: no tool calls, no steering, no follow-ups. */ "completed" /** A-1 layer ② guard: the abort signal was already set before the next provider request. */ | "aborted_before_stream" /** The assistant message itself ended with stopReason "error" or "aborted". */ | "assistant_error" /** A clean stop was requested at a turn boundary (mod #16 one-shot): `shouldStopAfterTurn` * answered true after a turn, or — #504's pre-turn half — `shouldHaltBeforeTurn` refused to * start the next one. One reason for both consult points: the fact reported is the same (the * loop ended cleanly at a manufactured boundary, no reconcile owed), and which consult caught * it is timing, not semantics. */ | "stop_requested" /** RB-206 (CC 220 counterpart @400176-400230, `Bxs` @397652-397674 buckets it * with budget_exhausted/api_error/model_error/turn_setup_failed, explicitly NOT `"completed"`): every * tool call in the turn was dropped as malformed AND the bounded `malformed_tool_use_retry` nudge * budget is used up — the model believes it acted, nothing ran, and there is no more recovery budget * left to try again. Before this reason existed, this case fell through to the generic no-more-work * path and was reported as a plain `"completed"`, indistinguishable from a turn that genuinely had * nothing left to do. * * 独立复审(视角一)提醒:`malformedToolUseRetries` 是**整个 run 生命周期内的绝对计数**,恢复成功后不 * 回退(与 `staticReasoningCutRecoveries` 同一姿势,非本次改动引入)——"exhausted" 说的是"这个 run 从 * 开始到现在总共用掉的重试次数已达上限",不是"刚刚连续失败了 maxRetries 次"。一次早于此刻很久、且已 * 成功恢复过的畸形调用,和刚刚才第一次触发畸形调用但恰好撞上早已用尽的预算,在这个 reason 面前不可 * 区分;调用方若需要"最近是否连续失败"这类更细的语义,需要自行在更高层维护窗口计数,这个 reason 只 * 承诺"预算耗尽"这一件事。 */ | "malformed_tool_use_exhausted" /** RB-258(RB-206 口径追查的衍生发现;RB-206 的同族对仗):the model kept ending its turn on the * provider's output cap (`length`) with no tool calls, and the bounded truncated-output continue * budget (`recovery.truncatedOutput.maxContinues`, default 3) is used up — every further nudge would * buy another cut-off fragment. Before this reason existed, this case fell through to the generic * no-more-work path and was reported as a plain `"completed"`, indistinguishable from an answer that * actually finished — the same defect RB-206 fixed in the malformed lane, on the shape production * hits MORE often (1/89 real questions reached the precondition). * * Scope notes (deliberate, mirroring the malformed lane): * - Only the CONFIGURED lane reports it. With `recovery.truncatedOutput` absent (the loop default * is OFF) there is no budget to exhaust, so a `length` stop still ends as a plain `completed` — * the pre-④b behavior, unchanged. * - `truncatedOutputContinues` counts CONSECUTIVE nudges and RESETS on any non-`length` turn (unlike * `malformedToolUseRetries`, which is absolute per run). So this reason means "the model just ended * `length` maxContinues+1 times in a row", not "this run has burnt N recoveries in total". * - Like every other terminal, it stops the loop BEFORE drain point B — a follow-up queue / Stop-hook * continuation no longer gets consulted on this path. That is the point (a stop-gate rescue would * re-enter the same wall with a spent budget), and queued follow-ups stay queued for the next * `prompt()` rather than being dropped. */ | "truncated_output_exhausted"; export type LoopStep = { kind: "continue"; reason: LoopContinueReason; } | { kind: "terminal"; reason: LoopTerminalReason; }; /** * Optional per-transition observer, for tests that assert WHICH path ran. * Attribution notes: `reactive_compact_retry` is traced IN-TURN at each retry (a turn may trace * several); the turn's boundary then traces at most ONE more Continue by priority — a recovery * reason (`max_output_tokens_recovery` / `midstream_partial_recovery` / `malformed_tool_use_retry` / * `thinking_only_retry`, mutually exclusive by stopReason/flag) wins, then `steer_injected` (masking the same turn's * `next_turn`), then `next_turn`. Don't read the trace as a complete enumeration of concurrent causes. */ export type LoopTraceSink = (step: LoopStep) => void; /** * A request to settle the transcript's trailing UNANSWERED tool batch before the prompts are delivered * (a resumed run whose suspended batch still holds calls no tool result answers). The batch is settled * as ONE turn of the loop: the live batch pipeline under a turn-scoped interrupt seat, the interrupted- * turn settlement when that seat is cut, then the ordinary turn boundary (`prepareNextTurn` + the stop * consult). `halt` settles every call as not-executed instead (the batch-halt form). `report` receives * the outcome once; `aborted` means the run ENDED before the prompts were delivered (the run signal * fired, or the boundary / the interrupt settlement stopped the run). */ export interface TrailingToolBatchSettlement { halt?: HaltRemainingDirective; report: (outcome: { assistantMessage: AssistantMessage; settled: number; aborted: boolean; }) => void; } /** The caller-facing seams of a prompt run beyond the prompts themselves: an optional trailing-batch * settlement, and the moment the prompts enter the loop transcript, before their frames are emitted * — delivery accounts precede the carrying messages' commits. */ export interface RunAgentLoopSeams { settle?: TrailingToolBatchSettlement; onPromptsDelivered?: () => void; } /** The last assistant message of `messages` when at least one of its tool calls has no tool result after it. */ export declare function findUnansweredToolBatch(messages: readonly AgentMessage[]): AssistantMessage | undefined; /** * Settle the unanswered tool calls of `assistantMessage` (the batch tail a suspended run left open): * ONE pipeline with the live turn — the same partition, concurrency cap, preflight gate (a call may * suspend again), execution and source-ordered result commit — or, under `halt`, the same not-executed * settlement a batch-halt directive applies to never-started siblings. Every unanswered call gets a * tool result unless the signal fires mid-batch (the dangling remainder is the suspended batch's own shape). */ export declare function settleUnansweredToolCalls(context: AgentContext, assistantMessage: AssistantMessage, config: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink, halt?: HaltRemainingDirective): Promise<ExecutedToolCallBatch>; /** Run a prompt-started loop and emit events through a caller-owned sink. `prompts` may be a thunk: it is * taken only when the prompts are about to be delivered, so a caller whose prompt assembly CONSUMES queued * input (a harness draining its next-turn queue) loses nothing when a trailing-batch settlement ends the run first. */ export declare function runAgentLoop(prompts: AgentMessage[] | (() => Promise<AgentMessage[]>), context: AgentContext, config: AgentLoopConfig, emit: AgentEventSink, signal?: AbortSignal, streamFn?: StreamFn, runtime?: AgentCoreStreamRuntimeDeps, trace?: LoopTraceSink, seams?: RunAgentLoopSeams): Promise<AgentMessage[]>; type ExecutedToolCallBatch = { messages: ToolResultMessage[]; terminate: boolean; }; /** An executed call's raw outcome, before (and, from {@link finalizeToolOutcome}, after) the post-execution half. */ export type ExecutedToolCallOutcome = { result: AgentToolResult<unknown>; isError: boolean; }; /** * How many tool names an unknown-tool miss spells out. A miss is a NAME problem: seeing a couple of dozen * real names is what lets a model spot the one it meant, while a full listing grows with the deployment * (a large roster turned every typo into a multi-kilobyte result) and buys no extra recovery. */ export declare const ROSTER_LISTING_MAX = 25; /** * The disclosure-search tool's wire name, kept as a literal here because this module is in the engine * layer and must not import the runner's tool definitions. A unit test asserts it equals the exported * `TOOL_SEARCH_NAME`, so the two cannot silently diverge (the `BRAIN_FLAT_CODES` arrangement in * core/tool-errors.ts, same reason). */ export declare const ROSTER_SEARCH_HINT_NAME = "ToolSearch"; /** The structured `details.code` of a tool result minted by an input pre-validation refusal (lifted onto * the wire frame's `errorCode` like every other loop-minted code, e.g. `tool.not_found`). */ export declare const TOOL_VALIDATION_REFUSED_CODE = "tool.validation_refused"; /** How long an asynchronous `validateInput` may take to answer before it is read as "no verdict" (and * reported on the fault seat): a validator is a precondition read, not a network call, so a single * bound serves every tool; a deployment validator that needs longer is a validator doing too much. */ export declare const TOOL_INPUT_VALIDATION_TIMEOUT_MS = 10000; /** * The post-execution half EVERY executed tool call passes through — the loop's own calls here, and a call * the harness executed outside a turn (a resumed approved call, `AgentHarness.finalizeToolCall`) through the * same function, so the two cannot drift into two dialects of one finalization: the `afterToolCall` patch * (content / details / terminate / isError), the D4 posture on a throwing hook, and the delivery mark. * `after` absent ⇒ the executed outcome stands as is (same object). */ export declare function finalizeToolOutcome<C>(toolCall: C, executed: ExecutedToolCallOutcome, after: ((result: AgentToolResult<unknown>, isError: boolean) => Promise<AfterToolCallResult | undefined>) | undefined): Promise<ExecutedToolCallOutcome & { toolCall: C; }>; export {};