diff --git a/AGENTS.md b/AGENTS.md index e0b7185..06455f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,14 +149,11 @@ delivered: **Zero chunks are delivered until the whole delivered prefix is matched**; any divergence — or a terminal chunk arriving before catch-up (`early_end`) — aborts that attempt. Each attempt reports `Kiro exact replay attempt finished` telemetry. -- **Clean-EOF action-commitment replay** — separate from semantic truncation. Under - `exact_replay` only, a clean EOF may spend one remaining attempt when the exact - request exposes tools, the response emitted visible text but zero tool calls and - no tool intent, and `action-commitment.ts` recognizes either an immediate - first-person execution promise or an ordered unfinished self-owned action - sequence. The same `ExactReplayMatcher` withholds duplicate bytes. This dedicated - retry runs at most once and does not fail, rotate, rate-limit, or back off the - healthy account. +- **Fully empty clean-EOF retry** — separate from semantic truncation. Under either + non-`off` recovery mode, a clean EOF may spend one remaining attempt only when it + emitted zero visible chars, zero tool calls, and no tool intent. This dedicated + retry runs at most once on the same account and does not classify a failure, + rotate, rate-limit, or back off the healthy account. - **`none`** — under `off`, or when neither tier is eligible; the failure is mapped to `UpstreamUnexpectedError` and terminates the stream. @@ -168,8 +165,7 @@ tri-state (`DialectToolResolution` = `none` | `complete` | `incomplete`, parser's code-region rules. Under a recovery mode, an `incomplete` resolution makes `transformSdkStream` suppress both the dialect `remainderText` and the whole turn's tool calls (raw SDK tool calls included), so half an invocation or a partial tool set -never leaks to the consumer. The action-commitment replay above is a separate, -pattern-gated reliability guard; it must not be labeled or implemented as truncation. +never leaks to the consumer. Assistant prose is never a truncation or retry signal. **Reasoning-signature publication is tier-dependent** (`commitReasoningCorrelation`, `request-handler.ts`). A Tier A recovery reports `recovered: true` and MUST NOT @@ -191,13 +187,13 @@ emitted chunk. It is threaded in via `lifecycle.streamObserver` and exposes and `dialectActive`. Stream failure logs carry `emittedReasoningChars`, `emittedVisibleChars`, `emittedToolCount`, `sawToolIntent`, plus the transport-side `sdkHttpKeepAlive`, `processId`, `bunVersion`, `streamElapsedMs`, and -`upstreamEventCount`. Three log-event constants live in -`src/core/request/stream-log-events.ts` and are re-exported from `request-handler.ts`: -`STREAM_REQUEST_STARTED_LOG` (`Kiro stream request started`, written unconditionally -once per inbound streaming request — the denominator for failure-rate measurement) and +`upstreamEventCount`. Stable log-event constants live in +`src/core/request/stream-log-events.ts`: `STREAM_REQUEST_STARTED_LOG` (`Kiro stream +request started`, written unconditionally once per inbound streaming request — the +denominator for failure-rate measurement), `STREAM_ATTEMPT_STARTED_LOG`, `STREAM_MISSING_COMPLETION_LOG` (`Kiro stream ended without completion metadata`, a -benign WARN that fires on essentially every stream from this endpoint), plus -`STREAM_ACTION_COMMITMENT_RETRY_LOG` for the narrow one-shot replay. +benign WARN that fires on essentially every stream from this endpoint), +`STREAM_EMPTY_CLEAN_EOF_RETRY_LOG`, and `STREAM_TERMINAL_LOG`. **Transport.** `sdk_http_keep_alive` (default `false`) disables socket reuse after a request completes, via `httpsAgent: { keepAlive, maxSockets: SDK_MAX_SOCKETS }` in @@ -341,11 +337,10 @@ and is the only class with direct access to the OpenCode `client` (used for `STREAM_MISSING_COMPLETION_LOG` fires on essentially every stream this endpoint serves, so treating "no completion metadata" as truncation declares every healthy turn truncated and makes a recovery mode replay all of them. The only truncation - signal is an unclosed tool intent (`StreamObserver.hasOpenToolIntent`). The narrow - clean-EOF action-commitment replay is not a truncation verdict: preserve every - additional gate in `StreamRecoveryCoordinator`, including `exact_replay`, available - tools, zero tool calls/intent, explicit text pattern, one-use flag, and remaining - attempt budget. + signal is an unclosed tool intent (`StreamObserver.hasOpenToolIntent`). The only + clean-EOF retry is the one-shot fully empty response path. Never inspect assistant + prose to decide whether to retry: a non-empty clean EOF completes regardless of its + wording. - **Keep the reasoning-signature publication split intact.** A Tier A recovery (`recovered: true`) must NOT publish its reasoning envelope — the delivered reasoning is old-partial + new-full, so publishing produces a false cache hit and @@ -442,7 +437,6 @@ four does not surface them. `sqlite-multiprocess-stress.test.ts` uses five. | Recovery coordinator / tier decision | `src/core/request/stream-recovery.ts` `StreamRecoveryCoordinator`, `decideRecoveryTier` | | Recovery attempt opening | `src/core/request/recovery-attempt.ts` `RecoveryAttemptFactory` | | Tier B prefix matching | `src/core/request/replay-matcher.ts` `ExactReplayMatcher` | -| Clean-EOF action commitment detector | `src/core/request/action-commitment.ts` `detectForwardActionCommitment` | | Stream observation signals | `src/plugin/streaming/stream-observer.ts` `StreamObserver` | | Stable stream log events | `src/core/request/stream-log-events.ts` (re-exported from `request-handler.ts`) | | Semantic truncation verdict | `src/core/request/response-handler.ts` `isSemanticTruncation` | diff --git a/README.md b/README.md index bd1c909..2667d0d 100644 --- a/README.md +++ b/README.md @@ -262,12 +262,13 @@ are already atomic by default; for ordinary requests, enable `stream_buffer_until_complete` when task continuity is more important than seeing tokens arrive live. -With `stream_recovery_mode: "exact_replay"`, the plugin may make one additional -SDK send after that otherwise-benign clean EOF only when a tool-enabled response -ends with an explicit immediate or unfinished self-owned action commitment but -contains no tool call or tool intent. The replay must byte-match the already -delivered response before any new suffix is released. This is not a generic -missing-metadata retry, and the additional SDK send consumes quota. +Assistant prose is never interpreted as a recovery control signal. A non-empty +clean EOF completes normally regardless of whether the response describes a next +step. With `stream_recovery_mode` set to `reasoning_restart` or `exact_replay`, the +plugin may spend one additional same-account SDK send only for a fully empty clean +EOF with no tool output or tool intent. Iterator failures and unclosed tool intent +continue through the configured recovery tier; every additional SDK send consumes +quota. ## Migration diff --git a/src/__tests__/action-commitment.test.ts b/src/__tests__/action-commitment.test.ts deleted file mode 100644 index 5ba5d4e..0000000 --- a/src/__tests__/action-commitment.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { detectForwardActionCommitment } from '../core/request/action-commitment.js' - -describe('detectForwardActionCommitment', () => { - test('detects the observed Chinese dispatch promise', () => { - const text = [ - '好消息是没有自写 dblclick,平台检测也正确。', - '', - '我现在派两个并行任务:标题栏与窗口 chrome 的完整修复,以及命令外壳的 mock_builder 覆盖。' - ].join('\n') - - expect(detectForwardActionCommitment(text)).toBe('zh_immediate_first_person') - }) - - test('detects immediate Chinese and English execution commitments', () => { - expect(detectForwardActionCommitment('问题已经定位。我马上运行聚焦测试。')).toBe( - 'zh_immediate_first_person' - ) - expect( - detectForwardActionCommitment("The defect is isolated. I'll now run the focused tests.") - ).toBe('en_immediate_first_person') - }) - - test('detects the observed semicolon-delimited implicit Chinese execution commitment', () => { - const observed = - '实现已完成且 8 个改动文件的 LSP 诊断均为零;现在依次执行用户指定的五道完整门禁,首次成功后不重复验证。' - - expect(detectForwardActionCommitment(observed)).toBe('zh_immediate_first_person') - expect(detectForwardActionCommitment('实现已完成;我现在执行用户指定的五道完整门禁。')).toBe( - 'zh_immediate_first_person' - ) - expect(detectForwardActionCommitment('我现在依次执行用户指定的五道完整门禁。')).toBe( - 'zh_immediate_first_person' - ) - }) - - test('detects the observed unfinished verification sequence before a later blocker note', () => { - const text = [ - '## 剩下要做的', - '', - '我还没核验:全套 guards、workspace 测试,以及两批改动合并后是否互不干扰。', - '', - '然后提交、推送。真实渲染目前只能如实标注为未验证。', - '', - '另一个推送通道我还没做;它需要你在屏幕前登录。' - ].join('\n') - - expect(detectForwardActionCommitment(text)).toBe('zh_unfinished_action_sequence') - }) - - test('detects the equivalent unfinished English action sequence', () => { - const text = [ - 'I have not yet run the focused guards or verified the combined changes.', - 'Then I will commit and push.', - 'The visual check still requires an interactive login.' - ].join(' ') - - expect(detectForwardActionCommitment(text)).toBe('en_unfinished_action_sequence') - }) - - test('rejects offers, advice, plans, and already-completed statements', () => { - const finals = [ - '如果你愿意,我可以继续修复。', - '下一步你可以运行聚焦测试。', - '建议接下来派两个并行任务。', - '后续计划是补充回归测试。', - '我现在已经完成了两个任务。', - '我现在运行正常。', - '你现在依次执行用户指定的五道完整门禁。', - '请现在依次执行以下命令。', - '现在依次执行以下命令即可。', - '模型最后说“我现在运行测试”。', - '我还没核验真实渲染,因为需要你在屏幕前登录。本轮先停在这里。', - '我还没核验。然后你可以提交、推送。', - '我还没核验 guards。然后运行测试。测试已经完成,全部通过。', - '旧日志写着“我还没核验,然后提交、推送”。当前任务已经完成。', - "If you'd like, I'll run the focused tests.", - 'You can now run the focused tests.', - 'I already ran the focused tests.', - 'I have not verified the visual flow because it needs your login. This run stops here.', - 'I have not run the guards. Then you can commit and push.', - 'I have not run the guards. Then I will run them. All tests passed.' - ] - - expect(finals.map(detectForwardActionCommitment)).toEqual(finals.map(() => null)) - }) - - test('ignores superseded, quoted, or fenced immediate commitments', () => { - expect( - detectForwardActionCommitment( - ['我现在运行测试。', '', '最终结论:当前实现无需继续修改。'].join('\n') - ) - ).toBeNull() - expect(detectForwardActionCommitment('> 我现在运行测试。\n\n当前实现无需继续修改。')).toBeNull() - expect( - detectForwardActionCommitment('```text\n我现在运行测试。\n```\n\n当前实现无需继续修改。') - ).toBeNull() - }) -}) diff --git a/src/__tests__/recovery-integration.test.ts b/src/__tests__/recovery-integration.test.ts index fd32b9b..dce4b81 100644 --- a/src/__tests__/recovery-integration.test.ts +++ b/src/__tests__/recovery-integration.test.ts @@ -5,10 +5,7 @@ import { type LiveRecoveryOptions } from '../core/request/recovery-integration.js' import type { SdkStreamingAttempt } from '../core/request/response-handler.js' -import { - STREAM_ACTION_COMMITMENT_RETRY_LOG, - STREAM_EMPTY_CLEAN_EOF_RETRY_LOG -} from '../core/request/stream-log-events.js' +import { STREAM_EMPTY_CLEAN_EOF_RETRY_LOG } from '../core/request/stream-log-events.js' import { AccountManager } from '../plugin/accounts.js' import * as logger from '../plugin/logger.js' import type { ManagedAccount } from '../plugin/types.js' @@ -518,157 +515,53 @@ describe('createLiveRecoveryResponse — account rotation', () => { } }) - test('action-commitment replay does not classify or rotate the healthy account', async () => { - const commitment = '我现在派两个并行任务。' - const account = makeAccount('commitment') - let selectCalls = 0 - let markRateLimitedCalls = 0 - let retryDelayCalls = 0 - let waitCalls = 0 - const warn = spyOn(logger, 'warn').mockImplementation(() => {}) - const attemptFactory: Pick = { - open: async (attemptIndex, selectedAccount) => ({ - account: selectedAccount, - logDetails: (details = {}) => ({ - conversationId: 'conversation-action-commitment', - model: 'claude-opus-5-xhigh', - terminalSource: 'clean_eof_without_completion_metadata', - ...details - }), - handle: { - ...(attemptIndex === 1 - ? makeAttempt({ - output: [ - chunk('commitment', { content: commitment }), - chunk('discarded-finish', {}, 'stop') - ], - observation: { - emitted: { visibleChars: commitment.length, toolCount: 0 }, - sawToolIntent: false, - terminalSource: 'clean_eof_without_completion_metadata', - availableToolCount: 94, - forwardActionCommitment: 'zh_immediate_first_person' - } - }) - : makeAttempt({ - output: [ - chunk('shadow', { content: commitment }), - chunk('task', { - tool_calls: [ - { - index: 0, - id: 'tool-1', - function: { name: 'task', arguments: '{"description":"fix"}' } - } - ] - }), - chunk('finish', {}, 'tool_calls') - ] - })), - complete: async () => {} - } - }) - } - const harness = recoveryOptions(attemptFactory) - - try { - const response = await createLiveRecoveryResponse({ - ...harness.options, - mode: 'exact_replay', - initialAccount: account, - retryDelay: () => { - retryDelayCalls++ - return 0 - }, - wait: async () => { - waitCalls++ - }, - selectAlternativeAccount: async () => { - selectCalls++ - return null - }, - markRateLimited: () => { - markRateLimitedCalls++ - } - }) - await response.text() - - expect(harness.options.failedAccountIds).toEqual(new Set()) - expect(selectCalls).toBe(0) - expect(markRateLimitedCalls).toBe(0) - expect(retryDelayCalls).toBe(0) - expect(waitCalls).toBe(0) - expect( - warn.mock.calls.filter((call) => call[0] === 'Kiro SDK event stream iteration failed') - ).toEqual([]) - expect( - warn.mock.calls.find((call) => call[0] === STREAM_ACTION_COMMITMENT_RETRY_LOG)?.[1] - ).toMatchObject({ - outcome: 'retrying', - recoveryTrigger: 'clean_eof_action_commitment', - actionCommitmentPattern: 'zh_immediate_first_person', - actionCommitmentVisibleChars: commitment.length, - availableToolCount: 94, - nextAttempt: 2 - }) - expect(harness.terminalRecords()).toEqual([ - expect.objectContaining({ - conversationId: 'conversation-action-commitment', - attemptsUsed: 2, - accountsTried: 1, - initialFailure: null, - finalFailure: null, - recovered: true, - quotaRelevant: false, - actionCommitmentRetryDecision: 'retried', - terminalSource: 'clean_eof_without_completion_metadata' - }) - ]) - } finally { - warn.mockRestore() - } - }) - - test('terminal record explains a clean EOF action-commitment pattern miss', async () => { - const answer = '当前分析已经完成。' + test('non-empty clean EOF prose does not open a recovery attempt', async () => { + const prose = '我先派发第二批三站' + const openedAttempts: number[] = [] const attemptFactory: Pick = { - open: async (_attemptIndex, selectedAccount) => ({ - account: selectedAccount, - logDetails: (details = {}) => ({ - conversationId: 'conversation-action-pattern-miss', - model: 'claude-opus-5-xhigh', - terminalSource: 'clean_eof_without_completion_metadata', - ...details - }), - handle: { - ...makeAttempt({ - output: [chunk('answer', { content: answer }), chunk('accepted-finish', {}, 'stop')], - observation: { - emitted: { visibleChars: answer.length, toolCount: 0 }, - sawToolIntent: false, - terminalSource: 'clean_eof_without_completion_metadata', - availableToolCount: 94, - forwardActionCommitment: null - } + open: async (attemptIndex, selectedAccount) => { + openedAttempts.push(attemptIndex) + return { + account: selectedAccount, + logDetails: (details = {}) => ({ + conversationId: 'conversation-action-prose', + model: 'claude-opus-5-xhigh', + terminalSource: 'clean_eof_without_completion_metadata', + ...details }), - complete: async () => {} + handle: { + ...makeAttempt({ + output: [chunk('action-prose', { content: prose }), chunk('finish', {}, 'stop')], + observation: { + emitted: { visibleChars: prose.length, toolCount: 0 }, + sawToolIntent: false, + terminalSource: 'clean_eof_without_completion_metadata' + } + }), + complete: async () => {} + } } - }) + } } const harness = recoveryOptions(attemptFactory) const response = await createLiveRecoveryResponse({ ...harness.options, - mode: 'exact_replay' + mode: 'exact_replay', + maxAttempts: 2 }) - await response.text() + expect(await response.text()).toContain(prose) + expect(openedAttempts).toEqual([1]) expect(harness.terminalRecords()).toEqual([ expect.objectContaining({ - conversationId: 'conversation-action-pattern-miss', + conversationId: 'conversation-action-prose', attemptsUsed: 1, + accountsTried: 1, + initialFailure: null, + finalFailure: null, recovered: false, - actionCommitmentRetryDecision: 'pattern_not_detected', + quotaRelevant: false, terminalSource: 'clean_eof_without_completion_metadata' }) ]) diff --git a/src/__tests__/response-handler-sdk.test.ts b/src/__tests__/response-handler-sdk.test.ts index 997ce5b..51fc578 100644 --- a/src/__tests__/response-handler-sdk.test.ts +++ b/src/__tests__/response-handler-sdk.test.ts @@ -191,26 +191,24 @@ describe('handleSdkSuccess — non-streaming', () => { }) describe('handleSdkSuccess — streaming', () => { - test('attempt observation marks a clean EOF immediate action commitment', async () => { + test('attempt observation does not classify assistant prose', async () => { const observer = new StreamObserver() - const commitment = + const prose = '实现已完成且 8 个改动文件的 LSP 诊断均为零;现在依次执行用户指定的五道完整门禁,首次成功后不重复验证。' const attempt = await new ResponseHandler().prepareSdkStreamingAttempt({ - sdkResponse: makeSdkResponse([{ assistantResponseEvent: { content: commitment } }]), + sdkResponse: makeSdkResponse([{ assistantResponseEvent: { content: prose } }]), model: 'auto', - conversationId: 'clean-eof-action-commitment', - lifecycle: { streamObserver: observer, availableToolCount: 94 }, + conversationId: 'clean-eof-action-prose', + lifecycle: { streamObserver: observer }, recoveryMode: 'exact_replay' }) while (!(await attempt.chunks.next()).done) {} expect(attempt.observed()).toEqual({ - emitted: { visibleChars: commitment.length, toolCount: 0 }, + emitted: { visibleChars: prose.length, toolCount: 0 }, sawToolIntent: false, - terminalSource: 'clean_eof_without_completion_metadata', - availableToolCount: 94, - forwardActionCommitment: 'zh_immediate_first_person' + terminalSource: 'clean_eof_without_completion_metadata' }) await attempt.close() }) diff --git a/src/__tests__/stream-recovery-exact-replay.test.ts b/src/__tests__/stream-recovery-exact-replay.test.ts index d191008..46eabf5 100644 --- a/src/__tests__/stream-recovery-exact-replay.test.ts +++ b/src/__tests__/stream-recovery-exact-replay.test.ts @@ -15,14 +15,12 @@ const VISIBLE_OBSERVATION = { sawToolIntent: false } as const -const COMMITMENT_TEXT = '我现在派两个并行任务。' -const COMMITMENT_OBSERVATION = { - emitted: { visibleChars: COMMITMENT_TEXT.length, toolCount: 0 }, - sawToolIntent: false, - terminalSource: 'clean_eof_without_completion_metadata', - availableToolCount: 94, - forwardActionCommitment: 'zh_immediate_first_person' -} as const +const ACTION_PROSE_CASES = [ + '现在依次执行用户指定的五道完整门禁', + '我先派发第二批三站', + '继续推进,直到完成', + 'Now I am handing off this final fix' +] as const const EMPTY_CLEAN_EOF_OBSERVATION = { emitted: { visibleChars: 0, toolCount: 0 }, @@ -95,7 +93,6 @@ describe('StreamRecoveryCoordinator exact replay', () => { ]) expect(harness.requestedAttempts).toEqual([1, 2]) expect(harness.emptyCleanEofRetries).toEqual([{ attemptIndex: 1 }]) - expect(harness.actionCommitmentRetries).toEqual([]) expect(harness.completions).toEqual([ { attemptIndex: 2, recoveryTier: 'reasoning_restart', recovered: true } ]) @@ -170,177 +167,27 @@ describe('StreamRecoveryCoordinator exact replay', () => { } }) - test('retries one clean EOF action commitment and releases only the replay suffix', async () => { - // Given - const first = makeAttempt({ - output: [ - chunk('commitment', { content: COMMITMENT_TEXT }), - chunk('discarded-finish', {}, 'stop') - ], - observation: COMMITMENT_OBSERVATION - }) - const replay = makeAttempt({ - output: [ - chunk('shadow', { content: COMMITMENT_TEXT }), - chunk('task', { - tool_calls: [toolCall(0, 'tool-1', 'task', '{"description":"fix titlebar"}')] - }), - chunk('finish', {}, 'tool_calls') - ] - }) - const harness = createHarness([first, replay], { mode: 'exact_replay' }) - - // When - const labels = await collect(harness.coordinator.stream) - - // Then - expect(labels).toEqual(['commitment', 'task', 'finish']) - expect(harness.requestedAttempts).toEqual([1, 2]) - expect(harness.actionCommitmentRetries).toEqual([ - { - attemptIndex: 1, - pattern: 'zh_immediate_first_person', - visibleChars: COMMITMENT_TEXT.length, - availableToolCount: 94 - } - ]) - expect(harness.replayTelemetry).toEqual([ - { - matchedReasoningChars: 0, - matchedVisibleChars: COMMITMENT_TEXT.length, - matchedToolCount: 0, - divergenceChannel: 'none', - replayOutcome: 'caught_up', - attempts: 2 - } - ]) - expect(harness.completions).toEqual([ - { attemptIndex: 2, recoveryTier: 'exact_replay', recovered: false } - ]) - }) - - test('never retries the same action commitment more than once', async () => { - // Given - const first = makeAttempt({ - output: [ - chunk('commitment', { content: COMMITMENT_TEXT }), - chunk('discarded-finish', {}, 'stop') - ], - observation: COMMITMENT_OBSERVATION - }) - const replay = makeAttempt({ - output: [chunk('shadow', { content: COMMITMENT_TEXT }), chunk('accepted-finish', {}, 'stop')], - observation: COMMITMENT_OBSERVATION - }) - const unused = makeAttempt({ output: [chunk('must-not-run', {}, 'stop')] }) - const harness = createHarness([first, replay, unused], { mode: 'exact_replay' }) - - // When - const labels = await collect(harness.coordinator.stream) - - // Then - expect(labels).toEqual(['commitment', 'accepted-finish']) - expect(harness.requestedAttempts).toEqual([1, 2]) - expect(harness.actionCommitmentRetries).toHaveLength(1) - }) - - test('does not open a third attempt when the action-commitment replay diverges', async () => { - // Given - const mapped = new TestStreamFailure('action commitment replay diverged') - const first = makeAttempt({ - output: [ - chunk('commitment', { content: COMMITMENT_TEXT }), - chunk('discarded-finish', {}, 'stop') - ], - observation: COMMITMENT_OBSERVATION - }) - const replay = makeAttempt({ - output: [chunk('divergent-shadow', { content: '不同的开头。' })] - }) - const unused = makeAttempt({ output: [chunk('must-not-run', {}, 'stop')] }) - const harness = createHarness([first, replay, unused], { - mode: 'exact_replay', - mapError: () => mapped - }) - const reader = harness.coordinator.stream.getReader() - - // When - expect(new TextDecoder().decode((await reader.read()).value)).toBe('commitment') - - // Then - await expectRejection(reader.read(), mapped) - expect(harness.requestedAttempts).toEqual([1, 2]) - expect(harness.actionCommitmentRetries).toHaveLength(1) - expect(harness.replayTelemetry[0]?.divergenceChannel).toBe('text') - expect(harness.completions).toEqual([]) - expect(harness.terminalCalls()).toBe(1) - }) - - test('requires every clean EOF action-commitment safety gate', async () => { - const cases = [ - { - name: 'completion metadata', - observation: { - ...COMMITMENT_OBSERVATION, - terminalSource: 'completion_metadata_received' - } as const, - mode: 'exact_replay' as const, - maxAttempts: 2 - }, - { - name: 'available tools', - observation: { ...COMMITMENT_OBSERVATION, availableToolCount: 0 }, - mode: 'exact_replay' as const, - maxAttempts: 2 - }, - { - name: 'no prior tool intent', - observation: { ...COMMITMENT_OBSERVATION, sawToolIntent: true }, - mode: 'exact_replay' as const, - maxAttempts: 2 - }, - { - name: 'detected commitment', - observation: { ...COMMITMENT_OBSERVATION, forwardActionCommitment: null }, - mode: 'exact_replay' as const, - maxAttempts: 2 - }, - { - name: 'exact replay mode', - observation: COMMITMENT_OBSERVATION, - mode: 'reasoning_restart' as const, - maxAttempts: 2 - }, - { - name: 'remaining attempt budget', - observation: COMMITMENT_OBSERVATION, - mode: 'exact_replay' as const, - maxAttempts: 1 - } - ] - - for (const row of cases) { + for (const [index, prose] of ACTION_PROSE_CASES.entries()) { + test(`does not replay action prose on clean EOF (${index + 1})`, async () => { const first = makeAttempt({ - output: [ - chunk(`${row.name}-commitment`, { content: COMMITMENT_TEXT }), - chunk(`${row.name}-finish`, {}, 'stop') - ], - observation: row.observation - }) - const unused = makeAttempt({ output: [chunk(`${row.name}-must-not-run`, {}, 'stop')] }) - const harness = createHarness([first, unused], { - mode: row.mode, - maxAttempts: row.maxAttempts + output: [chunk('action-prose', { content: prose }), chunk('original-finish', {}, 'stop')], + observation: { + emitted: { visibleChars: prose.length, toolCount: 0 }, + sawToolIntent: false, + terminalSource: 'clean_eof_without_completion_metadata' + } }) + const unused = makeAttempt({ output: [chunk('must-not-run', {}, 'stop')] }) + const harness = createHarness([first, unused], { mode: 'exact_replay' }) - expect(await collect(harness.coordinator.stream)).toEqual([ - `${row.name}-commitment`, - `${row.name}-finish` - ]) + expect(await collect(harness.coordinator.stream)).toEqual(['action-prose', 'original-finish']) expect(harness.requestedAttempts).toEqual([1]) - expect(harness.actionCommitmentRetries).toEqual([]) - } - }) + expect(harness.replayTelemetry).toEqual([]) + expect(harness.completions).toEqual([ + { attemptIndex: 1, recoveryTier: 'none', recovered: false } + ]) + }) + } test('matches different text chunk splits and releases only the new suffix plus one terminal', async () => { // Given diff --git a/src/__tests__/stream-recovery.fixture.ts b/src/__tests__/stream-recovery.fixture.ts index 2e5b317..015ae93 100644 --- a/src/__tests__/stream-recovery.fixture.ts +++ b/src/__tests__/stream-recovery.fixture.ts @@ -1,7 +1,6 @@ import { expect } from 'bun:test' import { StreamRecoveryCoordinator, - type ActionCommitmentRetryTelemetry, type AttemptHandle, type AttemptObservation, type EmptyCleanEofRetryTelemetry, @@ -85,7 +84,6 @@ export function createHarness( const requestedAttempts: number[] = [] const completions: StreamRecoveryCompletion[] = [] const replayTelemetry: ReplayAttemptTelemetry[] = [] - const actionCommitmentRetries: ActionCommitmentRetryTelemetry[] = [] const emptyCleanEofRetries: EmptyCleanEofRetryTelemetry[] = [] let terminalCalls = 0 const signal = overrides.signal ?? new AbortController().signal @@ -110,9 +108,6 @@ export function createHarness( onReplayAttempt: (telemetry) => { replayTelemetry.push(telemetry) }, - onActionCommitmentRetry: (telemetry) => { - actionCommitmentRetries.push(telemetry) - }, onEmptyCleanEofRetry: (telemetry) => { emptyCleanEofRetries.push(telemetry) }, @@ -125,7 +120,6 @@ export function createHarness( requestedAttempts, completions, replayTelemetry, - actionCommitmentRetries, emptyCleanEofRetries, terminalCalls: () => terminalCalls } diff --git a/src/core/request/action-commitment.ts b/src/core/request/action-commitment.ts deleted file mode 100644 index e70eece..0000000 --- a/src/core/request/action-commitment.ts +++ /dev/null @@ -1,149 +0,0 @@ -const MAX_PROSE_TAIL_CHARS = 1200 - -export type ForwardActionCommitmentKind = - | 'zh_immediate_first_person' - | 'zh_unfinished_action_sequence' - | 'en_immediate_first_person' - | 'en_unfinished_action_sequence' - -const ZH_ACTION = - '(?:派(?:出)?|分派|调用|运行|执行|启动|创建|修改|修复|编辑|补(?:上|充)?|添加|写入|检查|排查|核验|验证|测试|提交|推送|发布|部署|更新|安装|清理|继续(?:处理|执行|修复|排查|测试|实现)?|处理|实现)' -const ZH_IMMEDIATE_COMMITMENT = new RegExp( - `^(?:[-+]\\s*|\\d+[.)、]\\s*)?(?:我\\s*(?:现在|马上|立即|这就)|(?:接下来|下面)\\s*我\\s*(?:会|将|要)(?:\\s*(?:现在|马上|立即))?|(?:现在|马上|立即|这就)\\s*依次)\\s*(?:(?:先|依次)\\s*)?(?:开始\\s*)?${ZH_ACTION}`, - 'u' -) -const ZH_UNFINISHED_SELF_ACTION = new RegExp( - `^(?:[-+]\\s*|\\d+[.)、]\\s*)?我\\s*(?:还没|还未|尚未|仍未)\\s*(?:完成\\s*)?${ZH_ACTION}`, - 'u' -) -const ZH_SEQUENCED_ACTION = new RegExp( - `^(?:[-+]\\s*|\\d+[.)、]\\s*)?(?:然后|接着|随后|再)\\s*(?:我\\s*(?:会|将|要)\\s*)?(?:先\\s*)?${ZH_ACTION}`, - 'u' -) -const ZH_CONDITIONAL_OR_ADVISORY = - /(?:如果|若(?:你|您)?|如需|需要的话|你(?:可以|可)|您(?:可以|可)|要不要|建议|推荐|计划|打算|可能|也许|即可|便可|之后(?:可以|再)|后续(?:可以|再))/u -const ZH_COMPLETED_OR_STATE = - /(?:已经|已完成|已结束|完毕|无需|不用|不再|运行正常|执行正常|派不上|做不到)/u - -const EN_ACTION = - '(?:run|execute|call|dispatch|spawn|fork|launch|create|edit|modify|fix|patch|write|add|inspect|check|investigate|verify|test|commit|push|publish|release|deploy|update|install|clean|continue|resume|implement)' -const EN_IMMEDIATE_COMMITMENT = new RegExp( - `^(?:[-+]\\s*|\\d+[.)]\\s*)?(?:(?:now|next),?\\s+)?I(?:'ll| will| am going to| am now going to)\\s+(?:(?:now|immediately|next)\\s+)?(?:(?:start|begin)\\s+(?:by|with)\\s+)?${EN_ACTION}\\b`, - 'i' -) -const EN_UNFINISHED_SELF_ACTION = new RegExp( - `^(?:[-+]\\s*|\\d+[.)]\\s*)?I\\s+(?:(?:still\\s+)?(?:haven't|have not)(?:\\s+yet)?|still need to)\\s+${EN_ACTION}\\b`, - 'i' -) -const EN_SEQUENCED_ACTION = new RegExp( - `^(?:[-+]\\s*|\\d+[.)]\\s*)?(?:then|next),?\\s+(?:(?:I'll|I will|I am going to)\\s+)?${EN_ACTION}\\b`, - 'i' -) -const EN_CONDITIONAL_OR_ADVISORY = - /\b(?:if you(?:'d| would)? like|if you want|if needed|you can|would you like|I can|I could|recommend|suggest|might|maybe|later)\b/i -const EN_COMPLETED_OR_STATE = - /\b(?:already|(?:is|are) now complete|all tests passed|no longer need)\b/i - -function stripNonProse(text: string): string { - return text - .replace(/```[\s\S]*?```/g, ' ') - .replace(/~~~[\s\S]*?~~~/g, ' ') - .split('\n') - .filter((line) => !line.trimStart().startsWith('>')) - .join('\n') - .replace(/`([^`]*)`/g, '$1') - .replace(/[*_~]/g, '') - .replace(/\u2019/g, "'") -} - -function substantiveSentences(text: string): string[] { - const prose = stripNonProse(text).slice(-MAX_PROSE_TAIL_CHARS) - return prose - .split(/[。!?!?;;]\s*|\.\s+|\n\s*\n+/u) - .map((sentence) => sentence.trim()) - .filter(Boolean) -} - -function hasUnfinishedActionSequence( - sentences: string[], - unfinished: RegExp, - sequenced: RegExp, - conditionalOrAdvisory: RegExp, - completedOrState?: RegExp -): boolean { - for (let index = 0; index < sentences.length - 1; index++) { - const sentence = sentences[index]! - if ( - !unfinished.test(sentence) || - conditionalOrAdvisory.test(sentence) || - completedOrState?.test(sentence) - ) { - continue - } - - const followups = sentences.slice(index + 1, index + 4) - const followupOffset = followups.findIndex( - (followup) => - sequenced.test(followup) && - !conditionalOrAdvisory.test(followup) && - !completedOrState?.test(followup) - ) - if (followupOffset < 0) continue - - const laterSentences = sentences.slice(index + followupOffset + 2) - if (completedOrState && laterSentences.some((later) => completedOrState.test(later))) { - continue - } - return true - } - return false -} - -/** - * Detects narrow prose-only endings that either explicitly promise immediate - * agent action or leave a concrete self-owned action sequence unfinished. It - * intentionally ignores broad plans and user-facing suggestions: false positives - * would spend quota and can replay a valid final answer. - */ -export function detectForwardActionCommitment(text: string): ForwardActionCommitmentKind | null { - const sentences = substantiveSentences(text) - const lastSentence = sentences.at(-1) - if (!lastSentence) return null - - if ( - ZH_IMMEDIATE_COMMITMENT.test(lastSentence) && - !ZH_CONDITIONAL_OR_ADVISORY.test(lastSentence) && - !ZH_COMPLETED_OR_STATE.test(lastSentence) - ) { - return 'zh_immediate_first_person' - } - if ( - hasUnfinishedActionSequence( - sentences, - ZH_UNFINISHED_SELF_ACTION, - ZH_SEQUENCED_ACTION, - ZH_CONDITIONAL_OR_ADVISORY, - ZH_COMPLETED_OR_STATE - ) - ) { - return 'zh_unfinished_action_sequence' - } - if ( - EN_IMMEDIATE_COMMITMENT.test(lastSentence) && - !EN_CONDITIONAL_OR_ADVISORY.test(lastSentence) - ) { - return 'en_immediate_first_person' - } - if ( - hasUnfinishedActionSequence( - sentences, - EN_UNFINISHED_SELF_ACTION, - EN_SEQUENCED_ACTION, - EN_CONDITIONAL_OR_ADVISORY, - EN_COMPLETED_OR_STATE - ) - ) { - return 'en_unfinished_action_sequence' - } - return null -} diff --git a/src/core/request/recovery-attempt.ts b/src/core/request/recovery-attempt.ts index d3a9778..40303fa 100644 --- a/src/core/request/recovery-attempt.ts +++ b/src/core/request/recovery-attempt.ts @@ -208,9 +208,6 @@ export class RecoveryAttemptFactory { const isCurrent = (): boolean => this.services.isAccountAttemptCurrent(state.account.id, epoch) const attemptId = crypto.randomUUID() this.services.setCurrentAttemptId(attemptId) - const availableToolCount = - state.prepared.conversationState.currentMessage?.userInputMessage?.userInputMessageContext - ?.tools?.length ?? 0 let completionDone = false const onComplete = async (completed?: SdkCompletionPayload): Promise => { if (!completionDone) { @@ -272,8 +269,7 @@ export class RecoveryAttemptFactory { ? { inheritedLoopId: this.request.inheritedLoopId } : {}), effectiveModel: state.prepared.effectiveModel, - recoveryMode: this.config.stream_recovery_mode, - availableToolCount + recoveryMode: this.config.stream_recovery_mode } const client = this.services.makeSdkClient(state.auth, state.prepared) diff --git a/src/core/request/recovery-integration.ts b/src/core/request/recovery-integration.ts index 5244d42..f639b4c 100644 --- a/src/core/request/recovery-integration.ts +++ b/src/core/request/recovery-integration.ts @@ -6,14 +6,10 @@ import type { RecoveryAttemptFactory, RecoveryAttemptResult } from './recovery-a import { accountLogAlias } from './recovery-request-identity' import { encodeSseChunk, type SdkStreamingAttempt } from './response-handler' import { UpstreamUnexpectedError } from './stream-error' -import { - STREAM_ACTION_COMMITMENT_RETRY_LOG, - STREAM_EMPTY_CLEAN_EOF_RETRY_LOG -} from './stream-log-events' +import { STREAM_EMPTY_CLEAN_EOF_RETRY_LOG } from './stream-log-events' import { StreamRecoveryCoordinator, type StreamRecoveryMode, - type StreamRecoveryTerminalTelemetry, type StreamRecoveryTerminationReason } from './stream-recovery' @@ -116,7 +112,6 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): let initialFailure: unknown let finalFailure: unknown let quotaRelevant = false - let actionCommitmentRetried = false let emptyCleanEofRetried = false const getCurrentAttempt = (): RecoveryAttemptContext => { @@ -201,10 +196,7 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): } } - const finishTerminal = ( - terminationReason: StreamRecoveryTerminationReason, - telemetry: StreamRecoveryTerminalTelemetry - ): void => { + const finishTerminal = (terminationReason: StreamRecoveryTerminationReason): void => { if (terminalFinished) return terminalFinished = true const terminalSummary = { @@ -214,12 +206,8 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): initialFailure: initialFailure === undefined ? null : options.describeError(initialFailure), finalFailure: finalFailure === undefined ? null : options.describeError(finalFailure), recovered: - terminationReason === 'completed' && - (initialFailure !== undefined || actionCommitmentRetried || emptyCleanEofRetried), - quotaRelevant, - ...(telemetry.actionCommitmentRetryDecision - ? { actionCommitmentRetryDecision: telemetry.actionCommitmentRetryDecision } - : {}) + terminationReason === 'completed' && (initialFailure !== undefined || emptyCleanEofRetried), + quotaRelevant } if (currentAttempt) { @@ -363,11 +351,7 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): attemptLogDetails(getCurrentAttempt(), 'completed', undefined, { outcome: 'recovered', attempts: options.priorStreamFailures + completion.attemptIndex, - ...(actionCommitmentRetried - ? { recoveryTrigger: 'clean_eof_action_commitment' } - : emptyCleanEofRetried - ? { recoveryTrigger: 'clean_eof_empty_response' } - : {}) + ...(emptyCleanEofRetried ? { recoveryTrigger: 'clean_eof_empty_response' } : {}) }) ) } @@ -400,21 +384,6 @@ export async function createLiveRecoveryResponse(options: LiveRecoveryOptions): }) ) }, - onActionCommitmentRetry: (telemetry) => { - actionCommitmentRetried = true - logger.warn( - STREAM_ACTION_COMMITMENT_RETRY_LOG, - attemptLogDetails(getCurrentAttempt(), 'exact_replay', undefined, { - outcome: 'retrying', - recoveryTrigger: 'clean_eof_action_commitment', - actionCommitmentPattern: telemetry.pattern, - actionCommitmentVisibleChars: telemetry.visibleChars, - availableToolCount: telemetry.availableToolCount, - nextAttempt: options.priorStreamFailures + telemetry.attemptIndex + 1, - quotaNote: 'the one clean EOF action-commitment replay consumes one real SDK send' - }) - ) - }, onTerminal: finishTerminal, onCancel: options.onCancel }) diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index d64f680..0d3f75d 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -9,7 +9,6 @@ import { ReasoningAccumulator } from '../../plugin/streaming/reasoning-accumulat import { transformSdkStream } from '../../plugin/streaming/sdk-stream-transformer.js' import type { StreamObserver } from '../../plugin/streaming/stream-observer.js' import type { KiroReasoningContent } from '../../plugin/types.js' -import { detectForwardActionCommitment } from './action-commitment.js' import { SdkEventStreamIterationError } from './stream-error.js' import type { AttemptHandle, @@ -80,8 +79,6 @@ export interface SdkResponseLifecycle { */ onCleanEofWithoutCompletionMetadata?: () => void recoveryMode?: StreamRecoveryMode - /** Number of callable tools present on this exact prepared request. */ - availableToolCount?: number } interface WrappedSdkStream { @@ -375,19 +372,13 @@ export class ResponseHandler { observed: () => { const observed = lifecycle.streamObserver?.snapshot() const toolCount = emitted.toolUses().length - const availableToolCount = lifecycle.availableToolCount ?? 0 return { emitted: { visibleChars: emitted.visibleText.length, toolCount }, sawToolIntent: observed?.sawToolIntent ?? false, - terminalSource: observed?.terminalSource ?? null, - availableToolCount, - forwardActionCommitment: - availableToolCount > 0 && toolCount === 0 && observed?.sawToolIntent !== true - ? detectForwardActionCommitment(emitted.visibleText) - : null + terminalSource: observed?.terminalSource ?? null } }, close, diff --git a/src/core/request/stream-log-events.ts b/src/core/request/stream-log-events.ts index dd7e8a6..f0c3a80 100644 --- a/src/core/request/stream-log-events.ts +++ b/src/core/request/stream-log-events.ts @@ -7,9 +7,6 @@ export const STREAM_ATTEMPT_STARTED_LOG = 'Kiro stream attempt started' /** Stable marker for a clean SDK `done` without completion metadata. */ export const STREAM_MISSING_COMPLETION_LOG = 'Kiro stream ended without completion metadata' -/** High-confidence prose checkpoint retried once through exact replay. */ -export const STREAM_ACTION_COMMITMENT_RETRY_LOG = 'Kiro clean EOF action commitment retrying' - /** Fully empty clean EOF retried once on the same account without failure classification. */ export const STREAM_EMPTY_CLEAN_EOF_RETRY_LOG = 'Kiro empty clean EOF retrying' diff --git a/src/core/request/stream-recovery.ts b/src/core/request/stream-recovery.ts index 0fc185e..f4993b5 100644 --- a/src/core/request/stream-recovery.ts +++ b/src/core/request/stream-recovery.ts @@ -9,7 +9,6 @@ import { EmittedOutputAccumulator } from '../../plugin/reasoning/emitted-output.js' import type { StreamTerminalSource } from '../../plugin/streaming/stream-observer.js' -import type { ForwardActionCommitmentKind } from './action-commitment.js' import { ExactReplayMatcher, type ReplayDivergenceChannel, @@ -36,8 +35,6 @@ export type AttemptObservation = { } readonly sawToolIntent: boolean readonly terminalSource?: StreamTerminalSource | null - readonly availableToolCount?: number - readonly forwardActionCommitment?: ForwardActionCommitmentKind | null } export type AttemptHandle = { @@ -61,26 +58,6 @@ export type ReplayAttemptTelemetry = ReplayMatchProgress & { readonly attempts: number } -export type ActionCommitmentRetryTelemetry = { - readonly attemptIndex: number - readonly pattern: ForwardActionCommitmentKind - readonly visibleChars: number - readonly availableToolCount: number -} - -/** First failed safety gate, or `retried` once the dedicated replay is opened. */ -export type ActionCommitmentRetryDecision = - | 'retried' - | 'mode_not_exact_replay' - | 'already_retried' - | 'attempt_budget_exhausted' - | 'not_clean_eof' - | 'no_visible_output' - | 'tool_output_present' - | 'tool_intent_seen' - | 'no_tools_available' - | 'pattern_not_detected' - export type EmptyCleanEofRetryTelemetry = { readonly attemptIndex: number } @@ -93,10 +70,6 @@ export type StreamRecoveryTerminationReason = | 'attempt_budget_exhausted' | 'coordinator_failure' -export type StreamRecoveryTerminalTelemetry = { - readonly actionCommitmentRetryDecision?: ActionCommitmentRetryDecision -} - export type StreamRecoveryOptions = { readonly mode: StreamRecoveryMode readonly maxAttempts: number @@ -109,15 +82,9 @@ export type StreamRecoveryOptions = { readonly mapError: (failure: unknown) => Error readonly encodeChunk: (chunk: unknown) => Uint8Array readonly onComplete: (completion: StreamRecoveryCompletion) => void | Promise - readonly onTerminal: ( - reason: StreamRecoveryTerminationReason, - telemetry: StreamRecoveryTerminalTelemetry - ) => void + readonly onTerminal: (reason: StreamRecoveryTerminationReason) => void readonly onCancel?: (reason: unknown) => void readonly onReplayAttempt?: (telemetry: ReplayAttemptTelemetry, failure?: Error) => void - readonly onActionCommitmentRetry?: ( - telemetry: ActionCommitmentRetryTelemetry - ) => void | Promise readonly onEmptyCleanEofRetry?: (telemetry: EmptyCleanEofRetryTelemetry) => void | Promise } @@ -182,10 +149,7 @@ export class StreamRecoveryCoordinator { private sawToolIntent = false private activeRecoveryTier: RecoveryTier = 'none' private replayMatcher: ExactReplayMatcher | undefined - private actionCommitmentRetryUsed = false - private actionCommitmentReplayPending = false private emptyCleanEofRetryUsed = false - private actionCommitmentRetryDecision: ActionCommitmentRetryDecision | undefined private terminal = false private completionFired = false private abortListener: (() => void) | undefined @@ -278,11 +242,6 @@ export class StreamRecoveryCoordinator { continue } const observation = attempt.observed() - const actionCommitmentRetry = this.actionCommitmentRetryTelemetry(observation) - if (actionCommitmentRetry) { - if (!(await this.retryActionCommitment(observation, actionCommitmentRetry))) return - continue - } const emptyCleanEofRetry = this.emptyCleanEofRetryTelemetry(observation) if (emptyCleanEofRetry) { if (!(await this.retryEmptyCleanEof(observation, emptyCleanEofRetry))) return @@ -303,10 +262,7 @@ export class StreamRecoveryCoordinator { continue } if (match?.kind === 'release') { - if (match.caughtUp) { - this.actionCommitmentReplayPending = false - this.reportReplayAttempt('caught_up', 'none') - } + if (match.caughtUp) this.reportReplayAttempt('caught_up', 'none') this.pendingDeliveryChunks.push(...match.chunks) continue } @@ -363,13 +319,6 @@ export class StreamRecoveryCoordinator { await this.closeActiveAttempt() if (this.terminal) return false - if (this.actionCommitmentReplayPending) { - this.actionCommitmentReplayPending = false - this.finish('recovery_unavailable') - controller.error(this.options.mapError(failure)) - return false - } - const tier = decideRecoveryTier({ mode: this.options.mode, emitted: { @@ -400,66 +349,6 @@ export class StreamRecoveryCoordinator { return !this.terminal } - private actionCommitmentRetryTelemetry( - observation: AttemptObservation - ): ActionCommitmentRetryTelemetry | null { - const availableToolCount = observation.availableToolCount ?? 0 - if (this.options.mode !== 'exact_replay') - return this.skipActionCommitmentRetry('mode_not_exact_replay') - if (this.actionCommitmentRetryUsed) return this.skipActionCommitmentRetry('already_retried') - if (this.attemptIndex >= this.options.maxAttempts) - return this.skipActionCommitmentRetry('attempt_budget_exhausted') - if (observation.terminalSource !== 'clean_eof_without_completion_metadata') - return this.skipActionCommitmentRetry('not_clean_eof') - if (observation.emitted.visibleChars === 0) - return this.skipActionCommitmentRetry('no_visible_output') - if (observation.emitted.toolCount !== 0 || this.delivered.toolUses().length !== 0) - return this.skipActionCommitmentRetry('tool_output_present') - if (observation.sawToolIntent) return this.skipActionCommitmentRetry('tool_intent_seen') - if (availableToolCount === 0) return this.skipActionCommitmentRetry('no_tools_available') - - const pattern = observation.forwardActionCommitment - if (pattern === null || pattern === undefined) - return this.skipActionCommitmentRetry('pattern_not_detected') - - return { - attemptIndex: this.attemptIndex, - pattern, - visibleChars: this.delivered.visibleText.length, - availableToolCount - } - } - - private skipActionCommitmentRetry( - decision: Exclude - ): null { - this.actionCommitmentRetryDecision ??= decision - return null - } - - private async retryActionCommitment( - observation: AttemptObservation, - telemetry: ActionCommitmentRetryTelemetry - ): Promise { - this.actionCommitmentRetryUsed = true - this.mergeObservation(observation) - this.pendingTerminalChunks.length = 0 - this.pendingDeliveryChunks.length = 0 - await this.closeActiveAttempt() - if (this.terminal) return false - - this.activeRecoveryTier = 'exact_replay' - this.actionCommitmentRetryDecision = 'retried' - this.actionCommitmentReplayPending = true - this.replayMatcher = new ExactReplayMatcher({ - reasoningText: this.delivered.reasoningText, - visibleText: this.delivered.visibleText, - toolUses: this.delivered.toolUses() - }) - await this.options.onActionCommitmentRetry?.(telemetry) - return !this.terminal - } - private emptyCleanEofRetryTelemetry( observation: AttemptObservation ): EmptyCleanEofRetryTelemetry | null { @@ -569,10 +458,6 @@ export class StreamRecoveryCoordinator { this.options.signal.removeEventListener('abort', this.abortListener) this.abortListener = undefined } - this.options.onTerminal(reason, { - ...(this.actionCommitmentRetryDecision - ? { actionCommitmentRetryDecision: this.actionCommitmentRetryDecision } - : {}) - }) + this.options.onTerminal(reason) } }