diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts index 90ff94d15d..3b18e0397d 100644 --- a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts +++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts @@ -19,11 +19,117 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import type { FollowUpMode } from '@maka/core/events'; +import type { ComposerSendMetadata } from '@maka/ui'; import { + createRevisionAwareOnSend, mergeWorkspaceReferences, + type RevisionSendPorts, } from '../../renderer/features/conversation/testing.js'; +interface ObservedCall { + kind: 'enqueue' | 'send'; + mode?: FollowUpMode; + text?: string; +} + +function routingPorts(calls: ObservedCall[]): RevisionSendPorts<{ sourceSessionId: string; draftSessionId: string }> { + return { + shellCopy: { + sideChatUnavailableTitle: '', + sideChatUnavailableDescription: '', + sideChatContextPendingTitle: '', + sideChatContextPendingDescription: '', + swarmModeEnabledTitle: '', + swarmModeDisabledTitle: '', + swarmModeStatusDescription: '', + graphModeEnabledTitle: '', + graphModeDisabledTitle: '', + graphModeStatusDescription: '', + graphHistoryTitle: '', + graphHistoryDescription: '', + }, + toastApi: { info() {} }, + activeIdRef: { current: 'session-a' }, + revisionDraftRef: { current: null }, + composerRef: { current: null }, + retractedWorkspaceReferencesRef: { current: {} }, + captureStaging: () => ({ + draftKey: 'draft', + hasPendingContext: false, + hasStagedQuotes: false, + submittableAttachments: undefined, + directoryOptions: {}, + quotesForSend: () => undefined, + retainAttachments: () => () => {}, + clearSubmittedContext: () => {}, + clearQuotes: () => {}, + }), + prepareRevisionSend: async () => true, + send: async (text) => { + calls.push({ kind: 'send', text }); + return true; + }, + enqueueFollowUp: async (_sessionId, text, mode) => { + calls.push({ kind: 'enqueue', mode, text }); + return true; + }, + settleNewTaskImageNoticeOwner: () => {}, + commitRevisionDraft: () => {}, + completeRevisionCopyAttempt: () => {}, + parseSlashCommand: () => null, + mergeWorkspaceReferences, + rebaseWorkspaceFileReferences: (_source, _projected, references) => [...references], + revisionUnavailableCopy: { + revisionUnavailableTitle: '', + revisionAttachmentsUnsupported: '', + revisionCommandUnsupported: '', + }, + compactSession: async () => true, + resolveNewTaskSessionHandler: () => () => {}, + openSideChat: () => {}, + getActiveOrchestrationMode: () => 'default', + setOrchestrationModeActive: async () => true, + }; +} + describe('follow-up submit routing', () => { + it('queues an ordinary follow-up on a healthy turn without calling stop', async () => { + const calls: ObservedCall[] = []; + const ports = routingPorts(calls); + const send = createRevisionAwareOnSend({ + ...ports, + setNewTaskSendPending: () => {}, + }); + // Composer sets queue for plain Enter while a healthy Turn is running. + const metadata: ComposerSendMetadata = { followUpMode: 'queue' }; + assert.equal(await send('keep going', metadata), true); + assert.deepEqual(calls, [{ kind: 'enqueue', mode: 'queue', text: 'keep going' }]); + }); + + it('keeps an explicit steer on the queue path and still does not stop', async () => { + const calls: ObservedCall[] = []; + const ports = routingPorts(calls); + assert.equal( + await createRevisionAwareOnSend({ ...ports, setNewTaskSendPending: () => {} })( + 'change direction', + { followUpMode: 'steer' }, + ), + true, + ); + assert.deepEqual(calls, [{ kind: 'enqueue', mode: 'steer', text: 'change direction' }]); + }); + + it('sends a root turn when no follow-up mode is set, without calling stop', async () => { + const calls: ObservedCall[] = []; + const ports = routingPorts(calls); + assert.equal( + await createRevisionAwareOnSend({ ...ports, setNewTaskSendPending: () => {} })('hello'), + true, + ); + assert.deepEqual(calls, [{ kind: 'send', text: 'hello' }]); + }); + it('restores workspace references after queued text returns to the draft', () => { assert.deepEqual( mergeWorkspaceReferences( diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index cf9ad0e031..55465907df 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -2017,8 +2017,7 @@ function isToolActivityIdentity(value: Record): boolean { ); } -export const STEP_LIMIT_NOTICE_TEXT = - 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.'; +export const STEP_LIMIT_NOTICE_TEXT = 'Reached the tool-step limit. The task may be incomplete.'; /** Latest actual model recorded by a completed assistant step. */ export function latestAssistantModelId(messages: readonly StoredMessage[]): string | undefined { diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index fdc600aa3e..2ad8196296 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6439,6 +6439,325 @@ describe('AiSdkBackend model history', () => { assert.equal(usage?.type === 'token_usage' ? usage.total : undefined, 2); }); + test('stops an unbounded loop after consecutive identical empty tool steps', async () => { + // Desktop often omits maxSteps. A model that repeats the same tool call with + // no visible text would otherwise flood empty assistant rows forever (#4083). + const loop = countingToolLoopModel(undefined, true); + const durable = durableTurnHarness('turn-empty-loop', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(loop.callCount(), 3); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); + }); + + test('stops an unbounded loop when empty tool steps alternate between signatures', async () => { + // A consecutive-only counter resets on A→B→A→B. The recent window must still + // treat that as no distinct progress once it fills with fewer distinct + // signatures than steps (#4083). + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const path = calls % 2 === 1 ? 'notes-a.md' : 'notes-b.md'; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `tool-${calls}`, + toolName: 'Read', + input: JSON.stringify({ path }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-empty-alternating-loop', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(calls, 6); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 6); + }); + + test('stops an unbounded loop when Responses reasoning-end is only an empty carrier', async () => { + // OpenAI Responses emits `{ kind: 'thinking', text: '' }` at reasoning-end + // whenever provider metadata is present. That carrier must not count as + // visible thinking, or identical textless tool steps never reach the cap. + const reasoningMetadata = { + openai: { + itemId: 'rs_empty', + reasoningEncryptedContent: 'encrypted-carrier', + }, + }; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1', providerMetadata: reasoningMetadata }, + { type: 'reasoning-end', id: 'r1', providerMetadata: reasoningMetadata }, + { + type: 'tool-call', + toolCallId: `tool-${calls}`, + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-empty-responses-loop', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(calls, 3); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); + }); + + test('stops an unbounded loop when only a thinking signature accompanies identical tool calls', async () => { + // Anthropic can emit omitted/redacted reasoning as a standalone signature + // with no text. The signature must persist for replay, but must not count + // as visible thinking or the empty-step cap never fires (#4083). + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1' }, + { + type: 'reasoning-delta', + id: 'r1', + delta: '', + providerMetadata: { anthropic: { signature: `sig-${calls}` } }, + }, + { type: 'reasoning-end', id: 'r1' }, + { + type: 'tool-call', + toolCallId: `tool-${calls}`, + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-empty-signature-loop', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(calls, 3); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); + assert.ok( + events.some( + (event) => + event.type === 'thinking_complete' && event.signature !== undefined && event.text === '', + ), + 'signature-only reasoning must still persist', + ); + }); + + test('does not count ordinary progressing textless tool steps toward the empty-step cap', async () => { + // #4083 review P2: the window rule must not false-trigger on legitimate + // tool-only work where each step makes new progress. Six textless Read + // steps whose paths vary (a, b, c, a, d, e) then a real answer must end + // with end_turn — the pre-#4083 behaviour — not stop at the bound. + const paths = ['a.md', 'b.md', 'c.md', 'a.md', 'd.md', 'e.md']; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + const call = calls; + calls += 1; + const path = paths[call]; + const chunks: LanguageModelV4StreamPart[] = + path === undefined + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'all done' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `tool-${call + 1}`, + toolName: 'Read', + input: JSON.stringify({ path }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + const durable = durableTurnHarness('turn-empty-distinct-progress', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(calls, 7); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 6); + }); + + test('a mid-loop steer resets the empty-step streak so the next textless step is not charged against it', async () => { + // #4083 review P3: a steer injected between tool steps is user progress. + // The two identical textless steps before it must not combine with the one + // after it into the third consecutive repeat that stops the turn; without + // the reset the bound fires right after consuming the steer. + const loop = countingToolLoopModel(3, true); + const durable = durableTurnHarness('turn-empty-steer-reset', 'keep going'); + let steered = false; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably( + backend.send( + durable.input({ + pullSteering: async () => { + // Only after two textless tool steps have already run, so the + // steer lands between the second and the third. + if (steered || loop.callCount() < 2) return []; + steered = true; + return [ + { + id: 'lease-steer', + messageId: 'message-steer', + content: { text: 'STEER_SENTINEL' }, + }, + ]; + }, + }), + ), + durable, + ); + + // Three textless tool steps then the answer: the count never reaches the + // three-consecutive cap because the steer reset it in between. + assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal(events.filter((event) => event.type === 'steering_message').length, 1); + // The steer really reached the model rather than being dropped. + assert.match(JSON.stringify(loop.model.doStreamCalls.at(-1)?.prompt), /STEER_SENTINEL/); + }); + for (const decision of ['cancel', 'commit', 'stop'] as const) { test(`cooperative handoff ${decision} waits for the settled tool and gates the next request`, { timeout: 5_000, @@ -6537,8 +6856,9 @@ describe('AiSdkBackend model history', () => { boundaries += 1; return 'pause'; }, - })) + })) { events.push(event); + } assert.equal(boundaries, 0); assert.equal( events.some((event) => event.type === 'complete'), @@ -18066,7 +18386,10 @@ function planExecution(status: 'completed' | 'cancelled') { }; } -function countingToolLoopModel(toolCallsBeforeStop?: number): { +function countingToolLoopModel( + toolCallsBeforeStop?: number, + repeatToolInput = false, +): { model: MockLanguageModelV4; callCount: () => number; } { @@ -18096,7 +18419,7 @@ function countingToolLoopModel(toolCallsBeforeStop?: number): { type: 'tool-call', toolCallId: `tool-${calls}`, toolName: 'Read', - input: JSON.stringify({ path: `notes-${calls}.md` }), + input: JSON.stringify({ path: repeatToolInput ? 'notes.md' : `notes-${calls}.md` }), }, { type: 'finish', diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 309deb3b65..6f6e506576 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -167,6 +167,13 @@ interface ReactiveFixtureOptions { providerNative?: boolean; /** Explicit send-level step budget forwarded to the backend. */ maxSteps?: number; + /** + * Give each scripted `tool` step a distinct Read path. Needed when a test + * chains several textless tool steps: the Runtime empty-step cap (#4083) + * stops consecutive identical tool signatures, which would otherwise look + * like a stuck loop rather than intentional context growth. + */ + distinctToolPaths?: boolean; /** Per provider-call reported usage, keyed by 1-based call number. */ usageByCall?: Record; /** The FIRST tool step reports an unusable usage object (no token counts). */ @@ -431,7 +438,9 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture } const chunks = kind === 'tool' - ? toolCallChunks(call, 'Read', { path: 'one.md' }) + ? toolCallChunks(call, 'Read', { + path: options.distinctToolPaths ? `one-${call}.md` : 'one.md', + }) : kind === 'bigtool' ? toolCallChunks(call, 'Read', { path: 'big.md' }, RETRY_STEP_TEXT_SENTINEL) : kind === 'bigread' @@ -1733,9 +1742,11 @@ describe('reactive overflow recovery in the streaming backend', () => { // Review P1-1 repro: four completed tool steps grow the provider-visible // request far beyond the attempt's INITIAL messages. Recovery must fold the // durable rejected-request history rather than relying on that stale base; - // same-turn tool growth must remain recoverable. + // same-turn tool growth must remain recoverable. Distinct paths keep this + // growth from matching the identical empty-step cap (#4083). const fixture = buildReactiveFixture({ script: ['tool', 'tool', 'tool', 'tool', 'overflow', 'done'], + distinctToolPaths: true, }); await runTurn(fixture); @@ -1747,7 +1758,7 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(fixture.recorded.length, 1); assert.equal(fixture.model.doStreamCalls.length, 6); // The four completed tool steps ran exactly once each. - assert.deepEqual(fixture.toolExecutions, ['one.md', 'one.md', 'one.md', 'one.md']); + assert.deepEqual(fixture.toolExecutions, ['one-1.md', 'one-2.md', 'one-3.md', 'one-4.md']); }); test('an unusable first-attempt step usage fails the whole record closed even when the retry succeeds', async () => { @@ -2041,6 +2052,10 @@ describe('reactive overflow recovery in the streaming backend', () => { const fixture = buildReactiveFixture({ script: ['tool', 'overflow', 'tool', 'tool', 'overflow', 'done'], bigPriors: true, + // Distinct paths keep the post-retry tool steps from matching the + // identical empty-step cap (#4083) and turning the turn into a + // step_limit failure. + distinctToolPaths: true, }); await runTurn(fixture); @@ -2053,7 +2068,7 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(fixture.recorded.length, 2); assert.equal(fixture.summarizerCalls(), 2); // No completed tool step was replayed across either retry. - assert.deepEqual(fixture.toolExecutions, ['one.md', 'one.md', 'one.md']); + assert.deepEqual(fixture.toolExecutions, ['one-1.md', 'one-3.md', 'one-4.md']); // The second fold covers the steps accepted after the first one, so it is // a fold of new history rather than a repeat of the same prefix. assert.equal( diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index 0c032cb0d9..c9c773e507 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -23,6 +23,7 @@ * construction and cross-turn routing remain in AiSdkBackend. */ +import { createHash, type Hash } from 'node:crypto'; import type { AbortEvent, CompleteEvent, @@ -585,6 +586,84 @@ const MAX_WAITING_CODE_MODE_CELLS = 1; const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10; const CONTEXT_RECOVERY_MAX_OUTPUT_TOKENS = 8_000; +/** + * Desktop interactive turns often omit `maxSteps`, so a model that keeps + * emitting textless tool-only steps can loop forever and flood the transcript + * with empty AI replies (#4083). Ordinary multi-step tool workflows and an + * explicit `maxSteps` remain authoritative. + * + * Identical consecutive signatures still trip after three repeats. Alternating + * textless signatures (A B A B …) bypass a consecutive-only counter, so the + * recent window also stops when at most half its signatures are distinct — i.e. + * the window is cycling rather than merely containing one benign repeat among + * otherwise new work. + */ +const MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS = 3; +const EMPTY_STEP_SIGNATURE_WINDOW = 6; +/** + * Digest of a textless step's tool batch, covering both each call's arguments + * and its settled result. Results are part of the signature so a repeated call + * whose result changes (polling a job, re-reading an edited file) reads as + * progress instead of a loop. Values are fed to the hash piece by piece, so a + * large result (a screenshot, a big file read) is never copied into one + * serialized string, and no size cap lets a loop on it escape the bound. + * Follows JSON's view of the value: `toJSON` applies and undefined properties + * are absent. + */ +function hashEmptyStepSignature(payload: unknown): string { + const hash = createHash('sha256'); + updateEmptyStepDigest(hash, payload, new WeakSet()); + return hash.digest('hex'); +} + +function updateEmptyStepDigest(hash: Hash, value: unknown, ancestors: WeakSet): void { + if (value === null || value === undefined) { + hash.update('n;'); + return; + } + if (typeof value === 'string') { + hash.update(`s${value.length}:`); + hash.update(value); + return; + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + hash.update(`${typeof value}:${String(value)};`); + return; + } + if (typeof value !== 'object') { + hash.update('n;'); + return; + } + if (ArrayBuffer.isView(value)) { + hash.update(`b${value.byteLength}:`); + hash.update(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); + return; + } + const toJSON = (value as { toJSON?: unknown }).toJSON; + if (typeof toJSON === 'function') { + updateEmptyStepDigest(hash, toJSON.call(value), ancestors); + return; + } + if (ancestors.has(value)) { + hash.update('c;'); + return; + } + ancestors.add(value); + if (Array.isArray(value)) { + hash.update(`a${value.length}[`); + for (const item of value) updateEmptyStepDigest(hash, item, ancestors); + hash.update(']'); + } else { + const entries = Object.entries(value).filter(([, entry]) => entry !== undefined); + hash.update(`o${entries.length}{`); + for (const [key, entry] of entries) { + updateEmptyStepDigest(hash, key, ancestors); + updateEmptyStepDigest(hash, entry, ancestors); + } + hash.update('}'); + } + ancestors.delete(value); +} const PROVIDER_RETRY_BASE_DELAY_MS = 1_000; const PROVIDER_RETRY_MAX_DELAY_MS = 32_000; const PROVIDER_RETRY_JITTER_FACTOR = 0.25; @@ -1503,6 +1582,14 @@ export class AiSdkTurn { let providerOutcome: ModelStepOutcome; let finishReason: ModelFinishReason = 'stop'; let terminalProviderError: unknown; + let consecutiveIdenticalEmptySteps = 0; + let previousEmptyStepSignature: string | undefined; + const recentEmptyStepSignatures: string[] = []; + const clearEmptyStepProgress = (): void => { + consecutiveIdenticalEmptySteps = 0; + previousEmptyStepSignature = undefined; + recentEmptyStepSignatures.length = 0; + }; agentLoop: for (;;) { ({ plan, providerTools, modelTools, nestedTools } = snapshotStepTools()); resolvedSystemPrompt = await this.resolveSystemPrompt(); @@ -1512,7 +1599,18 @@ export class AiSdkTurn { this.orchestration.mode === 'graph' ? renderGraphModePrompt() : undefined, ]); capacityProviderTools.splice(0, capacityProviderTools.length, ...providerTools); + let stepSawVisibleText = false; + let stepSawThinking = false; + let settledToolResults: unknown[] | undefined; + // A mid-loop steer is user progress even when the next model step + // repeats the same textless tool result. Reset before the signature + // check so a mid-loop steer cannot be charged as the third identical + // empty step (#4083 review). + const injectedBeforeDrain = this.injectedSteeringMessages.length; await this.drainSteeringInto(input, queue); + if (this.injectedSteeringMessages.length > injectedBeforeDrain) { + clearEmptyStepProgress(); + } if (this.deps.backend.loadTurnRuntimeEvents) { requestMessages = await loadDurableTurnProjection(); } else { @@ -1712,7 +1810,10 @@ export class AiSdkTurn { stepTextPartStartOffset = stepText.length; } else if (event.kind === 'text') { stepText += event.text; - if (event.text.length > 0) attemptSawVisibleContent = true; + if (event.text.length > 0) { + attemptSawVisibleContent = true; + stepSawVisibleText = true; + } queue.push({ type: 'text_delta', id: this.deps.newId(), @@ -1751,7 +1852,15 @@ export class AiSdkTurn { stepThinkingPartsById.set(event.reasoningPartId, part); } } else if (event.kind === 'thinking') { - if (event.text.length > 0) attemptSawVisibleContent = true; + // OpenAI Responses emits an empty thinking carrier at + // `reasoning-end` whenever provider metadata is present. That + // is not user-visible progress, so it must not reset the + // empty-step loop cap (#4083). Persistence still appends to + // `stepThinkingParts` below so the encrypted carrier round-trips. + if (event.text.length > 0) { + attemptSawVisibleContent = true; + stepSawThinking = true; + } if (event.providerOptions !== undefined) { if (event.providerOptionsOrigin !== 'maka_transport') { attemptSawReplayBarrier = true; @@ -2261,6 +2370,11 @@ export class AiSdkTurn { this.handleAgentGraphYieldToolResult(settlement.result); } }); + // Only the results are kept, and only until this step's empty-step + // signature is taken below (#4083). + settledToolResults = settlementOutcomes.map((outcome) => + outcome.status === 'fulfilled' ? outcome.value.result : null, + ); // Continuation reads durable events, not raw results. Do not retain // an entire completed batch across the next provider request. settlementOutcomes.length = 0; @@ -2290,6 +2404,54 @@ export class AiSdkTurn { ...(providerStepUsage ? { usage: providerStepUsage } : {}), }); lastCompletedStepHadToolResult = returnedToolCalls.length > 0; + const stepResults = settledToolResults; + const emptyStepSignature = + !stepSawVisibleText && + !stepSawThinking && + returnedToolCalls.length > 0 && + stepResults !== undefined + ? hashEmptyStepSignature( + returnedToolCalls.map(({ toolName, input }, index) => ({ + toolName, + input, + result: stepResults[index] ?? null, + })), + ) + : undefined; + if ( + maxSteps === undefined && + emptyStepSignature !== undefined && + !this.loopStopRequested + ) { + consecutiveIdenticalEmptySteps = + emptyStepSignature === previousEmptyStepSignature + ? consecutiveIdenticalEmptySteps + 1 + : 1; + previousEmptyStepSignature = emptyStepSignature; + recentEmptyStepSignatures.push(emptyStepSignature); + if (recentEmptyStepSignatures.length > EMPTY_STEP_SIGNATURE_WINDOW) { + recentEmptyStepSignatures.shift(); + } + const distinctEmptySignatures = new Set(recentEmptyStepSignatures).size; + // Require a short cycle (≤ half distinct), not merely one duplicate + // among otherwise progressing textless steps. + const windowHasNoDistinctProgress = + recentEmptyStepSignatures.length >= EMPTY_STEP_SIGNATURE_WINDOW && + distinctEmptySignatures * 2 <= recentEmptyStepSignatures.length; + if ( + consecutiveIdenticalEmptySteps >= MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS || + windowHasNoDistinctProgress + ) { + // The model is repeating textless tool-only steps with no visible + // progress — either the same signature consecutively, or a short + // alternating cycle. Stop as a failed tool-step cap rather than + // reporting a successful end_turn with no answer (#4083). + this.loopStopReason = 'step_limit'; + this.loopStopRequested = true; + } + } else { + clearEmptyStepProgress(); + } const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps; if ( sandboxBoundaryFinalizationStep || diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index ed882c62ba..fe152b5c0e 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -541,7 +541,7 @@ const conversationCopyZhCn: ConversationCopy = { contextUsageUnavailable: '暂无上下文用量数据', contextUsageCompacted: '上下文已压缩,用量将在下一次请求完成后更新。', contextUsageOpen: '打开用量追踪', - stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', + stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。', }, }, chat: { @@ -665,7 +665,7 @@ const conversationCopyZhTw: ConversationCopy = { contextUsageUnavailable: '暫無上下文用量資料', contextUsageCompacted: '上下文已壓縮,用量將在下一次請求完成後更新。', contextUsageOpen: '開啟用量追蹤', - stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。傳送“繼續”即可接著處理。', + stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。', }, }, chat: { @@ -788,7 +788,7 @@ const conversationCopyEn: ConversationCopy = { contextUsageCompacted: 'Context has been compacted. Usage will update when the next request completes.', contextUsageOpen: 'Open usage trace', - stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.', + stepLimit: 'Reached the tool-step limit. The task may be incomplete.', }, }, chat: {