Skip to content

Commit 7a26bab

Browse files
Rangshcursoragent
andcommitted
fix(runtime): keep signature-only thinking out of the empty-step cap
A standalone Anthropic/Responses thinking signature is omitted or redacted reasoning, not user-visible progress. Persist it for replay, but do not let it prevent emptyStepSignature from bounding identical textless tool steps (#4083). Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b5470ca commit 7a26bab

2 files changed

Lines changed: 71 additions & 1 deletion

File tree

‎packages/runtime/src/__tests__/ai-sdk-backend.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6151,6 +6151,74 @@ describe('AiSdkBackend model history', () => {
61516151
assert.equal(events.filter((event) => event.type === 'tool_start').length, 3);
61526152
});
61536153

6154+
test('stops an unbounded loop when only a thinking signature accompanies identical tool calls', async () => {
6155+
// Anthropic can emit omitted/redacted reasoning as a standalone signature
6156+
// with no text. The signature must persist for replay, but must not count
6157+
// as visible thinking or the empty-step cap never fires (#4083).
6158+
let calls = 0;
6159+
const model = new MockLanguageModelV4({
6160+
doStream: async () => {
6161+
calls += 1;
6162+
return {
6163+
stream: simulateReadableStream({
6164+
chunks: [
6165+
{ type: 'stream-start', warnings: [] },
6166+
{ type: 'reasoning-start', id: 'r1' },
6167+
{
6168+
type: 'reasoning-delta',
6169+
id: 'r1',
6170+
delta: '',
6171+
providerMetadata: { anthropic: { signature: `sig-${calls}` } },
6172+
},
6173+
{ type: 'reasoning-end', id: 'r1' },
6174+
{
6175+
type: 'tool-call',
6176+
toolCallId: `tool-${calls}`,
6177+
toolName: 'Read',
6178+
input: JSON.stringify({ path: 'notes.md' }),
6179+
},
6180+
{
6181+
type: 'finish',
6182+
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
6183+
usage: emptyUsage(),
6184+
},
6185+
],
6186+
initialDelayInMs: null,
6187+
chunkDelayInMs: null,
6188+
}),
6189+
};
6190+
},
6191+
});
6192+
const durable = durableTurnHarness('turn-empty-signature-loop', 'keep going');
6193+
const backend = createTestAiSdkBackend({
6194+
sessionId: 'session-1',
6195+
header: header(),
6196+
appendMessage: async () => {},
6197+
connection: connection(),
6198+
apiKey: 'sk-test',
6199+
modelId: 'mock-model-id',
6200+
modelFactory: () => model,
6201+
tools: [testTool('Read', z.object({ path: z.string() }))],
6202+
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
6203+
newId: idGenerator(),
6204+
now: monotonicClock(),
6205+
});
6206+
6207+
const events = await drainDurably(backend.send(durable.input()), durable);
6208+
assert.equal(calls, 3);
6209+
assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit');
6210+
assert.equal(events.filter((event) => event.type === 'tool_start').length, 3);
6211+
assert.ok(
6212+
events.some(
6213+
(event) =>
6214+
event.type === 'thinking_complete' &&
6215+
event.signature !== undefined &&
6216+
event.text === '',
6217+
),
6218+
'signature-only reasoning must still persist',
6219+
);
6220+
});
6221+
61546222
test('aborting during post-stream persistence wins over step-limit completion', async () => {
61556223
const loop = countingToolLoopModel();
61566224
const gate = makeGate();

‎packages/runtime/src/ai-sdk-backend.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2447,7 +2447,9 @@ export class AiSdkBackend implements AgentBackend {
24472447
text: event.text,
24482448
} satisfies ThinkingDeltaEvent);
24492449
} else if (event.kind === 'thinking-signature') {
2450-
stepSawThinking = true;
2450+
// A standalone signature is omitted/redacted reasoning, not
2451+
// user-visible progress. Persist it for replay, but do not
2452+
// reset the empty-step loop cap (#4083).
24512453
attemptSawContinuationMetadata = true;
24522454
stepSignature = event.signature;
24532455
} else if (event.kind === 'provider-tool-input') {

0 commit comments

Comments
 (0)