From c40377dc5bd11f1c5a4f5ca020d74fb931c0ab8b Mon Sep 17 00:00:00 2001 From: LukasParke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:53:59 +0000 Subject: [PATCH 1/2] port: sync with @openrouter/agent upstream --- .upstreamer/coverage-floor.txt | 2 +- .upstreamer/eval-report.md | 542 +++++------ .upstreamer/state.yaml | 12 +- README.md | 22 +- async_params.go | 10 + conversation_state.go | 22 + doom_loop.go | 1267 ++++++++++++++++++++++++++ doom_loop_engine_test.go | 1170 ++++++++++++++++++++++++ doom_loop_test.go | 669 ++++++++++++++ hooks_manager.go | 10 +- hooks_resolve.go | 4 + hooks_schemas.go | 37 +- model_result.go | 675 +++++++++++++- run_cancellation_test.go | 198 ++++ testdata/doom-loop-fingerprints.json | 132 +++ tool.go | 44 +- tool_types.go | 10 +- upstreamer-changelog.md | 143 ++- 18 files changed, 4646 insertions(+), 323 deletions(-) create mode 100644 doom_loop.go create mode 100644 doom_loop_engine_test.go create mode 100644 doom_loop_test.go create mode 100644 run_cancellation_test.go create mode 100644 testdata/doom-loop-fingerprints.json diff --git a/.upstreamer/coverage-floor.txt b/.upstreamer/coverage-floor.txt index 92a5611..29179ad 100644 --- a/.upstreamer/coverage-floor.txt +++ b/.upstreamer/coverage-floor.txt @@ -1 +1 @@ -72.0 +77.0 diff --git a/.upstreamer/eval-report.md b/.upstreamer/eval-report.md index 6e39cb4..075b4fe 100644 --- a/.upstreamer/eval-report.md +++ b/.upstreamer/eval-report.md @@ -1,296 +1,252 @@ -PASS WITH WARNINGS - -# Eval Report — go-agent, @openrouter/agent 0.7.2 → 0.8.0 (Round 4) - -Fresh review context. No prior reasoning trusted — every claim below was -independently verified by reading upstream source, reading the port's code, -and writing/running throwaway probes against `StateAccessor` (not the raw -`State` pointer). Probes were deleted before finishing; `git status` before -and after this review is identical except for this report file. - -## Headline - -The Round-3 finding (an approval/HITL resume that itself re-pauses never -called `StateAccessor.Save`, so a `StateAccessor`-only caller could reload -stale state and re-execute an already-approved tool) **is genuinely fixed**, -and — going beyond just re-checking that one line — I traced every pause -path in `run()` and independently confirmed via fresh probes that the two -sibling pause boundaries the task specifically flagged as at-risk -(`awaiting_hitl` and `awaiting_client_tools`), plus the mixed-approval pause, -**all correctly persist via `StateAccessor.Save`** through a different, -independent code path (a `break` out of the main loop that falls through to -an unconditional save at the end of `run()`, rather than a `return`). - -While hunting for "the next place a similar bug could hide" (as the task -explicitly asked), I found one real, reproducible, but narrower-scope gap: -**go-agent does not persist state incrementally per model turn the way -upstream does** (upstream's `saveResponseToState`/`saveToolResultsToState` -save after every turn; go-agent only saves at the very end of `run()` or at -an explicit pause). A `StateAccessor`-only caller who hits a genuine -mid-run turn-level error (network failure, rate limit, etc.) on turn N+1 -loses visibility into turn N's already-executed tool work in the accessor, -even though that tool's side effect already happened. This is real and -worth fixing, but — unlike the Round-3 bug — it is (a) triggered by an -exceptional/error condition outside the documented pause/resume contract, -not the normal, expected, celebrated approval-pause path; (b) pre-existing -since the very first port (confirmed present in upstream's 0.7.2-era source -too, so it predates this whole 0.8.0 delta, not just this round); and (c) -not a violation of eval.md's actual enumerated "State" requirement, which is -scoped to "State survives a pause/resume cycle mid-approval" — a bullet this -round's fix (plus my own probes of the sibling boundaries) now fully -satisfies. I'm flagging it as a warning/follow-up rather than a blocker. - -Verdict: **PASS WITH WARNINGS.** +# Port Parity Eval — go-agent @ upstream 78c562e (doom-loop detection + run cancellation) + +Verdict: PASS WITH WARNINGS + +## Scope + +- Upstream delta: `680bceb4598f228d3e2ec58e2416e4335cdff059..78c562ef53da0edd84dfbcc6d6ee38a095d72b37`, `packages/agent/` only. The behavioral commit is 78c562e "feat(agent): doom-loop detection for the tool-execution loop (#73)". 89ae3ff and fc8a7f7 are release/publish infra and out of scope. `packages/agent/package.json` is still `0.8.0` at the target. 78c562e first ships in `@openrouter/agent@0.9.0`. +- Upstream surface added: `lib/doom-loop.ts` (new), wiring in `lib/model-result.ts`, the `DoomLoopDetected` hook and `SessionEnd` reason `doom_loop` (`hooks-schemas.ts`, `hooks-types.ts`), tool `loopKey` (`tool.ts`, `tool-types.ts`), the `markMcp(tool, { loopKey })` option, `ConversationState.doomLoop`, `CallModelInput.doomLoop` and `signal` (`async-params.ts`, `inner-loop/call-model.ts`), per-dispatch signal/timeout composition (`dispatchRequestOptions`), and `getDoomLoopVerdict()`. +- Go port files reviewed: `doom_loop.go` (new), `model_result.go` (`run`, `prepareResumeRequest`, `executeAutoApproveTools`, `executeToolCallsForTurn`, `runToolWithHooks`, `sendFinalResponseRequest`, `retryCurrentRequest`, the "Run cancellation" and "Doom-loop engine" regions, `pauseBeforeFirstTurnDoomStop`), `hooks_schemas.go`, `hooks_manager.go`, `hooks_resolve.go`, `tool.go`, `tool_types.go`, `conversation_state.go`, `async_params.go`, `README.md`, `upstreamer-changelog.md`. New tests: `doom_loop_test.go`, `doom_loop_engine_test.go`, `run_cancellation_test.go`, `testdata/doom-loop-fingerprints.json`. + +## Method + +1. Read `.upstreamer/eval.md` and the binding contract `.upstreamer/upstreamer.md` (Required Public API, Idiomatic Divergences, Test Quality). +2. Read the full upstream source diff (2,821 lines), including all of `doom-loop.ts`, and the final `model-result.ts` `executeToolsIfNeeded`, `handleApprovalCheck`, `processApprovalDecisions`, `continueWithUnsentResults` and `initStream` restore site, so ordering could be compared in context. +3. Read the Go implementation line by line against it, case by case (listed below). I did not rely on the changelog or code comments. +4. Ran the full suite with `-race` and `.upstreamer/scripts/verify.sh`. Read every new Go test and mapped it to the upstream `it(...)` cases in `doom-loop.test.ts`, `doom-loop-integration.test.ts`, `doom-loop-remediation.test.ts`, `doom-loop-escalation.test.ts`, `run-cancellation.test.ts` and `e2e/cancellation.test.ts`. +5. Ran throwaway experiments in a copy (`tmp/upstreamer/opencode/evalcopy`, deleted afterwards): + - a 3,008-value differential of `CanonicalizeKeyMaterial` number output against Node's `JSON.stringify`: 0 mismatches on finite values; + - cancellation landing after a turn>0 response with no tool calls; + - a text stop with no tools configured; + - a pre-cancelled approval resume. +6. Read the changelog last, and skeptically, to classify each divergence as documented or undocumented. + +Behaviors verified as matching upstream: + +- **Ladder.** Rungs compare with `>=` and the strongest wins (stop > block > escalate > steer > observe). Defaults are observe 2, block 3, stop 6, with steer and escalate off. Text and server-tool verdicts are non-blockable (block falls through, stop still stops). Escalate is budget-gated in the monitor. Dead-rung, block-without-stop and escalate-without-mechanism warnings are present. `advisor:false` is not a mechanism. `maxEscalations` < 1 clamps to 2. (`doom_loop.go: ResolveLadderAction, ResolveDoomLoopOption, warnOnLadderHazards`) +- **Round-scoped streaks.** `beginDoomLoopRound` is called at the same three batch boundaries as upstream: `executeToolCallsForTurn` (the `executeToolRound` equivalent), `executeAutoApproveTools`, and `prepareResumeRequest` (the `processApprovalDecisions` equivalent). Duplicates reuse the cached round decision with no second hook emit. Restored streaks carry no round, so the first resumed record increments. Interleaved other tools do not reset a streak, and a different fingerprint resets it to 1. +- **Block and stop before execution.** The checkpoint runs before PreToolUse, on the model-issued (pre-mutation) arguments. Block and stop both refuse the call with the same blocked shape as a PreToolUse block, and PreToolUse does not fire. The output is `{"error": ""}`, the same as upstream. +- **Sealing.** Stop sites are the doom gate before stopWhen and approval, the post-round break, and the stopWhen final-response gate. The final-response gate skips the `tool_choice:none` request. Each seals unresolved calls with a `Run halted by doom-loop detection: …` error output. The no-tools early return and the approval-resume gate do not seal, which also matches upstream. A doom-stopped run also skips the empty-final retry. +- **First-turn ordering.** On the first response of a call, upstream runs `handleApprovalCheck` (with the auto-approve batch) and the all-manual `persistClientToolsPause` before the loop-top doom gate. On later turns the gate runs first. `pauseBeforeFirstTurnDoomStop` reproduces this asymmetry, including the PermissionRequest gate. +- **Steer.** Steer messages are deduplicated. They are flushed only at the in-loop point after the doom-stop check and before the follow-up request, and are never injected at an approval, HITL or client-tools pause. They persist as `pendingSteer` and are re-queued on restore. The final-response path does not flush. +- **Escalation.** The first verdict per window is latched and queues an "An escalated turn follows…" steer. Overrides apply to the next in-loop follow-up only (`turn > 0`; not resume continuations, the final-response request or the retry), as one-turn copies. A model override clears `Models`. The advisor override appends `openrouter:advisor` with `forward_transcript` and default instructions (merged under caller parameters), and sets `tool_choice` to `allowed_tools`/`required`. Budget is consumed at application time, `escalationsUsed` is persisted, and the pending escalation itself is not persisted. +- **Hook override.** The last non-empty `OverrideAction` wins. Block on a non-tool-fingerprint verdict becomes observe. Escalate without config or budget becomes observe. The payload carries `toolInput` only for tool verdicts with record arguments, and the per-emit session/toolName context is threaded through. +- **Stop verdict across resumes.** The stop verdict is restored only when `ApproveToolCalls`/`RejectToolCalls` is non-empty, and is cleared on a fresh turn while streaks are kept. A stop armed while executing approved calls halts with no unsent-results request. +- **State persistence.** State is persisted in `ConversationState.DoomLoop`. Decoding is lenient: corrupt blobs and entries are dropped with a warning instead of failing deserialization. With detection off, a stale blob is left untouched and is inert. +- **Server-tool detection.** Every output item that is not a message, reasoning or `function_call` is treated as a server tool, recorded as `server:` with id/type/status/output/result/results/error excluded and items with no identity skipped. Detection is non-blocking and recorded at the current round. +- **Text detectors.** These are a line-by-line port: char-budget tail slice in UTF-16 units, tie breaks to the smallest period, and the within-response verdict wins ties. Whitespace normalization uses the ECMAScript `\s` set (U+FEFF counted, U+0085 not). +- **JCS and SHA-256.** Keys sort by UTF-16 code units, number formatting is ECMAScript `Number::toString` (differential-tested), there is no HTML escaping, and lone surrogates (as WTF-8 bytes) become `\udXXX`. Depth limit 64, cycle detection and rejection of non-finite or big numbers are present. The shared vector file is byte-identical to upstream (`cmp`), and every vector reproduces. +- **SessionEnd.** `SessionEnd` reports `doom_loop` for stop sites in non-resume runs. A paused run reports `complete`. `ModelResult.DoomLoopVerdict(ctx)` is the equivalent of `getDoomLoopVerdict()`. +- **Cancellation.** + - A pre-cancelled ctx fails before any dispatch, with `context.Cause`. + - Cancellation between requests (inside a tool, or in AfterTurn) stops at the turn boundary, before any of that response's tools run. + - An in-flight request is aborted through `ctx`, by either the caller's cancel or `Cancel()`. + - The cancellation check also runs before the final-response and retry dispatches. + - The per-request SDK timeout still bounds a request while the run ctx is cancellable, because the Go SDK applies `context.WithTimeout` per `Send`. + +## Findings + +1. **warning — `stream_transformers.go: ExtractToolCallsFromResponse` / `model_result.go: runToolWithHooks`. Upstream's "repeated unparseable tool call" doom path is not ported.** + - Upstream records the raw argument string as the call identity before returning the parse-error output, so a model stuck emitting the same invalid JSON trips the ladder. Reference: `model-result.ts` `runToolWithHooks`, the `typeof rawArgs === 'string'` branch with `enqueueDoomLoopEvaluation({ keyMaterial: rawArgs, fallbackKeyMaterial: null })`, and the test `doom-loop-integration.test.ts` "counts parse-error repeats and stops instead of bouncing off the parse error forever". + - Go silently drops function calls whose arguments are not valid JSON, so the path is unreachable. The turn then ends `complete`, the dropped `function_call` is left in the history with no output, and there is no parse-error feedback turn. + - This predates the delta but was undocumented before it. It is now recorded as a compatibility note, so it is not blocking. It is still a real load-bearing-loop gap that this delta's feature depends on. + - Fix: surface invalid-JSON calls as parse-error `function_call_output`s, as upstream does, and route them through `evaluateDoomLoop` with the raw string as key material. + +2. **warning — `model_result.go: run` (`maxTurns` default 5). The default stop rung cannot fire within one call.** + - Upstream has no default turn cap: with no `stopWhen`, `shouldStopExecution()` returns false. So `doomLoop: true`, whose stop rung is at 6, can halt a single run. + - Go caps a run at `MaxTurns` requests, default 5. `&DoomLoopConfig{}` therefore observes and blocks but never stops within one call, and `SessionEnd` reports `complete` rather than `doom_loop`. + - The cap predates the delta. Its interaction with the delta is documented in the changelog and README. + - Fix: none needed for this delta. Consider aligning the default cap with upstream, or documenting `MaxTurns` as the Go-specific bound in the contract. + +3. **warning — `model_result.go: run` (the `len(calls) == 0` break placed before `runCanceledErr`). A cancellation that lands after a turn>0 response with no tool calls is ignored.** + - Upstream calls `this.options.signal?.throwIfAborted()` at the top of every loop iteration. A signal aborted during `onTurnEnd`, or during a `DoomLoopDetected` handler, on a follow-up response therefore rejects the run with the abort reason even when that response has no tool calls. Reference: `executeToolsIfNeeded`, loop top. + - In Go, `AfterTurn` cancelling on turn 2 of a tool→text run returns `text="final", err=nil` (verified by experiment). The turn-0 behavior matches upstream (an early return with no check). + - The window is narrow, but this is a cancellation-semantics divergence that is not documented. + - Fix: on `turn > 0`, check `runCanceledErr()` before the no-tool-calls break, or add a compatibility note. + +4. **info — `model_result.go: pauseBeforeFirstTurnDoomStop` with no tools configured.** + - A first-response text stop on a response that carries `function_call`s while `CallModelInput.Tools` is empty is sealed with halt outputs in Go. + - Upstream takes the `!this.options.tools?.length` early return: `doom_loop`, no seal, dangling calls left in place. + - This follows from a pre-existing divergence: Go treats calls with no tools configured as client tools. Go's result is the better-formed history. It is not documented. + +5. **info — `model_result.go: run` turn 0. Cancellation is checked before the approval gate.** + - Go checks `runCanceledErr()` right after the first response, before the approval/auto-approve batch. + - Upstream's first `throwIfAborted` comes after `handleApprovalCheck`, so a signal aborted between the response and the approval check (for example inside a `DoomLoopDetected` handler) still runs the auto-approved calls and pauses upstream, but fails in Go. + - The window is very narrow, and Go's ordering is arguably safer. + +6. **info — `model_result.go: prepareResumeRequest` and the pre-cancelled decision resume.** + - With a pre-cancelled ctx, Go executes the approved tool and then fails at dispatch without saving to the StateAccessor. The accessor still says `awaiting_approval`, so a retry re-executes the side-effecting tool. + - Upstream's `processApprovalDecisions` calls `saveStateSafely` before `continueWithUnsentResults` throws. + - This predates the delta and is covered by the existing documented per-turn checkpoint gap, but cancellation makes it easier to hit. + +7. **info — `model_result.go: pauseBeforeFirstTurnDoomStop` and PermissionRequest denies.** + - When a first-turn doom stop coincides with a PermissionRequest gate that denies every gated call, Go keeps the `Denied by PermissionRequest hook` output and excludes it from the seal. + - Upstream records denies only in `hookDeniedCalls`, so its loop-top seal writes a halt output for those calls instead. + - Only the message text of a synthetic output differs. + +8. **info — `doom_loop.go` / `model_result.go: toolInputMap`. Minor identity edge cases.** + - Non-object JSON arguments (an array or scalar) are fingerprinted as `{}` in Go; upstream uses the parsed value. Tools are typed structs, so this is practically unreachable. + - `DoomLoopMonitor.Restore` drops entries with an empty fingerprint; upstream accepts any string. + - Server-tool identities come from snake_case wire JSON, which is documented: fingerprints are stable within go-agent but not cross-port. + - None of these is user-visible in normal traffic. + +9. **info — documented divergences, confirmed acceptable.** + - `signal` maps to `ctx` (contract divergence #1). Upstream's timeout composition is unnecessary because the Go SDK applies its timeout per `Send`. + - `loopKey` returning `undefined` has no Go equivalent; returning an error falls back instead. + - `DoomLoopThreshold` uses 0 for the default and a negative value for disabled. + - Unpaired surrogates in decoded arguments become U+FFFD. + - A `DoomLoopDetected` handler error is logged rather than thrown. + - A doom stop on a decision resume records the executed outputs in `Messages`; upstream keeps them in `unsentToolResults`, which a fresh upstream turn never sends. Go's shape is the well-formed one. + - Evaluation is sequential, with no promise chain, so evaluations record in emission order. + +## Test parity + +Upstream case → Go test (`DL` = `doom_loop_test.go`, `DLE` = `doom_loop_engine_test.go`, `RC` = `run_cancellation_test.go`): + +**doom-loop.test.ts (unit)** +- canonicalize: key order, array order, dropped undefined, primitives per JCS → `DL TestCanonicalizeKeyMaterialJCSSemantics` (adds UTF-16 key-order, no-HTML-escape and U+2028 cases). +- rejects non-representable values, circular input and over-deep nesting → `DL TestCanonicalizeKeyMaterialRejectsUnrepresentable`. +- vectors (tool-call and bare), 64-char hex, tool name participates, non-ASCII → `DL TestDoomLoopFingerprintCrossPortVectors` (same vector file, byte-identical). +- resolveLoopKeyMaterial forms (absent, false, fields, `__proto__`, value, null, throw) → `DL TestResolveLoopKeyMaterialForms`. The `undefined` case is N/A and documented. +- detectTextRepetition: all 9 cases, including the multi-megabyte input and custom thresholds → `DL TestDetectTextRepetition`. +- resolveLadderAction (>=, non-blockable, disabled) → `DL TestResolveLadderAction`. +- resolveDoomLoopOption: null, defaults, partial, advisor false/true, text false, warnings → `DL TestResolveDoomLoopOption`. +- Monitor tool streaks (thresholds, same round, reset, interleave, key order, empty calls, bigint, server label, determinism) → `DL TestDoomLoopMonitorToolStreaks`. +- Monitor text (immediate stop, streak with block downgrade, whitespace, paraphrase miss, empty text, disabled) → `DL TestDoomLoopMonitorText` and `TestDoomLoopTextUsesJSWhitespace`. +- State round-trip, corrupt state, `__proto__` (both), snapshot → `DL TestDoomLoopMonitorStateRoundTrip`. + +**doom-loop-integration.test.ts** +- observe→block→recover → `DLE TestDoomLoopObservesThenBlocksAndModelRecovers`. +- stop at threshold → `TestDoomLoopStopsRunAtStopThreshold`. +- SessionEnd `doom_loop` → `TestDoomLoopSessionEndReasonDoomLoop`. +- healthy run, and inert when off → `TestDoomLoopHealthyRunAndInertWhenOff`. +- empty `{}` calls → `TestDoomLoopRepeatedEmptyCallsBlock`. +- **parse-error repeats → no Go test. The path is unreachable (Finding 1).** +- degenerate repeated tokens → `TestDoomLoopTextRepetitionStopsNoToolsRun`. +- identical filler text across a tool loop → `TestDoomLoopTextStreakHaltsToolLoop`. +- loopKey cwd, null exemption, throwing key → `TestDoomLoopToolLoopKeyForms` (plus a MarkMcp loopKey subtest). +- hook de-escalate and escalate overrides → `TestDoomLoopHookOverrideAction` (adds a text-block downgrade via InlineHookConfig). +- persistence across resume, JSON round-trip → `TestDoomLoopStatePersistsAcrossResume`. + +**doom-loop-remediation.test.ts** +- B3: 6-wide fan-out, and shared block decision → `TestDoomLoopParallelDuplicatesAreOneRound`. +- B1/D7: text stop with tool calls is sealed, resume is well-formed, no-tools reason → `TestDoomLoopTextStopSealsDanglingCalls` and `TestDoomLoopTextRepetitionStopsNoToolsRun`. +- D1: no final-response request after a stop → `TestDoomLoopStopSkipsFinalResponseRequest`. +- D2/D3: approval-resume stop, fresh-turn clear → `TestDoomLoopApprovalResumeStopAndCondemnation` (plus `TestDoomLoopCondemnedRunStaysHaltedOnDecisionResume`). +- D4: in-loop steer, and steer at a HITL pause persisted → `TestDoomLoopSteer`. Delivery on resume → `TestDoomLoopPendingSteerDeliveredAfterResume`. +- API1: field-array forms, `false` → `TestDoomLoopToolLoopKeyForms`. D5 (`undefined`) is N/A and documented. +- H1: nonce miss, and field list catches it → `TestDoomLoopNonceArgsEvadeDefaultIdentity`. +- H2: server streak stops, distinct queries do not → `TestDoomLoopServerToolRepetition`. The `__proto__` server field case is N/A in Go (no prototype setter). +- ON→OFF and ON→ON → `TestDoomLoopStatePersistsAcrossResume`. +- Go-only additions: + - `TestDoomLoopCheckpointRunsBeforePreToolUseMutation` (upstream has the behavior, not a dedicated test); + - `TestDoomLoopFirstTurnPauseTakesPrecedenceOverDoomStop` (pins the first-turn vs later-turn asymmetry); + - `TestDoomLoopUnhashableLoopKeyFallsBack`. + +**doom-loop-escalation.test.ts** +- Config resolution (4 cases) → `DL TestResolveDoomLoopOption`. +- Monitor escalate rung, budget exhaustion, `escalationsUsed` round-trip → `DL TestDoomLoopMonitorEscalationBudget`. +- Engine: + - one-turn model swap; + - advisor and pinned `toolChoice`, checked on the serialized request; + - advisor merge; + - steer notice; + - per-applied budget; + - hook-override escalate with and without config; + - text escalation; + - escalate once for two detectors + + → the 7 subtests of `DLE TestDoomLoopEscalationRecovery`. + +**run-cancellation.test.ts / e2e/cancellation.test.ts** +- pre-aborted signal → `RC TestRunCancellationPreCanceledFailsBeforeDispatch`. +- abort between requests → `TestRunCancellationBetweenRequestsStopsLoop` (also asserts stream consumers see the cause). +- mid-request abort → `TestRunCancellationAbortsInFlightRequest` (cause and `Cancel()`). +- timeoutMs still bounds with a signal present → `TestRunCancellationPerRequestTimeoutComposesWithRunContext`. +- Go-only: `TestRunCancellationBeforeToolExecution`. +- "threads a composed signal into every dispatch" and "timeoutMs alone passes through" are N/A: they are TS SDK composition mechanics. +- **Gaps:** + - No Go test that the per-request timeout is applied per dispatch across multiple turns (upstream: "the composed timeout is created per dispatch, not shared across turns"). + - No Go live e2e counterpart for `e2e/cancellation.test.ts`; `e2e_test.go` is unchanged. + - No test for cancellation after a final no-call response (Finding 3). + +The tests assert upstream-observable behavior: request counts and request contents, tool output payloads, hook payload sequences, SessionEnd reasons, persisted `ConversationState.DoomLoop` shape, well-formed history, and serialized request JSON for the advisor case. They are not pinned to internal structure. Concurrency-touching tests pass under `-race`. + +## Documented divergences check + +Divergences recorded in the contract or the changelog (acceptable): + +- `ctx` for `signal` (contract #1). +- Explicit `(string, error)` from the canonicalize and fingerprint functions (contract #2). +- `DoomLoopThreshold` sentinel values. +- `LoopKeyFunc` nil/error semantics, with no `undefined` case. +- Invalid-JSON calls dropped, so the parse-error doom path does not arise (Finding 1). +- Unpaired surrogates become U+FFFD. +- Server-tool fingerprints from snake_case wire JSON. +- `DoomLoopDetected` handler errors are logged. +- A doom stop on a decision resume stores outputs in `Messages`. +- The `MaxTurns` default of 5 versus the stop rung (Finding 2). +- Sequential evaluation. + +The new naming mappings are recorded in the changelog's mapping table: `DoomLoop`, `DoomLoopVerdict(ctx)`, `LoopKey*`, `MarkMcpOptions`, `OnDoomLoopDetected`, the monitor and primitive functions, the constants, and `signal` → ctx. They are not yet in the contract's Naming Map. Per the contract they should be reported for addition. + +Undocumented divergences: + +- Finding 3: cancellation ignored after a turn>0 no-call response (warning). +- Finding 4: a text stop with no tools configured seals halt outputs (info). +- Finding 5: turn-0 cancellation ordering versus the approval gate (info). +- Finding 7: PermissionRequest-deny text on a first-turn seal (info). +- Finding 8: non-object argument identity (info). + +Version honesty: the changelog labels this work "ported from `@openrouter/agent` work after 0.8.0, not yet in an upstream release". That is accurate at the target commit (`package.json` is still 0.8.0). `verify.sh` correctly warns not to tag v0.8.0 from this tree. `.upstreamer/state.yaml` is unchanged. + +## Repo-owned files check + +- `git status --short`: modified files are `.upstreamer/coverage-floor.txt`, `.upstreamer/eval-report.md`, `README.md`, `async_params.go`, `conversation_state.go`, `hooks_manager.go`, `hooks_resolve.go`, `hooks_schemas.go`, `model_result.go`, `tool.go`, `tool_types.go` and `upstreamer-changelog.md`. New files are `doom_loop.go`, `doom_loop_engine_test.go`, `doom_loop_test.go`, `run_cancellation_test.go` and `testdata/`. +- `.github/`, `LICENSE`, `scripts/upstream`, `go.mod` and `go.sum` are unchanged (`git diff --stat` is empty for them). +- The module path is `github.com/OpenRouterTeam/go-agent`, and `go-sdk` is pinned at `v0.5.4`. The `service_tier` workaround is retained, per `verify.sh`. +- `.upstreamer/coverage-floor.txt` was raised from 72.0 to 77.0, which is allowed; measured coverage is 78.4%. `.upstreamer/state.yaml` is unchanged. + +## Verification commands and results + +- `go test -race -count=1 ./...` → `ok github.com/OpenRouterTeam/go-agent 1.927s` (exit 0). +- `.upstreamer/scripts/verify.sh .` → `=== PASS: 0 failures ===`: + - gofmt, build, vet, test and race all pass; + - all 24 required symbols are exported, and every required symbol has coverage; + - coverage is 78.4% against the 77.0% floor; + - go-sdk is pinned at v0.5.4, and the service_tier pin is retained; + - no TS artifacts, and the repo-owned files are present. + - Advisory notes: the tree is 3 commits ahead of the 0.8.0 tag, and the `shared-*.test.ts` cluster has no Go mention. Both predate this delta. +- `cmp testdata/doom-loop-fingerprints.json tmp/upstreamer/upstream/packages/agent/tests/vectors/doom-loop-fingerprints.json` → identical. +- Throwaway experiments in `tmp/upstreamer/opencode/evalcopy`, now deleted: + - JCS number differential against Node `JSON.stringify` (3,008 values): 0 mismatches on finite values. + - Cancel in AfterTurn after a turn-2 text response: Go returns `("final", nil)`, while upstream would reject (Finding 3). + - Text stop with no tools and a `function_call`: Go `complete` with 1 halt output, while upstream is `complete` with no output (Finding 4). + - Pre-cancelled approval resume: the tool ran once, the run returned `context canceled`, and the accessor was still `awaiting_approval` (Finding 6). --- -## 1. Upstream reference re-confirmed from source - -`tmp/upstreamer/upstream/packages/agent/src/lib/model-result.ts`: - -- `saveStateSafely` (2428-2445): the single choke point that calls - `stateAccessor.save`. It is unconditional — no caller checks the resulting - status before deciding whether to save. -- `processApprovalDecisions` (2735-2881): computes `nextStatus` (which may be - `awaiting_approval`, `awaiting_hitl`, or `in_progress`) and calls - `await this.saveStateSafely(stateUpdates)` at line 2859 **before** the - `if (nextStatus !== 'in_progress') { return; }` check at 2875 — i.e. the - save happens regardless of whether the resume resolves or re-pauses. This - is the exact behavior the Round-3/4 fix needed to match. -- `persistHitlPause` (1711-1727) and `persistClientToolsPause` (1753-1769) - each call `saveStateSafely(stateUpdates)` unconditionally when a HITL or - manual-tool pause happens on the **initial** (non-resume) path — confirming - the initial pause paths, not just resumes, must persist. -- `handleApprovalCheck`'s mixed-approval-turn save (1690) is likewise - unconditional before pausing with `awaiting_approval`. -- `saveResponseToState` (721-758) and `saveToolResultsToState` (776+) are - called after **every** turn's response and after **every** tool-round's - results (call sites at lines 3003, 3155, 3200, 3246, 3286, 3304) — i.e. - upstream persists incrementally per turn, not just at pause/completion - boundaries. This is the source of the warning-level finding in §4. - -## 2. Go port control flow re-traced end to end (`model_result.go` `run()`) - -Traced every `return` and every `break` in `run()` (lines 182-498): - -- **Resume-repause branch** (227-251, this round's fix): `paused, err := - m.prepareResumeRequest(&req); if paused { ...StateAccessor.Save...; return - }`. Confirmed present, calls `Save` before `return`, mirrors upstream's - unconditional `saveStateSafely` in `processApprovalDecisions`. This is a - faithful, minimal, correctly-placed fix. -- **Main loop pause exits** (`ConversationStatusAwaitingApproval` at 380-381 - and 437-439 for the mixed-approval case; `isPausedStatus` break at 391 and - 448-449 for HITL/client-tools/interrupted) all use `break`, not `return` — - which drops out of the `for turn` loop and falls through to the - **unconditional** `StateAccessor.Save` at lines 493-497. Since none of - these pause paths early-`return`, they all reach that save. This is a - structurally different (but equally correct) mechanism than the - resume-repause fix, and it already covered these cases correctly — I did - not find a second instance of the Round-3 bug class on the *initial* pause - paths. -- `executeToolCallsForTurn` (763-822) sets - `ConversationStatusAwaitingHITL`/`ConversationStatusAwaitingClientTools` - directly on `m.state` and returns to the caller, which then `break`s — so - the same unconditional end-of-`run()` save covers both. -- Genuine mid-loop **errors** (`m.err = err; return`, e.g. `SendResponse` - failure, stream-consume failure, marshal failure) skip the end-of-`run()` - save entirely, same as upstream would skip a `saveStateSafely` call it - never reaches because the enclosing `async` function threw first — *except* - upstream also has the extra `saveResponseToState`/`saveToolResultsToState` - calls sprinkled through the loop that persist progress from **earlier, - already-completed** turns before a **later** turn's request throws. Go-agent - has no equivalent per-turn checkpoint; see §4. - -## 3. Fresh probes (written, run, deleted — not the port's own tests) - -Three throwaway tests in `probe_pause_stateaccessor_test.go`, using -`StateAccessor` end-to-end (a `memoryStateAccessor` test double, not -`ModelResult.State()`) and a **third** `CallModel` call reloading from the -same accessor to check for duplicate tool execution, exactly like the -port's own `TestStateAccessorPersistsWhenResumeRepauses`: - -- `TestProbeStateAccessorSavedOnHITLPause`: an auto tool + a HITL tool - (`OnToolCalled` returning `false`) pause the run with `awaiting_hitl`. - `accessor.saves > 0`, `accessor.state.Status == awaiting_hitl`, the - regular tool's output was already in `accessor.state.Messages`. A second - `CallModel` call reloading from the same accessor and approving the HITL - call did **not** re-run the regular tool (`regularRan == 1`). -- `TestProbeStateAccessorSavedOnAwaitingClientToolsPause`: an auto tool + an - unresolved manual tool pause with `awaiting_client_tools`. Accessor saved, - status and pending call correctly reflected, regular tool's output - persisted before the pause. -- `TestProbeStateAccessorSavedOnMixedApprovalPause`: an auto tool + an - approval-required tool pause with `awaiting_approval`. Accessor saved, the - auto tool's unsent result was persisted to the accessor **before** the - pause. A second `CallModel` call reloading from the same accessor and - approving the pending call did not re-run the auto tool, ran the danger - tool exactly once, and both outputs reached the resume request as - `function_call_output` items in the correct order. - -All three passed, including under `-race`. (My first draft of these probes -had a synchronization bug — checking counters immediately after `CallModel` -returns without blocking on `.State(ctx)`/`.Text(ctx)` first, since -`CallModel` starts `run()` in a goroutine and returns immediately. Fixed by -blocking before asserting; this was a bug in my probe, not the port.) - -A fourth, throwaway probe (`probe_midrun_error_test.go`, -`TestProbeStateAccessorSavedAfterEarlierTurnWhenLaterTurnErrors`) is the -source of the §4 warning: a tool executes successfully on turn 1, a second -call errors on turn 2 (simulated transport failure), and -`accessor.saves == 0` — turn 1's already-executed tool work never reached -the accessor. - -All probe files were deleted before finishing. `git status --porcelain` -before and after probing is identical (only `.upstreamer/eval-report.md` is -new/modified across the whole session). - -## 4. Warning: no per-turn incremental `StateAccessor` persistence - -Confirmed via probe (above) and via reading -`tmp/upstreamer/upstream/packages/agent/src/lib/model-result.ts` at both the -0.7.2 baseline (`adc7939`) and the 0.8.0 target (`680bceb`): upstream calls -`saveResponseToState`/`saveToolResultsToState` after **every** model -response and **every** tool-execution round in the main loop, not just at -pause/completion boundaries. `go-agent`'s `run()` only reaches -`StateAccessor.Save` at the very end (line 493-497, after the `for` loop -exits via `break` or falls through normally) or at the Round-4-fixed -resume-repause early return. Any hard error inside the loop (`m.err = err; -return`) skips persistence of everything accumulated in prior, already- -completed turns of the *same* run. - -Impact: a caller who relies **solely** on `StateAccessor` (never manually -re-threading `ModelResult.State()`, which the contract explicitly allows as -an alternative) and who experiences a genuine mid-run turn-level failure -after at least one earlier turn already executed a tool with a real side -effect, has no durable record of that side effect. A naive retry from the -stale accessor state could re-execute that tool a second time — the same -failure mode as the Round-3 bug, but triggered by an exceptional error -condition on an ordinary multi-turn loop, rather than by the normal, -documented, partial-approval-resume pattern the Round-3/4 fix targets. - -This predates the 0.8.0 delta (confirmed present in upstream's own 0.7.2-era -source, so it is not something this round — or even the initial port round — -was tasked with introducing or fixing as part of *this* sync), is not a -violation of eval.md's actual "State" bullet (scoped specifically to -"a pause/resume cycle mid-approval," which is satisfied — see §2-3), and does -not affect the correctness of `ModelResult.State()`/`.Text()`/etc. for a -caller who does use those directly. I'm recording it here as an actionable, -honestly-disclosed finding for a follow-up round rather than a blocker for -this one. Recommend: either add per-turn `saveResponseToState`/ -`saveToolResultsToState`-equivalent checkpoints inside the loop, or add an -explicit compatibility note documenting that `StateAccessor`-only callers -should treat a `CallModel` error as "state as of the last successful pause," -not "state as of the last successful turn." - -## 5. Full test suite - -`go test ./... -v`: 60 tests (57 port + 3 of my probes, later deleted), 1 -skip (`TestE2ESimpleResponsesCall`, requires a live API key), 0 failures. -Read the assertions directly rather than trusting names: -- `TestStateAccessorPersistsWhenResumeRepauses` (the port's own Round-4 test): - pauses on two approval-required calls, resumes approving only one (which - re-pauses), asserts `accessor.saves` increased across that re-pause, then - makes a **third** `CallModel` call from the same accessor and asserts the - first tool did not re-execute. This is real parity coverage, not a - shape-only test — it matches my own independent probes' methodology. -- `TestAwaitingClientToolsStatusForUnresolvedManualTool`, - `TestMixedRegularHITLPreservesRegularOutput`, - `TestMixedApprovalExecutesAutoToolsBeforePausing`, - `TestMixedApprovalRejectStillSendsAutoOutput`: cover the mixed-turn - ordering and manual/HITL pause requirements directly, asserting - `function_call` → `function_call_output` ordering and pre-pause - persistence, not just status strings. -- `TestHooksSessionStartEndDoNotRefireOnApprovalResume`, - `TestPostModelCallTurnTypeDistinguishesResumeFromInitial`: cover the - Round-2/Round-3 fixes; still pass, confirming no regression. -- `TestSDKStreamErrorPropagatesToConsumers`: confirms `Text`, - `FullResponsesStream`, and `ToolStream` consumers all surface a stream - error rather than hanging — required quality "Streaming" satisfied. -- `TestClaudeToFromRoundTripLossless`, - `TestFormatCompatibilityCarriesMetadataAndUnsupportedContent`: confirm the - Claude/Chat round-trip preserves metadata, reasoning, tool use, and - unsupported content — required quality "Compatibility helpers" satisfied. - -## 6. Verifier - -`.upstreamer/scripts/verify.sh`: **PASS, 0 failures** — gofmt clean, `go -build`/`go vet`/`go test` all clean, all 24 required exported symbols -present, hooks manager + versioned state serialization present, go-sdk -pinned at v0.5.4, `service_tier: auto` workaround retained, no leaked -TS/JS artifacts, LICENSE/README/go.mod/scripts/upstream all present. - -## 7. Required Qualities re-checked from scratch (not just state/approval) - -- **Public API completeness**: every symbol in the contract's Required - Public API list (`CallModel`, `NewOpenRouter`, `NewTool`/`MustNewTool`/ - `NewServerTool`, all nine `ModelResult` consumers, `CreateInitialState`/ - `AppendToMessages`/`UpdateState`/`PartitionToolCalls`, all five stop - conditions, `ToClaudeMessage`/`FromClaudeMessages`/`FromChatMessages`/ - `ToChatMessage`, `ExtractUnsupportedContent`/`HasUnsupportedContent`/ - `GetUnsupportedContentSummary`) confirmed present and exported via direct - `grep` against each file (not just the verifier's own list) — - `model_result.go`, `agent.go`, `tool.go`, `conversation_state.go`, - `stop_conditions.go`, `anthropic_compat.go`, `chat_compat.go`, - `stream_transformers.go`. -- **Version honesty**: upstream target commit `680bceb4598f228d3e2ec58e2416e4335cdff059` - has `packages/agent/package.json` version `0.8.0`, matching the - changelog's claim. `HooksManager` and the full nine-hook surface - (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, - `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, `PostModelCall`) - are present with typed `OnXxx` registration methods in `hooks_manager.go` — - not a hollow claim. -- **The load-bearing loop**: `TestFollowUpRequestPreservesAccumulatedInputHistory` - and `TestApprovalResumeReplaysFunctionCallBeforeOutput` confirm - `previous_response_id` carry-forward and correct accumulated-history - ordering across turns. -- **Streaming**: confirmed above (§5). -- **Approval/HITL ordering**: confirmed above (§3, §5) — mixed-turn - auto-before-pause ordering and `function_call`→`function_call_output` - replay ordering both hold, independently re-verified with my own probes, - not just by trusting the port's test names. -- **Hooks**: all nine present; `TestHooksStopForceResumeAndAppendPrompt`, - `TestHooksPermissionRequestAllowBypassesApprovalGate`/`Deny.../AskUser...`, - `TestHooksUserPromptSubmitMutatesInitialInput`/`Rejection...` each assert - the specific documented behavior, not just that a handler fired. -- **Compatibility helpers**: confirmed above (§5). -- **Divergences documented**: the six Idiomatic Divergences in - `upstreamer.md` are unaffected by this round. The Round-4-specific - changelog entry and README updates (`git diff README.md`, - `git diff upstreamer-changelog.md`) accurately describe the fix without - overclaiming — the changelog explicitly scopes the fix to "an approval/HITL - resume that itself left calls pending" and does not claim broader - incremental-persistence parity, which is honest given the §4 finding. - The §4 finding itself is **not yet documented** anywhere in - `upstreamer-changelog.md`'s Compatibility Notes — recommend adding it in a - follow-up. -- **Repo-owned files intact**: `git diff --stat HEAD -- .github LICENSE - go.mod` is empty — no changes to CI, license, or the substrate pin. - -## 8. Changelog honesty - -The new changelog bullet ("Fixed a `CallModelInput.StateAccessor` -persistence bug: an approval/HITL resume that itself left calls pending...") -accurately and narrowly describes exactly what was fixed, matches what I -verified in the code, and does not overclaim broader persistence guarantees -it doesn't provide (consistent with the §4 finding still being open). No -overclaiming found. - ---- - -## Verdict: PASS WITH WARNINGS - -The Round-4 targeted fix is real, correctly implemented, and I independently -verified — via fresh code tracing and fresh probes exercising -`StateAccessor` end-to-end (not `ModelResult.State()`) — that the same fix -class already correctly covers the two sibling pause boundaries the task -flagged as most at-risk (`awaiting_hitl`, `awaiting_client_tools`) plus the -mixed-approval pause. All Required Qualities re-checked from scratch pass. -`go test ./... -v` and `.upstreamer/scripts/verify.sh` both pass cleanly. - -One real, reproducible, but out-of-this-round's-scope finding remains open -(§4: no per-turn incremental `StateAccessor` persistence, unlike upstream) — -recorded here as an honest warning and a recommended follow-up, not a -blocker, because it predates this delta, does not violate eval.md's actual -"pause/resume cycle mid-approval" requirement (which is now fully satisfied -across all pause types), and does not affect the correctness of the -primary `ModelResult` consumer API. +## Converter follow-up (after this eval; written by the converter, not the evaluator) + +This section is not part of the independent evaluation above. It records what +the converter changed in response to the findings, so the next reader can +tell which findings are still open. + +- **Fixed, warning 3 (cancellation on a later, text-only turn was ignored).** + `model_result.go:run` now checks `runCanceledErr()` on every follow-up + response (`turn > 0`) before the no-tool-calls break. This matches + upstream's signal check at the top of the loop. Turn 0 skips the check, just + as upstream's first-response no-tools path returns before its loop. The new + test is `run_cancellation_test.go:TestRunCancellationOnTextOnlyFollowUpTurn`. + I disabled the check and confirmed that the test then fails. The verifier + was re-run and passes, with coverage at 78.4% (floor 77.0%). +- **Still open, warning 1 (invalid-JSON tool calls).** This predates the delta. + `ExtractToolCallsFromResponse` drops calls whose arguments are invalid JSON. + The gap is documented in `upstreamer-changelog.md` and needs a deliberate + behavior change (parse-error outputs) outside this delta's scope. + Recommended as a follow-up port item. +- **Still open, warning 2 (`MaxTurns` default 5 vs the default stop rung).** + The turn cap predates the delta. The interaction is documented in the README + and the changelog. Recommended contract decision: record `MaxTurns` as a + Go-specific bound, or align its default with upstream's unbounded loop. +- **Naming-map additions** for the contract are listed in the final run report. diff --git a/.upstreamer/state.yaml b/.upstreamer/state.yaml index 1410c3a..023eff2 100644 --- a/.upstreamer/state.yaml +++ b/.upstreamer/state.yaml @@ -1,9 +1,9 @@ # Last upstream commit successfully ported AND verified AND eval-passed. -# 680bceb ports @openrouter/agent 0.7.2 -> 0.8.0: HooksManager lifecycle -# hooks (upstream #7/#67), versioned ConversationState serialization -# (#66), PostModelCall telemetry + SessionEnd usage totals, default -# final-answer directive for AllowFinalResponse (#68), and -# awaiting_client_tools (#64). Eval verdict: PASS WITH WARNINGS — see +# 78c562e ports the post-0.8.0 (unreleased) @openrouter/agent work: +# doom-loop detection (upstream #73: DoomLoopMonitor, ladder incl. the +# escalate rung, DoomLoopDetected hook, tool LoopKey, ConversationState +# doomLoop persistence, JCS/SHA-256 fingerprints) and run-level +# cancellation via ctx. Eval verdict: PASS WITH WARNINGS — see # .upstreamer/eval-report.md. # Only scripts/upstream runs should change this. -upstream_commit: 680bceb4598f228d3e2ec58e2416e4335cdff059 +upstream_commit: 78c562ef53da0edd84dfbcc6d6ee38a095d72b37 diff --git a/README.md b/README.md index 90e0693..1d049c2 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Use `StepCountIs`, `HasToolCall`, `MaxTokensUsed`, `MaxCost`, `FinishReasonIs`, ## Lifecycle Hooks -`HooksManager` (`agent.NewHooksManager`) supports the nine built-in lifecycle hooks — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, and `PostModelCall` — plus fully custom hooks via the generic `agent.On`/`agent.Emit`. Register handlers with the typed `OnXxx` methods (e.g. `manager.OnPreToolUse(...)`) and pass the manager on `CallModelInput.Hooks`: +`HooksManager` (`agent.NewHooksManager`) supports the ten built-in lifecycle hooks — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, `PostModelCall`, and `DoomLoopDetected` — plus fully custom hooks via the generic `agent.On`/`agent.Emit`. Register handlers with the typed `OnXxx` methods (e.g. `manager.OnPreToolUse(...)`) and pass the manager on `CallModelInput.Hooks`: ```go hooks := agent.NewHooksManager() @@ -111,6 +111,26 @@ result, err := agent.CallModel(ctx, client, agent.CallModelInput{ `SessionStart`/`SessionEnd` fire once per non-resuming run (`SessionEnd` carries aggregated token usage across that run's model calls); an approval/HITL resume call is a continuation of the same session and does not get its own pair. `PostModelCall` fires once per model response, tagged `initial`/`resume`/`tool_round`/`final`/`retry`. `PreToolUse`/`PostToolUse`/`PostToolUseFailure` fire around every client-tool execution path, including during a resume. `PermissionRequest` fires before the human-approval pause and can `allow`/`deny`/`ask_user` (default) a gated call. `Stop` fires whenever a stop condition halts the loop mid-tool-call and can force a resume and/or inject a prompt. `UserPromptSubmit` fires once per non-resuming run against the initial user input and can mutate or reject it. Session identity is threaded per emit, so one `HooksManager` is safe to share across concurrent `CallModel` runs. Call `manager.Drain()` to await fire-and-forget handler work; go-agent always drains on every exit path, including no-tools error paths. +## Doom-Loop Detection + +Set `CallModelInput.DoomLoop` to catch runs that stop making progress while continuing to spend: the same tool call with identical arguments in consecutive rounds (including repeated empty `{}` calls), repeated identical server-tool requests, or the same text repeated within or across responses. `&agent.DoomLoopConfig{}` enables the defaults (observe at 2 consecutive identical rounds, block at 3, stop at 6); `nil` (the default) leaves detection off. A run is also capped at `MaxTurns` requests (default 5), so raise `MaxTurns` or lower `Ladder.Stop` if you want the stop rung to bound the run. + +```go +result, err := agent.CallModel(ctx, client, agent.CallModelInput{ + Model: "openai/gpt-4o-mini", + Input: "...", + Tools: []agent.Tool{searchTool}, + DoomLoop: &agent.DoomLoopConfig{Ladder: agent.DoomLoopLadder{Steer: 3, Block: 4, Stop: 8}}, +}) +verdict, err := result.DoomLoopVerdict(ctx) // non-nil when detection stopped the run +``` + +The ladder escalates `observe` (emit the `DoomLoopDetected` hook) → `steer` (inject corrective guidance before the next request) → `escalate` (run the next turn on `Escalation.Model` and/or force an `openrouter:advisor` consult, budgeted by `MaxEscalations`) → `block` (refuse the call with an explanatory tool error) → `stop` (halt before any further model request; `SessionEnd` reason `doom_loop`). A zero threshold uses the default, `agent.DoomLoopRungDisabled` turns a rung off. `DoomLoopDetected` handlers can override the action per event. Streaks are round-scoped (parallel identical calls in one turn count once) and persist in `ConversationState.DoomLoop`, so pass `DoomLoop` again when resuming. Tools declare call identity with `ToolConfig.LoopKey`: `agent.LoopKeyFields("command", "cwd")`, `agent.LoopKeyFunc(func(in Args) (any, error) {...})` (return `nil` to exempt a call), or `agent.LoopKeyExempt()`; `agent.MarkMcp(tool, agent.MarkMcpOptions{LoopKey: ...})` attaches one to an MCP-wrapped tool. Fingerprints are RFC 8785 + SHA-256 and match the TypeScript and Python ports' conformance vectors (arguments carrying unpaired UTF-16 surrogates are the one documented exception, since Go strings are UTF-8). + +## Cancellation + +The `ctx` passed to `CallModel` cancels the whole run: a canceled ctx (or `result.Cancel()`) stops the loop at the next turn boundary and aborts any in-flight request, and every consumer returns the cancellation cause (`context.Cause`, so `context.WithCancelCause` reasons propagate). A pre-canceled ctx fails before any network dispatch. `SDKOptions.Timeout` bounds each request independently. + ## Format Compatibility `ToClaudeMessage` / `FromClaudeMessages` and `ToChatMessage` / `FromChatMessages` convert between OpenRouter Responses output and Claude or Chat-style messages. Unsupported content is carried with the structured `original_type`, `data`, and `reason` shape so it can round-trip without being silently lost. diff --git a/async_params.go b/async_params.go index e56158a..b7c0d5f 100644 --- a/async_params.go +++ b/async_params.go @@ -41,6 +41,16 @@ type CallModelInput struct { // Hooks accepts either a *HooksManager or an InlineHookConfig. See // ResolveHooks. nil means no hooks. Hooks any + // DoomLoop enables doom-loop detection (upstream `doomLoop`): catch runs + // that repeat the same tool call with identical arguments (including + // repeated empty calls), repeat identical server-tool requests, or emit + // the same text over and over, and respond with a graduated ladder + // (observe → steer → escalate → block → stop; defaults observe@2, + // block@3, stop@6). nil (the default) disables detection; + // &DoomLoopConfig{} is upstream's `doomLoop: true`. Tools declare + // precise call identity via ToolConfig.LoopKey. Pass it again on a + // resuming call for persisted streaks to keep counting. + DoomLoop *DoomLoopConfig } type CallModelInputWithState = CallModelInput diff --git a/conversation_state.go b/conversation_state.go index 887ef1b..0749669 100644 --- a/conversation_state.go +++ b/conversation_state.go @@ -156,6 +156,10 @@ func UpdateState(state ConversationState, mutate func(*ConversationState)) Conve copyState.Messages = append([]components.InputsUnion1{}, state.Messages...) copyState.PendingToolCalls = append([]ParsedToolCall{}, state.PendingToolCalls...) copyState.UnsentToolResults = append([]UnsentToolResult{}, state.UnsentToolResults...) + if state.DoomLoop != nil { + doomLoop := cloneDoomLoopState(*state.DoomLoop) + copyState.DoomLoop = &doomLoop + } mutate(©State) copyState.UpdatedAt = time.Now().UnixMilli() return copyState @@ -232,3 +236,21 @@ func UnsentResultsToAPIFormatWithError(results []UnsentToolResult) ([]components } return items, nil } + +func cloneDoomLoopState(s DoomLoopSerializedState) DoomLoopSerializedState { + out := s + out.Tools = make(map[string]DoomLoopStreak, len(s.Tools)) + for k, v := range s.Tools { + out.Tools[k] = v + } + if s.Text != nil { + text := *s.Text + out.Text = &text + } + if s.StopVerdict != nil { + v := *s.StopVerdict + out.StopVerdict = &v + } + out.PendingSteer = append([]string(nil), s.PendingSteer...) + return out +} diff --git a/doom_loop.go b/doom_loop.go new file mode 100644 index 0000000..4cd42d8 --- /dev/null +++ b/doom_loop.go @@ -0,0 +1,1267 @@ +package agent + +// Doom-loop detection for the tool-execution loop. Ported from upstream +// lib/doom-loop.ts. +// +// A "doom loop" is a run that stops making progress while continuing to +// spend: the model re-issues the same tool call with identical arguments +// (including repeated *empty* calls), repeats identical server-tool +// requests, or emits the same text tokens over and over. This file provides +// the deterministic detection primitives; ModelResult wires them into the +// loop behind CallModelInput.DoomLoop. +// +// Design principles (mirroring upstream): +// +// - Deterministic. Every verdict is a pure function of the recorded +// sequence of tool calls / assistant texts — no wall clocks, no +// randomness. +// - Cross-port fingerprints. Key material is canonicalized per RFC 8785 +// (JCS) and hashed with SHA-256 over the UTF-8 bytes. The upstream +// conformance vectors (tests/vectors/doom-loop-fingerprints.json) are +// reproduced byte-for-byte by this implementation; see +// doom_loop_test.go. +// - Tool-declared identity via ToolLoopKey (function, declarative field +// list, or static exemption). +// - Round-scoped streaks: N identical calls in ONE round count once. +// - Graduated response: observe → steer → escalate → block → stop. +// - Never the cause of failure: an internal detection error degrades to a +// fallback identity or skips detection for that call. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "math/big" + "reflect" + "sort" + "strconv" + "strings" + "sync" + "unicode/utf16" + "unicode/utf8" + + "github.com/OpenRouterTeam/go-sdk/models/components" +) + +//#region Public Types + +// DoomLoopAction is a ladder rung, weakest to strongest: +// +// - observe — emit the DoomLoopDetected hook only. +// - steer — inject a corrective user message before the next model turn +// (same mechanism as the Stop hook's AppendPrompt); the call still runs. +// - escalate — run the NEXT turn with more intelligence (a stronger model +// and/or a forced openrouter:advisor consult), per the Escalation +// config. One-turn overrides, budgeted by MaxEscalations; the call still +// runs. +// - block — refuse the tool call and synthesize an error +// function_call_output (same shape as a PreToolUse block). +// - stop — halt the run before the next model request +// (SessionEnd reason "doom_loop"). +type DoomLoopAction string + +const ( + DoomLoopActionObserve DoomLoopAction = "observe" + DoomLoopActionSteer DoomLoopAction = "steer" + DoomLoopActionEscalate DoomLoopAction = "escalate" + DoomLoopActionBlock DoomLoopAction = "block" + DoomLoopActionStop DoomLoopAction = "stop" +) + +// DoomLoopDetectorKind identifies which detector produced a verdict. +type DoomLoopDetectorKind string + +const ( + DoomLoopDetectorToolFingerprint DoomLoopDetectorKind = "tool-fingerprint" + DoomLoopDetectorServerToolFingerprint DoomLoopDetectorKind = "server-tool-fingerprint" + DoomLoopDetectorTextRepetition DoomLoopDetectorKind = "text-repetition" + DoomLoopDetectorTextStreak DoomLoopDetectorKind = "text-streak" +) + +// DoomLoopVerdict is a doom-loop detection event. +// +// Streak is the repetition count that crossed a ladder threshold: for +// fingerprint detectors the consecutive identical-fingerprint round count +// for that tool; for text-repetition the number of consecutive repeats of a +// token block within one response; for text-streak the number of +// consecutive steps with identical assistant text. +type DoomLoopVerdict struct { + Detector DoomLoopDetectorKind `json:"detector"` + Action DoomLoopAction `json:"action"` + Streak int `json:"streak"` + // Fingerprint is the deterministic fingerprint of the repeated unit. + Fingerprint string `json:"fingerprint"` + // ToolName is set for fingerprint verdicts. + ToolName string `json:"toolName,omitempty"` + // Message is the human/model-readable explanation, used verbatim for + // block outputs and steer messages. + Message string `json:"message"` +} + +// DoomLoopCallRecord is the result of recording one tool call with the +// monitor. DuplicateInRound is true when the same (toolName, fingerprint) +// was already recorded in this round — the streak did NOT increment and the +// caller should reuse the decision it applied for the first occurrence. +type DoomLoopCallRecord struct { + Fingerprint string + Streak int + DuplicateInRound bool + Verdict *DoomLoopVerdict +} + +// DoomLoopThreshold is one ladder rung's streak threshold. The zero value +// means "use the default for this rung"; a positive value is the threshold +// (compared with >=); DoomLoopRungDisabled (any negative value) disables the +// rung — the Go stand-in for upstream's `false`. +type DoomLoopThreshold int + +// DoomLoopRungDisabled disables a ladder rung (upstream `false`). +const DoomLoopRungDisabled DoomLoopThreshold = -1 + +func (t DoomLoopThreshold) enabled() bool { return t > 0 } + +// DoomLoopLadder holds the streak thresholds for each action. When several +// rungs are crossed the strongest wins (stop > block > escalate > steer > +// observe). Thresholds compare with >=, so a verdict fires on every record +// at or past a rung. +type DoomLoopLadder struct { + Observe DoomLoopThreshold + Steer DoomLoopThreshold + Escalate DoomLoopThreshold + Block DoomLoopThreshold + Stop DoomLoopThreshold +} + +// DefaultDoomLoopLadder returns the default ladder: observe at 2 +// consecutive identical rounds, block at 3, stop at 6. Steer and escalate +// are off by default. +func DefaultDoomLoopLadder() DoomLoopLadder { + return DoomLoopLadder{Observe: 2, Steer: DoomLoopRungDisabled, Escalate: DoomLoopRungDisabled, Block: 3, Stop: 6} +} + +// DefaultMaxEscalations is the escalation budget when the rung is enabled +// without a cap. +const DefaultMaxEscalations = 2 + +// DoomLoopEscalationConfig configures the recovery applied by the escalate +// rung. At least one of Model / Advisor / AdvisorParameters must be set for +// the rung to do anything. +type DoomLoopEscalationConfig struct { + // Model is the slug to run the NEXT turn on, replacing the request's + // configured model (and clearing any Models fallback list) for one + // request only. + Model string + // Advisor forces an openrouter:advisor consult on the next turn with + // default parameters (transcript forwarded, loop-diagnosing + // instructions). Upstream `advisor: true`. + Advisor bool + // AdvisorParameters, when non-nil, enables the advisor consult and is + // passed through as the advisor tool's parameters; nil ForwardTranscript + // / Instructions are filled with the defaults. Upstream's object form of + // `advisor`. + AdvisorParameters *components.AdvisorServerToolConfig + // MaxEscalations caps recoveries per conversation. Values < 1 mean + // DefaultMaxEscalations. + MaxEscalations int +} + +// DoomLoopTextOptions tunes within-response text-repetition detection. +// Zero-valued numeric fields use the defaults. +type DoomLoopTextOptions struct { + // Disabled turns off both text detectors (upstream `text: false` / + // `enabled: false`). + Disabled bool + // MaxPeriodTokens is the longest repeating token block to search for. Default 16. + MaxPeriodTokens int + // MinRepeats is the minimum consecutive repeats of a block. Default 4. + MinRepeats int + // MinCoveredTokens is the minimum total tokens covered. Default 12. + MinCoveredTokens int + // MaxWindowTokens: only the trailing N tokens of a response are scanned. Default 400. + MaxWindowTokens int +} + +// DoomLoopConfig configures doom-loop detection. A zero DoomLoopConfig +// (`&agent.DoomLoopConfig{}`) is upstream's `doomLoop: true`: every default. +type DoomLoopConfig struct { + Ladder DoomLoopLadder + Text DoomLoopTextOptions + Escalation *DoomLoopEscalationConfig +} + +// DoomLoopStreak is a consecutive-repetition counter for one identity. +type DoomLoopStreak struct { + Fingerprint string `json:"fingerprint"` + Streak int `json:"streak"` +} + +// DoomLoopSerializedState is the plain-JSON detector state persisted inside +// ConversationState.DoomLoop so loop memory survives serialize → resume +// (provided the resuming call passes DoomLoop again). +// +// Decoding is deliberately lenient (see UnmarshalJSON): a corrupt detector +// blob never fails ConversationState deserialization; invalid entries are +// dropped, matching upstream's "a corrupt detector state must never take +// down a resumed run". +type DoomLoopSerializedState struct { + // Tools holds per-tool streaks keyed by tool name. + Tools map[string]DoomLoopStreak `json:"tools"` + // Text is the cross-step assistant-text streak. + Text *DoomLoopStreak `json:"text,omitempty"` + // StopVerdict is the verdict that condemned this run, kept across + // decision-only resumes and cleared by a fresh conversational turn. + StopVerdict *DoomLoopVerdict `json:"stopVerdict,omitempty"` + // PendingSteer is steer guidance queued but not yet injected when the + // run paused; flushed into the conversation on resume. + PendingSteer []string `json:"pendingSteer,omitempty"` + // EscalationsUsed counts consumed escalations against MaxEscalations. + EscalationsUsed int `json:"escalationsUsed,omitempty"` +} + +// UnmarshalJSON decodes leniently: invalid shapes are ignored with a +// warning instead of returning an error. +func (s *DoomLoopSerializedState) UnmarshalJSON(data []byte) error { + *s = DoomLoopSerializedState{Tools: map[string]DoomLoopStreak{}} + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil || raw == nil { + log.Printf("[DoomLoop] Ignoring invalid persisted doom-loop state") + return nil + } + if b, ok := raw["tools"]; ok { + var tools map[string]json.RawMessage + if json.Unmarshal(b, &tools) == nil { + for name, entry := range tools { + if streak, ok := decodeStreak(entry); ok { + s.Tools[name] = streak + } + } + } + } + if b, ok := raw["text"]; ok { + if streak, ok := decodeStreak(b); ok { + s.Text = &streak + } + } + if b, ok := raw["stopVerdict"]; ok { + var v DoomLoopVerdict + if json.Unmarshal(b, &v) == nil && v.Action != "" { + s.StopVerdict = &v + } + } + if b, ok := raw["pendingSteer"]; ok { + var items []any + if json.Unmarshal(b, &items) == nil { + for _, item := range items { + if msg, ok := item.(string); ok { + s.PendingSteer = append(s.PendingSteer, msg) + } + } + } + } + if b, ok := raw["escalationsUsed"]; ok { + var n float64 + if json.Unmarshal(b, &n) == nil && n >= 0 && !math.IsInf(n, 0) { + s.EscalationsUsed = int(math.Floor(n)) + } + } + return nil +} + +func decodeStreak(b json.RawMessage) (DoomLoopStreak, bool) { + var raw map[string]any + if json.Unmarshal(b, &raw) != nil || raw == nil { + return DoomLoopStreak{}, false + } + fp, ok1 := raw["fingerprint"].(string) + n, ok2 := raw["streak"].(float64) + if !ok1 || !ok2 { + return DoomLoopStreak{}, false + } + return DoomLoopStreak{Fingerprint: fp, Streak: int(n)}, true +} + +// LoopKeyResolutionKind classifies a LoopKeyResolution. +type LoopKeyResolutionKind string + +const ( + LoopKeyResolutionExempt LoopKeyResolutionKind = "exempt" + LoopKeyResolutionKey LoopKeyResolutionKind = "key" + LoopKeyResolutionFallback LoopKeyResolutionKind = "fallback" +) + +// LoopKeyResolution is the result of resolving a tool's loop-key +// declaration against one call's arguments. See ResolveLoopKeyMaterial. +type LoopKeyResolution struct { + Kind LoopKeyResolutionKind + KeyMaterial any + // Warning is set for LoopKeyResolutionFallback. + Warning string +} + +//#endregion + +//#region Config Resolution + +var defaultDoomLoopTextOptions = ResolvedDoomLoopTextOptions{Enabled: true, MaxPeriodTokens: 16, MinRepeats: 4, MinCoveredTokens: 12, MaxWindowTokens: 400} + +// ResolvedEscalationConfig is an escalation config with the budget resolved. +type ResolvedEscalationConfig struct { + Model string + Advisor bool + AdvisorParameters *components.AdvisorServerToolConfig + MaxEscalations int +} + +func (c *ResolvedEscalationConfig) hasAdvisor() bool { + return c != nil && (c.Advisor || c.AdvisorParameters != nil) +} + +// ResolvedDoomLoopTextOptions is DoomLoopTextOptions with defaults applied. +type ResolvedDoomLoopTextOptions struct { + Enabled bool + MaxPeriodTokens int + MinRepeats int + MinCoveredTokens int + MaxWindowTokens int +} + +// ResolvedDoomLoopConfig is the fully-resolved config used by the monitor. +// Every ladder rung is either a positive threshold or DoomLoopRungDisabled. +type ResolvedDoomLoopConfig struct { + Ladder DoomLoopLadder + Text ResolvedDoomLoopTextOptions + Escalation *ResolvedEscalationConfig +} + +func sanitizeThreshold(value, fallback DoomLoopThreshold) DoomLoopThreshold { + switch { + case value < 0: + return DoomLoopRungDisabled + case value > 0: + return value + default: + return fallback + } +} + +// warnOnLadderHazards logs configurations that are accepted but probably +// not what the caller meant: block enabled with stop disabled (unbounded +// block/re-issue), and dead rungs that can never fire under strongest-wins. +func warnOnLadderHazards(ladder DoomLoopLadder) { + if ladder.Block.enabled() && !ladder.Stop.enabled() { + log.Printf("[DoomLoop] ladder has block enabled with stop disabled: a model that keeps " + + "re-issuing a blocked call loops indefinitely (each blocked round still costs a " + + "model request). Bound the run with StopWhen/MaxTurns, or enable the stop rung.") + } + names := []string{"observe", "steer", "escalate", "block", "stop"} + values := []DoomLoopThreshold{ladder.Observe, ladder.Steer, ladder.Escalate, ladder.Block, ladder.Stop} + for weak := range values { + if !values[weak].enabled() { + continue + } + for strong := weak + 1; strong < len(values); strong++ { + if values[strong].enabled() && values[weak] >= values[strong] { + log.Printf("[DoomLoop] ladder rung %q (%d) can never fire: stronger rung %q (%d) already wins at that streak (strongest crossed rung is applied).", names[weak], values[weak], names[strong], values[strong]) + break + } + } + } +} + +// ResolveDoomLoopOption normalizes CallModelInput.DoomLoop. nil → nil +// (detection off — the default posture). +func ResolveDoomLoopOption(option *DoomLoopConfig) *ResolvedDoomLoopConfig { + if option == nil { + return nil + } + defaults := DefaultDoomLoopLadder() + ladder := DoomLoopLadder{ + Observe: sanitizeThreshold(option.Ladder.Observe, defaults.Observe), + Steer: sanitizeThreshold(option.Ladder.Steer, defaults.Steer), + Escalate: sanitizeThreshold(option.Ladder.Escalate, defaults.Escalate), + Block: sanitizeThreshold(option.Ladder.Block, defaults.Block), + Stop: sanitizeThreshold(option.Ladder.Stop, defaults.Stop), + } + warnOnLadderHazards(ladder) + + var escalation *ResolvedEscalationConfig + in := option.Escalation + hasAdvisor := in != nil && (in.Advisor || in.AdvisorParameters != nil) + hasMechanism := in != nil && (in.Model != "" || hasAdvisor) + if hasMechanism { + maxEscalations := in.MaxEscalations + if maxEscalations < 1 { + maxEscalations = DefaultMaxEscalations + } + escalation = &ResolvedEscalationConfig{Model: in.Model, MaxEscalations: maxEscalations} + if hasAdvisor { + escalation.Advisor = true + escalation.AdvisorParameters = in.AdvisorParameters + } + if !ladder.Escalate.enabled() { + log.Printf("[DoomLoop] escalation config provided but the escalate ladder rung is disabled; " + + "set Ladder.Escalate to a streak threshold for recovery to trigger.") + } + } else if ladder.Escalate.enabled() { + log.Printf("[DoomLoop] Ladder.Escalate is enabled but no escalation config (Model/Advisor) was " + + "provided; the rung is skipped and verdicts fall through to weaker rungs.") + } + + return &ResolvedDoomLoopConfig{Ladder: ladder, Escalation: escalation, Text: resolveTextOptions(option.Text)} +} + +func resolveTextOptions(in DoomLoopTextOptions) ResolvedDoomLoopTextOptions { + out := defaultDoomLoopTextOptions + out.Enabled = !in.Disabled + if in.MaxPeriodTokens > 0 { + out.MaxPeriodTokens = in.MaxPeriodTokens + } + if in.MinRepeats > 0 { + out.MinRepeats = in.MinRepeats + } + if in.MinCoveredTokens > 0 { + out.MinCoveredTokens = in.MinCoveredTokens + } + if in.MaxWindowTokens > 0 { + out.MaxWindowTokens = in.MaxWindowTokens + } + return out +} + +//#endregion + +//#region loopKey + +// ToolLoopKeyFn computes the key material identifying a call for doom-loop +// detection from the call's (decoded JSON) arguments. Return (nil, nil) to +// exempt THIS call (upstream `null`); return an error (or panic) to fall +// back to the full-arguments identity. Must be pure and deterministic. +type ToolLoopKeyFn func(args map[string]any) (any, error) + +// ToolLoopKey is a tool's doom-loop identity declaration (upstream +// `loopKey`). A nil *ToolLoopKey means "absent": the full arguments object +// is the identity. Precedence: Exempt, then Fields (non-nil), then Func. +type ToolLoopKey struct { + // Func computes key material per call. + Func ToolLoopKeyFn + // Fields is a declarative field subset of the arguments (data, not + // code); missing fields are simply absent. + Fields []string + // Exempt statically exempts every call of the tool (upstream `false`). + Exempt bool +} + +// LoopKeyFields declares a field-subset loop key (upstream `['a', 'b']`). +func LoopKeyFields(fields ...string) *ToolLoopKey { + return &ToolLoopKey{Fields: append([]string{}, fields...)} +} + +// LoopKeyExempt statically exempts a tool from detection (upstream `false`). +func LoopKeyExempt() *ToolLoopKey { return &ToolLoopKey{Exempt: true} } + +// LoopKeyFunc declares a typed loop-key function: the call's arguments are +// decoded into In before fn runs (a decode failure falls back to the full +// arguments). Return nil to exempt the call. +func LoopKeyFunc[In any](fn func(In) (any, error)) *ToolLoopKey { + return &ToolLoopKey{Func: func(args map[string]any) (any, error) { + b, err := json.Marshal(args) + if err != nil { + return nil, err + } + var in In + if err := json.Unmarshal(b, &in); err != nil { + return nil, err + } + return fn(in) + }} +} + +// loopKeyProvider is implemented by tools that carry a loop-key declaration +// (TypedTool via ToolConfig.LoopKey, and MarkMcp-wrapped tools). +type loopKeyProvider interface{ toolLoopKey() *ToolLoopKey } + +func loopKeyOf(t Tool) *ToolLoopKey { + if p, ok := t.(loopKeyProvider); ok { + return p.toolLoopKey() + } + return nil +} + +// ResolveLoopKeyMaterial resolves a loop-key declaration against one call's +// arguments: +// +// - nil → the full arguments (LoopKeyResolutionKey). +// - Exempt → LoopKeyResolutionExempt. +// - Fields → the declared field subset (LoopKeyResolutionKey). +// - Func → its result; nil exempts THIS call; an error or panic falls +// back to the full arguments with a warning (detection must never take +// down a run). +func ResolveLoopKeyMaterial(loopKey *ToolLoopKey, args map[string]any) (res LoopKeyResolution) { + if loopKey == nil { + return LoopKeyResolution{Kind: LoopKeyResolutionKey, KeyMaterial: args} + } + if loopKey.Exempt { + return LoopKeyResolution{Kind: LoopKeyResolutionExempt} + } + if loopKey.Fields != nil { + subset := map[string]any{} + for _, field := range loopKey.Fields { + if v, ok := args[field]; ok { + subset[field] = v + } + } + return LoopKeyResolution{Kind: LoopKeyResolutionKey, KeyMaterial: subset} + } + if loopKey.Func == nil { + return LoopKeyResolution{Kind: LoopKeyResolutionFallback, KeyMaterial: args, Warning: "loopKey has unsupported shape (empty declaration); falling back to full arguments"} + } + defer func() { + if r := recover(); r != nil { + res = LoopKeyResolution{Kind: LoopKeyResolutionFallback, KeyMaterial: args, Warning: fmt.Sprintf("loopKey threw (%v); falling back to full arguments", r)} + } + }() + key, err := loopKey.Func(args) + if err != nil { + return LoopKeyResolution{Kind: LoopKeyResolutionFallback, KeyMaterial: args, Warning: fmt.Sprintf("loopKey threw (%v); falling back to full arguments", err)} + } + if key == nil { + return LoopKeyResolution{Kind: LoopKeyResolutionExempt} + } + return LoopKeyResolution{Kind: LoopKeyResolutionKey, KeyMaterial: key} +} + +//#endregion + +//#region Canonicalization & Fingerprinting + +// MaxCanonicalizeDepth is the maximum nesting depth accepted by +// CanonicalizeKeyMaterial. +const MaxCanonicalizeDepth = 64 + +// ErrUncanonicalizable is wrapped by every CanonicalizeKeyMaterial error. +var ErrUncanonicalizable = errors.New("cannot canonicalize key material for doom-loop fingerprinting") + +// CanonicalizeKeyMaterial renders value as RFC 8785 (JCS) canonical JSON: +// object keys sorted by UTF-16 code units (recursively), arrays in order, +// strings and numbers serialized exactly as ECMAScript JSON.stringify does +// (-0 → 0, 1e21 → 1e+21, lone surrogates → \udXXX). Byte-identical to the +// TypeScript and Python ports for the same key material. +// +// Values outside the JSON data model are mapped the way JSON.stringify maps +// them: func/chan entries are dropped from objects and serialize as null in +// arrays. Integer types are converted to float64 (ECMAScript numbers); +// structs and other typed values go through encoding/json first. +// +// Returns an error wrapping ErrUncanonicalizable for values RFC 8785 cannot +// represent — non-finite numbers, *big.Int / complex numbers (upstream's +// bigint), circular references, and nesting deeper than +// MaxCanonicalizeDepth. Real tool arguments (decoded JSON) never trigger +// these; the engine falls back to the full-arguments identity when a +// computed loop key does. +func CanonicalizeKeyMaterial(value any) (string, error) { + c := &canonicalizer{seen: map[uintptr]bool{}} + var b strings.Builder + if err := c.write(&b, value, 0); err != nil { + return "", err + } + return b.String(), nil +} + +type canonicalizer struct{ seen map[uintptr]bool } + +func (c *canonicalizer) write(b *strings.Builder, v any, depth int) error { + if depth > MaxCanonicalizeDepth { + return fmt.Errorf("%w: nested deeper than %d levels", ErrUncanonicalizable, MaxCanonicalizeDepth) + } + switch x := v.(type) { + case nil: + b.WriteString("null") + return nil + case bool: + if x { + b.WriteString("true") + } else { + b.WriteString("false") + } + return nil + case string: + writeJCSString(b, x) + return nil + case float64: + return writeJCSNumber(b, x) + case float32: + return writeJCSNumber(b, float64(x)) + case json.Number: + f, err := x.Float64() + if err != nil { + return fmt.Errorf("%w: invalid json.Number %q", ErrUncanonicalizable, string(x)) + } + return writeJCSNumber(b, f) + case *big.Int, big.Int, *big.Float, *big.Rat: + return fmt.Errorf("%w: big numbers have no RFC 8785 representation (convert to string or number in the loop key)", ErrUncanonicalizable) + case map[string]any: + return c.writeMap(b, reflect.ValueOf(x), depth) + case []any: + return c.writeSlice(b, reflect.ValueOf(x), depth) + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return writeJCSNumber(b, float64(rv.Int())) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return writeJCSNumber(b, float64(rv.Uint())) + case reflect.Float32, reflect.Float64: + return writeJCSNumber(b, rv.Float()) + case reflect.Bool: + return c.write(b, rv.Bool(), depth) + case reflect.String: + writeJCSString(b, rv.String()) + return nil + case reflect.Complex64, reflect.Complex128: + return fmt.Errorf("%w: complex numbers have no RFC 8785 representation", ErrUncanonicalizable) + case reflect.Func, reflect.Chan, reflect.UnsafePointer: + b.WriteString("null") + return nil + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + b.WriteString("null") + return nil + } + if rv.Kind() == reflect.Pointer { + ptr := rv.Pointer() + if c.seen[ptr] { + return fmt.Errorf("%w: circular reference", ErrUncanonicalizable) + } + c.seen[ptr] = true + defer delete(c.seen, ptr) + } + return c.write(b, rv.Elem().Interface(), depth) + case reflect.Map: + if rv.Type().Key().Kind() == reflect.String { + return c.writeMap(b, rv, depth) + } + case reflect.Slice, reflect.Array: + if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 { + break // []byte: encoding/json renders base64, follow it + } + return c.writeSlice(b, rv, depth) + } + // Structs, non-string-keyed maps, []byte, json.Marshaler values: map + // through encoding/json's data model, then canonicalize that. + raw, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("%w: %v", ErrUncanonicalizable, err) + } + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return fmt.Errorf("%w: %v", ErrUncanonicalizable, err) + } + return c.write(b, decoded, depth) +} + +func (c *canonicalizer) enter(rv reflect.Value) (func(), error) { + if rv.IsNil() || (rv.Kind() == reflect.Slice && rv.Len() == 0) { + return func() {}, nil + } + ptr := rv.Pointer() + if c.seen[ptr] { + return nil, fmt.Errorf("%w: circular reference", ErrUncanonicalizable) + } + c.seen[ptr] = true + return func() { delete(c.seen, ptr) }, nil +} + +func (c *canonicalizer) writeMap(b *strings.Builder, rv reflect.Value, depth int) error { + if rv.IsNil() { + b.WriteString("null") + return nil + } + leave, err := c.enter(rv) + if err != nil { + return err + } + defer leave() + type entry struct { + key string + units []uint16 + value any + } + entries := make([]entry, 0, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + val := iter.Value().Interface() + if isDroppedJSONValue(val) { + continue + } + key := iter.Key().String() + entries = append(entries, entry{key: key, units: utf16.Encode([]rune(key)), value: val}) + } + sort.Slice(entries, func(i, j int) bool { return compareUTF16(entries[i].units, entries[j].units) < 0 }) + b.WriteByte('{') + for i, e := range entries { + if i > 0 { + b.WriteByte(',') + } + writeJCSString(b, e.key) + b.WriteByte(':') + if err := c.write(b, e.value, depth+1); err != nil { + return err + } + } + b.WriteByte('}') + return nil +} + +func (c *canonicalizer) writeSlice(b *strings.Builder, rv reflect.Value, depth int) error { + if rv.Kind() == reflect.Slice { + if rv.IsNil() { + b.WriteString("null") + return nil + } + leave, err := c.enter(rv) + if err != nil { + return err + } + defer leave() + } + b.WriteByte('[') + for i := 0; i < rv.Len(); i++ { + if i > 0 { + b.WriteByte(',') + } + if err := c.write(b, rv.Index(i).Interface(), depth+1); err != nil { + return err + } + } + b.WriteByte(']') + return nil +} + +// isDroppedJSONValue mirrors JSON.stringify dropping function/symbol object +// entries (Go: func and chan values). +func isDroppedJSONValue(v any) bool { + if v == nil { + return false + } + switch reflect.ValueOf(v).Kind() { + case reflect.Func, reflect.Chan, reflect.UnsafePointer: + return true + } + return false +} + +func compareUTF16(a, b []uint16) int { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] != b[i] { + if a[i] < b[i] { + return -1 + } + return 1 + } + } + return len(a) - len(b) +} + +// writeJCSNumber serializes a finite float64 exactly as ECMAScript's +// Number::toString does (which is the JCS number serialization). +func writeJCSNumber(b *strings.Builder, f float64) error { + if math.IsNaN(f) || math.IsInf(f, 0) { + return fmt.Errorf("%w: non-finite number (NaN/Infinity) has no RFC 8785 representation", ErrUncanonicalizable) + } + if f == 0 { + b.WriteByte('0') // also collapses -0 + return nil + } + if f < 0 { + b.WriteByte('-') + f = -f + } + // Shortest round-trip digits and decimal exponent. + sci := strconv.FormatFloat(f, 'e', -1, 64) // d.ddddde±XX + mantissa, expPart, _ := strings.Cut(sci, "e") + digits := strings.Replace(mantissa, ".", "", 1) + exp, _ := strconv.Atoi(expPart) + k := len(digits) + n := exp + 1 + switch { + case k <= n && n <= 21: + b.WriteString(digits) + b.WriteString(strings.Repeat("0", n-k)) + case 0 < n && n <= 21: + b.WriteString(digits[:n]) + b.WriteByte('.') + b.WriteString(digits[n:]) + case -6 < n && n <= 0: + b.WriteString("0.") + b.WriteString(strings.Repeat("0", -n)) + b.WriteString(digits) + default: + e := n - 1 + b.WriteByte(digits[0]) + if k > 1 { + b.WriteByte('.') + b.WriteString(digits[1:]) + } + b.WriteByte('e') + if e >= 0 { + b.WriteByte('+') + } else { + b.WriteByte('-') + e = -e + } + b.WriteString(strconv.Itoa(e)) + } + return nil +} + +// writeJCSString serializes s as JSON.stringify would: only `"`, `\`, and +// control characters are escaped (short forms for \b \f \n \r \t, \u00xx +// otherwise); everything else is emitted literally. Go strings are UTF-8, +// so an unpaired UTF-16 surrogate can only appear as its WTF-8 byte +// sequence (ED A0..BF xx); those are escaped as \udxxx exactly like +// JSON.stringify's well-formed output. Any other invalid byte becomes +// U+FFFD, matching encoding/json. +func writeJCSString(b *strings.Builder, s string) { + b.WriteByte('"') + for i := 0; i < len(s); { + c := s[i] + if c < utf8.RuneSelf { + switch c { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if c < 0x20 { + fmt.Fprintf(b, `\u%04x`, c) + } else { + b.WriteByte(c) + } + } + i++ + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + if i+2 < len(s) && s[i] == 0xED && s[i+1] >= 0xA0 && s[i+1] <= 0xBF && s[i+2] >= 0x80 && s[i+2] <= 0xBF { + unit := 0xD000 | (uint16(s[i+1]&0x3F) << 6) | uint16(s[i+2]&0x3F) + fmt.Fprintf(b, `\u%04x`, unit) + i += 3 + continue + } + b.WriteString("\uFFFD") + i++ + continue + } + b.WriteString(s[i : i+size]) + i += size + } + b.WriteByte('"') +} + +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// FingerprintKeyMaterial is sha256(utf8(jcs(keyMaterial))), lowercase hex. +func FingerprintKeyMaterial(keyMaterial any) (string, error) { + canonical, err := CanonicalizeKeyMaterial(keyMaterial) + if err != nil { + return "", err + } + return sha256Hex(canonical), nil +} + +// FingerprintToolCall is sha256(utf8(toolName + "\n" + jcs(keyMaterial))), +// lowercase hex — the cross-port tool-call fingerprint contract. +func FingerprintToolCall(toolName string, keyMaterial any) (string, error) { + canonical, err := CanonicalizeKeyMaterial(keyMaterial) + if err != nil { + return "", err + } + return sha256Hex(toolName + "\n" + canonical), nil +} + +//#endregion + +//#region Text Repetition Detection + +// TextRepetitionResult is a detected repeating token block at the tail of a +// response. +type TextRepetitionResult struct { + // Repeats is the number of consecutive repeats (feeds the ladder). + Repeats int + // PeriodTokens is the block length in whitespace-delimited tokens. + PeriodTokens int + // CoveredTokens is Repeats × PeriodTokens. + CoveredTokens int + // Sample is the repeating block itself. + Sample string +} + +// DetectTextRepetition detects a period-p token block repeating at the +// tail of text ("I am stuck. I am stuck. …"). Pure and deterministic: +// whitespace tokenization, suffix comparison. Only the trailing +// MaxWindowTokens×64 characters (UTF-16 code units, as upstream) are +// examined. Ties on covered tokens prefer the smallest period. Returns nil +// when nothing meets MinRepeats and MinCoveredTokens. Zero-valued options +// use the defaults; Disabled is ignored here. +// +// KNOWN LIMIT: token blocks must repeat exactly; paraphrased loops do not +// trip this detector. +func DetectTextRepetition(text string, options DoomLoopTextOptions) *TextRepetitionResult { + opts := resolveTextOptions(options) + charBudget := opts.MaxWindowTokens * 64 + tail := tailUTF16Units(text, charBudget) + tokens := strings.FieldsFunc(tail, isJSSpace) + if len(tokens) > opts.MaxWindowTokens { + tokens = tokens[len(tokens)-opts.MaxWindowTokens:] + } + minTokens := opts.MinCoveredTokens + if minTokens < 2 { + minTokens = 2 + } + if len(tokens) < minTokens { + return nil + } + var best *TextRepetitionResult + maxPeriod := opts.MaxPeriodTokens + if half := len(tokens) / 2; half < maxPeriod { + maxPeriod = half + } + for period := 1; period <= maxPeriod; period++ { + repeats := 1 + outer: + for start := len(tokens) - 2*period; start >= 0; start -= period { + for i := 0; i < period; i++ { + if tokens[start+i] != tokens[len(tokens)-period+i] { + break outer + } + } + repeats++ + } + covered := repeats * period + if repeats >= opts.MinRepeats && covered >= opts.MinCoveredTokens { + if best == nil || covered > best.CoveredTokens { + best = &TextRepetitionResult{Repeats: repeats, PeriodTokens: period, CoveredTokens: covered, Sample: strings.Join(tokens[len(tokens)-period:], " ")} + } + } + } + return best +} + +// isJSSpace reports whether r is in ECMAScript's `\s` class (WhiteSpace + +// LineTerminator), which upstream uses to tokenize and normalize text. +// It differs from unicode.IsSpace in two code points: U+FEFF counts as +// whitespace here, U+0085 (NEL) does not. Matching it keeps text +// fingerprints identical to the TypeScript port. +func isJSSpace(r rune) bool { + switch r { + case '\t', '\n', '\v', '\f', '\r', ' ', 0x00A0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF: + return true + } + return r >= 0x2000 && r <= 0x200A +} + +// tailUTF16Units returns the suffix of s spanning at most budget UTF-16 code +// units (upstream slices the JS string by code units). +func tailUTF16Units(s string, budget int) string { + units := 0 + i := len(s) + for i > 0 { + r, size := utf8.DecodeLastRuneInString(s[:i]) + n := 1 + if r >= 0x10000 { + n = 2 + } + if units+n > budget { + break + } + units += n + i -= size + } + return s[i:] +} + +//#endregion + +//#region Ladder Resolution + +// ResolveLadderOptions tunes ResolveLadderAction. +type ResolveLadderOptions struct { + // NonBlocking makes block unavailable (text and server-tool verdicts: + // the tokens are already emitted / the tool already ran); a block-level + // streak falls through to escalate/steer/observe, while stop still stops. + NonBlocking bool + // NoEscalate disables the escalate rung for this resolution (no + // mechanism configured or budget exhausted). + NoEscalate bool +} + +// ResolveLadderAction maps a streak onto the strongest crossed ladder rung, +// or "" when no rung is crossed. +func ResolveLadderAction(ladder DoomLoopLadder, streak int, opts ResolveLadderOptions) DoomLoopAction { + meets := func(t DoomLoopThreshold) bool { return t.enabled() && streak >= int(t) } + switch { + case meets(ladder.Stop): + return DoomLoopActionStop + case !opts.NonBlocking && meets(ladder.Block): + return DoomLoopActionBlock + case !opts.NoEscalate && meets(ladder.Escalate): + return DoomLoopActionEscalate + case meets(ladder.Steer): + return DoomLoopActionSteer + case meets(ladder.Observe): + return DoomLoopActionObserve + } + return "" +} + +var doomLoopActionStrength = map[DoomLoopAction]int{ + DoomLoopActionObserve: 0, DoomLoopActionSteer: 1, DoomLoopActionEscalate: 2, DoomLoopActionBlock: 3, DoomLoopActionStop: 4, +} + +func strongerVerdict(a, b *DoomLoopVerdict) *DoomLoopVerdict { + if a == nil { + return b + } + if b == nil { + return a + } + // Within-response wins ties: its message names the repeated block. + if doomLoopActionStrength[b.Action] > doomLoopActionStrength[a.Action] { + return b + } + return a +} + +//#endregion + +//#region Monitor + +type streakEntry struct { + DoomLoopStreak + round int + hasRound bool +} + +// RecordToolCallOptions tunes DoomLoopMonitor.RecordToolCall. +type RecordToolCallOptions struct { + // NonBlocking is set for post-execution records (server tools): block + // is meaningless. + NonBlocking bool + // Detector labels the verdict; default DoomLoopDetectorToolFingerprint. + Detector DoomLoopDetectorKind +} + +// DoomLoopMonitor is the pure state machine over the recorded transcript: +// feed it tool calls and assistant texts, get verdicts back. It holds no +// engine concerns (hook emission, blocking, steering, stopping live in +// ModelResult). State is bounded plain JSON and round-trips through +// ConversationState. Safe for concurrent use. +type DoomLoopMonitor struct { + mu sync.Mutex + config ResolvedDoomLoopConfig + tools map[string]streakEntry + text *DoomLoopStreak + escalationsUsed int +} + +// NewDoomLoopMonitor constructs a monitor, optionally restoring persisted state. +func NewDoomLoopMonitor(config ResolvedDoomLoopConfig, initial *DoomLoopSerializedState) *DoomLoopMonitor { + m := &DoomLoopMonitor{config: config, tools: map[string]streakEntry{}} + if initial != nil { + m.Restore(initial) + } + return m +} + +// CanEscalate reports whether the escalate rung can still fire: a mechanism +// is configured and the budget is not exhausted. +func (m *DoomLoopMonitor) CanEscalate() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.canEscalateLocked() +} + +func (m *DoomLoopMonitor) canEscalateLocked() bool { + return m.config.Escalation != nil && m.escalationsUsed < m.config.Escalation.MaxEscalations +} + +// ConsumeEscalation burns one unit of the escalation budget. The engine +// calls it when it actually APPLIES a recovery, not at verdict time. +func (m *DoomLoopMonitor) ConsumeEscalation() { + m.mu.Lock() + m.escalationsUsed++ + m.mu.Unlock() +} + +// State snapshots the serializable detector state (deep copy). Round +// markers are dropped (rounds are meaningful only within one run); +// StopVerdict/PendingSteer are owned by the engine. +func (m *DoomLoopMonitor) State() DoomLoopSerializedState { + m.mu.Lock() + defer m.mu.Unlock() + out := DoomLoopSerializedState{Tools: make(map[string]DoomLoopStreak, len(m.tools)), EscalationsUsed: m.escalationsUsed} + for name, entry := range m.tools { + out.Tools[name] = entry.DoomLoopStreak + } + if m.text != nil { + text := *m.text + out.Text = &text + } + return out +} + +// Restore replaces the detector state from a persisted blob. Engine-owned +// fields (StopVerdict, PendingSteer) are ignored here. Entries with an +// empty fingerprint are dropped. +func (m *DoomLoopMonitor) Restore(state *DoomLoopSerializedState) { + m.mu.Lock() + defer m.mu.Unlock() + m.tools = map[string]streakEntry{} + m.text = nil + m.escalationsUsed = 0 + if state == nil { + return + } + for name, entry := range state.Tools { + if entry.Fingerprint == "" { + continue + } + // round intentionally absent: the first resumed record increments. + m.tools[name] = streakEntry{DoomLoopStreak: entry} + } + if state.Text != nil && state.Text.Fingerprint != "" { + text := *state.Text + m.text = &text + } + if state.EscalationsUsed > 0 { + m.escalationsUsed = state.EscalationsUsed + } +} + +// RecordToolCall records one tool call and returns the streak record plus +// any verdict. +// +// - Per tool: interleaved calls to other tools do not reset a tool's streak. +// - Per round: the same (tool, fingerprint) recorded again in the SAME +// round is a duplicate (no increment, DuplicateInRound true). +// - A different fingerprint for the same tool resets the streak to 1. +// +// Returns the canonicalization error for unhashable key material; the +// engine catches it and falls back. +func (m *DoomLoopMonitor) RecordToolCall(toolName string, keyMaterial any, round int, opts RecordToolCallOptions) (DoomLoopCallRecord, error) { + fingerprint, err := FingerprintToolCall(toolName, keyMaterial) + if err != nil { + return DoomLoopCallRecord{}, err + } + m.mu.Lock() + defer m.mu.Unlock() + previous, ok := m.tools[toolName] + streak := 1 + duplicate := false + if ok && previous.Fingerprint == fingerprint { + if previous.hasRound && previous.round == round { + streak = previous.Streak + duplicate = true + } else { + streak = previous.Streak + 1 + } + } + m.tools[toolName] = streakEntry{DoomLoopStreak: DoomLoopStreak{Fingerprint: fingerprint, Streak: streak}, round: round, hasRound: true} + + action := ResolveLadderAction(m.config.Ladder, streak, ResolveLadderOptions{NonBlocking: opts.NonBlocking, NoEscalate: !m.canEscalateLocked()}) + record := DoomLoopCallRecord{Fingerprint: fingerprint, Streak: streak, DuplicateInRound: duplicate} + if action == "" { + return record, nil + } + detector := opts.Detector + if detector == "" { + detector = DoomLoopDetectorToolFingerprint + } + record.Verdict = &DoomLoopVerdict{ + Detector: detector, + Action: action, + Streak: streak, + Fingerprint: fingerprint, + ToolName: toolName, + Message: fmt.Sprintf("Doom loop suspected: tool \"%s\" was invoked in %d consecutive rounds "+ + "with identical arguments (fingerprint %s…). Repeating the call "+ + "will not change the result. Take a different approach, or explain why repetition is required.", toolName, streak, fingerprint[:16]), + } + return record, nil +} + +// RecordAssistantText records one step's assistant text and returns the +// strongest verdict from the two text detectors (within-response +// text-repetition and cross-step text-streak). Empty/whitespace-only text +// is a no-op: it neither counts nor resets the cross-step streak. +func (m *DoomLoopMonitor) RecordAssistantText(text string) (*DoomLoopVerdict, error) { + m.mu.Lock() + defer m.mu.Unlock() + if !m.config.Text.Enabled { + return nil, nil + } + normalized := strings.Join(strings.FieldsFunc(text, isJSSpace), " ") + if normalized == "" { + return nil, nil + } + textOpts := DoomLoopTextOptions{MaxPeriodTokens: m.config.Text.MaxPeriodTokens, MinRepeats: m.config.Text.MinRepeats, MinCoveredTokens: m.config.Text.MinCoveredTokens, MaxWindowTokens: m.config.Text.MaxWindowTokens} + + var within *DoomLoopVerdict + if rep := DetectTextRepetition(normalized, textOpts); rep != nil { + action := ResolveLadderAction(m.config.Ladder, rep.Repeats, ResolveLadderOptions{NonBlocking: true, NoEscalate: !m.canEscalateLocked()}) + if action != "" { + fp, err := FingerprintKeyMaterial(rep.Sample) + if err != nil { + return nil, err + } + within = &DoomLoopVerdict{ + Detector: DoomLoopDetectorTextRepetition, + Action: action, + Streak: rep.Repeats, + Fingerprint: fp, + Message: fmt.Sprintf("Doom loop suspected: the response repeats \"%s\" %d times in a row. Stop repeating and take a different approach.", rep.Sample, rep.Repeats), + } + } + } + + fingerprint, err := FingerprintKeyMaterial(normalized) + if err != nil { + return nil, err + } + streak := 1 + if m.text != nil && m.text.Fingerprint == fingerprint { + streak = m.text.Streak + 1 + } + m.text = &DoomLoopStreak{Fingerprint: fingerprint, Streak: streak} + var cross *DoomLoopVerdict + if action := ResolveLadderAction(m.config.Ladder, streak, ResolveLadderOptions{NonBlocking: true, NoEscalate: !m.canEscalateLocked()}); action != "" { + cross = &DoomLoopVerdict{ + Detector: DoomLoopDetectorTextStreak, + Action: action, + Streak: streak, + Fingerprint: fingerprint, + Message: fmt.Sprintf("Doom loop suspected: the assistant produced identical text for %d consecutive turns. Stop repeating and take a different approach.", streak), + } + } + return strongerVerdict(within, cross), nil +} + +//#endregion diff --git a/doom_loop_engine_test.go b/doom_loop_engine_test.go new file mode 100644 index 0000000..fcfa52a --- /dev/null +++ b/doom_loop_engine_test.go @@ -0,0 +1,1170 @@ +package agent + +// Engine-level doom-loop tests, ported from upstream +// tests/unit/doom-loop-integration.test.ts, doom-loop-remediation.test.ts and +// the engine half of doom-loop-escalation.test.ts. No live model: the +// "simulated LLM" is fakeSender playing scripted responses back in order, +// and each scenario asserts the exact request on which the ladder fires and +// how the engine responds (hook emission, blocked tool output, halted loop, +// persisted state, escalated request shape). + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strings" + "testing" + + "github.com/OpenRouterTeam/go-sdk/models/components" + "github.com/OpenRouterTeam/go-sdk/models/operations" +) + +// doomScript builds scripted model turns with unique call/item ids. +type doomScript struct{ n int } + +func (s *doomScript) call(name string, args any) components.OutputItems { + s.n++ + b, _ := json.Marshal(args) + id := fmt.Sprintf("fc_%d", s.n) + return components.CreateOutputItemsFunctionCall(components.OutputFunctionCallItem{ID: &id, CallID: fmt.Sprintf("call_%d", s.n), Name: name, Arguments: string(b)}) +} + +func (s *doomScript) msg(text string) components.OutputItems { + s.n++ + return components.CreateOutputItemsMessage(components.OutputMessageItem{ + ID: fmt.Sprintf("msg_%d", s.n), + Role: components.OutputMessageItemRoleAssistant, + Type: components.OutputMessageItemTypeMessage, + Content: []components.OutputMessageItemContent{components.CreateOutputMessageItemContentOutputText(components.ResponseOutputText{ + Text: text, Type: components.ResponseOutputTextTypeOutputText, + })}, + }) +} + +// serverSearch is a web_search_call server-tool output item echoing its query. +func (s *doomScript) serverSearch(t *testing.T, query string) components.OutputItems { + t.Helper() + s.n++ + raw := fmt.Sprintf(`{"type":"web_search_call","id":"ws_%d","status":"completed","action":{"type":"search","query":%q}}`, s.n, query) + var item components.OutputItems + if err := json.Unmarshal([]byte(raw), &item); err != nil { + t.Fatal(err) + } + return item +} + +func (s *doomScript) turn(items ...components.OutputItems) components.OpenResponsesResult { + s.n++ + return components.OpenResponsesResult{ID: fmt.Sprintf("resp_%d", s.n), Model: "test-model", Status: components.OpenAIResponsesResponseStatusCompleted, Output: items} +} + +func (s *doomScript) toolTurn(name string, args any) components.OpenResponsesResult { + return s.turn(s.call(name, args)) +} + +func (s *doomScript) textTurn(text string) components.OpenResponsesResult { + return s.turn(s.msg(text)) +} + +func scripted(turns ...components.OpenResponsesResult) *fakeSender { + sender := &fakeSender{} + for _, turn := range turns { + created := operations.CreateCreateResponsesResponseOpenResponsesResult(turn) + sender.responses = append(sender.responses, &created) + } + return sender +} + +// sentToolOutputs returns the function_call_output payloads in request i. +func sentToolOutputs(sender *fakeSender, i int) []string { + var out []string + if i >= len(sender.requests) || sender.requests[i].Input == nil { + return nil + } + for _, item := range sender.requests[i].Input.ArrayOfInputsUnion1 { + if item.FunctionCallOutputItem != nil && item.FunctionCallOutputItem.Output.Str != nil { + out = append(out, *item.FunctionCallOutputItem.Output.Str) + } + } + return out +} + +// userMessages returns the user-role string messages in items. +func userMessages(items []components.InputsUnion1) []string { + var out []string + for _, item := range items { + msg := item.EasyInputMessage + if msg == nil || msg.Role.EasyInputMessageRoleUser == nil { + continue + } + if content, ok := msg.Content.GetOrZero(); ok && content.Str != nil { + out = append(out, *content.Str) + } + } + return out +} + +func requestInput(sender *fakeSender, i int) []components.InputsUnion1 { + if i >= len(sender.requests) || sender.requests[i].Input == nil { + return nil + } + return sender.requests[i].Input.ArrayOfInputsUnion1 +} + +// assertWellFormedHistory asserts every function_call has a matching output. +func assertWellFormedHistory(t *testing.T, items []components.InputsUnion1) { + t.Helper() + calls, outputs := map[string]bool{}, map[string]bool{} + for _, item := range items { + switch { + case item.FunctionCallItem != nil: + calls[item.FunctionCallItem.CallID] = true + case item.OutputFunctionCallItem != nil: + calls[item.OutputFunctionCallItem.CallID] = true + case item.FunctionCallOutputItem != nil: + outputs[item.FunctionCallOutputItem.CallID] = true + } + } + for id := range calls { + if !outputs[id] { + t.Fatalf("function_call %s has no matching output", id) + } + } +} + +type searchArgs struct { + Query string `json:"query"` +} + +// doomSearchTool mirrors upstream's searchTool: identity is the normalized query. +func doomSearchTool(executions *int) Tool { + return MustNewTool(ToolConfig[searchArgs]{ + Name: "web_search", + LoopKey: LoopKeyFunc(func(in searchArgs) (any, error) { + return strings.ToLower(strings.TrimSpace(in.Query)), nil + }), + Execute: func(_ context.Context, in searchArgs, _ ToolExecuteContext) (any, error) { + if executions != nil { + *executions++ + } + return map[string]any{"results": []string{"result for " + in.Query}}, nil + }, + }) +} + +type detectionLog struct{ events []DoomLoopDetectedPayload } + +func (d *detectionLog) pairs() [][2]any { + var out [][2]any + for _, e := range d.events { + out = append(out, [2]any{e.Action, e.Streak}) + } + return out +} + +func collectDetections(h *HooksManager) *detectionLog { + log := &detectionLog{} + h.OnDoomLoopDetected(HookEntry[DoomLoopDetectedPayload, DoomLoopDetectedResult]{Handler: func(p DoomLoopDetectedPayload, _ LifecycleHookContext) (HookHandlerResult[DoomLoopDetectedResult], error) { + log.events = append(log.events, p) + return VoidResult[DoomLoopDetectedResult](), nil + }}) + return log +} + +func runText(t *testing.T, sender *fakeSender, input CallModelInput) (*ModelResult, string) { + t.Helper() + if input.Model == "" { + input.Model = "test-model" + } + if input.Input == nil && input.State == nil && input.StateAccessor == nil { + input.Input = "go" + } + result, err := CallModel(context.Background(), sender, input) + if err != nil { + t.Fatal(err) + } + text, err := result.Text(context.Background()) + if err != nil { + t.Fatal(err) + } + return result, text +} + +func verdictOf(t *testing.T, r *ModelResult) *DoomLoopVerdict { + t.Helper() + v, err := r.DoomLoopVerdict(context.Background()) + if err != nil { + t.Fatal(err) + } + return v +} + +func pairsEqual(got [][2]any, want ...[2]any) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +func TestDoomLoopObservesThenBlocksAndModelRecovers(t *testing.T) { + var s doomScript + executions := 0 + hooks := NewHooksManager() + detections := collectDetections(hooks) + sender := scripted( + s.toolTurn("web_search", searchArgs{"openrouter sdk"}), + s.toolTurn("web_search", searchArgs{"OpenRouter SDK"}), + s.toolTurn("web_search", searchArgs{" openrouter sdk "}), + s.textTurn("Giving up on repeating; here is my answer."), + ) + _, text := runText(t, sender, CallModelInput{Tools: []Tool{doomSearchTool(&executions)}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if text != "Giving up on repeating; here is my answer." { + t.Fatalf("text = %q", text) + } + if executions != 2 { + t.Fatalf("calls 1-2 execute, call 3 is blocked before execution; got %d executions", executions) + } + if !pairsEqual(detections.pairs(), [2]any{DoomLoopActionObserve, 2}, [2]any{DoomLoopActionBlock, 3}) { + t.Fatalf("detections = %v", detections.pairs()) + } + if d := detections.events[0]; d.Detector != DoomLoopDetectorToolFingerprint || d.ToolName != "web_search" || d.ToolInput["query"] != "OpenRouter SDK" { + t.Fatalf("payload = %#v", d) + } + outputs := sentToolOutputs(sender, 3) + blocked := outputs[len(outputs)-1] + for _, want := range []string{"Doom loop suspected", "web_search", "3 consecutive rounds"} { + if !strings.Contains(blocked, want) { + t.Fatalf("blocked output %q must contain %q", blocked, want) + } + } +} + +func TestDoomLoopStopsRunAtStopThreshold(t *testing.T) { + var s doomScript + var turns []components.OpenResponsesResult + for i := 0; i < 10; i++ { + turns = append(turns, s.toolTurn("web_search", searchArgs{"same query"})) + } + sender := scripted(turns...) + result, err := CallModel(context.Background(), sender, CallModelInput{Model: "test-model", Input: "Find it.", MaxTurns: 20, Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 2, Block: 3, Stop: 4}}}) + if err != nil { + t.Fatal(err) + } + resp, err := result.Response(context.Background()) + if err != nil { + t.Fatal(err) + } + if sender.calls != 4 { + t.Fatalf("the loop must halt before a 5th request; got %d requests", sender.calls) + } + v := verdictOf(t, result) + if v == nil || v.Action != DoomLoopActionStop || v.Detector != DoomLoopDetectorToolFingerprint || v.Streak != 4 || v.ToolName != "web_search" { + t.Fatalf("verdict = %#v", v) + } + if resp.ID != turns[3].ID { + t.Fatalf("the run surfaces the last response, got %s", resp.ID) + } + state, _ := result.State(context.Background()) + if state.Status != ConversationStatusComplete { + t.Fatalf("status = %s", state.Status) + } + assertWellFormedHistory(t, state.Messages) +} + +func sessionEndReasons(h *HooksManager) *[]SessionEndReason { + reasons := &[]SessionEndReason{} + h.OnSessionEnd(HookEntry[SessionEndPayload, EmptyHookResult]{Handler: func(p SessionEndPayload, _ LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + *reasons = append(*reasons, p.Reason) + return VoidResult[EmptyHookResult](), nil + }}) + return reasons +} + +func TestDoomLoopSessionEndReasonDoomLoop(t *testing.T) { + var s doomScript + hooks := NewHooksManager() + reasons := sessionEndReasons(hooks) + var turns []components.OpenResponsesResult + for i := 0; i < 6; i++ { + turns = append(turns, s.toolTurn("web_search", searchArgs{"x"})) + } + runText(t, scripted(turns...), CallModelInput{MaxTurns: 10, Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Stop: 3}}, Hooks: hooks}) + if len(*reasons) != 1 || (*reasons)[0] != SessionEndReasonDoomLoop { + t.Fatalf("SessionEnd reasons = %v", *reasons) + } +} + +func TestDoomLoopHealthyRunAndInertWhenOff(t *testing.T) { + var s doomScript + hooks := NewHooksManager() + detections := collectDetections(hooks) + sender := scripted(s.toolTurn("web_search", searchArgs{"first"}), s.toolTurn("web_search", searchArgs{"second"}), s.toolTurn("web_search", searchArgs{"third"}), s.textTurn("All three found.")) + _, text := runText(t, sender, CallModelInput{Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if text != "All three found." || len(detections.events) != 0 || sender.calls != 4 { + t.Fatalf("healthy run disturbed: text=%q detections=%v calls=%d", text, detections.pairs(), sender.calls) + } + + executions := 0 + sender = scripted(s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.textTurn("done")) + result, text := runText(t, sender, CallModelInput{MaxTurns: 10, Tools: []Tool{doomSearchTool(&executions)}}) + if text != "done" || executions != 4 { + t.Fatalf("detection off must be inert: text=%q executions=%d", text, executions) + } + for i := 1; i <= 4; i++ { + for _, out := range sentToolOutputs(sender, i) { + if strings.Contains(out, "Doom loop") { + t.Fatalf("request %d carries a doom output with detection off", i) + } + } + } + if state, _ := result.State(context.Background()); state.DoomLoop != nil { + t.Fatalf("detection off must not persist detector state: %#v", state.DoomLoop) + } +} + +func TestDoomLoopRepeatedEmptyCallsBlock(t *testing.T) { + var s doomScript + listTool := MustNewTool(ToolConfig[struct{}]{Name: "list_tasks", Execute: func(context.Context, struct{}, ToolExecuteContext) (any, error) { + return map[string]any{"tasks": []string{"task-1"}}, nil + }}) + hooks := NewHooksManager() + detections := collectDetections(hooks) + sender := scripted(s.toolTurn("list_tasks", map[string]any{}), s.toolTurn("list_tasks", map[string]any{}), s.toolTurn("list_tasks", map[string]any{}), s.textTurn("The list has not changed; stopping.")) + _, text := runText(t, sender, CallModelInput{Tools: []Tool{listTool}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if text != "The list has not changed; stopping." { + t.Fatalf("text = %q", text) + } + if !pairsEqual(detections.pairs(), [2]any{DoomLoopActionObserve, 2}, [2]any{DoomLoopActionBlock, 3}) { + t.Fatalf("detections = %v", detections.pairs()) + } + outputs := sentToolOutputs(sender, 3) + if !strings.Contains(outputs[len(outputs)-1], "Doom loop suspected") { + t.Fatalf("blocked output = %q", outputs[len(outputs)-1]) + } +} + +func TestDoomLoopTextRepetitionStopsNoToolsRun(t *testing.T) { + var s doomScript + hooks := NewHooksManager() + detections := collectDetections(hooks) + reasons := sessionEndReasons(hooks) + doomText := strings.TrimSpace(strings.Repeat("I need to check the file again. ", 40)) + result, text := runText(t, scripted(s.textTurn(doomText)), CallModelInput{Input: "Summarize.", DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if text != doomText { + t.Fatal("tokens already emitted are surfaced as-is") + } + if len(detections.events) != 1 || detections.events[0].Detector != DoomLoopDetectorTextRepetition || detections.events[0].Action != DoomLoopActionStop || detections.events[0].Streak != 40 { + t.Fatalf("detections = %#v", detections.events) + } + if v := verdictOf(t, result); v == nil || v.Detector != DoomLoopDetectorTextRepetition || v.Action != DoomLoopActionStop { + t.Fatalf("verdict = %#v", v) + } + if len(*reasons) != 1 || (*reasons)[0] != SessionEndReasonDoomLoop { + t.Fatalf("text-only stop must report doom_loop, got %v", *reasons) + } +} + +func TestDoomLoopTextStreakHaltsToolLoop(t *testing.T) { + var s doomScript + filler := "Hmm, let me look somewhere else." + var turns []components.OpenResponsesResult + for i := 1; i <= 5; i++ { + turns = append(turns, s.turn(s.msg(filler), s.call("web_search", searchArgs{fmt.Sprintf("q%d", i)}))) + } + sender := scripted(turns...) + result, _ := runText(t, sender, CallModelInput{MaxTurns: 10, Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 2, Block: 3, Stop: 3}}}) + if sender.calls != 3 { + t.Fatalf("text streak stop arms after turn 3; got %d requests", sender.calls) + } + if v := verdictOf(t, result); v == nil || v.Detector != DoomLoopDetectorTextStreak || v.Action != DoomLoopActionStop || v.Streak != 3 { + t.Fatalf("verdict = %#v", v) + } +} + +type bashArgs struct { + Command string `json:"command"` + Cwd string `json:"cwd"` + Verbose bool `json:"verbose,omitempty"` +} + +func TestDoomLoopToolLoopKeyForms(t *testing.T) { + t.Run("field list: same command in a different cwd is not a loop", func(t *testing.T) { + var s doomScript + var runs []string + bash := MustNewTool(ToolConfig[bashArgs]{Name: "bash", LoopKey: LoopKeyFields("command", "cwd"), Execute: func(_ context.Context, in bashArgs, _ ToolExecuteContext) (any, error) { + runs = append(runs, in.Cwd+":"+in.Command) + return map[string]any{"stdout": "ok"}, nil + }}) + _, text := runText(t, scripted(s.toolTurn("bash", bashArgs{Command: "ls", Cwd: "/a"}), s.toolTurn("bash", bashArgs{Command: "ls", Cwd: "/b"}), s.textTurn("both listed")), CallModelInput{Tools: []Tool{bash}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Block: 2}}}) + if text != "both listed" || strings.Join(runs, ",") != "/a:ls,/b:ls" { + t.Fatalf("text=%q runs=%v", text, runs) + } + }) + t.Run("field list collapses excluded-field variation", func(t *testing.T) { + var s doomScript + executions := 0 + bash := MustNewTool(ToolConfig[bashArgs]{Name: "bash", LoopKey: LoopKeyFields("command", "cwd"), Execute: func(context.Context, bashArgs, ToolExecuteContext) (any, error) { + executions++ + return "ok", nil + }}) + hooks := NewHooksManager() + detections := collectDetections(hooks) + runText(t, scripted(s.toolTurn("bash", bashArgs{Command: "ls", Cwd: "/a"}), s.toolTurn("bash", bashArgs{Command: "ls", Cwd: "/a", Verbose: true}), s.textTurn("done")), CallModelInput{Tools: []Tool{bash}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Block: 2}}, Hooks: hooks}) + if executions != 1 || !pairsEqual(detections.pairs(), [2]any{DoomLoopActionBlock, 2}) { + t.Fatalf("executions=%d detections=%v", executions, detections.pairs()) + } + }) + t.Run("exempt and nil-returning functions exempt legitimate repetition", func(t *testing.T) { + for name, key := range map[string]*ToolLoopKey{ + "static": LoopKeyExempt(), + "per-call": {Func: func(map[string]any) (any, error) { return nil, nil }}, + } { + var s doomScript + polls := 0 + poller := MustNewTool(ToolConfig[struct{}]{Name: "poll_status", LoopKey: key, Execute: func(context.Context, struct{}, ToolExecuteContext) (any, error) { + polls++ + return "pending", nil + }}) + hooks := NewHooksManager() + detections := collectDetections(hooks) + var turns []components.OpenResponsesResult + for i := 0; i < 5; i++ { + turns = append(turns, s.toolTurn("poll_status", map[string]any{})) + } + turns = append(turns, s.textTurn("ready")) + runText(t, scripted(turns...), CallModelInput{MaxTurns: 10, Tools: []Tool{poller}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if polls != 5 || len(detections.events) != 0 { + t.Fatalf("%s: polls=%d detections=%v", name, polls, detections.pairs()) + } + } + }) + t.Run("erroring loop key falls back to full-arguments identity", func(t *testing.T) { + var s doomScript + tool := MustNewTool(ToolConfig[searchArgs]{Name: "web_search", LoopKey: &ToolLoopKey{Func: func(map[string]any) (any, error) { return nil, fmt.Errorf("loopKey bug") }}, Execute: func(context.Context, searchArgs, ToolExecuteContext) (any, error) { + return "r", nil + }}) + hooks := NewHooksManager() + detections := collectDetections(hooks) + logs := captureLog(t, func() { + runText(t, scripted(s.toolTurn("web_search", searchArgs{"x"}), s.toolTurn("web_search", searchArgs{"x"}), s.toolTurn("web_search", searchArgs{"x"}), s.textTurn("done")), CallModelInput{Tools: []Tool{tool}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + }) + if !pairsEqual(detections.pairs(), [2]any{DoomLoopActionObserve, 2}, [2]any{DoomLoopActionBlock, 3}) { + t.Fatalf("detections = %v", detections.pairs()) + } + if !strings.Contains(logs, "loopKey threw (loopKey bug); falling back to full arguments") { + t.Fatalf("fallback must warn: %q", logs) + } + }) + t.Run("MarkMcp attaches a loop key to a wrapped tool", func(t *testing.T) { + var s doomScript + var runs int + inner := MustNewTool(ToolConfig[bashArgs]{Name: "remote_bash", Execute: func(context.Context, bashArgs, ToolExecuteContext) (any, error) { + runs++ + return "ok", nil + }}) + marked := MarkMcp(inner, MarkMcpOptions{LoopKey: LoopKeyFields("command")}) + if !IsMcpTool(marked) { + t.Fatal("MarkMcp must still brand the tool") + } + runText(t, scripted(s.toolTurn("remote_bash", bashArgs{Command: "ls", Cwd: "/a"}), s.toolTurn("remote_bash", bashArgs{Command: "ls", Cwd: "/b"}), s.textTurn("done")), CallModelInput{Tools: []Tool{marked}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Block: 2}}}) + if runs != 1 { + t.Fatalf("the MCP loop key (command only) must collapse different cwds; runs=%d", runs) + } + }) +} + +func TestDoomLoopNonceArgsEvadeDefaultIdentity(t *testing.T) { + type nonceArgs struct { + Command string `json:"command"` + Nonce string `json:"nonce"` + } + script := func(s *doomScript) []components.OpenResponsesResult { + var turns []components.OpenResponsesResult + for i := 0; i < 4; i++ { + turns = append(turns, s.toolTurn("run", nonceArgs{Command: "deploy", Nonce: fmt.Sprintf("n%d", i)})) + } + return append(turns, s.textTurn("done")) + } + execute := func(context.Context, nonceArgs, ToolExecuteContext) (any, error) { return "ok", nil } + var s doomScript + hooks := NewHooksManager() + detections := collectDetections(hooks) + runText(t, scripted(script(&s)...), CallModelInput{MaxTurns: 10, Tools: []Tool{MustNewTool(ToolConfig[nonceArgs]{Name: "run", Execute: execute})}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if len(detections.events) != 0 { + t.Fatalf("DOCUMENTED MISS: a fresh nonce each turn evades the default identity; got %v", detections.pairs()) + } + hooks = NewHooksManager() + detections = collectDetections(hooks) + runText(t, scripted(script(&s)...), CallModelInput{MaxTurns: 10, Tools: []Tool{MustNewTool(ToolConfig[nonceArgs]{Name: "run", LoopKey: LoopKeyFields("command"), Execute: execute})}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if len(detections.events) == 0 { + t.Fatal("a field-list loop key must catch the nonce loop") + } +} + +func TestDoomLoopCheckpointRunsBeforePreToolUseMutation(t *testing.T) { + var s doomScript + hooks := NewHooksManager() + detections := collectDetections(hooks) + preToolUse := 0 + nonce := 0 + hooks.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{Handler: func(p PreToolUsePayload, _ LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + preToolUse++ + nonce++ + return SyncResult(PreToolUseResult{MutatedInput: map[string]any{"query": fmt.Sprintf("%v-%d", p.ToolInput["query"], nonce)}}), nil + }}) + executions := 0 + tool := MustNewTool(ToolConfig[searchArgs]{Name: "web_search", Execute: func(context.Context, searchArgs, ToolExecuteContext) (any, error) { + executions++ + return "r", nil + }}) + runText(t, scripted(s.toolTurn("web_search", searchArgs{"x"}), s.toolTurn("web_search", searchArgs{"x"}), s.toolTurn("web_search", searchArgs{"x"}), s.textTurn("done")), CallModelInput{Tools: []Tool{tool}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if !pairsEqual(detections.pairs(), [2]any{DoomLoopActionObserve, 2}, [2]any{DoomLoopActionBlock, 3}) { + t.Fatalf("a mutating PreToolUse must not mask the model's repetition: %v", detections.pairs()) + } + if preToolUse != 2 || executions != 2 { + t.Fatalf("PreToolUse must not fire for a doom-blocked call: preToolUse=%d executions=%d", preToolUse, executions) + } +} + +func TestDoomLoopHookOverrideAction(t *testing.T) { + override := func(action DoomLoopAction) *HooksManager { + h := NewHooksManager() + h.OnDoomLoopDetected(HookEntry[DoomLoopDetectedPayload, DoomLoopDetectedResult]{Handler: func(DoomLoopDetectedPayload, LifecycleHookContext) (HookHandlerResult[DoomLoopDetectedResult], error) { + return SyncResult(DoomLoopDetectedResult{OverrideAction: action}), nil + }}) + return h + } + var s doomScript + executions := 0 + noisy := MustNewTool(ToolConfig[struct{}]{Name: "noisy", Execute: func(context.Context, struct{}, ToolExecuteContext) (any, error) { + executions++ + return map[string]any{"ok": true}, nil + }}) + _, text := runText(t, scripted(s.toolTurn("noisy", map[string]any{}), s.toolTurn("noisy", map[string]any{}), s.toolTurn("noisy", map[string]any{}), s.textTurn("done")), CallModelInput{Tools: []Tool{noisy}, DoomLoop: &DoomLoopConfig{}, Hooks: override(DoomLoopActionObserve)}) + if text != "done" || executions != 3 { + t.Fatalf("de-escalated block must execute: text=%q executions=%d", text, executions) + } + + sender := scripted(s.toolTurn("web_search", searchArgs{"x"}), s.toolTurn("web_search", searchArgs{"x"}), s.toolTurn("web_search", searchArgs{"x"})) + result, _ := runText(t, sender, CallModelInput{Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{}, Hooks: override(DoomLoopActionStop)}) + if sender.calls != 2 { + t.Fatalf("observe escalated to stop halts after the 2nd turn; requests=%d", sender.calls) + } + if v := verdictOf(t, result); v == nil || v.Streak != 2 { + t.Fatalf("verdict = %#v", v) + } + + // A block override on a text verdict downgrades to observe (nothing to block). + var inlineSeen int + inline := InlineHookConfig{DoomLoopDetected: []HookEntry[DoomLoopDetectedPayload, DoomLoopDetectedResult]{{Handler: func(DoomLoopDetectedPayload, LifecycleHookContext) (HookHandlerResult[DoomLoopDetectedResult], error) { + inlineSeen++ + return SyncResult(DoomLoopDetectedResult{OverrideAction: DoomLoopActionBlock}), nil + }}}} + result, _ = runText(t, scripted(s.textTurn(strings.Repeat("loop ", 30))), CallModelInput{DoomLoop: &DoomLoopConfig{}, Hooks: inline}) + if inlineSeen != 1 || verdictOf(t, result) != nil { + t.Fatalf("inline DoomLoopDetected must fire, and a text block override must downgrade (not stop): seen=%d", inlineSeen) + } +} + +func TestDoomLoopStatePersistsAcrossResume(t *testing.T) { + var s doomScript + accessor := &memoryStateAccessor{state: CreateInitialState()} + runText(t, scripted(s.toolTurn("web_search", searchArgs{"persistent"}), s.toolTurn("web_search", searchArgs{"persistent"}), s.textTurn("pausing here")), CallModelInput{Input: "go", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{}, StateAccessor: accessor}) + if accessor.state.DoomLoop == nil || accessor.state.DoomLoop.Tools["web_search"].Streak != 2 { + t.Fatalf("streak 2 must persist: %#v", accessor.state.DoomLoop) + } + // The detector blob survives the versioned serialization contract. + blob, err := SerializeConversationState(accessor.state) + if err != nil { + t.Fatal(err) + } + restored, err := DeserializeConversationState(blob) + if err != nil { + t.Fatal(err) + } + if restored.DoomLoop == nil || restored.DoomLoop.Tools["web_search"] != accessor.state.DoomLoop.Tools["web_search"] || *restored.DoomLoop.Text != *accessor.state.DoomLoop.Text { + t.Fatalf("doomLoop must round-trip: %#v vs %#v", restored.DoomLoop, accessor.state.DoomLoop) + } + if !strings.Contains(blob, `"DoomLoop":{"tools":{"web_search":{"fingerprint":`) { + t.Fatalf("serialized shape: %s", blob) + } + + hooks := NewHooksManager() + detections := collectDetections(hooks) + runText(t, scripted(s.toolTurn("web_search", searchArgs{"persistent"}), s.textTurn("recovered")), CallModelInput{Input: "continue", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{}, StateAccessor: accessor, Hooks: hooks}) + if !pairsEqual(detections.pairs(), [2]any{DoomLoopActionBlock, 3}) { + t.Fatalf("one more identical call after resume crosses block: %v", detections.pairs()) + } + + // ON→OFF: the stale blob is inert and left untouched; the OFF run repeats freely. + executions := 0 + before := *accessor.state.DoomLoop + runText(t, scripted(s.toolTurn("web_search", searchArgs{"persistent"}), s.toolTurn("web_search", searchArgs{"persistent"}), s.textTurn("free")), CallModelInput{Input: "again", Tools: []Tool{doomSearchTool(&executions)}, StateAccessor: accessor}) + if executions != 2 || accessor.state.DoomLoop == nil || accessor.state.DoomLoop.Tools["web_search"] != before.Tools["web_search"] { + t.Fatalf("OFF run must be inert: executions=%d state=%#v", executions, accessor.state.DoomLoop) + } +} + +func TestDoomLoopParallelDuplicatesAreOneRound(t *testing.T) { + var s doomScript + executions := 0 + fanout := MustNewTool(ToolConfig[searchArgs]{Name: "web_search", Execute: func(context.Context, searchArgs, ToolExecuteContext) (any, error) { + executions++ + return "r", nil + }}) + hooks := NewHooksManager() + detections := collectDetections(hooks) + var items []components.OutputItems + for i := 0; i < 6; i++ { + items = append(items, s.call("web_search", searchArgs{"same"})) + } + _, text := runText(t, scripted(s.turn(items...), s.textTurn("All six ran.")), CallModelInput{Tools: []Tool{fanout}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if text != "All six ran." || executions != 6 || len(detections.events) != 0 { + t.Fatalf("6-wide fan-out is one piece of evidence: executions=%d detections=%v", executions, detections.pairs()) + } + + executions = 0 + noisy := MustNewTool(ToolConfig[struct{}]{Name: "noisy", Execute: func(context.Context, struct{}, ToolExecuteContext) (any, error) { + executions++ + return "ok", nil + }}) + hooks = NewHooksManager() + detections = collectDetections(hooks) + sender := scripted(s.toolTurn("noisy", map[string]any{}), s.toolTurn("noisy", map[string]any{}), s.turn(s.call("noisy", map[string]any{}), s.call("noisy", map[string]any{}), s.call("noisy", map[string]any{})), s.textTurn("done")) + _, text = runText(t, sender, CallModelInput{Tools: []Tool{noisy}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + if text != "done" || executions != 2 || !pairsEqual(detections.pairs(), [2]any{DoomLoopActionObserve, 2}, [2]any{DoomLoopActionBlock, 3}) { + t.Fatalf("round-3 duplicates share one block decision: executions=%d detections=%v", executions, detections.pairs()) + } + blocked := 0 + for _, out := range sentToolOutputs(sender, 3) { + if strings.Contains(out, "Doom loop suspected") { + blocked++ + } + } + if blocked != 3 { + t.Fatalf("all three duplicates get the block output, got %d", blocked) + } +} + +func TestDoomLoopTextStopSealsDanglingCalls(t *testing.T) { + var s doomScript + accessor := &memoryStateAccessor{state: CreateInitialState()} + hooks := NewHooksManager() + reasons := sessionEndReasons(hooks) + doomText := strings.TrimSpace(strings.Repeat("I am stuck. ", 20)) + executions := 0 + sender := scripted(s.turn(s.msg(doomText), s.call("web_search", searchArgs{"never runs"}))) + runText(t, sender, CallModelInput{Input: "go", Tools: []Tool{doomSearchTool(&executions)}, DoomLoop: &DoomLoopConfig{}, StateAccessor: accessor, Hooks: hooks}) + if sender.calls != 1 || executions != 0 { + t.Fatalf("only the initial request; the call never executes: calls=%d executions=%d", sender.calls, executions) + } + if len(*reasons) != 1 || (*reasons)[0] != SessionEndReasonDoomLoop { + t.Fatalf("reasons = %v", *reasons) + } + assertWellFormedHistory(t, accessor.state.Messages) + var halts []string + for _, item := range accessor.state.Messages { + if item.FunctionCallOutputItem != nil { + halts = append(halts, *item.FunctionCallOutputItem.Output.Str) + } + } + if len(halts) != 1 || !strings.Contains(halts[0], "halted by doom-loop detection") { + t.Fatalf("synthesized halt output: %v", halts) + } + + resume := scripted(s.textTurn("resumed fine")) + _, text := runText(t, resume, CallModelInput{Input: "continue please", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{}, StateAccessor: accessor}) + if text != "resumed fine" { + t.Fatalf("text = %q", text) + } + assertWellFormedHistory(t, requestInput(resume, 0)) +} + +func TestDoomLoopStopSkipsFinalResponseRequest(t *testing.T) { + var s doomScript + sender := scripted(s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.textTurn("should never be fetched")) + stopAfterTwoSteps := func(_ context.Context, steps []StepResult) (bool, error) { return len(steps) >= 2, nil } + result, _ := runText(t, sender, CallModelInput{Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 2, Block: 2, Stop: 2}}, StopWhen: []StopCondition{stopAfterTwoSteps}, AllowFinalResponse: true}) + if sender.calls != 2 { + t.Fatalf("the final text-coercion request must be skipped after a doom stop; requests=%d", sender.calls) + } + if v := verdictOf(t, result); v == nil || v.Action != DoomLoopActionStop || v.Streak != 2 { + t.Fatalf("verdict = %#v", v) + } +} + +func TestDoomLoopApprovalResumeStopAndCondemnation(t *testing.T) { + type riskyArgs struct { + Target string `json:"target"` + } + risky := MustNewTool(ToolConfig[riskyArgs]{Name: "risky", RequireApproval: true, Execute: func(context.Context, riskyArgs, ToolExecuteContext) (any, error) { + return map[string]any{"done": true}, nil + }}) + cfg := func() *DoomLoopConfig { + return &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 1, Block: DoomLoopRungDisabled, Stop: 2}} + } + var s doomScript + accessor := &memoryStateAccessor{state: CreateInitialState()} + first := scripted(s.toolTurn("risky", riskyArgs{"same"})) + if _, err := mustResult(t, first, CallModelInput{Model: "test-model", Input: "do the risky thing", Tools: []Tool{risky}, DoomLoop: cfg(), StateAccessor: accessor}).Response(context.Background()); err != nil { + t.Fatal(err) + } + if accessor.state.Status != ConversationStatusAwaitingApproval || accessor.state.DoomLoop == nil { + t.Fatalf("paused state: %s %#v", accessor.state.Status, accessor.state.DoomLoop) + } + firstCall := accessor.state.PendingToolCalls[0].CallID + + // Resume 1: approve → executes (streak 1); the model calls it again → pauses. + resume1 := scripted(s.toolTurn("risky", riskyArgs{"same"})) + if _, err := mustResult(t, resume1, CallModelInput{Model: "test-model", Tools: []Tool{risky}, DoomLoop: cfg(), StateAccessor: accessor, ApproveToolCalls: []string{firstCall}}).Response(context.Background()); err != nil { + t.Fatal(err) + } + if accessor.state.Status != ConversationStatusAwaitingApproval { + t.Fatalf("status after resume 1 = %s", accessor.state.Status) + } + secondCall := accessor.state.PendingToolCalls[0].CallID + + // Resume 2: approving the identical call crosses stop (restored streak 1 + 1). + resume2 := scripted(s.textTurn("must not be requested")) + result := mustResult(t, resume2, CallModelInput{Model: "test-model", Tools: []Tool{risky}, DoomLoop: cfg(), StateAccessor: accessor, ApproveToolCalls: []string{secondCall}}) + v := verdictOf(t, result) + if v == nil || v.Action != DoomLoopActionStop || v.Streak != 2 || v.ToolName != "risky" { + t.Fatalf("verdict = %#v", v) + } + if resume2.calls != 0 { + t.Fatalf("no unsent-results request after a doom stop; requests=%d", resume2.calls) + } + if accessor.state.DoomLoop.StopVerdict == nil || accessor.state.DoomLoop.StopVerdict.Streak != 2 { + t.Fatalf("stop verdict must persist for decision-only resumes: %#v", accessor.state.DoomLoop) + } + assertWellFormedHistory(t, accessor.state.Messages) + + // D3: a fresh conversational turn clears the verdict; streaks survive. + fresh := scripted(s.textTurn("recovered")) + result, text := runText(t, fresh, CallModelInput{Input: "try something new", Tools: []Tool{risky}, DoomLoop: cfg(), StateAccessor: accessor}) + if text != "recovered" || verdictOf(t, result) != nil { + t.Fatalf("fresh turn: text=%q", text) + } + if accessor.state.DoomLoop.StopVerdict != nil || accessor.state.DoomLoop.Tools["risky"].Streak != 2 { + t.Fatalf("verdict cleared, streak kept: %#v", accessor.state.DoomLoop) + } +} + +func mustResult(t *testing.T, sender *fakeSender, input CallModelInput) *ModelResult { + t.Helper() + result, err := CallModel(context.Background(), sender, input) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestDoomLoopSteer(t *testing.T) { + steerLadder := DoomLoopLadder{Observe: 2, Steer: 2, Block: DoomLoopRungDisabled, Stop: 6} + var s doomScript + sender := scripted(s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.textTurn("adjusted course")) + _, text := runText(t, sender, CallModelInput{Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: steerLadder}}) + if text != "adjusted course" { + t.Fatalf("text = %q", text) + } + steers := 0 + for _, m := range userMessages(requestInput(sender, 2)) { + if strings.Contains(m, "Doom loop suspected") { + steers++ + } + } + if steers != 1 { + t.Fatalf("the third request must carry exactly one steer message, got %d", steers) + } + for _, m := range userMessages(requestInput(sender, 1)) { + if strings.Contains(m, "Doom loop suspected") { + t.Fatal("no steer before the verdict fired") + } + } + + // Steer queued at a HITL pause persists and is delivered on resume. + type keyArgs struct { + Key string `json:"key"` + } + type questionArgs struct { + Question string `json:"question"` + } + lookup := MustNewTool(ToolConfig[keyArgs]{Name: "lookup", Execute: func(context.Context, keyArgs, ToolExecuteContext) (any, error) { return "v", nil }}) + askHuman := MustNewTool(ToolConfig[questionArgs]{Name: "ask_human", OnToolCalled: func(context.Context, questionArgs, ToolExecuteContext) (any, bool, error) { + return nil, false, nil + }}) + accessor := &memoryStateAccessor{state: CreateInitialState()} + runText(t, scripted(s.toolTurn("lookup", keyArgs{"same"}), s.turn(s.call("lookup", keyArgs{"same"}), s.call("ask_human", questionArgs{"help?"}))), CallModelInput{Input: "go", Tools: []Tool{lookup, askHuman}, DoomLoop: &DoomLoopConfig{Ladder: steerLadder}, StateAccessor: accessor}) + if accessor.state.Status != ConversationStatusAwaitingHITL { + t.Fatalf("status = %s", accessor.state.Status) + } + if accessor.state.DoomLoop == nil || len(accessor.state.DoomLoop.PendingSteer) != 1 || !strings.Contains(accessor.state.DoomLoop.PendingSteer[0], "Doom loop suspected") { + t.Fatalf("pending steer must persist, not be injected mid-pause: %#v", accessor.state.DoomLoop) + } + for _, m := range userMessages(accessor.state.Messages) { + if strings.Contains(m, "Doom loop suspected") { + t.Fatal("steer must not be injected between a dangling function_call and its output") + } + } +} + +func TestDoomLoopServerToolRepetition(t *testing.T) { + type noteArgs struct { + N int `json:"n"` + } + note := MustNewTool(ToolConfig[noteArgs]{Name: "note", Execute: func(context.Context, noteArgs, ToolExecuteContext) (any, error) { + return map[string]any{"ok": true}, nil + }}) + var s doomScript + hooks := NewHooksManager() + detections := collectDetections(hooks) + sender := scripted( + s.turn(s.serverSearch(t, "same query"), s.call("note", noteArgs{1})), + s.turn(s.serverSearch(t, "same query"), s.call("note", noteArgs{2})), + s.turn(s.serverSearch(t, "same query"), s.call("note", noteArgs{3})), + s.textTurn("done"), + ) + result, _ := runText(t, sender, CallModelInput{Tools: []Tool{note}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 2, Block: 3, Stop: 3}}, Hooks: hooks}) + if sender.calls != 3 { + t.Fatalf("server streak stops after turn 3; requests=%d", sender.calls) + } + if len(detections.events) != 2 || detections.events[0].Detector != DoomLoopDetectorServerToolFingerprint || detections.events[1].Detector != DoomLoopDetectorServerToolFingerprint { + t.Fatalf("detections = %#v", detections.events) + } + if v := verdictOf(t, result); v == nil || v.Detector != DoomLoopDetectorServerToolFingerprint || v.Action != DoomLoopActionStop || v.Streak != 3 || v.ToolName != "server:web_search_call" { + t.Fatalf("verdict = %#v", v) + } + + hooks = NewHooksManager() + detections = collectDetections(hooks) + sender = scripted( + s.turn(s.serverSearch(t, "q1"), s.call("note", noteArgs{1})), + s.turn(s.serverSearch(t, "q2"), s.call("note", noteArgs{2})), + s.turn(s.serverSearch(t, "q3"), s.call("note", noteArgs{3})), + s.textTurn("done"), + ) + runText(t, sender, CallModelInput{Tools: []Tool{note}, DoomLoop: &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 2, Block: 3, Stop: 3}}, Hooks: hooks}) + if len(detections.events) != 0 || sender.calls != 4 { + t.Fatalf("distinct server queries must not streak: %v calls=%d", detections.pairs(), sender.calls) + } +} + +var escalateAt2 = DoomLoopLadder{Observe: 1, Steer: DoomLoopRungDisabled, Escalate: 2, Block: 5, Stop: 8} + +func modelOf(req components.ResponsesRequest) string { + if req.Model == nil { + return "" + } + return *req.Model +} + +func TestDoomLoopEscalationRecovery(t *testing.T) { + t.Run("swaps the model for exactly one turn, then reverts", func(t *testing.T) { + var s doomScript + sender := scripted(s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"different now"}), s.textTurn("recovered")) + _, text := runText(t, sender, CallModelInput{Model: "cheap-model", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: escalateAt2, Escalation: &DoomLoopEscalationConfig{Model: "frontier-model"}}}) + if text != "recovered" { + t.Fatalf("text = %q", text) + } + got := []string{modelOf(sender.requests[0]), modelOf(sender.requests[1]), modelOf(sender.requests[2]), modelOf(sender.requests[3])} + if strings.Join(got, ",") != "cheap-model,cheap-model,frontier-model,cheap-model" { + t.Fatalf("models = %v", got) + } + notices := 0 + for _, m := range userMessages(requestInput(sender, 2)) { + if strings.Contains(m, "escalated turn follows") { + notices++ + } + } + if notices != 1 { + t.Fatalf("a steer notice must accompany the escalated turn, got %d", notices) + } + }) + t.Run("injects the advisor tool with pinned toolChoice for the escalated turn only", func(t *testing.T) { + var s doomScript + sender := scripted(s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"unblocked"}), s.textTurn("done")) + runText(t, sender, CallModelInput{Model: "cheap-model", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: escalateAt2, Escalation: &DoomLoopEscalationConfig{Advisor: true}}}) + escalated := sender.requests[2] + advisor := findAdvisor(escalated) + if advisor == nil || advisor.Parameters == nil || advisor.Parameters.ForwardTranscript == nil || !*advisor.Parameters.ForwardTranscript || !strings.Contains(*advisor.Parameters.Instructions, "escalation advisor") { + t.Fatalf("advisor tool = %#v", advisor) + } + if escalated.ToolChoice == nil || escalated.ToolChoice.ToolChoiceAllowed == nil || escalated.ToolChoice.ToolChoiceAllowed.Mode.ModeRequired == nil || escalated.ToolChoice.ToolChoiceAllowed.Tools[0]["type"] != "openrouter:advisor" { + t.Fatalf("toolChoice must be pinned to the advisor: %#v", escalated.ToolChoice) + } + if modelOf(escalated) != "cheap-model" { + t.Fatal("advisor-only escalation keeps the model") + } + b, err := json.Marshal(escalated) + if err != nil || !strings.Contains(string(b), `"type":"allowed_tools"`) || !strings.Contains(string(b), `"forward_transcript":true`) { + t.Fatalf("escalated request must serialize the advisor consult: %v %s", err, b) + } + reverted := sender.requests[3] + if findAdvisor(reverted) != nil || (reverted.ToolChoice != nil && reverted.ToolChoice.ToolChoiceAllowed != nil) { + t.Fatal("the next dispatch must revert") + } + if findAdvisor(sender.requests[1]) != nil { + t.Fatal("no advisor before the verdict") + } + }) + t.Run("advisor parameters merge over the defaults", func(t *testing.T) { + var s doomScript + model := "anthropic/claude-opus-4.6" + forward := false + sender := scripted(s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.textTurn("done")) + runText(t, sender, CallModelInput{Model: "cheap-model", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: escalateAt2, Escalation: &DoomLoopEscalationConfig{AdvisorParameters: &components.AdvisorServerToolConfig{Model: &model, ForwardTranscript: &forward}}}}) + advisor := findAdvisor(sender.requests[2]) + if advisor == nil || *advisor.Parameters.Model != model || *advisor.Parameters.ForwardTranscript || !strings.Contains(*advisor.Parameters.Instructions, "escalation advisor") { + t.Fatalf("advisor params = %#v", advisor) + } + }) + t.Run("consumes budget per applied escalation and falls through when exhausted", func(t *testing.T) { + var s doomScript + hooks := NewHooksManager() + detections := collectDetections(hooks) + var turns []components.OpenResponsesResult + for i := 0; i < 6; i++ { + turns = append(turns, s.toolTurn("web_search", searchArgs{"same"})) + } + turns = append(turns, s.textTurn("gave up")) + sender := scripted(turns...) + accessor := &memoryStateAccessor{state: CreateInitialState()} + runText(t, sender, CallModelInput{Model: "cheap-model", Input: "go", MaxTurns: 10, Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: escalateAt2, Escalation: &DoomLoopEscalationConfig{Model: "frontier-model", MaxEscalations: 1}}, StateAccessor: accessor, Hooks: hooks}) + var tool [][2]any + for _, e := range detections.events { + if e.Detector == DoomLoopDetectorToolFingerprint { + tool = append(tool, [2]any{e.Action, e.Streak}) + } + } + if !pairsEqual(tool, [2]any{DoomLoopActionObserve, 1}, [2]any{DoomLoopActionEscalate, 2}, [2]any{DoomLoopActionObserve, 3}, [2]any{DoomLoopActionObserve, 4}, [2]any{DoomLoopActionBlock, 5}, [2]any{DoomLoopActionBlock, 6}) { + t.Fatalf("tool ladder = %v", tool) + } + frontier := 0 + for _, r := range sender.requests { + if modelOf(r) == "frontier-model" { + frontier++ + } + } + if frontier != 1 || accessor.state.DoomLoop.EscalationsUsed != 1 { + t.Fatalf("exactly one escalated dispatch: frontier=%d used=%d", frontier, accessor.state.DoomLoop.EscalationsUsed) + } + }) + t.Run("hook override escalate is honored with budget and downgraded without config", func(t *testing.T) { + escalateHook := func() *HooksManager { + h := NewHooksManager() + h.OnDoomLoopDetected(HookEntry[DoomLoopDetectedPayload, DoomLoopDetectedResult]{Handler: func(DoomLoopDetectedPayload, LifecycleHookContext) (HookHandlerResult[DoomLoopDetectedResult], error) { + return SyncResult(DoomLoopDetectedResult{OverrideAction: DoomLoopActionEscalate}), nil + }}) + return h + } + ladder := DoomLoopLadder{Observe: 2, Block: 5, Stop: 8} + var s doomScript + sender := scripted(s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.textTurn("done")) + runText(t, sender, CallModelInput{Model: "cheap-model", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: ladder, Escalation: &DoomLoopEscalationConfig{Model: "frontier-model"}}, Hooks: escalateHook()}) + if modelOf(sender.requests[2]) != "frontier-model" { + t.Fatalf("override escalate with config = %s", modelOf(sender.requests[2])) + } + sender = scripted(s.toolTurn("web_search", searchArgs{"same"}), s.toolTurn("web_search", searchArgs{"same"}), s.textTurn("done")) + _, text := runText(t, sender, CallModelInput{Model: "cheap-model", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: ladder}, Hooks: escalateHook()}) + if text != "done" || modelOf(sender.requests[2]) != "cheap-model" { + t.Fatalf("override escalate without config must downgrade: %s", modelOf(sender.requests[2])) + } + }) + t.Run("text-detector verdicts can escalate too", func(t *testing.T) { + var s doomScript + filler := "Let me look somewhere else." + sender := scripted(s.turn(s.msg(filler), s.call("web_search", searchArgs{"q1"})), s.turn(s.msg(filler), s.call("web_search", searchArgs{"q2"})), s.toolTurn("web_search", searchArgs{"q3"}), s.textTurn("unblocked")) + runText(t, sender, CallModelInput{Model: "cheap-model", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: escalateAt2, Escalation: &DoomLoopEscalationConfig{Model: "frontier-model"}}}) + frontier := 0 + for _, r := range sender.requests { + if modelOf(r) == "frontier-model" { + frontier++ + } + } + if frontier != 1 { + t.Fatalf("one escalated dispatch, got %d", frontier) + } + }) + t.Run("two detectors firing in one window escalate once", func(t *testing.T) { + var s doomScript + same := func() components.OpenResponsesResult { + return s.turn(s.msg("Trying the same thing."), s.call("web_search", searchArgs{"same"})) + } + accessor := &memoryStateAccessor{state: CreateInitialState()} + runText(t, scripted(same(), same(), s.textTurn("done")), CallModelInput{Model: "cheap-model", Input: "go", Tools: []Tool{doomSearchTool(nil)}, DoomLoop: &DoomLoopConfig{Ladder: escalateAt2, Escalation: &DoomLoopEscalationConfig{Model: "frontier-model", MaxEscalations: 5}}, StateAccessor: accessor}) + if accessor.state.DoomLoop.EscalationsUsed != 1 { + t.Fatalf("escalationsUsed = %d", accessor.state.DoomLoop.EscalationsUsed) + } + }) +} + +func findAdvisor(req components.ResponsesRequest) *components.AdvisorServerToolOpenRouter { + for _, tool := range req.Tools { + if tool.AdvisorServerToolOpenRouter != nil { + return tool.AdvisorServerToolOpenRouter + } + } + return nil +} + +type riskyArgs struct { + Target string `json:"target"` +} + +func riskyTool(executions *int) Tool { + return MustNewTool(ToolConfig[riskyArgs]{Name: "risky", RequireApproval: true, Execute: func(context.Context, riskyArgs, ToolExecuteContext) (any, error) { + if executions != nil { + *executions++ + } + return map[string]any{"done": true}, nil + }}) +} + +// A stop armed during the auto-approve batch of a mixed turn pauses for +// approval first; the condemned run then stays halted on a decision-only +// resume (upstream's condemnation rule), making no further model request. +func TestDoomLoopCondemnedRunStaysHaltedOnDecisionResume(t *testing.T) { + var s doomScript + noisy := MustNewTool(ToolConfig[struct{}]{Name: "noisy", Execute: func(context.Context, struct{}, ToolExecuteContext) (any, error) { return "ok", nil }}) + riskyRuns := 0 + tools := []Tool{noisy, riskyTool(&riskyRuns)} + cfg := func() *DoomLoopConfig { + return &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 1, Block: DoomLoopRungDisabled, Stop: 2}} + } + accessor := &memoryStateAccessor{state: CreateInitialState()} + first := scripted(s.toolTurn("noisy", map[string]any{}), s.turn(s.call("noisy", map[string]any{}), s.call("risky", riskyArgs{"x"}))) + if _, err := mustResult(t, first, CallModelInput{Model: "test-model", Input: "go", Tools: tools, DoomLoop: cfg(), StateAccessor: accessor}).Response(context.Background()); err != nil { + t.Fatal(err) + } + if accessor.state.Status != ConversationStatusAwaitingApproval || accessor.state.DoomLoop.StopVerdict == nil || accessor.state.DoomLoop.StopVerdict.ToolName != "noisy" { + t.Fatalf("mixed turn must pause with the stop verdict persisted: %s %#v", accessor.state.Status, accessor.state.DoomLoop) + } + resume := scripted(s.textTurn("must not be requested")) + result := mustResult(t, resume, CallModelInput{Model: "test-model", Tools: tools, DoomLoop: cfg(), StateAccessor: accessor, ApproveToolCalls: []string{accessor.state.PendingToolCalls[0].CallID}}) + v := verdictOf(t, result) + if v == nil || v.Action != DoomLoopActionStop || v.ToolName != "noisy" || v.Streak != 2 { + t.Fatalf("restored verdict = %#v", v) + } + if resume.calls != 0 { + t.Fatalf("a condemned run must not request again on a decision-only resume; requests=%d", resume.calls) + } + if riskyRuns != 1 { + t.Fatalf("the approved call itself still executes; runs=%d", riskyRuns) + } + if accessor.state.DoomLoop.StopVerdict == nil { + t.Fatal("the stop verdict must stay persisted") + } + assertWellFormedHistory(t, accessor.state.Messages) +} + +// Steer guidance queued right before a pause is delivered by the resumed +// run at its next safe flush point (and deduplicated). +func TestDoomLoopPendingSteerDeliveredAfterResume(t *testing.T) { + type keyArgs struct { + Key string `json:"key"` + } + var s doomScript + lookup := MustNewTool(ToolConfig[keyArgs]{Name: "lookup", Execute: func(context.Context, keyArgs, ToolExecuteContext) (any, error) { return "v", nil }}) + tools := []Tool{lookup, riskyTool(nil)} + cfg := func() *DoomLoopConfig { + return &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 2, Steer: 2, Block: DoomLoopRungDisabled, Stop: 6}} + } + accessor := &memoryStateAccessor{state: CreateInitialState()} + runText(t, scripted(s.toolTurn("lookup", keyArgs{"same"}), s.turn(s.call("lookup", keyArgs{"same"}), s.call("risky", riskyArgs{"x"}))), CallModelInput{Input: "go", Tools: tools, DoomLoop: cfg(), StateAccessor: accessor}) + if accessor.state.Status != ConversationStatusAwaitingApproval || len(accessor.state.DoomLoop.PendingSteer) != 1 { + t.Fatalf("steer must be queued at the pause: %s %#v", accessor.state.Status, accessor.state.DoomLoop) + } + steer := accessor.state.DoomLoop.PendingSteer[0] + // A duplicate queued entry must still be delivered once. + accessor.state.DoomLoop.PendingSteer = []string{steer, steer} + + resume := scripted(s.toolTurn("lookup", keyArgs{"other"}), s.textTurn("done")) + _, text := runText(t, resume, CallModelInput{Tools: tools, DoomLoop: cfg(), StateAccessor: accessor, ApproveToolCalls: []string{accessor.state.PendingToolCalls[0].CallID}}) + if text != "done" { + t.Fatalf("text = %q", text) + } + count := func(i int) int { + n := 0 + for _, m := range userMessages(requestInput(resume, i)) { + if m == steer { + n++ + } + } + return n + } + if count(0) != 0 { + t.Fatal("the continuation request carries no steer (it follows dangling outputs)") + } + if count(1) != 1 { + t.Fatalf("the follow-up after the next tool round must carry the steer exactly once, got %d", count(1)) + } + if len(accessor.state.DoomLoop.PendingSteer) != 0 { + t.Fatalf("delivered steer must leave the queue: %#v", accessor.state.DoomLoop.PendingSteer) + } +} + +// On the first response of a call, the approval and client-tools pauses take +// precedence over a doom stop (upstream checks them before the loop's gate). +func TestDoomLoopFirstTurnPauseTakesPrecedenceOverDoomStop(t *testing.T) { + doomText := strings.TrimSpace(strings.Repeat("I am stuck. ", 20)) + var s doomScript + hooks := NewHooksManager() + reasons := sessionEndReasons(hooks) + result, _ := runText(t, scripted(s.turn(s.msg(doomText), s.call("risky", riskyArgs{"x"}))), CallModelInput{Tools: []Tool{riskyTool(nil)}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + state, _ := result.State(context.Background()) + if state.Status != ConversationStatusAwaitingApproval || len(state.PendingToolCalls) != 1 { + t.Fatalf("approval pause must win on the first response: %s", state.Status) + } + if state.DoomLoop == nil || state.DoomLoop.StopVerdict == nil || state.DoomLoop.StopVerdict.Detector != DoomLoopDetectorTextRepetition { + t.Fatalf("the armed stop verdict persists for the decision resume: %#v", state.DoomLoop) + } + if len(*reasons) != 1 || (*reasons)[0] != SessionEndReasonComplete { + t.Fatalf("a paused run reports complete, got %v", *reasons) + } + + manual := MustNewTool(ToolConfig[struct{}]{Name: "client_side", Manual: true}) + result, _ = runText(t, scripted(s.turn(s.msg(doomText), s.call("client_side", map[string]any{}))), CallModelInput{Tools: []Tool{manual}, DoomLoop: &DoomLoopConfig{}}) + state, _ = result.State(context.Background()) + if state.Status != ConversationStatusAwaitingClientTools || len(state.PendingToolCalls) != 1 { + t.Fatalf("client-tools pause must win on the first response: %s", state.Status) + } + + // Later turns hit the doom gate first: a stop armed on turn 2 seals + // instead of pausing for approval. + executions := 0 + sender := scripted(s.toolTurn("web_search", searchArgs{"a"}), s.turn(s.msg(doomText), s.call("risky", riskyArgs{"x"}))) + result, _ = runText(t, sender, CallModelInput{Tools: []Tool{doomSearchTool(nil), riskyTool(&executions)}, DoomLoop: &DoomLoopConfig{}}) + state, _ = result.State(context.Background()) + if state.Status != ConversationStatusComplete || executions != 0 || verdictOf(t, result) == nil { + t.Fatalf("turn-2 stop must seal: status=%s executions=%d", state.Status, executions) + } + assertWellFormedHistory(t, state.Messages) +} + +// Unhashable computed key material falls back to the full-arguments identity +// in the engine; detection keeps working and the call is never failed. +func TestDoomLoopUnhashableLoopKeyFallsBack(t *testing.T) { + var s doomScript + executions := 0 + tool := MustNewTool(ToolConfig[searchArgs]{Name: "web_search", LoopKey: LoopKeyFunc(func(searchArgs) (any, error) { return math.NaN(), nil }), Execute: func(context.Context, searchArgs, ToolExecuteContext) (any, error) { + executions++ + return "r", nil + }}) + hooks := NewHooksManager() + detections := collectDetections(hooks) + logs := captureLog(t, func() { + runText(t, scripted(s.toolTurn("web_search", searchArgs{"x"}), s.toolTurn("web_search", searchArgs{"x"}), s.toolTurn("web_search", searchArgs{"x"}), s.textTurn("done")), CallModelInput{Tools: []Tool{tool}, DoomLoop: &DoomLoopConfig{}, Hooks: hooks}) + }) + if !pairsEqual(detections.pairs(), [2]any{DoomLoopActionObserve, 2}, [2]any{DoomLoopActionBlock, 3}) || executions != 2 { + t.Fatalf("fallback identity must keep detecting: %v executions=%d", detections.pairs(), executions) + } + if !strings.Contains(logs, "falling back to full arguments") { + t.Fatalf("fallback must warn: %q", logs) + } +} diff --git a/doom_loop_test.go b/doom_loop_test.go new file mode 100644 index 0000000..2153389 --- /dev/null +++ b/doom_loop_test.go @@ -0,0 +1,669 @@ +package agent + +// Unit tests for the doom-loop detection primitives (doom_loop.go), ported +// from upstream tests/unit/doom-loop.test.ts and the config/monitor halves +// of tests/unit/doom-loop-escalation.test.ts. Everything here is pure and +// deterministic: JCS canonicalization, SHA-256 fingerprints (asserted +// against upstream's cross-port vector file), text-repetition detection, +// ladder resolution, loop-key resolution, and the monitor state machine. + +import ( + "bytes" + "encoding/json" + "errors" + "log" + "math" + "math/big" + "os" + "regexp" + "strings" + "testing" +) + +// captureLog redirects the standard logger for the duration of fn and +// returns what was logged (the Go stand-in for upstream's console.warn spy). +func captureLog(t *testing.T, fn func()) string { + t.Helper() + var buf bytes.Buffer + prev := log.Writer() + flags := log.Flags() + log.SetOutput(&buf) + log.SetFlags(0) + defer func() { + log.SetOutput(prev) + log.SetFlags(flags) + }() + fn() + return buf.String() +} + +func mustCanon(t *testing.T, v any) string { + t.Helper() + s, err := CanonicalizeKeyMaterial(v) + if err != nil { + t.Fatalf("canonicalize %#v: %v", v, err) + } + return s +} + +func TestCanonicalizeKeyMaterialJCSSemantics(t *testing.T) { + a := mustCanon(t, map[string]any{"b": 2.0, "a": map[string]any{"d": 4.0, "c": 3.0}}) + b := mustCanon(t, map[string]any{"a": map[string]any{"c": 3.0, "d": 4.0}, "b": 2.0}) + if a != b || a != `{"a":{"c":3,"d":4},"b":2}` { + t.Fatalf("key order must not matter, recursively: %s vs %s", a, b) + } + if mustCanon(t, []any{1.0, 2.0}) == mustCanon(t, []any{2.0, 1.0}) { + t.Fatal("array order is data") + } + // func entries are dropped from objects (JSON.stringify drops functions). + if got := mustCanon(t, map[string]any{"a": 1, "f": func() {}}); got != `{"a":1}` { + t.Fatalf("func entries must be dropped: %s", got) + } + cases := map[string]any{ + `"query"`: "query", + "42": 42, + "null": nil, + "0": math.Copysign(0, -1), + "1e+21": 1e21, + "1e-7": 1e-7, + "0.000001": 1e-6, + "1.5": 1.5, + "-12.25": -12.25, + "123456789012345680000": 123456789012345678901.0, + "true": true, + `"a\"b\\c\n\u0001"`: "a\"b\\c\n\x01", + `"<&>"`: "<&>", // no HTML escaping, unlike encoding/json + "\"\u2028\"": "\u2028", // JSON.stringify leaves U+2028 literal + } + for want, in := range cases { + if got := mustCanon(t, in); got != want { + t.Errorf("canonicalize(%#v) = %s, want %s", in, got, want) + } + } + // Keys sort by UTF-16 code units: U+FF61 (one unit, 0xFF61) sorts AFTER + // U+1F600 (surrogate pair starting 0xD83D), the reverse of UTF-8 byte order. + if got := mustCanon(t, map[string]any{"\uff61": 1, "\U0001F600": 2}); got != "{\"\U0001F600\":2,\"\uff61\":1}" { + t.Fatalf("keys must sort by UTF-16 code units: %s", got) + } +} + +func TestCanonicalizeKeyMaterialRejectsUnrepresentable(t *testing.T) { + for name, v := range map[string]any{ + "NaN": math.NaN(), + "Infinity": math.Inf(1), + "bigint": map[string]any{"id": big.NewInt(10)}, + } { + if _, err := CanonicalizeKeyMaterial(v); !errors.Is(err, ErrUncanonicalizable) { + t.Errorf("%s: expected ErrUncanonicalizable, got %v", name, err) + } + } + if _, err := CanonicalizeKeyMaterial(math.NaN()); err == nil || !strings.Contains(err.Error(), "non-finite") { + t.Errorf("NaN error should name non-finite: %v", err) + } + circular := map[string]any{} + circular["self"] = circular + if _, err := CanonicalizeKeyMaterial(circular); err == nil || !strings.Contains(err.Error(), "circular") { + t.Fatalf("circular key material must error, got %v", err) + } + var deep any = "leaf" + for i := 0; i < MaxCanonicalizeDepth+10; i++ { + deep = []any{deep} + } + if _, err := CanonicalizeKeyMaterial(deep); err == nil || !strings.Contains(err.Error(), "deeper than") { + t.Fatalf("over-deep key material must error, got %v", err) + } +} + +type vectorFile struct { + ToolCallVectors []struct { + Name string `json:"name"` + ToolName string `json:"toolName"` + KeyMaterial any `json:"keyMaterial"` + JCS string `json:"jcs"` + Fingerprint string `json:"fingerprint"` + } `json:"toolCallVectors"` + KeyMaterialVectors []struct { + Name string `json:"name"` + KeyMaterial any `json:"keyMaterial"` + JCS string `json:"jcs"` + Fingerprint string `json:"fingerprint"` + } `json:"keyMaterialVectors"` +} + +// loneSurrogateWTF8 is U+D800 as WTF-8 bytes. encoding/json decodes the +// vector's "\ud800" escape to U+FFFD (Go strings are UTF-8), so the vector's +// lone-surrogate key material is rebuilt with the only representation a Go +// string can carry for it. +const loneSurrogateWTF8 = "\xed\xa0\x80" + +func TestDoomLoopFingerprintCrossPortVectors(t *testing.T) { + raw, err := os.ReadFile("testdata/doom-loop-fingerprints.json") + if err != nil { + t.Fatal(err) + } + var vectors vectorFile + if err := json.Unmarshal(raw, &vectors); err != nil { + t.Fatal(err) + } + if len(vectors.ToolCallVectors) <= 5 { + t.Fatalf("expected the upstream vector set, got %d", len(vectors.ToolCallVectors)) + } + for _, v := range vectors.ToolCallVectors { + key := v.KeyMaterial + if strings.Contains(v.Name, "lone surrogate") { + key = map[string]any{"s": loneSurrogateWTF8} + } + if got := mustCanon(t, key); got != v.JCS { + t.Errorf("%s: jcs = %s, want %s", v.Name, got, v.JCS) + } + fp, err := FingerprintToolCall(v.ToolName, key) + if err != nil || fp != v.Fingerprint { + t.Errorf("%s: fingerprint = %s (%v), want %s", v.Name, fp, err, v.Fingerprint) + } + } + for _, v := range vectors.KeyMaterialVectors { + fp, err := FingerprintKeyMaterial(v.KeyMaterial) + if err != nil || fp != v.Fingerprint { + t.Errorf("%s: fingerprint = %s (%v), want %s", v.Name, fp, err, v.Fingerprint) + } + } + fp, _ := FingerprintToolCall("t", map[string]any{}) + if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(fp) { + t.Fatalf("fingerprint must be 64-char lowercase hex: %s", fp) + } + a, _ := FingerprintToolCall("web_search", map[string]any{"q": "x"}) + b, _ := FingerprintToolCall("fetch_page", map[string]any{"q": "x"}) + if a == b { + t.Fatal("tool name must participate in the fingerprint") + } + // The -0 vector's JSON can only spell 0; assert the -0 collapse directly. + negZero, _ := FingerprintToolCall("calc", map[string]any{"value": math.Copysign(0, -1)}) + zero, _ := FingerprintToolCall("calc", map[string]any{"value": 0}) + if negZero != zero || zero != "2c0292f2f8e9099f2879c189b8bd0d61fdc24dea98f06ec1d96f17f8ccb86f7d" { + t.Fatalf("-0 must fingerprint like 0: %s %s", negZero, zero) + } +} + +func TestResolveLoopKeyMaterialForms(t *testing.T) { + args := map[string]any{"command": "ls", "cwd": "/tmp", "verbose": true} + if r := ResolveLoopKeyMaterial(nil, args); r.Kind != LoopKeyResolutionKey || mustCanon(t, r.KeyMaterial) != mustCanon(t, args) { + t.Fatalf("absent → full args, got %#v", r) + } + if r := ResolveLoopKeyMaterial(LoopKeyExempt(), args); r.Kind != LoopKeyResolutionExempt { + t.Fatalf("exempt → exempt, got %#v", r) + } + r := ResolveLoopKeyMaterial(LoopKeyFields("command", "cwd", "not_a_field"), args) + if r.Kind != LoopKeyResolutionKey || mustCanon(t, r.KeyMaterial) != `{"command":"ls","cwd":"/tmp"}` { + t.Fatalf("field list → subset, got %#v", r) + } + var protoArgs map[string]any + _ = json.Unmarshal([]byte(`{"__proto__":"declared"}`), &protoArgs) + if r := ResolveLoopKeyMaterial(LoopKeyFields("__proto__"), protoArgs); mustCanon(t, r.KeyMaterial) != `{"__proto__":"declared"}` { + t.Fatalf("__proto__ is ordinary data, got %#v", r) + } + type bashArgs struct { + Command string `json:"command"` + } + r = ResolveLoopKeyMaterial(LoopKeyFunc(func(in bashArgs) (any, error) { return strings.TrimSpace(in.Command), nil }), args) + if r.Kind != LoopKeyResolutionKey || r.KeyMaterial != "ls" { + t.Fatalf("function value → key, got %#v", r) + } + if r := ResolveLoopKeyMaterial(&ToolLoopKey{Func: func(map[string]any) (any, error) { return nil, nil }}, args); r.Kind != LoopKeyResolutionExempt { + t.Fatalf("function nil → per-call exemption, got %#v", r) + } + r = ResolveLoopKeyMaterial(&ToolLoopKey{Func: func(map[string]any) (any, error) { return nil, errors.New("loopKey bug") }}, args) + if r.Kind != LoopKeyResolutionFallback || !strings.Contains(r.Warning, "threw (loopKey bug)") || mustCanon(t, r.KeyMaterial) != mustCanon(t, args) { + t.Fatalf("erroring function → fallback to full args, got %#v", r) + } + r = ResolveLoopKeyMaterial(&ToolLoopKey{Func: func(map[string]any) (any, error) { panic("boom") }}, args) + if r.Kind != LoopKeyResolutionFallback || !strings.Contains(r.Warning, "threw") { + t.Fatalf("panicking function → fallback, got %#v", r) + } + if r := ResolveLoopKeyMaterial(&ToolLoopKey{}, args); r.Kind != LoopKeyResolutionFallback { + t.Fatalf("empty declaration → fallback, got %#v", r) + } +} + +func TestDetectTextRepetition(t *testing.T) { + r := DetectTextRepetition("no no no no no no no no no no no no", DoomLoopTextOptions{}) + if r == nil || r.PeriodTokens != 1 || r.Repeats != 12 || r.Sample != "no" { + t.Fatalf("single repeated token: %#v", r) + } + phrase := "I am stuck in a loop." + r = DetectTextRepetition(strings.TrimSpace(strings.Repeat(phrase+" ", 6)), DoomLoopTextOptions{}) + if r == nil || r.PeriodTokens != 6 || r.Repeats != 6 || r.Sample != phrase { + t.Fatalf("repeating phrase: %#v", r) + } + looping := strings.TrimSpace(strings.Repeat("retry now ", 6)) + if r := DetectTextRepetition(looping+" but then I found the actual answer to your question about databases", DoomLoopTextOptions{}); r != nil { + t.Fatalf("recovered output must not fire: %#v", r) + } + if r := DetectTextRepetition("The quick brown fox jumps over the lazy dog and then runs far away into the woods.", DoomLoopTextOptions{}); r != nil { + t.Fatalf("prose must not fire: %#v", r) + } + if r := DetectTextRepetition("this is very very very good and quite long enough overall", DoomLoopTextOptions{}); r != nil { + t.Fatalf("below threshold must not fire: %#v", r) + } + paraphrased := "I am unable to find the file. I cannot locate the file. I am not able to find that file. The file cannot be found by me. Finding the file is not possible. I could not locate that file." + if r := DetectTextRepetition(paraphrased, DoomLoopTextOptions{}); r != nil { + t.Fatalf("DOCUMENTED MISS: paraphrase must not fire: %#v", r) + } + text := strings.TrimSpace(strings.Repeat("loop detected ", 8)) + if *DetectTextRepetition(text, DoomLoopTextOptions{}) != *DetectTextRepetition(text, DoomLoopTextOptions{}) { + t.Fatal("must be deterministic") + } + var filler strings.Builder + for i := 0; i < 400_000; i++ { + filler.WriteString("w") + filler.WriteString(strings.Repeat("x", i%3)) + filler.WriteString(" ") + } + r = DetectTextRepetition(filler.String()+strings.TrimSpace(strings.Repeat("stuck ", 20)), DoomLoopTextOptions{}) + if r == nil || r.PeriodTokens != 1 || r.Repeats != 20 { + t.Fatalf("multi-megabyte input tail: %#v", r) + } + if r := DetectTextRepetition("ha ha ha", DoomLoopTextOptions{}); r != nil { + t.Fatalf("defaults must not fire on 3 repeats: %#v", r) + } + if r := DetectTextRepetition("ha ha ha", DoomLoopTextOptions{MinRepeats: 3, MinCoveredTokens: 3}); r == nil || r.Repeats != 3 || r.PeriodTokens != 1 { + t.Fatalf("custom thresholds: %#v", r) + } +} + +func TestResolveLadderAction(t *testing.T) { + ladder := DoomLoopLadder{Observe: 2, Steer: DoomLoopRungDisabled, Escalate: DoomLoopRungDisabled, Block: 3, Stop: 6} + want := map[int]DoomLoopAction{1: "", 2: DoomLoopActionObserve, 3: DoomLoopActionBlock, 5: DoomLoopActionBlock, 6: DoomLoopActionStop} + for streak, action := range want { + if got := ResolveLadderAction(ladder, streak, ResolveLadderOptions{}); got != action { + t.Errorf("streak %d = %q, want %q", streak, got, action) + } + } + if got := ResolveLadderAction(ladder, 3, ResolveLadderOptions{NonBlocking: true}); got != DoomLoopActionObserve { + t.Errorf("non-blockable streak 3 = %q", got) + } + if got := ResolveLadderAction(ladder, 6, ResolveLadderOptions{NonBlocking: true}); got != DoomLoopActionStop { + t.Errorf("non-blockable streak 6 = %q", got) + } + disabled := DoomLoopLadder{Observe: DoomLoopRungDisabled, Steer: DoomLoopRungDisabled, Escalate: DoomLoopRungDisabled, Block: DoomLoopRungDisabled, Stop: 4} + if got := ResolveLadderAction(disabled, 3, ResolveLadderOptions{}); got != "" { + t.Errorf("disabled rungs = %q", got) + } + esc := DoomLoopLadder{Observe: 1, Steer: DoomLoopRungDisabled, Escalate: 2, Block: 5, Stop: 8} + if got := ResolveLadderAction(esc, 2, ResolveLadderOptions{}); got != DoomLoopActionEscalate { + t.Errorf("escalate rung = %q", got) + } + if got := ResolveLadderAction(esc, 2, ResolveLadderOptions{NoEscalate: true}); got != DoomLoopActionObserve { + t.Errorf("escalate unavailable falls through = %q", got) + } +} + +func TestResolveDoomLoopOption(t *testing.T) { + if ResolveDoomLoopOption(nil) != nil { + t.Fatal("nil option = detection off") + } + var resolved *ResolvedDoomLoopConfig + logs := captureLog(t, func() { resolved = ResolveDoomLoopOption(&DoomLoopConfig{}) }) + if resolved.Ladder != DefaultDoomLoopLadder() || !resolved.Text.Enabled || resolved.Escalation != nil { + t.Fatalf("zero config = defaults, got %#v", resolved) + } + if logs != "" { + t.Fatalf("default ladder must not warn: %q", logs) + } + resolved = ResolveDoomLoopOption(&DoomLoopConfig{Ladder: DoomLoopLadder{Block: 5, Stop: 0}}) + if resolved.Ladder.Block != 5 || resolved.Ladder.Stop != 6 || resolved.Ladder.Observe != 2 { + t.Fatalf("partial ladder merge: %#v", resolved.Ladder) + } + if ResolveDoomLoopOption(&DoomLoopConfig{Escalation: &DoomLoopEscalationConfig{}}).Escalation != nil { + t.Fatal("an escalation config with no mechanism (upstream advisor:false) is not a mechanism") + } + r := ResolveDoomLoopOption(&DoomLoopConfig{Escalation: &DoomLoopEscalationConfig{Model: "stronger-model"}}) + if r.Escalation == nil || r.Escalation.Model != "stronger-model" || r.Escalation.hasAdvisor() || r.Escalation.MaxEscalations != DefaultMaxEscalations { + t.Fatalf("model-only mechanism: %#v", r.Escalation) + } + if r := ResolveDoomLoopOption(&DoomLoopConfig{Escalation: &DoomLoopEscalationConfig{Advisor: true}}); r.Escalation == nil || !r.Escalation.Advisor { + t.Fatal("advisor true IS a mechanism") + } + if ResolveDoomLoopOption(&DoomLoopConfig{Text: DoomLoopTextOptions{Disabled: true}}).Text.Enabled { + t.Fatal("text disabled") + } + logs = captureLog(t, func() { + ResolveDoomLoopOption(&DoomLoopConfig{Ladder: DoomLoopLadder{Block: 3, Stop: DoomLoopRungDisabled}}) + }) + if !strings.Contains(logs, "stop disabled") { + t.Fatalf("block without stop must warn: %q", logs) + } + logs = captureLog(t, func() { ResolveDoomLoopOption(&DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 5, Block: 2}}) }) + if !strings.Contains(logs, `"observe" (5) can never fire`) { + t.Fatalf("dead rung must warn: %q", logs) + } + // Escalation config resolution (doom-loop-escalation.test.ts). + r = ResolveDoomLoopOption(&DoomLoopConfig{Ladder: DoomLoopLadder{Escalate: 3}, Escalation: &DoomLoopEscalationConfig{Model: "openai/gpt-5"}}) + if r.Escalation.MaxEscalations != 2 || r.Ladder.Escalate != 3 { + t.Fatalf("default budget: %#v", r) + } + logs = captureLog(t, func() { r = ResolveDoomLoopOption(&DoomLoopConfig{Ladder: DoomLoopLadder{Escalate: 3}}) }) + if r.Escalation != nil || !strings.Contains(logs, "no escalation config") { + t.Fatalf("rung without mechanism must warn and resolve nil: %q", logs) + } + logs = captureLog(t, func() { + r = ResolveDoomLoopOption(&DoomLoopConfig{Escalation: &DoomLoopEscalationConfig{Model: "openai/gpt-5"}}) + }) + if r.Escalation == nil || r.Ladder.Escalate != DoomLoopRungDisabled || !strings.Contains(logs, "escalate ladder rung is disabled") { + t.Fatalf("mechanism without rung must warn and keep the mechanism: %q %#v", logs, r) + } + r = ResolveDoomLoopOption(&DoomLoopConfig{Ladder: DoomLoopLadder{Escalate: 2}, Escalation: &DoomLoopEscalationConfig{Model: "openai/gpt-5", MaxEscalations: 0}}) + if r.Escalation.MaxEscalations != 2 { + t.Fatalf("invalid budget clamps to default: %d", r.Escalation.MaxEscalations) + } +} + +func makeTestMonitor(t *testing.T, cfg *DoomLoopConfig) *DoomLoopMonitor { + t.Helper() + if cfg == nil { + cfg = &DoomLoopConfig{} + } + return NewDoomLoopMonitor(*ResolveDoomLoopOption(cfg), nil) +} + +func record(t *testing.T, m *DoomLoopMonitor, tool string, key any, round int) DoomLoopCallRecord { + t.Helper() + r, err := m.RecordToolCall(tool, key, round, RecordToolCallOptions{}) + if err != nil { + t.Fatal(err) + } + return r +} + +func TestDoomLoopMonitorToolStreaks(t *testing.T) { + m := makeTestMonitor(t, nil) + args := map[string]any{"query": "same thing"} + if v := record(t, m, "search", args, 1).Verdict; v != nil { + t.Fatalf("streak 1 has no verdict: %#v", v) + } + v := record(t, m, "search", args, 2).Verdict + if v == nil || v.Action != DoomLoopActionObserve || v.Streak != 2 || v.Detector != DoomLoopDetectorToolFingerprint || v.ToolName != "search" { + t.Fatalf("streak 2: %#v", v) + } + if v := record(t, m, "search", args, 3).Verdict; v == nil || v.Action != DoomLoopActionBlock || v.Streak != 3 { + t.Fatalf("streak 3: %#v", v) + } + if !strings.Contains(v.Message, `tool "search" was invoked in 2 consecutive rounds`) { + t.Fatalf("message: %s", v.Message) + } + + m = makeTestMonitor(t, nil) + var last DoomLoopCallRecord + for round := 1; round <= 6; round++ { + last = record(t, m, "search", map[string]any{"q": "x"}, round) + } + if last.Verdict == nil || last.Verdict.Action != DoomLoopActionStop || last.Verdict.Streak != 6 { + t.Fatalf("stop at 6: %#v", last.Verdict) + } + + // Same-round duplicates: one increment, no verdicts. + m = makeTestMonitor(t, nil) + if r := record(t, m, "search", map[string]any{"q": "parallel"}, 1); r.Streak != 1 || r.DuplicateInRound { + t.Fatalf("first: %#v", r) + } + for i := 0; i < 5; i++ { + if r := record(t, m, "search", map[string]any{"q": "parallel"}, 1); r.Streak != 1 || !r.DuplicateInRound || r.Verdict != nil { + t.Fatalf("dup %d: %#v", i, r) + } + } + if r := record(t, m, "search", map[string]any{"q": "parallel"}, 2); r.Streak != 2 || r.DuplicateInRound { + t.Fatalf("next round: %#v", r) + } + + // Different args reset; interleaved other tools do not. + m = makeTestMonitor(t, nil) + record(t, m, "search", map[string]any{"q": "a"}, 1) + if r := record(t, m, "search", map[string]any{"q": "b"}, 2); r.Streak != 1 { + t.Fatalf("different args must reset: %#v", r) + } + m = makeTestMonitor(t, nil) + search := map[string]any{"q": "x"} + record(t, m, "search", search, 1) + record(t, m, "read", map[string]any{"path": "a"}, 2) + record(t, m, "search", search, 3) + record(t, m, "read", map[string]any{"path": "b"}, 4) + if v := record(t, m, "search", search, 5).Verdict; v == nil || v.Action != DoomLoopActionBlock || v.Streak != 3 { + t.Fatalf("interleaved other tools must not reset: %#v", v) + } + + // Key order and empty calls. + m = makeTestMonitor(t, nil) + record(t, m, "search", map[string]any{"a": 1, "b": 2}, 1) + if r := record(t, m, "search", map[string]any{"b": 2, "a": 1}, 2); r.Streak != 2 { + t.Fatalf("key order must not defeat detection: %#v", r) + } + m = makeTestMonitor(t, nil) + record(t, m, "list_tasks", map[string]any{}, 1) + record(t, m, "list_tasks", map[string]any{}, 2) + if v := record(t, m, "list_tasks", map[string]any{}, 3).Verdict; v == nil || v.Action != DoomLoopActionBlock { + t.Fatalf("repeated empty calls must trip: %#v", v) + } + + // Unhashable key material propagates for the engine fallback. + if _, err := m.RecordToolCall("search", map[string]any{"id": big.NewInt(1)}, 9, RecordToolCallOptions{}); !errors.Is(err, ErrUncanonicalizable) { + t.Fatalf("bigint must propagate: %v", err) + } + + // Server-tool records: non-blockable, server detector label. + m = makeTestMonitor(t, nil) + var rec DoomLoopCallRecord + for round := 1; round <= 3; round++ { + rec, _ = m.RecordToolCall("server:web_search_call", map[string]any{"q": "x"}, round, RecordToolCallOptions{NonBlocking: true, Detector: DoomLoopDetectorServerToolFingerprint}) + } + if rec.Verdict == nil || rec.Verdict.Action != DoomLoopActionObserve || rec.Verdict.Detector != DoomLoopDetectorServerToolFingerprint { + t.Fatalf("server-tool verdict: %#v", rec.Verdict) + } + + // Determinism: two monitors fed the same sequence agree. + run := func() []DoomLoopCallRecord { + mon := makeTestMonitor(t, nil) + var out []DoomLoopCallRecord + for i, k := range []string{"a", "a", "b", "a", "a", "a"} { + out = append(out, record(t, mon, "t", map[string]any{"k": k}, i+1)) + } + return out + } + a, b := run(), run() + for i := range a { + if a[i].Fingerprint != b[i].Fingerprint || a[i].Streak != b[i].Streak || (a[i].Verdict == nil) != (b[i].Verdict == nil) { + t.Fatalf("nondeterministic at %d: %#v vs %#v", i, a[i], b[i]) + } + } +} + +func TestDoomLoopMonitorText(t *testing.T) { + m := makeTestMonitor(t, nil) + v, _ := m.RecordAssistantText(strings.TrimSpace(strings.Repeat("I am stuck. ", 20))) + if v == nil || v.Detector != DoomLoopDetectorTextRepetition || v.Action != DoomLoopActionStop || v.Streak != 20 { + t.Fatalf("within-response stop: %#v", v) + } + + m = makeTestMonitor(t, nil) + say := func(s string) *DoomLoopVerdict { + v, err := m.RecordAssistantText(s) + if err != nil { + t.Fatal(err) + } + return v + } + if v := say("Let me try that again."); v != nil { + t.Fatalf("first text: %#v", v) + } + if v := say("Let me try that again."); v == nil || v.Detector != DoomLoopDetectorTextStreak || v.Action != DoomLoopActionObserve || v.Streak != 2 { + t.Fatalf("streak 2: %#v", v) + } + if v := say("Let me try that again."); v == nil || v.Action != DoomLoopActionObserve || v.Streak != 3 { + t.Fatalf("block downgrades to observe for text: %#v", v) + } + say("Let me try that again.") + say("Let me try that again.") + if v := say("Let me try that again."); v == nil || v.Action != DoomLoopActionStop || v.Streak != 6 { + t.Fatalf("text stop at 6: %#v", v) + } + + m = makeTestMonitor(t, nil) + say("Retrying the\nsame plan.") + if v := say("Retrying the same plan."); v == nil || v.Streak != 2 { + t.Fatalf("whitespace-only differences: %#v", v) + } + + m = makeTestMonitor(t, nil) + for _, s := range []string{"I will try again.", "Let me attempt that once more.", "Trying one more time.", "Once more, then."} { + if v := say(s); v != nil { + t.Fatalf("DOCUMENTED MISS: paraphrase must not streak: %#v", v) + } + } + + m = makeTestMonitor(t, nil) + say("same") + say("") + say(" ") + if v := say("same"); v == nil || v.Streak != 2 { + t.Fatalf("empty text neither counts nor resets: %#v", v) + } + + m = makeTestMonitor(t, &DoomLoopConfig{Text: DoomLoopTextOptions{Disabled: true}}) + if v := say(strings.Repeat("loop ", 30)); v != nil { + t.Fatalf("text disabled: %#v", v) + } +} + +func TestDoomLoopMonitorStateRoundTrip(t *testing.T) { + m := makeTestMonitor(t, nil) + record(t, m, "search", map[string]any{"q": "x"}, 1) + record(t, m, "search", map[string]any{"q": "x"}, 2) + blob, err := json.Marshal(m.State()) + if err != nil { + t.Fatal(err) + } + var restored DoomLoopSerializedState + if err := json.Unmarshal(blob, &restored); err != nil { + t.Fatal(err) + } + resumed := NewDoomLoopMonitor(*ResolveDoomLoopOption(&DoomLoopConfig{}), &restored) + // Rounds restart at 1 after a resume; the first resumed record still increments. + if v := record(t, resumed, "search", map[string]any{"q": "x"}, 1).Verdict; v == nil || v.Action != DoomLoopActionBlock || v.Streak != 3 { + t.Fatalf("resumed streak: %#v", v) + } + + // Corrupt persisted state is ignored with a warning, never an error. + var corrupt DoomLoopSerializedState + logs := captureLog(t, func() { + if err := json.Unmarshal([]byte(`"not an object"`), &corrupt); err != nil { + t.Fatalf("corrupt blob must not error: %v", err) + } + }) + if !strings.Contains(logs, "Ignoring invalid persisted doom-loop state") { + t.Fatalf("corrupt blob must warn: %q", logs) + } + fresh := NewDoomLoopMonitor(*ResolveDoomLoopOption(&DoomLoopConfig{}), &corrupt) + if v := record(t, fresh, "search", map[string]any{"q": "x"}, 1).Verdict; v != nil { + t.Fatalf("fresh state after corrupt blob: %#v", v) + } + var partial DoomLoopSerializedState + _ = json.Unmarshal([]byte(`{"tools":{"ok":{"fingerprint":"f","streak":2},"bad":{"streak":"x"},"worse":7},"text":5,"pendingSteer":["a",3],"escalationsUsed":-1}`), &partial) + if len(partial.Tools) != 1 || partial.Text != nil || len(partial.PendingSteer) != 1 || partial.EscalationsUsed != 0 { + t.Fatalf("invalid entries must be dropped individually: %#v", partial) + } + + // A tool named "__proto__" is ordinary data and cannot pollute other tools. + var hostile DoomLoopSerializedState + _ = json.Unmarshal([]byte(`{"tools":{"__proto__":{"fingerprint":"seeded","streak":5}}}`), &hostile) + h := NewDoomLoopMonitor(*ResolveDoomLoopOption(&DoomLoopConfig{}), &hostile) + if r := record(t, h, "unseen", map[string]any{}, 1); r.Streak != 1 || r.Verdict != nil { + t.Fatalf("hostile name must not leak into other tools: %#v", r) + } + p := makeTestMonitor(t, nil) + record(t, p, "__proto__", map[string]any{}, 1) + if r := record(t, p, "__proto__", map[string]any{}, 2); r.Streak != 2 { + t.Fatalf("__proto__ tool tracked as data: %#v", r) + } + if _, ok := p.State().Tools["__proto__"]; !ok { + t.Fatal("__proto__ must appear in the snapshot") + } + + // State returns a snapshot, not a live reference. + s := makeTestMonitor(t, nil) + record(t, s, "a", map[string]any{}, 1) + snap := s.State() + record(t, s, "a", map[string]any{}, 2) + if snap.Tools["a"].Streak != 1 || s.State().Tools["a"].Streak != 2 { + t.Fatal("State must be a snapshot") + } +} + +func TestDoomLoopMonitorEscalationBudget(t *testing.T) { + cfg := func(maxEscalations int) *DoomLoopConfig { + return &DoomLoopConfig{ + Ladder: DoomLoopLadder{Observe: 1, Steer: DoomLoopRungDisabled, Escalate: 2, Block: 5, Stop: 8}, + Escalation: &DoomLoopEscalationConfig{Model: "openai/gpt-5", MaxEscalations: maxEscalations}, + } + } + m := makeTestMonitor(t, cfg(2)) + args := map[string]any{"q": "same"} + if v := record(t, m, "search", args, 1).Verdict; v == nil || v.Action != DoomLoopActionObserve || v.Streak != 1 { + t.Fatalf("streak 1: %#v", v) + } + if v := record(t, m, "search", args, 2).Verdict; v == nil || v.Action != DoomLoopActionEscalate { + t.Fatalf("streak 2 escalates: %#v", v) + } + if v := record(t, m, "search", args, 3).Verdict; v == nil || v.Action != DoomLoopActionEscalate { + t.Fatalf("budget unconsumed keeps escalating: %#v", v) + } + + m = makeTestMonitor(t, cfg(1)) + record(t, m, "search", args, 1) + record(t, m, "search", args, 2) + m.ConsumeEscalation() + if m.CanEscalate() { + t.Fatal("budget of 1 must be exhausted") + } + if v := record(t, m, "search", args, 3).Verdict; v == nil || v.Action != DoomLoopActionObserve { + t.Fatalf("exhausted budget falls through: %#v", v) + } + record(t, m, "search", args, 4) + if v := record(t, m, "search", args, 5).Verdict; v == nil || v.Action != DoomLoopActionBlock { + t.Fatalf("block unaffected by budget: %#v", v) + } + + m = makeTestMonitor(t, cfg(2)) + m.ConsumeEscalation() + blob, _ := json.Marshal(m.State()) + if !strings.Contains(string(blob), `"escalationsUsed":1`) { + t.Fatalf("escalationsUsed must serialize: %s", blob) + } + var restored DoomLoopSerializedState + _ = json.Unmarshal(blob, &restored) + resumed := NewDoomLoopMonitor(*ResolveDoomLoopOption(cfg(2)), &restored) + if !resumed.CanEscalate() { + t.Fatal("one of two used") + } + resumed.ConsumeEscalation() + if resumed.CanEscalate() { + t.Fatal("budget must persist across resume") + } +} + +// Text normalization uses ECMAScript's \s set (upstream's regex), not +// unicode.IsSpace: U+FEFF separates tokens, U+0085 does not. +func TestDoomLoopTextUsesJSWhitespace(t *testing.T) { + m := makeTestMonitor(t, &DoomLoopConfig{Ladder: DoomLoopLadder{Observe: 1}}) + v, err := m.RecordAssistantText("a\ufeffb\u3000 c") + if err != nil || v == nil { + t.Fatalf("observe@1 must fire: %v %v", v, err) + } + if want, _ := FingerprintKeyMaterial("a b c"); v.Fingerprint != want { + t.Fatal("U+FEFF and U+3000 must normalize to single spaces like JS \\s") + } + v, _ = m.RecordAssistantText("a\u0085b") + if want, _ := FingerprintKeyMaterial("a\u0085b"); v.Fingerprint != want { + t.Fatal("U+0085 is not JS whitespace and must be kept") + } + if r := DetectTextRepetition(strings.Repeat("no\ufeff", 12), DoomLoopTextOptions{}); r == nil || r.Repeats != 12 { + t.Fatalf("U+FEFF must split tokens: %#v", r) + } +} diff --git a/hooks_manager.go b/hooks_manager.go index 889ae7a..8e10948 100644 --- a/hooks_manager.go +++ b/hooks_manager.go @@ -8,7 +8,7 @@ import ( ) // HooksManager is a typed, extensible hook system for agent lifecycle -// events (upstream #7/#67, the 0.8.0 headline). Supports both the nine +// events (upstream #7/#67, the 0.8.0 headline). Supports both the ten // built-in hooks (PreToolUse, PostToolUse, ...) and user-defined custom // hooks registered via the package-level generic On/Emit functions (Go's // generics stand-in for upstream's `AllHooks` conditional-type @@ -390,4 +390,12 @@ func (m *HooksManager) EmitPostModelCall(payload PostModelCallPayload, opts Emit return emitTyped[PostModelCallPayload, EmptyHookResult](m, string(HookNamePostModelCall), payload, opts) } +func (m *HooksManager) OnDoomLoopDetected(entry HookEntry[DoomLoopDetectedPayload, DoomLoopDetectedResult]) func() { + return On(m, string(HookNameDoomLoopDetected), entry) +} + +func (m *HooksManager) EmitDoomLoopDetected(payload DoomLoopDetectedPayload, opts EmitOptions) (EmitResult[DoomLoopDetectedResult, DoomLoopDetectedPayload], error) { + return emitTyped[DoomLoopDetectedPayload, DoomLoopDetectedResult](m, string(HookNameDoomLoopDetected), payload, opts) +} + //#endregion diff --git a/hooks_resolve.go b/hooks_resolve.go index c1efe1e..6818d5e 100644 --- a/hooks_resolve.go +++ b/hooks_resolve.go @@ -16,6 +16,7 @@ type InlineHookConfig struct { SessionStart []HookEntry[SessionStartPayload, EmptyHookResult] SessionEnd []HookEntry[SessionEndPayload, EmptyHookResult] PostModelCall []HookEntry[PostModelCallPayload, EmptyHookResult] + DoomLoopDetected []HookEntry[DoomLoopDetectedPayload, DoomLoopDetectedResult] } // ResolveHooks normalizes a CallModelInput.Hooks value into a *HooksManager. @@ -62,6 +63,9 @@ func ResolveHooks(hooks any) *HooksManager { for _, e := range v.PostModelCall { manager.OnPostModelCall(e) } + for _, e := range v.DoomLoopDetected { + manager.OnDoomLoopDetected(e) + } return manager default: log.Printf("[ResolveHooks] Ignoring CallModelInput.Hooks of unsupported type %T; expected *HooksManager or InlineHookConfig.", hooks) diff --git a/hooks_schemas.go b/hooks_schemas.go index 0e167e8..ba9c9e6 100644 --- a/hooks_schemas.go +++ b/hooks_schemas.go @@ -16,6 +16,7 @@ const ( HookNameSessionStart HookName = "SessionStart" HookNameSessionEnd HookName = "SessionEnd" HookNamePostModelCall HookName = "PostModelCall" + HookNameDoomLoopDetected HookName = "DoomLoopDetected" ) // builtInHookNames mirrors upstream's BUILT_IN_HOOK_NAMES set, used by @@ -31,9 +32,10 @@ var builtInHookNames = map[string]bool{ string(HookNameSessionStart): true, string(HookNameSessionEnd): true, string(HookNamePostModelCall): true, + string(HookNameDoomLoopDetected): true, } -// IsBuiltInHookName reports whether name is one of the nine built-in hooks. +// IsBuiltInHookName reports whether name is one of the ten built-in hooks. func IsBuiltInHookName(name string) bool { return builtInHookNames[name] } //#region Payload & Result types @@ -204,6 +206,9 @@ const ( SessionEndReasonError SessionEndReason = "error" SessionEndReasonMaxTurns SessionEndReason = "max_turns" SessionEndReasonComplete SessionEndReason = "complete" + // SessionEndReasonDoomLoop means doom-loop detection halted the run + // (a stop verdict; see CallModelInput.DoomLoop). + SessionEndReasonDoomLoop SessionEndReason = "doom_loop" ) // SessionEndPayload is delivered exactly once per run that reached @@ -235,6 +240,36 @@ type PostModelCallPayload struct { Usage *ModelCallUsage } +// DoomLoopDetectedPayload is delivered for every doom-loop verdict (see +// CallModelInput.DoomLoop) before the engine applies its action. +type DoomLoopDetectedPayload struct { + // Detector is which detector fired. + Detector DoomLoopDetectorKind + // Action is the ladder action the engine resolved for this streak. + Action DoomLoopAction + // Streak is the repetition count that crossed a ladder rung. + Streak int + // Fingerprint is the deterministic fingerprint of the repeated unit. + Fingerprint string + // ToolName is set for tool and server-tool fingerprint verdicts. + ToolName string + // ToolInput is set for tool-fingerprint verdicts: the repeated call's + // arguments as the model issued them. + ToolInput map[string]any + // Message is the explanation used for block outputs / steer messages. + Message string +} + +// DoomLoopDetectedResult lets a handler override the resolved action for +// THIS event — de-escalate (observe on a would-be block) or escalate (stop +// immediately). The last non-empty override wins. A block override on a +// text/server-tool verdict downgrades to observe (nothing to block); an +// escalate override without an escalation config or remaining budget +// downgrades to observe. +type DoomLoopDetectedResult struct { + OverrideAction DoomLoopAction +} + // EmptyHookResult is the result type for observation-only hooks // (PostToolUse, PostToolUseFailure, SessionStart, SessionEnd, PostModelCall): // handlers have no meaningful result to return. Stands in for upstream's diff --git a/model_result.go b/model_result.go index 35dd14f..a0286ee 100644 --- a/model_result.go +++ b/model_result.go @@ -65,11 +65,45 @@ type ModelResult struct { sessionEndEmitted bool sessionUsage sessionUsageAggregate toolRoundsExecuted int + + // Doom-loop detection (doomLoopMonitor is nil when CallModelInput.DoomLoop + // is unset). The monitor is a pure, serializable state machine; this + // type owns the engine-side consequences (hook emission, block + // synthesis, steer injection, escalation, stopping the loop). All of it + // runs on the run() goroutine; DoomLoopVerdict reads doomLoopStop only + // after done is closed. + doomLoopMonitor *DoomLoopMonitor + // doomLoopEscalation mirrors the monitor's resolved escalation config. + doomLoopEscalation *ResolvedEscalationConfig + // doomLoopStop is set when a verdict resolves to stop; checked at the + // loop boundaries and persisted as DoomLoop.StopVerdict. + doomLoopStop *DoomLoopVerdict + // doomStopped records that a stop site actually halted this run + // (SessionEnd reason doom_loop). + doomStopped bool + // pendingDoomLoopSteer holds steer messages injected before the next + // model request; persisted as DoomLoop.PendingSteer across pauses. + pendingDoomLoopSteer []string + // doomLoopRound is bumped at every execution-batch boundary; identical + // calls within one round count as ONE piece of loop evidence. + doomLoopRound int + // doomLoopRoundDecisions caches the decision for each (tool, + // fingerprint) in the current round so duplicates share it. + doomLoopRoundDecisions map[string]doomLoopDecision + // pendingDoomLoopEscalation is armed by an escalate verdict and consumed + // by the next follow-up request. Deliberately not persisted (the + // consumed budget is, via the monitor). + pendingDoomLoopEscalation *DoomLoopVerdict } func newModelResult(ctx context.Context, client ResponseSender, input CallModelInput, req components.ResponsesRequest, state ConversationState) *ModelResult { ctx2, cancel := context.WithCancel(ctx) - return &ModelResult{ctx: ctx2, cancel: cancel, client: client, input: input, req: req, state: state, store: NewToolContextStore(input.Context), done: make(chan struct{}), textStream: NewReusableStream[string](), reasoningStream: NewReusableStream[string](), toolStream: NewToolEventBroadcaster(), toolCallStream: NewReusableStream[ParsedToolCall](), fullStream: NewReusableStream[ResponseStreamEvent](), newMessages: NewReusableStream[components.InputsUnion1](), hooksManager: ResolveHooks(input.Hooks)} + mr := &ModelResult{ctx: ctx2, cancel: cancel, client: client, input: input, req: req, state: state, store: NewToolContextStore(input.Context), done: make(chan struct{}), textStream: NewReusableStream[string](), reasoningStream: NewReusableStream[string](), toolStream: NewToolEventBroadcaster(), toolCallStream: NewReusableStream[ParsedToolCall](), fullStream: NewReusableStream[ResponseStreamEvent](), newMessages: NewReusableStream[components.InputsUnion1](), hooksManager: ResolveHooks(input.Hooks), doomLoopRoundDecisions: map[string]doomLoopDecision{}} + if cfg := ResolveDoomLoopOption(input.DoomLoop); cfg != nil { + mr.doomLoopMonitor = NewDoomLoopMonitor(*cfg, nil) + mr.doomLoopEscalation = cfg.Escalation + } + return mr } func NewModelResultFromResponse(resp components.OpenResponsesResult) *ModelResult { @@ -205,12 +239,19 @@ func (m *ModelResult) run() { reason := SessionEndReasonComplete if m.err != nil { reason = SessionEndReasonError + } else if m.doomStopped { + reason = SessionEndReasonDoomLoop } m.finishHooksSession(reason) }() + // Piggyback the doom-loop detector snapshot on the final state so + // State() (and any caller persisting it) carries loop memory. Runs + // before the stream/session defers above (LIFO). + defer m.syncDoomLoopState() if m.client == nil { return } + m.restoreDoomLoopState() req := m.req maxTurns := m.input.MaxTurns if maxTurns <= 0 { @@ -237,7 +278,7 @@ func (m *ModelResult) run() { m.err = err return } - if paused { + if paused || m.doomLoopStop != nil { // The resume itself left calls pending (not every approved/ // rejected decision resolved the round) — persist the updated // state before returning, mirroring upstream's @@ -246,6 +287,15 @@ func (m *ModelResult) run() { // calls pending. Without this, a StateAccessor-backed caller // that reloads on the next CallModel call would see the stale // pre-resume state and could re-execute an already-decided call. + // + // A doom-loop stop armed while executing the approved calls (or + // restored for this decision-only resume) halts here too: no + // unsent-results model request after a stop. The executed + // outputs are already recorded in state. + if !paused { + m.doomStopped = true + } + m.syncDoomLoopState() if m.input.StateAccessor != nil { if err := m.input.StateAccessor.Save(m.ctx, m.state); err != nil { m.err = err @@ -268,11 +318,26 @@ func (m *ModelResult) run() { return } } + // Run-level cancellation at the turn boundary: a canceled ctx (or + // Cancel()) fails the run with the cancellation cause before any + // further network dispatch — including a pre-canceled ctx on the + // very first request. + if err := m.runCanceledErr(); err != nil { + m.err = err + return + } + // Escalation recovery: one-turn overrides (model swap / forced + // advisor consult) applied to THIS follow-up dispatch only — req + // keeps the configured model/tools, so the next turn reverts. + sendReq := req + if turn > 0 { + m.applyDoomLoopEscalationOverrides(&sendReq) + } m.fullStream.Push(ResponseStreamEvent{Type: "turn.start", Turn: turn}) turnStartedAt := time.Now() - res, err := m.client.SendResponse(m.ctx, req, m.input.MetadataLevel, operations.WithSetHeaders(map[string]string{"x-openrouter-callmodel": "true"})) + res, err := m.client.SendResponse(m.ctx, sendReq, m.input.MetadataLevel, operations.WithSetHeaders(map[string]string{"x-openrouter-callmodel": "true"})) if err != nil { - m.err = err + m.err = m.dispatchErr(err) return } resp, events, err := consumeCreateResponse(res, func(ev components.StreamEvents) { @@ -285,7 +350,7 @@ func (m *ModelResult) run() { m.fullStream.Push(ResponseStreamEvent{Type: "response.event", Turn: turn, Event: &ev}) }) if err != nil { - m.err = err + m.err = m.dispatchErr(err) return } turnType := ModelCallTurnTypeToolRound @@ -330,7 +395,62 @@ func (m *ModelResult) run() { for _, item := range responseItems { m.newMessages.Push(item) } + // Doom-loop step checkpoint on the fresh response: text detectors + // (within-response repetition + cross-step streak) and server-tool + // fingerprints. + m.checkDoomLoopForResponse(resp) + // Follow-up responses re-enter upstream's loop, whose top checks + // the run signal before anything else — so a cancellation during a + // later turn (AfterTurn, hook work) fails the run even when that + // turn is text-only. The first response's no-tools path returns + // before upstream's loop, so turn 0 skips this. + if turn > 0 { + if err := m.runCanceledErr(); err != nil { + m.err = err + return + } + } if len(calls) == 0 { + // A text verdict on this response may have armed a stop — + // report the run as doom-stopped, not as a normal completion. + if m.doomLoopStop != nil { + m.doomStopped = true + } + m.state.Status = ConversationStatusComplete + break + } + // Run-level cancellation at the turn boundary, before this + // response's tool calls execute (upstream's throwIfAborted at the + // top of each loop iteration): a run canceled while the response + // was streaming, or from AfterTurn/hook work, runs no further tools. + if err := m.runCanceledErr(); err != nil { + m.err = err + return + } + // A doom-loop stop verdict (from this response's text or server + // tools, or restored for a decision-only resume) halts the run + // before any further spend. Seal first: this response's tool calls + // never execute, so they get synthesized halt outputs or the + // persisted history would carry dangling function_calls. + if m.doomLoopStop != nil { + // On the first response of a call (initial or resume + // continuation) upstream runs its approval check and its + // all-manual-calls check BEFORE the loop's doom-stop gate, so a + // pause takes precedence there; later turns hit the gate first. + resolved := map[string]bool{} + if turn == 0 { + paused, gateResolved, err := m.pauseBeforeFirstTurnDoomStop(calls, turn, &req) + if err != nil { + m.err = err + return + } + if paused { + break + } + resolved = gateResolved + } + m.sealDoomLoopStop(resp, resolved) + m.doomStopped = true m.state.Status = ConversationStatusComplete break } @@ -391,6 +511,15 @@ func (m *ModelResult) run() { if isPausedStatus(m.state.Status) { break } + // A doom stop armed while executing the halted turn's + // pending calls: seal and skip the final text-coercion + // request — no further model requests after a stop. + if m.doomLoopStop != nil { + m.sealDoomLoopStop(resp, outputCallIDs(outputs)) + m.doomStopped = true + m.state.Status = ConversationStatusComplete + break + } finalResp, err := m.sendFinalResponseRequest(req, resp, outputs, turn+1) if err != nil { m.err = err @@ -447,6 +576,20 @@ func (m *ModelResult) run() { } outputs = append(outputs, deniedOutputs...) if isPausedStatus(m.state.Status) { + // Queued doom-loop steer guidance is NOT injected here: the + // paused calls are dangling function_calls, and a user message + // between a function_call and its eventual output is invalid + // history. It persists in State.DoomLoop.PendingSteer and the + // resumed run delivers it at its next safe flush point. + break + } + // A doom-loop stop armed during this round halts the run before the + // next model request. Seal defensively: the condemned call already + // got a blocked error output, but other calls may be unresolved. + if m.doomLoopStop != nil { + m.sealDoomLoopStop(resp, outputCallIDs(outputs)) + m.doomStopped = true + m.state.Status = ConversationStatusComplete break } if len(outputs) > 0 { @@ -455,6 +598,11 @@ func (m *ModelResult) run() { // against a later, independent one (mirrors upstream). forceResumeCount = 0 } + // Inject queued doom-loop steer guidance so the very next request + // carries it. It lands in the input before this round's call/output + // items (the injection point predates the follow-up accumulation), + // mirroring upstream. + m.flushDoomLoopSteer(&req) base := requestInputItems(req.Input) base = append(base, responseItems...) base = append(base, outputs...) @@ -472,8 +620,9 @@ func (m *ModelResult) run() { // convenience field alongside `Output` items, so an empty `Output` array // is not on its own a reliable "invalid response" signal the way it is // upstream, where `output` is the sole content carrier. See - // upstreamer-changelog.md for this compatibility note. - if m.state.Status == ConversationStatusComplete && m.toolRoundsExecuted > 0 && !m.input.StrictFinalResponse && isEmptyFinalResponse(m.resp) { + // upstreamer-changelog.md for this compatibility note. A doom-stopped + // run makes no further model requests, so it never retries. + if !m.doomStopped && m.state.Status == ConversationStatusComplete && m.toolRoundsExecuted > 0 && !m.input.StrictFinalResponse && isEmptyFinalResponse(m.resp) { retryResp, err := m.retryCurrentRequest(req, len(m.steps)+1) if err != nil { m.err = err @@ -490,6 +639,7 @@ func (m *ModelResult) run() { } m.state = appendResponseItemsToState(m.state, retryResp, retryItems) } + m.syncDoomLoopState() if m.input.StateAccessor != nil { if err := m.input.StateAccessor.Save(m.ctx, m.state); err != nil { m.err = err @@ -582,6 +732,9 @@ func hasDecision(ids []string, call ParsedToolCall) bool { } func (m *ModelResult) prepareResumeRequest(req *components.ResponsesRequest) (bool, error) { + // The approved batch is one doom-loop round: N approved duplicates of + // the same call count once (restored streaks carry forward). + m.beginDoomLoopRound() unsent := append([]UnsentToolResult{}, m.state.UnsentToolResults...) remaining := []ParsedToolCall{} turn := len(m.steps) @@ -709,6 +862,8 @@ func (m *ModelResult) applyPermissionRequestGate(pending []ParsedToolCall) (appr } func (m *ModelResult) executeAutoApproveTools(calls []ParsedToolCall, turn int, req *components.ResponsesRequest) ([]UnsentToolResult, error) { + // Auto-approved batch = one doom-loop round. + m.beginDoomLoopRound() var results []UnsentToolResult for _, call := range calls { t := FindToolByName(m.input.Tools, call.Name) @@ -758,6 +913,9 @@ func (m *ModelResult) executeAutoApproveTools(calls []ParsedToolCall, turn int, // returns ErrHITLPause, the remaining (un-run) calls are left for resume and the // loop stops without sending an incomplete output set to the model. func (m *ModelResult) executeToolCallsForTurn(calls []ParsedToolCall, turn int, req *components.ResponsesRequest) ([]components.InputsUnion1, error) { + // One executed batch = one doom-loop round: identical calls in this + // batch count as ONE piece of loop evidence and share a decision. + m.beginDoomLoopRound() var outputs []components.InputsUnion1 for _, call := range calls { t := FindToolByName(m.input.Tools, call.Name) @@ -856,11 +1014,14 @@ func (m *ModelResult) sendFinalResponseRequest(req components.ResponsesRequest, base = append(base, components.CreateInputsUnion1EasyInputMessage(msg)) } finalReq.Input = openrouter.Pointer(components.CreateInputsUnionArrayOfInputsUnion1(base)) + if err := m.runCanceledErr(); err != nil { + return components.OpenResponsesResult{}, err + } m.fullStream.Push(ResponseStreamEvent{Type: "turn.start", Turn: turn}) startedAt := time.Now() res, err := m.client.SendResponse(m.ctx, finalReq, m.input.MetadataLevel, operations.WithSetHeaders(map[string]string{"x-openrouter-callmodel": "true"})) if err != nil { - return components.OpenResponsesResult{}, err + return components.OpenResponsesResult{}, m.dispatchErr(err) } finalResp, events, err := consumeCreateResponse(res, func(ev components.StreamEvents) { if ev.TextDeltaEvent != nil { @@ -869,7 +1030,7 @@ func (m *ModelResult) sendFinalResponseRequest(req components.ResponsesRequest, m.fullStream.Push(ResponseStreamEvent{Type: "response.event", Turn: turn, Event: &ev}) }) if err != nil { - return components.OpenResponsesResult{}, err + return components.OpenResponsesResult{}, m.dispatchErr(err) } m.emitPostModelCall(finalResp, startedAt, ModelCallTurnTypeFinal, turn) text := ExtractTextFromResponse(finalResp) @@ -891,11 +1052,14 @@ func (m *ModelResult) retryCurrentRequest(req components.ResponsesRequest, turn if len(retryReq.Tools) > 0 { retryReq.ToolChoice = noneToolChoice() } + if err := m.runCanceledErr(); err != nil { + return components.OpenResponsesResult{}, err + } m.fullStream.Push(ResponseStreamEvent{Type: "turn.start", Turn: turn}) startedAt := time.Now() res, err := m.client.SendResponse(m.ctx, retryReq, m.input.MetadataLevel, operations.WithSetHeaders(map[string]string{"x-openrouter-callmodel": "true"})) if err != nil { - return components.OpenResponsesResult{}, err + return components.OpenResponsesResult{}, m.dispatchErr(err) } retryResp, events, err := consumeCreateResponse(res, func(ev components.StreamEvents) { if delta, ok := textDeltaText(ev); ok { @@ -904,7 +1068,7 @@ func (m *ModelResult) retryCurrentRequest(req components.ResponsesRequest, turn m.fullStream.Push(ResponseStreamEvent{Type: "response.event", Turn: turn, Event: &ev}) }) if err != nil { - return components.OpenResponsesResult{}, err + return components.OpenResponsesResult{}, m.dispatchErr(err) } m.emitPostModelCall(retryResp, startedAt, ModelCallTurnTypeRetry, turn) text := ExtractTextFromResponse(retryResp) @@ -1232,6 +1396,15 @@ func latestUserStringMessage(items []components.InputsUnion1) (int, string, bool // would, not be treated as a per-call rejection. func (m *ModelResult) runToolWithHooks(t Tool, call ParsedToolCall, execCtx ToolExecuteContext) (effectiveCall ParsedToolCall, result ToolExecutionResult, blocked bool, fatalErr error) { effectiveCall = call + // Doom-loop checkpoint — BEFORE PreToolUse, on the arguments the MODEL + // issued (pre-mutation): a PreToolUse hook that rewrites input each call + // must not mask the model repeating itself. A block (or stop) verdict + // returns the same blocked shape as a PreToolUse block, so every caller + // synthesizes the same error output; PreToolUse deliberately does not + // fire for doom-blocked calls (nothing will execute). + if doomBlocked, reason := m.checkDoomLoopBeforeExecution(t, call); doomBlocked { + return effectiveCall, ToolExecutionResult{CallID: call.CallID, Name: call.Name, Error: errors.New(reason)}, true, nil + } if m.hooksManager == nil { res, err := ExecuteTool(m.ctx, t, effectiveCall, execCtx) if err != nil { @@ -1360,6 +1533,24 @@ func (m *ModelResult) State(ctx context.Context) (ConversationState, error) { m.ensure() return m.state, m.err } + +// DoomLoopVerdict returns the doom-loop verdict that stopped this run, or +// nil when the run was not stopped by doom-loop detection (including when +// detection is off). Blocks until the run settles (upstream +// getDoomLoopVerdict). For per-event observation use the DoomLoopDetected +// hook. +func (m *ModelResult) DoomLoopVerdict(ctx context.Context) (*DoomLoopVerdict, error) { + m.ensure() + if m.doomLoopStop == nil { + return nil, m.err + } + v := *m.doomLoopStop + return &v, m.err +} + +// Cancel cancels the run: the loop stops at the next turn boundary and any +// in-flight request is aborted (its ctx is canceled). Consumers observe +// context.Canceled. func (m *ModelResult) Cancel() { if m.cancel != nil { m.cancel() @@ -1526,3 +1717,465 @@ func encodeFunctionOutput(output any) (components.FunctionCallOutputItemOutputUn } return components.CreateFunctionCallOutputItemOutputUnion2Str(string(b)), nil } + +//#region Run cancellation + +// runCanceledErr is the per-dispatch fail-fast checkpoint: when the run's +// ctx is done (caller cancellation, deadline, or Cancel()) it returns the +// cancellation cause — context.Cause, so a caller using +// context.WithCancelCause sees its own reason (upstream's abort reason). +func (m *ModelResult) runCanceledErr() error { + if m.ctx.Err() == nil { + return nil + } + return context.Cause(m.ctx) +} + +// dispatchErr maps a dispatch/stream error observed after the run's ctx was +// canceled to the cancellation cause, so a stalled request aborted by +// cancellation fails with the reason rather than a transport artifact. +// Other errors pass through unchanged. +func (m *ModelResult) dispatchErr(err error) error { + if cause := m.runCanceledErr(); cause != nil { + if errors.Is(err, cause) { + return err + } + return fmt.Errorf("%w (%v)", cause, err) + } + return err +} + +//#endregion + +//#region Doom-loop engine + +// doomLoopDecision is the final decision for one (tool, fingerprint) in the +// current round. Action "" means proceed. +type doomLoopDecision struct { + action DoomLoopAction + message string +} + +// doomLoopEvaluation is one call to evaluate against the monitor. +type doomLoopEvaluation struct { + toolName string + keyMaterial any + // fallback is the identity to retry with when keyMaterial is + // unhashable; hasFallback false means "skip detection" instead. + fallback any + hasFallback bool + nonBlocking bool + detector DoomLoopDetectorKind + call *ParsedToolCall +} + +// beginDoomLoopRound advances the round counter at every execution-batch +// boundary (tool round, auto-approve batch, approved-on-resume batch). +func (m *ModelResult) beginDoomLoopRound() { + if m.doomLoopMonitor == nil { + return + } + m.doomLoopRound++ + m.doomLoopRoundDecisions = map[string]doomLoopDecision{} +} + +// evaluateDoomLoop records one call with the monitor and applies the +// fallback invariant: unhashable key material degrades to the fallback +// identity; if that also fails (or there is none), detection is skipped for +// the call — never failing the call itself. Side effects (steer queue, +// escalation latch, stop arm) are applied here so every checkpoint shares +// them. Tool execution is sequential in this port, so evaluations record in +// model-emission order without upstream's promise chain. +func (m *ModelResult) evaluateDoomLoop(ev doomLoopEvaluation) doomLoopDecision { + monitor := m.doomLoopMonitor + if monitor == nil { + return doomLoopDecision{} + } + opts := RecordToolCallOptions{NonBlocking: ev.nonBlocking, Detector: ev.detector} + record, err := monitor.RecordToolCall(ev.toolName, ev.keyMaterial, m.doomLoopRound, opts) + if err != nil { + if !ev.hasFallback { + log.Printf("[DoomLoop] could not fingerprint call to %q; skipping detection for this call: %v", ev.toolName, err) + return doomLoopDecision{} + } + log.Printf("[DoomLoop] could not fingerprint call to %q; falling back to full arguments: %v", ev.toolName, err) + record, err = monitor.RecordToolCall(ev.toolName, ev.fallback, m.doomLoopRound, opts) + if err != nil { + log.Printf("[DoomLoop] fallback identity for %q also unhashable; skipping detection for this call: %v", ev.toolName, err) + return doomLoopDecision{} + } + } + // Same (tool, fingerprint) already decided this round: reuse the + // decision — no second streak increment, no second hook emission. + key := ev.toolName + "\n" + record.Fingerprint + if record.DuplicateInRound { + return m.doomLoopRoundDecisions[key] + } + if record.Verdict == nil { + m.doomLoopRoundDecisions[key] = doomLoopDecision{} + return doomLoopDecision{} + } + action := m.applyDoomLoopVerdict(*record.Verdict, ev.call) + decision := doomLoopDecision{action: action, message: record.Verdict.Message} + m.doomLoopRoundDecisions[key] = decision + m.applyDoomLoopSideEffects(action, *record.Verdict) + return decision +} + +// checkDoomLoopBeforeExecution is the per-call checkpoint run before +// PreToolUse/execution. It resolves the tool's loop key and evaluates the +// call. block — and stop, which also refuses the condemned call — return +// blocked with the verdict's explanation. +func (m *ModelResult) checkDoomLoopBeforeExecution(t Tool, call ParsedToolCall) (bool, string) { + if m.doomLoopMonitor == nil { + return false, "" + } + args := toolInputMap(call) + loopKey := loopKeyOf(t) + resolution := ResolveLoopKeyMaterial(loopKey, args) + switch resolution.Kind { + case LoopKeyResolutionExempt: + return false, "" + case LoopKeyResolutionFallback: + log.Printf("[DoomLoop] tool %q: %s", call.Name, resolution.Warning) + } + // A computed identity (field subset / function result) can fall back to + // the full arguments; the full arguments themselves have no fallback. + hasFallback := loopKey != nil && resolution.Kind == LoopKeyResolutionKey + decision := m.evaluateDoomLoop(doomLoopEvaluation{toolName: call.Name, keyMaterial: resolution.KeyMaterial, fallback: args, hasFallback: hasFallback, detector: DoomLoopDetectorToolFingerprint, call: &call}) + if decision.action == DoomLoopActionBlock || decision.action == DoomLoopActionStop { + reason := decision.message + if reason == "" { + reason = fmt.Sprintf("Doom loop suspected: tool \"%s\" is repeating identical calls. Repeating the call will not change the result. Take a different approach.", call.Name) + } + return true, reason + } + return false, "" +} + +// applyDoomLoopVerdict emits DoomLoopDetected and resolves the final +// action. The LAST non-empty OverrideAction wins. Non-tool verdicts +// downgrade a block override to observe; an escalate override without +// config/budget downgrades to observe (never silently to something +// stronger). A hook error is logged, never fails the run. +func (m *ModelResult) applyDoomLoopVerdict(verdict DoomLoopVerdict, call *ParsedToolCall) DoomLoopAction { + action := verdict.Action + if m.hooksManager != nil { + payload := DoomLoopDetectedPayload{Detector: verdict.Detector, Action: verdict.Action, Streak: verdict.Streak, Fingerprint: verdict.Fingerprint, ToolName: verdict.ToolName, Message: verdict.Message} + if call != nil { + if args, ok := call.Arguments.(map[string]any); ok { + payload.ToolInput = args + } + } + emit, err := m.hooksManager.EmitDoomLoopDetected(payload, m.hookEmitOptions(verdict.ToolName)) + if err != nil { + log.Printf("[DoomLoopDetected] hook error: %v", err) + } + for _, r := range emit.Results { + if r.OverrideAction != "" { + action = r.OverrideAction + } + } + } + if _, known := doomLoopActionStrength[action]; !known { + action = verdict.Action + } + if verdict.Detector != DoomLoopDetectorToolFingerprint && action == DoomLoopActionBlock { + action = DoomLoopActionObserve + } + if action == DoomLoopActionEscalate && (m.doomLoopMonitor == nil || !m.doomLoopMonitor.CanEscalate()) { + action = DoomLoopActionObserve + } + return action +} + +// applyDoomLoopSideEffects applies a resolved action's shared side effect: +// queue steer guidance, latch an escalation for the next request (once per +// window, with an accompanying notice), or arm the stop verdict. block has +// no shared side effect — the checkpoint synthesizes the blocked output. +func (m *ModelResult) applyDoomLoopSideEffects(action DoomLoopAction, verdict DoomLoopVerdict) { + switch action { + case DoomLoopActionSteer: + m.queueDoomLoopSteer(verdict.Message) + case DoomLoopActionEscalate: + if m.pendingDoomLoopEscalation == nil { + v := verdict + m.pendingDoomLoopEscalation = &v + m.queueDoomLoopSteer(verdict.Message + " An escalated turn follows: use the additional guidance to change course.") + } + case DoomLoopActionStop: + v := verdict + m.doomLoopStop = &v + } +} + +// doomLoopAdvisorInstructions is the default escalation-advisor prompt. +func doomLoopAdvisorInstructions(verdict DoomLoopVerdict) string { + return "You are an escalation advisor. The executing model appears stuck in a loop: " + + verdict.Message + " Diagnose why its approach is failing and give concrete, " + + "specific instructions for a DIFFERENT approach. Do not restate the problem." +} + +// applyDoomLoopEscalationOverrides consumes a pending escalation into +// ONE-TURN overrides on req (a per-dispatch copy): +// +// - Model replaces the request's model for this dispatch only and clears +// any Models fallback list. +// - Advisor appends an openrouter:advisor server tool (transcript +// forwarded, loop-diagnosing instructions) and pins ToolChoice to it +// via allowed_tools/required. +// +// Burns one unit of the escalation budget at APPLICATION time. +func (m *ModelResult) applyDoomLoopEscalationOverrides(req *components.ResponsesRequest) { + verdict := m.pendingDoomLoopEscalation + m.pendingDoomLoopEscalation = nil + config := m.doomLoopEscalation + if verdict == nil || config == nil || m.doomLoopMonitor == nil || !m.doomLoopMonitor.CanEscalate() { + return + } + m.doomLoopMonitor.ConsumeEscalation() + if config.Model != "" { + model := config.Model + req.Model = &model + req.Models = nil + } + if config.hasAdvisor() { + params := components.AdvisorServerToolConfig{} + if config.AdvisorParameters != nil { + params = *config.AdvisorParameters + } + if params.ForwardTranscript == nil { + forward := true + params.ForwardTranscript = &forward + } + if params.Instructions == nil { + instructions := doomLoopAdvisorInstructions(*verdict) + params.Instructions = &instructions + } + tools := make([]components.ResponsesRequestToolUnion, 0, len(req.Tools)+1) + tools = append(tools, req.Tools...) + tools = append(tools, components.CreateResponsesRequestToolUnionOpenrouterAdvisor(components.AdvisorServerToolOpenRouter{Parameters: ¶ms, Type: components.AdvisorServerToolOpenRouterTypeOpenrouterAdvisor})) + req.Tools = tools + choice := components.CreateOpenAIResponsesToolChoiceUnionToolChoiceAllowed(components.ToolChoiceAllowed{ + Type: components.ToolChoiceAllowedTypeAllowedTools, + Mode: components.CreateModeModeRequired(components.ModeRequiredRequired), + Tools: []map[string]any{{"type": string(components.AdvisorServerToolOpenRouterTypeOpenrouterAdvisor)}}, + }) + req.ToolChoice = &choice + } +} + +// serverToolIdentityExcluded are the volatile / result-payload fields +// excluded from a server-tool item's echoed call identity. +var serverToolIdentityExcluded = map[string]bool{"id": true, "type": true, "status": true, "output": true, "result": true, "results": true, "error": true} + +// serverToolIdentity extracts the call identity echoed on a server-tool +// output item (a web-search item carries its action/query, an MCP item its +// name/arguments, ...), dropping ids, status, and result payloads so +// identical repeated requests fingerprint identically. Returns the item +// type and ok=false for client item types (message/reasoning/function_call) +// or items with nothing identifying left. +// +// Fields are taken from the item's wire JSON (snake_case keys), so +// server-tool fingerprints are stable within this port but not +// byte-identical to the TypeScript port's (which reads SDK-normalized +// camelCase fields); server-tool fingerprints are not part of the +// cross-port vector contract. +func serverToolIdentity(item components.OutputItems) (string, map[string]any, bool) { + b, err := json.Marshal(item) + if err != nil { + return "", nil, false + } + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil || raw == nil { + return "", nil, false + } + itemType, _ := raw["type"].(string) + switch itemType { + case "", "message", "reasoning", "function_call": + return itemType, nil, false + } + identity := map[string]any{} + for k, v := range raw { + if serverToolIdentityExcluded[k] { + continue + } + identity[k] = v + } + if len(identity) == 0 { + return itemType, nil, false + } + return itemType, identity, true +} + +// checkDoomLoopForResponse is the step-level checkpoint on a fresh model +// response: text detectors first, then server-tool fingerprints (post- +// execution, so block is meaningless: observe/steer/escalate/stop). +func (m *ModelResult) checkDoomLoopForResponse(resp components.OpenResponsesResult) { + if m.doomLoopMonitor == nil { + return + } + verdict, err := m.doomLoopMonitor.RecordAssistantText(ExtractTextFromResponse(resp)) + if err != nil { + log.Printf("[DoomLoop] text checkpoint failed; skipping for this response: %v", err) + verdict = nil + } + if verdict != nil { + action := m.applyDoomLoopVerdict(*verdict, nil) + m.applyDoomLoopSideEffects(action, *verdict) + } + for _, item := range resp.Output { + itemType, identity, ok := serverToolIdentity(item) + if !ok { + continue + } + m.evaluateDoomLoop(doomLoopEvaluation{toolName: "server:" + itemType, keyMaterial: identity, nonBlocking: true, detector: DoomLoopDetectorServerToolFingerprint}) + } +} + +// queueDoomLoopSteer queues steer guidance, deduplicating identical messages. +func (m *ModelResult) queueDoomLoopSteer(message string) { + for _, existing := range m.pendingDoomLoopSteer { + if existing == message { + return + } + } + m.pendingDoomLoopSteer = append(m.pendingDoomLoopSteer, message) +} + +// flushDoomLoopSteer injects queued steer guidance as one user message +// (the Stop hook's AppendPrompt path) before the next model request. +func (m *ModelResult) flushDoomLoopSteer(req *components.ResponsesRequest) { + if len(m.pendingDoomLoopSteer) == 0 { + return + } + prompt := strings.Join(m.pendingDoomLoopSteer, "\n") + m.pendingDoomLoopSteer = nil + m.injectAppendPromptMessage(req, prompt) +} + +// sealDoomLoopStop keeps persisted history well-formed when a doom stop +// halts the run: every function_call in resp without an output yet gets a +// synthesized halt-error output appended to state, so a resumed +// conversation never sends a dangling function_call. +func (m *ModelResult) sealDoomLoopStop(resp components.OpenResponsesResult, resolved map[string]bool) { + haltMessage := "Run halted by doom-loop detection." + if m.doomLoopStop != nil { + haltMessage = "Run halted by doom-loop detection: " + m.doomLoopStop.Message + } + var halts []UnsentToolResult + for _, call := range ExtractToolCallsFromResponse(resp) { + if resolved[call.CallID] { + continue + } + halts = append(halts, CreateRejectedResult(call, haltMessage)) + } + if len(halts) == 0 { + return + } + items, err := UnsentResultsToAPIFormatWithError(halts) + if err != nil { + log.Printf("[DoomLoop] could not seal halted calls: %v", err) + return + } + m.state = AppendToMessages(m.state, items...) +} + +// outputCallIDs returns the call ids that have a function_call_output in items. +func outputCallIDs(items []components.InputsUnion1) map[string]bool { + ids := map[string]bool{} + for _, item := range items { + if item.FunctionCallOutputItem != nil { + ids[item.FunctionCallOutputItem.CallID] = true + } + } + return ids +} + +// syncDoomLoopState writes the detector snapshot plus the engine-owned +// fields (stop verdict, queued steer) into m.state.DoomLoop. A run with +// detection off leaves any persisted blob untouched (inert). +func (m *ModelResult) syncDoomLoopState() { + if m.doomLoopMonitor == nil { + return + } + snapshot := m.doomLoopMonitor.State() + if m.doomLoopStop != nil { + v := *m.doomLoopStop + snapshot.StopVerdict = &v + } + if len(m.pendingDoomLoopSteer) > 0 { + snapshot.PendingSteer = append([]string{}, m.pendingDoomLoopSteer...) + } + m.state.DoomLoop = &snapshot +} + +// restoreDoomLoopState rehydrates detector state from the loaded +// conversation: streaks and queued steer always; the stop verdict only for +// a decision-only resume (ApproveToolCalls/RejectToolCalls) — a fresh +// conversational turn is new information and clears it. Streaks are kept +// either way, so renewed repetition re-condemns quickly. +func (m *ModelResult) restoreDoomLoopState() { + if m.doomLoopMonitor == nil || m.state.DoomLoop == nil { + return + } + persisted := m.state.DoomLoop + m.doomLoopMonitor.Restore(persisted) + for _, msg := range persisted.PendingSteer { + m.queueDoomLoopSteer(msg) + } + if persisted.StopVerdict != nil && (len(m.input.ApproveToolCalls) > 0 || len(m.input.RejectToolCalls) > 0) { + v := *persisted.StopVerdict + m.doomLoopStop = &v + } +} + +//#endregion + +// pauseBeforeFirstTurnDoomStop mirrors upstream's ordering on the first +// response of a call: the approval check (PermissionRequest gate, then the +// mixed-turn auto-approve batch and awaiting_approval pause) and the +// all-calls-are-client-tools pause run before the doom-stop gate. Returns +// paused=true when the run paused instead of doom-stopping, plus the call +// ids the PermissionRequest gate already resolved (denied outputs are in +// state, so the seal must not duplicate them). +func (m *ModelResult) pauseBeforeFirstTurnDoomStop(calls []ParsedToolCall, turn int, req *components.ResponsesRequest) (bool, map[string]bool, error) { + resolved := map[string]bool{} + if len(m.input.Tools) == 0 { + return false, resolved, nil + } + approved, pending, err := PartitionToolCalls(m.ctx, m.input.Tools, calls, BuildTurnContext(nil, turn, req), m.input.Approval) + if err != nil { + return false, nil, err + } + if len(pending) > 0 { + gateApproved, stillPending, gateDenied, err := m.applyPermissionRequestGate(pending) + if err != nil { + return false, nil, err + } + for id := range outputCallIDs(gateDenied) { + resolved[id] = true + } + if len(stillPending) > 0 { + approved = append(approved, gateApproved...) + unsent, err := m.executeAutoApproveTools(approved, turn, req) + if err != nil { + return false, nil, err + } + m.state.UnsentToolResults = append(m.state.UnsentToolResults, unsent...) + m.state.PendingToolCalls = stillPending + m.state.Status = ConversationStatusAwaitingApproval + return true, nil, nil + } + } + for _, call := range calls { + if t := FindToolByName(m.input.Tools, call.Name); t != nil && IsAutoResolvableTool(t) { + return false, resolved, nil + } + } + // Every call is a manual/client tool (or unknown): surface them as + // pending client tools rather than sealing them with halt outputs. + m.state.PendingToolCalls = append(m.state.PendingToolCalls, calls...) + m.state.Status = ConversationStatusAwaitingClientTools + return true, nil, nil +} diff --git a/run_cancellation_test.go b/run_cancellation_test.go new file mode 100644 index 0000000..1a80e01 --- /dev/null +++ b/run_cancellation_test.go @@ -0,0 +1,198 @@ +package agent + +// Run-level cancellation, ported from upstream tests/unit/run-cancellation.test.ts. +// +// Upstream adds a `signal` option on callModel; in Go the run's +// context.Context already plays that role (idiomatic divergence #1), so the +// behaviors under test are the ones upstream's signal guarantees: a +// pre-canceled run fails before any dispatch, cancellation between requests +// stops the loop at the next turn boundary with the cancellation cause, a +// cancellation mid-request aborts the in-flight dispatch, and a per-request +// timeout still bounds each request while the run ctx is cancelable. + +import ( + "context" + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/OpenRouterTeam/go-sdk/models/components" + "github.com/OpenRouterTeam/go-sdk/models/operations" +) + +// blockingSender blocks every dispatch until the request ctx is done, after +// signaling that the dispatch started (a stalled provider). +type blockingSender struct { + started chan struct{} + once sync.Once + mu sync.Mutex + calls int +} + +func (b *blockingSender) SendResponse(ctx context.Context, _ components.ResponsesRequest, _ *components.MetadataLevel, _ ...operations.Option) (*operations.CreateResponsesResponse, error) { + b.mu.Lock() + b.calls++ + b.mu.Unlock() + b.once.Do(func() { close(b.started) }) + <-ctx.Done() + return nil, ctx.Err() +} + +func TestRunCancellationPreCanceledFailsBeforeDispatch(t *testing.T) { + cause := errors.New("user aborted") + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(cause) + sender := scripted(completedResponse("never")) + result, err := CallModel(ctx, sender, CallModelInput{Model: "test-model", Input: "hi"}) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); !errors.Is(err, cause) { + t.Fatalf("a pre-canceled run must fail with the cancellation cause, got %v", err) + } + if sender.calls != 0 { + t.Fatalf("no network dispatch may happen; got %d", sender.calls) + } +} + +func TestRunCancellationBetweenRequestsStopsLoop(t *testing.T) { + cause := errors.New("stop after the tool") + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + var s doomScript + tool := MustNewTool(ToolConfig[searchArgs]{Name: "web_search", Execute: func(context.Context, searchArgs, ToolExecuteContext) (any, error) { + cancel(cause) // cancellation lands during tool execution, between requests + return "r", nil + }}) + sender := scripted(s.toolTurn("web_search", searchArgs{"x"}), s.textTurn("never fetched")) + result, err := CallModel(ctx, sender, CallModelInput{Model: "test-model", Input: "hi", Tools: []Tool{tool}}) + if err != nil { + t.Fatal(err) + } + text, done := result.TextStream(context.Background()) + for range text { + } + if err := done(); !errors.Is(err, cause) { + t.Fatalf("stream consumers must see the cancellation cause, got %v", err) + } + if _, err := result.Response(context.Background()); !errors.Is(err, cause) { + t.Fatalf("Response must surface the cancellation cause, got %v", err) + } + if sender.calls != 1 { + t.Fatalf("the loop must stop at the next turn boundary; requests=%d", sender.calls) + } +} + +func TestRunCancellationAbortsInFlightRequest(t *testing.T) { + for _, viaMethod := range []bool{false, true} { + cause := errors.New("stalled provider") + ctx, cancel := context.WithCancelCause(context.Background()) + sender := &blockingSender{started: make(chan struct{})} + result, err := CallModel(ctx, sender, CallModelInput{Model: "test-model", Input: "hi"}) + if err != nil { + t.Fatal(err) + } + errCh := make(chan error, 1) + go func() { + _, err := result.Text(context.Background()) + errCh <- err + }() + <-sender.started + if viaMethod { + result.Cancel() + } else { + cancel(cause) + } + err = <-errCh + cancel(nil) + want := cause + if viaMethod { + want = context.Canceled + } + if !errors.Is(err, want) { + t.Fatalf("viaMethod=%v: in-flight dispatch must fail fast with %v, got %v", viaMethod, want, err) + } + } +} + +// hangingHTTPClient never answers: it waits for the request ctx to end. +type hangingHTTPClient struct{} + +func (hangingHTTPClient) Do(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() +} + +func TestRunCancellationPerRequestTimeoutComposesWithRunContext(t *testing.T) { + // The Go SDK applies its timeout per Send by deriving a fresh + // context.WithTimeout from the run ctx, so a cancelable run ctx never + // disables the per-request bound (the TypeScript SDK bug upstream works + // around does not exist here). + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := NewOpenRouter(OpenRouterOptions{SDK: SDKOptions{APIKey: "test-key", HTTPClient: hangingHTTPClient{}, Timeout: 20 * time.Millisecond}}) + result, err := client.CallModel(ctx, CallModelInput{Model: "test-model", Input: "hi"}) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("the per-request timeout must bound the request, got %v", err) + } + if ctx.Err() != nil { + t.Fatal("the run ctx itself was not canceled") + } +} + +// A run canceled after a response lands (here from AfterTurn) runs none of +// that response's tools: upstream checks the signal at the top of each loop +// iteration, before tool execution. +func TestRunCancellationBeforeToolExecution(t *testing.T) { + cause := errors.New("canceled after the response") + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + var s doomScript + executions := 0 + tool := MustNewTool(ToolConfig[searchArgs]{Name: "web_search", Execute: func(context.Context, searchArgs, ToolExecuteContext) (any, error) { + executions++ + return "r", nil + }}) + sender := scripted(s.toolTurn("web_search", searchArgs{"x"}), s.textTurn("never fetched")) + result, err := CallModel(ctx, sender, CallModelInput{Model: "test-model", Input: "hi", Tools: []Tool{tool}, AfterTurn: func(context.Context, TurnContext, StepResult) error { + cancel(cause) + return nil + }}) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); !errors.Is(err, cause) { + t.Fatalf("want the cancellation cause, got %v", err) + } + if executions != 0 || sender.calls != 1 { + t.Fatalf("no tool may run after cancellation: executions=%d requests=%d", executions, sender.calls) + } +} + +// A cancellation during a later, text-only turn still fails the run, as +// upstream's loop-top signal check does on every follow-up response. +func TestRunCancellationOnTextOnlyFollowUpTurn(t *testing.T) { + cause := errors.New("canceled on the final turn") + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + var s doomScript + tool := MustNewTool(ToolConfig[searchArgs]{Name: "web_search", Execute: func(context.Context, searchArgs, ToolExecuteContext) (any, error) { return "r", nil }}) + sender := scripted(s.toolTurn("web_search", searchArgs{"x"}), s.textTurn("final")) + result, err := CallModel(ctx, sender, CallModelInput{Model: "test-model", Input: "hi", Tools: []Tool{tool}, AfterTurn: func(_ context.Context, turn TurnContext, _ StepResult) error { + if turn.NumberOfTurns == 1 { + cancel(cause) + } + return nil + }}) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); !errors.Is(err, cause) { + t.Fatalf("want the cancellation cause, got %v", err) + } +} diff --git a/testdata/doom-loop-fingerprints.json b/testdata/doom-loop-fingerprints.json new file mode 100644 index 0000000..db2c5e5 --- /dev/null +++ b/testdata/doom-loop-fingerprints.json @@ -0,0 +1,132 @@ +{ + "description": "Cross-port doom-loop fingerprint conformance vectors. fingerprint = sha256(utf8(toolName + \"\\n\" + jcs(keyMaterial))) for tool calls, sha256(utf8(jcs(keyMaterial))) for bare key material. jcs = RFC 8785 canonical JSON. Ports MUST use an RFC 8785 implementation (pip jcs, cyberphone/json-canonicalization), NOT their stdlib JSON serializer. All hex lowercase.", + "toolCallVectors": [ + { + "name": "basic bash identity", + "toolName": "bash", + "keyMaterial": { + "command": "ls -la", + "cwd": "/tmp" + }, + "jcs": "{\"command\":\"ls -la\",\"cwd\":\"/tmp\"}", + "fingerprint": "c3430466c9a1a7c11c8e23328fcf3057189c44d86839527ac6e61bb4574410d4" + }, + { + "name": "key order insensitivity (same fingerprint as basic bash identity)", + "toolName": "bash", + "keyMaterial": { + "cwd": "/tmp", + "command": "ls -la" + }, + "jcs": "{\"command\":\"ls -la\",\"cwd\":\"/tmp\"}", + "fingerprint": "c3430466c9a1a7c11c8e23328fcf3057189c44d86839527ac6e61bb4574410d4" + }, + { + "name": "empty object (repeated empty call)", + "toolName": "list_tasks", + "keyMaterial": {}, + "jcs": "{}", + "fingerprint": "b84a3e324e9dc95335f72cfee5e5898465349be72cb1348fe7927c49c8d65d92" + }, + { + "name": "non-ascii + emoji", + "toolName": "web_search", + "keyMaterial": { + "query": "café 🎉" + }, + "jcs": "{\"query\":\"café 🎉\"}", + "fingerprint": "a8bd7e02e874cd1d68db436c3228a42ed2d656ddd30d3195db0e72378479b160" + }, + { + "name": "negative zero collapses to 0 (JCS)", + "toolName": "calc", + "keyMaterial": { + "value": 0 + }, + "jcs": "{\"value\":0}", + "fingerprint": "2c0292f2f8e9099f2879c189b8bd0d61fdc24dea98f06ec1d96f17f8ccb86f7d" + }, + { + "name": "large magnitude number 1e21 (JCS exponent form)", + "toolName": "calc", + "keyMaterial": { + "value": 1e+21 + }, + "jcs": "{\"value\":1e+21}", + "fingerprint": "53f17cc32c78fee57f8e6aba17c4d5f75ca2aa5a1d06d359880c584993dcaaff" + }, + { + "name": "nested structures with arrays", + "toolName": "query", + "keyMaterial": { + "filter": { + "tags": [ + "b", + "a" + ], + "depth": 2 + }, + "sort": null + }, + "jcs": "{\"filter\":{\"depth\":2,\"tags\":[\"b\",\"a\"]},\"sort\":null}", + "fingerprint": "b42e583c626c3b2bcbd5adf86b7099d784ef15398f2e0c530262372e73850f4e" + }, + { + "name": "lone surrogate escapes as \\ud800 (JSON.stringify)", + "toolName": "echo", + "keyMaterial": { + "s": "\ud800" + }, + "jcs": "{\"s\":\"\\ud800\"}", + "fingerprint": "9d901512fe3c9139d48aeaec555bc91c5b8069950bc17a6d014c85bf03c0d8eb" + }, + { + "name": "unicode normalization NOT applied (NFC vs NFD differ)", + "toolName": "echo", + "keyMaterial": { + "s": "é" + }, + "jcs": "{\"s\":\"é\"}", + "fingerprint": "a281d1b32556677dee1b6039b78cde21229a6777874c6dc8eafc4bff64058568" + }, + { + "name": "string primitive key material", + "toolName": "web_search", + "keyMaterial": "openrouter agent sdk", + "jcs": "\"openrouter agent sdk\"", + "fingerprint": "dd066597b3c639cd2c905159c3ee5bda98c29add4deef7d20f723583c1e7e177" + } + ], + "keyMaterialVectors": [ + { + "name": "plain phrase", + "keyMaterial": "I am stuck.", + "jcs": "\"I am stuck.\"", + "fingerprint": "37c38ab53f4c3570d8baf87392cb035ef8b2ad98ca1c1e083ea84184173911aa" + }, + { + "name": "whitespace-normalized cross-step text", + "keyMaterial": "Retrying the same plan.", + "jcs": "\"Retrying the same plan.\"", + "fingerprint": "764fb75d0e16f75f0265b68d579c571e704b6ce9ea2b5dea784200cb701fe3df" + } + ], + "rejected": [ + { + "name": "bigint", + "reason": "RFC 8785 has no representation; canonicalize throws, engine falls back to full arguments" + }, + { + "name": "NaN / Infinity", + "reason": "non-finite numbers unrepresentable; throws, engine falls back" + }, + { + "name": "circular reference", + "reason": "throws, engine falls back" + }, + { + "name": "nesting > 64 levels", + "reason": "depth cap; throws, engine falls back" + } + ] +} diff --git a/tool.go b/tool.go index 76f9154..030f81f 100644 --- a/tool.go +++ b/tool.go @@ -27,6 +27,12 @@ type ToolConfig[In any] struct { Approval ToolApprovalCheck OnToolCalled func(context.Context, In, ToolExecuteContext) (any, bool, error) OnResponseReceived func(context.Context, any, ToolExecuteContext) (any, error) + // LoopKey declares this tool's doom-loop call identity (upstream + // `loopKey`): LoopKeyFunc (computed key material; nil exempts a call), + // LoopKeyFields (declarative field subset), or LoopKeyExempt (static + // exemption). nil: the full arguments object is the identity. Only + // consulted when CallModelInput.DoomLoop is set. + LoopKey *ToolLoopKey } type TypedTool[In any] struct { @@ -89,6 +95,8 @@ func (t *TypedTool[In]) RequiresApproval(ctx context.Context, call ParsedToolCal return t.config.RequireApproval, nil } +func (t *TypedTool[In]) toolLoopKey() *ToolLoopKey { return t.config.LoopKey } + func (t *TypedTool[In]) HandleResponseReceived(ctx context.Context, output any, execCtx ToolExecuteContext) (any, error) { if t.config.OnResponseReceived == nil { return output, nil @@ -254,10 +262,22 @@ func deepCopyJSONValue(v any) any { // unmarked tool. Execute is forwarded explicitly (rather than inherited via // embedding, which only promotes the narrow Tool interface) so a marked // executable tool keeps executing normally. -type mcpBrand struct{ Tool } +type mcpBrand struct { + Tool + loopKey *ToolLoopKey +} func (b mcpBrand) isMcpTool() {} +// toolLoopKey returns the MarkMcp-attached loop key, else the wrapped +// tool's own declaration. +func (b mcpBrand) toolLoopKey() *ToolLoopKey { + if b.loopKey != nil { + return b.loopKey + } + return loopKeyOf(b.Tool) +} + func (b mcpBrand) Execute(ctx context.Context, raw json.RawMessage, execCtx ToolExecuteContext) (ToolExecutionResult, error) { exec, ok := b.Tool.(ToolWithExecute) if !ok { @@ -275,8 +295,26 @@ type mcpMarked interface{ isMcpTool() } // classification (and downstream Source discrimination on // ToolExecutionResult/ToolStreamEvent) now identifies it as MCP-originated. // Intended for use by an MCP integration package that wraps remote tools. -func MarkMcp(t Tool) Tool { - return mcpBrand{Tool: t} +// +// An optional MarkMcpOptions.LoopKey attaches a doom-loop identity to the +// wrapped tool — the only injection point for MCP tools, whose remote +// definitions cannot carry client-side functions. Prefer LoopKeyFields +// (data, not code). Ignored for server tools, matching upstream's +// client-tool-only attachment. +func MarkMcp(t Tool, opts ...MarkMcpOptions) Tool { + brand := mcpBrand{Tool: t} + for _, o := range opts { + if o.LoopKey != nil && IsClientTool(t) { + brand.loopKey = o.LoopKey + } + } + return brand +} + +// MarkMcpOptions configures MarkMcp. +type MarkMcpOptions struct { + // LoopKey attaches a doom-loop identity to the wrapped tool. + LoopKey *ToolLoopKey } // IsMcpTool reports whether tool carries the additive MCP brand (see MarkMcp). diff --git a/tool_types.go b/tool_types.go index b17ebd0..1a3d5e3 100644 --- a/tool_types.go +++ b/tool_types.go @@ -219,9 +219,13 @@ type ConversationState struct { UnsentToolResults []UnsentToolResult PartialResponse *PartialResponse InterruptedBy *string - Status ConversationStatus - CreatedAt int64 - UpdatedAt int64 + // DoomLoop is the doom-loop detector state (see CallModelInput.DoomLoop), + // persisted so repetition streaks survive serialize → resume. nil when + // detection is off. Additive within ConversationStateVersion 1. + DoomLoop *DoomLoopSerializedState `json:",omitempty"` + Status ConversationStatus + CreatedAt int64 + UpdatedAt int64 } type StateAccessor interface { diff --git a/upstreamer-changelog.md b/upstreamer-changelog.md index df94e6d..0fa1993 100644 --- a/upstreamer-changelog.md +++ b/upstreamer-changelog.md @@ -2,13 +2,141 @@ ## Unreleased +### Doom-loop detection (ported from `@openrouter/agent` work after 0.8.0, not yet in an upstream release) + +- Added opt-in doom-loop detection via `CallModelInput.DoomLoop`. It catches + runs that stop making progress while still spending: the model re-issuing the + same tool call with identical arguments in consecutive rounds (including + repeated empty `{}` calls), repeating identical server-tool requests (for + example `web_search_call`, detected after the response lands), or emitting the + same text over and over, either within one response or across turns. + `&agent.DoomLoopConfig{}` turns on the defaults. `nil` (the default) leaves it off. +- Detection is deterministic and goes through a graduated ladder (`DoomLoopLadder`): + `observe` emits the new `DoomLoopDetected` hook, `steer` injects corrective + guidance before the next request, `escalate` runs the next turn with more help, + `block` refuses the call before it executes and returns an explanatory tool + error, and `stop` halts before any further model request. The defaults are + observe at 2, block at 3, and stop at 6. When a stop happens, unresolved calls + in the final turn get synthesized halt-error outputs so the persisted history + stays well-formed, `SessionEnd` reports the new `SessionEndReasonDoomLoop` + (`"doom_loop"`), and `ModelResult.DoomLoopVerdict(ctx)` returns the verdict that + stopped the run. +- `escalate` rung: set `DoomLoopConfig.Escalation` to run the next turn on a + stronger `Model` (a one-turn override that reverts automatically), to force an + `openrouter:advisor` consult (`Advisor: true`, or `AdvisorParameters` for custom + advisor settings), or both. The advisor tool is appended with + `forward_transcript` and loop-diagnosing instructions, and `tool_choice` is + pinned to it. A notice naming the loop goes with the escalated turn. Escalations + are capped by `MaxEscalations` (default 2). Budget is consumed only when an + escalation is actually applied, the count persists across resumes, and two + detectors firing in the same window escalate only once. A spent or + unconfigured escalation falls through to the weaker rungs. +- `DoomLoopDetected` handlers can override the action for each event with + `DoomLoopDetectedResult.OverrideAction` (the last override wins). A `block` + override on a text or server-tool verdict downgrades to `observe`, and an + `escalate` override without config or remaining budget also downgrades to + `observe`. The hook can be registered with `HooksManager.OnDoomLoopDetected` or + `InlineHookConfig.DoomLoopDetected`. +- Tools can declare what makes two calls "the same" with `ToolConfig.LoopKey`: + `agent.LoopKeyFields("command", "cwd")` (a declarative field subset), + `agent.LoopKeyFunc(func(in Args) (any, error) {...})` (computed key material; + return `nil` to exempt that call), or `agent.LoopKeyExempt()` (the tool is never + checked). Without a declaration, the full arguments object is the identity. + `MarkMcp` takes an optional `MarkMcpOptions{LoopKey: ...}` for MCP-wrapped + tools. A loop key that errors or panics falls back to the full arguments, so + detection never fails a run. +- Streaks are round-scoped: identical calls fanned out in parallel within one + turn count once and share one decision. Detector state persists in the new + `ConversationState.DoomLoop` field, so streaks survive serialize and resume + (pass `DoomLoop` again when resuming). The field is additive within + `ConversationStateVersion` 1. A stop verdict survives approve/reject-only + resumes and is cleared by a fresh conversational turn. Steer guidance queued + at a pause is delivered after the resume. A corrupt detector blob is ignored + instead of failing deserialization. Resolving a config logs a warning for dead + rungs, for `block` without `stop`, and for an `escalate` rung without a + mechanism (or a mechanism without the rung). +- Fingerprints follow the cross-port contract: RFC 8785 (JCS) canonicalization + plus SHA-256 (`CanonicalizeKeyMaterial`, `FingerprintToolCall`, + `FingerprintKeyMaterial`). They match upstream's conformance vectors + byte-for-byte (see the compatibility note on unpaired surrogates). The primitives (`DoomLoopMonitor`, `DetectTextRepetition`, + `ResolveLadderAction`, `ResolveDoomLoopOption`, `ResolveLoopKeyMaterial`) are + exported for direct use. +- On the first response of a call, an approval pause (including running the + turn's auto-approved calls) or an all-client-tools pause takes precedence over + a doom stop, as upstream. The armed verdict is persisted, so the following + approve/reject resume stays halted. On later turns, the stop seals the + turn's calls first. +- Documented limits, as upstream: loops whose arguments vary each time (for + example a fresh nonce) evade the default identity unless the tool declares a + `LoopKey`. Paraphrased text repetition is not detected. Manual or + client-executed tool calls are not recorded. + +### Run cancellation + +- Cancelling the `ctx` passed to `CallModel`, or calling `result.Cancel()`, now + reliably stops the run at the next turn boundary *and* fails before any + further dispatch. A pre-cancelled ctx fails without touching the network, + and every consumer returns the cancellation cause (`context.Cause`, so a + `context.WithCancelCause` reason comes through). Before this change, a + cancelled ctx was only noticed by the HTTP transport. +- An in-flight request aborted by cancellation now fails with the cancellation + cause (wrapped together with the transport error) instead of only a + transport-specific error. + +### Compatibility notes + +- Upstream's new `signal` option maps to the `ctx` argument (context + cancellation instead of `AbortSignal`). Upstream also composes + `RequestOptions.timeoutMs` with the signal to work around a TypeScript SDK + quirk. The Go SDK already derives a fresh per-request timeout from the run ctx + (`SDKOptions.Timeout`), so it needs no composition step and no new option. +- Go has no `undefined`. A `LoopKeyFunc` that returns `nil` exempts the call + (upstream `null`). Upstream's "returned `undefined` → warn and fall back" case + has no Go equivalent. Return an error to fall back instead. +- Ladder thresholds use `DoomLoopThreshold`: `0` means use the default, a + positive value is the threshold, and `agent.DoomLoopRungDisabled` (any negative + value) is upstream's `false`. +- Upstream's "repeated invalid-JSON tool call" scenario does not arise in this + port. Function calls whose arguments are not valid JSON are dropped when tool + calls are extracted (this is long-standing go-agent behavior), so such a turn + ends the run instead of looping on parse errors. +- Go strings are UTF-8, so `encoding/json` decodes an unpaired UTF-16 + surrogate escape (for example `"\ud800"`) in tool arguments to U+FFFD. + Arguments containing one therefore fingerprint differently from the + TypeScript port. `CanonicalizeKeyMaterial` does escape a surrogate carried + as WTF-8 bytes exactly as upstream would, which is what upstream's + conformance vector for this case checks. Every other upstream vector matches + as-is. Text normalization uses ECMAScript's `\s` whitespace set (not + `unicode.IsSpace`), so text fingerprints match upstream. +- Server-tool fingerprints are computed from the output item's wire JSON fields + (snake_case). They are stable within go-agent but not byte-identical to the + TypeScript port's server-tool fingerprints, which upstream's cross-port vectors + do not cover. Client tool-call and text fingerprints are identical across + ports. +- An error from a `DoomLoopDetected` handler under `ThrowOnHandlerError` is + logged and the resolved ladder action still applies. Detection never fails a + run. +- A doom stop on an approve/reject resume records the executed outputs in the + conversation messages (well-formed `function_call` → `function_call_output`) + and sends no follow-up request. The next fresh-input call continues from + there. +- go-agent caps a run at `CallModelInput.MaxTurns` model requests, which + defaults to 5. Upstream's loop has no default cap. With the default cap, the + default `stop` rung (6 consecutive identical rounds) cannot fire inside one + call; `observe` and `block` still do. Raise `MaxTurns`, or lower + `Ladder.Stop`, if you want the stop rung to bound long runs. +- Tool execution in this port is sequential, so doom-loop evaluations record in + the order the model emitted the calls, with no extra serialization step. + +### Maintenance + - Fixed a possible nil-pointer dereference in the `AllowFinalResponse` test path and lower-cased the `stop hook: …` wrapped error string to follow Go conventions (it was `Stop hook: …`). If you match on that error's text rather than using `errors.Is`/`errors.As`, update the comparison. -- No behavior changes to the public API. The rest of this release is test and CI - hardening: the race detector, a coverage ratchet, and per-required-symbol - coverage are now enforced gates, and `ToolEventBroadcaster` plus the +- Apart from the doom-loop detection and cancellation work above, the rest of + this release is test and CI hardening: the race detector, a coverage ratchet, + and per-required-symbol coverage are now enforced gates, and `ToolEventBroadcaster` plus the `stream_guards.go` predicates are now used by the streaming loop instead of sitting alongside duplicate inline logic. @@ -97,6 +225,15 @@ | `markMcp(tool)` / `isMcpTool(tool)` | `MarkMcp(tool) Tool` / `IsMcpTool(tool) bool` | | tool result / stream event `source` | `Source ToolSource` (`ToolSourceClient` / `ToolSourceMCP`) on `ToolExecutionResult`, `ToolStreamEvent`, `ToolResultEvent` | | `awaiting_client_tools` | `ConversationStatusAwaitingClientTools` | +| `doomLoop` option (`true` / config) | `DoomLoop *DoomLoopConfig` on `CallModelInput` (`&DoomLoopConfig{}` / config) | +| `result.getDoomLoopVerdict()` | `result.DoomLoopVerdict(ctx)` | +| tool `loopKey` (function / field list / `false`) | `ToolConfig.LoopKey` (`LoopKeyFunc` / `LoopKeyFields` / `LoopKeyExempt`) | +| `markMcp(tool, { loopKey })` | `MarkMcp(tool, MarkMcpOptions{LoopKey: ...})` | +| `DoomLoopDetected` hook | `HooksManager.OnDoomLoopDetected` / `InlineHookConfig.DoomLoopDetected` | +| `DoomLoopMonitor`, `detectTextRepetition`, `resolveLadderAction`, `resolveDoomLoopOption`, `resolveLoopKeyMaterial` | `DoomLoopMonitor` (`NewDoomLoopMonitor`), `DetectTextRepetition`, `ResolveLadderAction`, `ResolveDoomLoopOption`, `ResolveLoopKeyMaterial` | +| `canonicalizeKeyMaterial`, `fingerprintKeyMaterial`, `fingerprintToolCall` | `CanonicalizeKeyMaterial`, `FingerprintKeyMaterial`, `FingerprintToolCall` (`(string, error)`) | +| `DEFAULT_DOOM_LOOP_LADDER`, `DEFAULT_MAX_ESCALATIONS`, `MAX_CANONICALIZE_DEPTH` | `DefaultDoomLoopLadder()`, `DefaultMaxEscalations`, `MaxCanonicalizeDepth` | +| `signal` option | the `ctx` argument to `CallModel` | ## Compatibility Notes From 13834e069d36a30e9fb6b1a67984827907d6ef5a Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:57:32 +0000 Subject: [PATCH 2/2] test: avoid SA4000 identical-expression comparison in doom-loop determinism check Co-Authored-By: Claude Opus 5.5 (1M context) --- doom_loop_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doom_loop_test.go b/doom_loop_test.go index 2153389..c22631e 100644 --- a/doom_loop_test.go +++ b/doom_loop_test.go @@ -249,7 +249,8 @@ func TestDetectTextRepetition(t *testing.T) { t.Fatalf("DOCUMENTED MISS: paraphrase must not fire: %#v", r) } text := strings.TrimSpace(strings.Repeat("loop detected ", 8)) - if *DetectTextRepetition(text, DoomLoopTextOptions{}) != *DetectTextRepetition(text, DoomLoopTextOptions{}) { + first, second := DetectTextRepetition(text, DoomLoopTextOptions{}), DetectTextRepetition(text, DoomLoopTextOptions{}) + if first == nil || second == nil || *first != *second { t.Fatal("must be deterministic") } var filler strings.Builder