mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(core): Allow agents to finish without a reply (no-changelog) (#38973)
This commit is contained in:
parent
3dd22cfd60
commit
512ac3be49
|
|
@ -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' }] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user