fix(core): Allow agents to finish without a reply (no-changelog) (#38973)

This commit is contained in:
Robin Braumann 2026-09-17 13:56:31 +00:00 committed by GitHub
parent 3dd22cfd60
commit 512ac3be49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 112 additions and 51 deletions

View File

@ -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' }] }),
);
});
});
// ---------------------------------------------------------------------------

View File

@ -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', () => {

View File

@ -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?.();

View File

@ -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;
}