@sema-agent/core
Version:
Stateless, task-oriented AI agent core
193 lines (192 loc) • 13.6 kB
TypeScript
/**
* 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 {};