From 512ac3be49fb25c32703cee4d4dc151fc7e2e588 Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:56:31 +0000 Subject: [PATCH] fix(core): Allow agents to finish without a reply (no-changelog) (#38973) --- .../runtime/__tests__/agent-runtime.test.ts | 123 +++++++++++++----- .../runtime/__tests__/runtime-helpers.test.ts | 4 +- .../agents/src/runtime/loop/agent-runtime.ts | 3 +- .../src/runtime/loop/runtime-helpers.ts | 33 ++--- 4 files changed, 112 insertions(+), 51 deletions(-) diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index f32cb44b5b1..e7d796a72a5 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -893,6 +893,17 @@ describe('AgentRuntime — reasoning-only turn', () => { }; } + function makeGenerateReasoningOnly(finishReason = 'stop') { + return { + finishReason, + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + response: { + messages: [{ role: 'assistant', content: [{ type: 'reasoning', text: 'thinking...' }] }], + }, + toolCalls: [], + }; + } + it('retries a reasoning-only turn instead of ending the run', async () => { streamText .mockReturnValueOnce(makeStreamReasoningOnly()) @@ -927,41 +938,87 @@ describe('AgentRuntime — reasoning-only turn', () => { expect(String((error?.error as Error).message)).toContain('no output'); }); - it.each([ - { finishReason: 'length', errorText: 'output token limit' }, - { finishReason: 'stop', errorText: 'without returning an answer' }, - ])( - 'fails a reasoning-only $finishReason turn without retrying or persisting it', - async ({ finishReason, errorText }) => { - streamText.mockReturnValue(makeStreamReasoningOnly(finishReason)); - const memory = new InMemoryMemory(); - const runtime = new AgentRuntime({ - name: 'test', - model: 'anthropic/claude-opus-5', - instructions: 'You are a test assistant.', - memory, - }); + it('fails a reasoning-only length turn without retrying or persisting it', async () => { + streamText.mockReturnValue(makeStreamReasoningOnly('length')); + const memory = new InMemoryMemory(); + const runtime = new AgentRuntime({ + name: 'test', + model: 'anthropic/claude-opus-5', + instructions: 'You are a test assistant.', + memory, + }); - const result = await runtime.stream('make my workflow smarter', { - persistence: { threadId: 'thread-1', resourceId: 'user-1' }, - }); - const chunks = await collectChunks(result.stream); + const result = await runtime.stream('make my workflow smarter', { + persistence: { threadId: 'thread-1', resourceId: 'user-1' }, + }); + const chunks = await collectChunks(result.stream); - expect(streamText).toHaveBeenCalledTimes(1); - expect(chunks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: 'error' }), - expect.objectContaining({ type: 'finish', finishReason: 'error' }), - ]), - ); - const error = chunks.find((chunk) => chunk.type === 'error'); - expect(String(error?.error)).toContain(errorText); - const persisted = await memory.getMessages('thread-1', { resourceId: 'user-1' }); - expect( - persisted.filter((message) => (message as { role?: string }).role === 'assistant'), - ).toHaveLength(0); - }, - ); + expect(streamText).toHaveBeenCalledTimes(1); + expect(chunks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'error' }), + expect.objectContaining({ type: 'finish', finishReason: 'error' }), + ]), + ); + const error = chunks.find((chunk) => chunk.type === 'error'); + expect(String(error?.error)).toContain('output token limit'); + const persisted = await memory.getMessages('thread-1', { resourceId: 'user-1' }); + expect( + persisted.filter((message) => (message as { role?: string }).role === 'assistant'), + ).toHaveLength(0); + }); + + it('streams a reasoning-only stop without an error or retry', async () => { + streamText.mockReturnValue(makeStreamReasoningOnly('stop')); + const { runtime } = createRuntime(); + + const result = await runtime.stream('make my workflow smarter'); + const chunks = await collectChunks(result.stream); + + expect(streamText).toHaveBeenCalledTimes(1); + expect(chunks.find((chunk) => chunk.type === 'error')).toBeUndefined(); + expect(chunks).toContainEqual( + expect.objectContaining({ type: 'finish', finishReason: 'stop' }), + ); + }); + + it('completes a reasoning-only stop and keeps it out of follow-up history', async () => { + generateText + .mockResolvedValueOnce(makeGenerateReasoningOnly()) + .mockResolvedValueOnce(makeGenerateSuccess('Follow-up answer')); + const memory = new InMemoryMemory(); + const runtime = new AgentRuntime({ + name: 'test', + model: 'anthropic/claude-opus-5', + instructions: 'You are a test assistant.', + memory, + }); + + const first = await runtime.generate('make my workflow smarter', { + persistence: { threadId: 'thread-1', resourceId: 'user-1' }, + }); + const second = await runtime.generate('what next?', { + persistence: { threadId: 'thread-1', resourceId: 'user-1' }, + }); + + expect(first).toEqual( + expect.objectContaining({ + finishReason: 'stop', + usage: expect.objectContaining({ totalTokens: 15 }), + }), + ); + expect(first.error).toBeUndefined(); + expect(second).toEqual(expect.objectContaining({ finishReason: 'stop' })); + expect(generateText).toHaveBeenCalledTimes(2); + const persisted = await memory.getMessages('thread-1', { resourceId: 'user-1' }); + const assistantMessages = persisted.filter( + (message) => (message as { role?: string }).role === 'assistant', + ); + expect(assistantMessages).toHaveLength(1); + expect(assistantMessages[0]).toEqual( + expect.objectContaining({ content: [{ type: 'text', text: 'Follow-up answer' }] }), + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/@n8n/agents/src/runtime/__tests__/runtime-helpers.test.ts b/packages/@n8n/agents/src/runtime/__tests__/runtime-helpers.test.ts index 6befbcd4c59..f04ac970752 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/runtime-helpers.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/runtime-helpers.test.ts @@ -19,13 +19,13 @@ describe('isEmptyModelTurn', () => { ).toBe(true); }); - it('is true for a reasoning-only stop turn', () => { + it('is false for a reasoning-only stop turn', () => { expect( isEmptyModelTurn({ aiFinishReason: 'stop', newMessages: [assistant([{ type: 'reasoning', text: 'thinking…' }])], }), - ).toBe(true); + ).toBe(false); }); it('is false when the turn has text', () => { diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index 62e74c5eb64..9eb6ab33c80 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -12,6 +12,7 @@ import { extractSettledToolCalls, formatMcpConnectionNote, isEmptyModelTurn, + isReasoningOnlyStop, makeErrorStream, mergeUsage, normalizeInput, @@ -969,7 +970,7 @@ export class AgentRuntime { this.assertNotAborted(abortScope); lastFinishReason = turn.finishReason; - list.addResponse(turn.newMessages); + if (!isReasoningOnlyStop(turn)) list.addResponse(turn.newMessages); // The turn is now in the list; drop any retained streamed text so a later // abort's snapshot can't duplicate it (a stop before this point recovers it). sink.onTurnFolded?.(); diff --git a/packages/@n8n/agents/src/runtime/loop/runtime-helpers.ts b/packages/@n8n/agents/src/runtime/loop/runtime-helpers.ts index fcc3d8111f2..bcc183db4f8 100644 --- a/packages/@n8n/agents/src/runtime/loop/runtime-helpers.ts +++ b/packages/@n8n/agents/src/runtime/loop/runtime-helpers.ts @@ -60,8 +60,7 @@ const EMPTY_RESPONSE_ERROR_FINISH_REASONS = new Set([ /** * Whether a turn carries output the user can see or the loop can act on. - * Reasoning is neither: a thinking block the model never turned into an answer - * or a tool call leaves the run with nothing to show and nothing to do next. + * Reasoning is neither: it is not visible output or an action for the loop. */ function hasActionableContent(messages: AgentMessage[]): boolean { return messages.some( @@ -88,6 +87,17 @@ function hasReasoningContent(messages: AgentMessage[]): boolean { ); } +export function isReasoningOnlyStop(turn: { + aiFinishReason: string; + newMessages: AgentMessage[]; +}): boolean { + return ( + turn.aiFinishReason === 'stop' && + hasReasoningContent(turn.newMessages) && + !hasActionableContent(turn.newMessages) + ); +} + /** * Classify a turn that produced no output as a recognized failure, or return * `undefined` when it doesn't look like a provider rejection. Some providers @@ -102,12 +112,6 @@ export function classifyModelTurnError(turn: { providerError?: RawProviderError; }): ModelTurnError | undefined { if (hasActionableContent(turn.newMessages)) return undefined; - if (turn.aiFinishReason === 'stop' && hasReasoningContent(turn.newMessages)) { - return { - type: 'no_output', - message: 'The model finished without returning an answer. Try again or use another model.', - }; - } if (!EMPTY_RESPONSE_ERROR_FINISH_REASONS.has(turn.aiFinishReason)) return undefined; const guidance = @@ -136,11 +140,10 @@ export function classifyModelTurnError(turn: { * call, no file. Providers emit such a turn mid-task in more than one shape: a * bare `stop` (observed with Kimi via Together), or a stream that dies before * its terminal chunk, leaving the SDK to synthesize a finish from its defaults - * (`other`, no usage) around a reasoning-only message. Either way the run would - * end silently with work half-done, so callers retry a bounded number of times - * before accepting it. Reasoning-only turns count as empty: they carry no - * user-visible output and no action. `tool-calls` is the one finish reason that - * cannot be empty — the calls are the turn's output. + * (`other`, no usage) around a reasoning-only message. Callers retry these + * broken or bare turns a bounded number of times. A normal reasoning-only + * `stop` is a successful silent completion. `tool-calls` cannot be empty + * because the calls are the turn's output. */ export function isEmptyModelTurn(turn: { aiFinishReason: string; @@ -150,8 +153,8 @@ export function isEmptyModelTurn(turn: { }): boolean { if (turn.aiFinishReason === 'tool-calls') return false; if (turn.structuredOutput !== undefined) return false; - // A reasoning-only stop or a truncated turn cannot recover under the same - // conditions. Retrying only consumes more tokens. + if (isReasoningOnlyStop(turn)) return false; + // An output-limit error cannot recover under the same conditions. if (turn.errorReason && (turn.aiFinishReason === 'stop' || turn.aiFinishReason === 'length')) { return false; }