Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-tool-terminal-empty-final.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openrouter/agent": patch
---

Tolerate empty final `output` after completed tool rounds: retry the follow-up request once, then resolve successfully with empty text instead of throwing `Invalid final response: empty or invalid output`. Mini-class models intermittently treat a successful tool call as the terminal answer. Opt into the old throw with `strictFinalResponse: true`. Runs with no completed tool work still throw on empty output.
4 changes: 4 additions & 0 deletions packages/agent/src/inner-loop/call-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function callModel<
onTurnStart,
onTurnEnd,
allowFinalResponse,
strictFinalResponse,
...apiRequest
} = request;

Expand Down Expand Up @@ -165,5 +166,8 @@ export function callModel<
...(allowFinalResponse !== undefined && {
allowFinalResponse,
}),
...(strictFinalResponse !== undefined && {
strictFinalResponse,
}),
} as GetResponseOptions<TTools, TShared>);
}
7 changes: 7 additions & 0 deletions packages/agent/src/lib/async-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ type BaseCallModelInput<
* (HITL pause, approval pause, interruption, or natural completion).
*/
allowFinalResponse?: boolean | string;
/**
* When true, throw if the final response has an empty `output` array even
* after completed tool rounds (legacy behavior). Default false: empty
* finals after tool work are retried once, then accepted with empty text.
*/
strictFinalResponse?: boolean;
};

/**
Expand Down Expand Up @@ -199,6 +205,7 @@ export async function resolveAsyncFunctions<TTools extends readonly Tool[] = rea
'onTurnStart', // Client-side turn start callback
'onTurnEnd', // Client-side turn end callback
'allowFinalResponse', // Client-side: triggers no-tools final turn when stopWhen breaks the loop
'strictFinalResponse', // Client-side: restore throw on empty final after tool rounds
]);

// Iterate over all keys in the input
Expand Down
113 changes: 108 additions & 5 deletions packages/agent/src/lib/model-result.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New client-only option leaks into API requests when no async parameter functions are used

The new strictFinalResponse option is sent to the API as an unknown field (resolveRequestForContext at packages/agent/src/lib/model-result.ts:1367-1375) because the non-async code path only strips six client-only fields, while the async path (packages/agent/src/lib/async-params.ts:197-208) correctly strips all ten.

Impact: An unrecognized field is included in every API request when strictFinalResponse is set without any async parameter functions, which could cause unexpected API errors.

Mismatch between async and non-async field stripping

The async path in resolveAsyncFunctions (packages/agent/src/lib/async-params.ts:197-208) uses a clientOnlyFields set that includes strictFinalResponse, allowFinalResponse, onTurnStart, onTurnEnd, and sharedContextSchema. The non-async path in resolveRequestForContext (packages/agent/src/lib/model-result.ts:1367-1375) only destructures out stopWhen, state, requireApproval, approveToolCalls, rejectToolCalls, and context. The remaining client-only fields (including the newly added strictFinalResponse) pass through into this.resolvedRequest and are sent to the API via betaResponsesSend.

(Refers to lines 1367-1375)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ export interface GetResponseOptions<
* a final user message.
*/
allowFinalResponse?: boolean | string;
/**
* When true, always throw if the final response has an empty `output` array
* (legacy behavior). Default false: after at least one completed tool
* execution round, an empty final turn is retried once and then accepted
* so tool-terminal runs are not reported as failures.
*/
strictFinalResponse?: boolean;
}

/**
Expand Down Expand Up @@ -1253,6 +1260,7 @@ export class ModelResult<
input: newInput,
stream: true,
};
this.resolvedRequest = finalRequest;

const result = await betaResponsesSend(
this.options.client,
Expand Down Expand Up @@ -1284,17 +1292,80 @@ export class ModelResult<
* Validate the final response has required fields.
*
* @param response - The response to validate
* @param allowEmptyOutput - When true, tolerate an empty (but present) output array
* @throws Error if response is missing required fields or has invalid output
*/
private validateFinalResponse(response: models.OpenResponsesResult): void {
private validateFinalResponse(
response: models.OpenResponsesResult,
allowEmptyOutput = false,
): void {
if (!response?.id || !response?.output) {
throw new Error('Invalid final response: missing required fields');
}
if (!Array.isArray(response.output) || response.output.length === 0) {
if (allowEmptyOutput) {
return;
}
throw new Error('Invalid final response: empty or invalid output');
}
}

/**
* Re-send the current resolved request (same accumulated input) once.
* Used when a follow-up after tool execution returned an empty `output`.
*
* `tools`, `toolChoice`, and `parallelToolCalls` are stripped (mirroring
* `makeFinalResponseRequest`) so the retry coerces a text turn: on the
* natural-loop-completion path the resolved request still carries tools,
* and a retry that emitted a fresh `function_call` would pass
* `validateFinalResponse` but never be executed — silently dropping a
* proposed tool call.
*/
private async retryCurrentRequest(turnNumber: number): Promise<models.OpenResponsesResult> {
if (!this.resolvedRequest) {
throw new Error('Request not initialized');
}

const {
tools: _tools,
toolChoice: _toolChoice,
parallelToolCalls: _parallelToolCalls,
...rest
} = this.resolvedRequest;

const newRequest: models.ResponsesRequest = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retryCurrentRequest re-sends this.resolvedRequest verbatim, which on the natural-loop-completion path still carries tools/toolChoice/parallelToolCalls (only makeFinalResponseRequest, used on the stopWhen path, strips them). So a retry after an empty final can legitimately come back with a fresh function_call rather than text. That output is non-empty, so validateFinalResponse passes, getText() returns '', and the newly-proposed tool call is silently dropped (never executed, no matching output persisted). Two questions: (1) is dropping a proposed tool call on the retry acceptable, or should the retry strip tools like makeFinalResponseRequest does to force a text turn? (2) worth a test where the empty-final retry returns a function_call to pin the intended behavior.

▶ Prompt for agents: In retryCurrentRequest, strip tools/toolChoice/parallelToolCalls from the re-sent request (mirroring makeFinalResponseRequest) so the retry coerces a text turn instead of possibly emitting an unexecuted tool call, OR document why carrying tools through is intended. Add a unit test covering an empty-final retry that returns a function_call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e25af4aretryCurrentRequest now strips tools/toolChoice/parallelToolCalls (mirroring makeFinalResponseRequest) so the retry coerces a text turn instead of possibly emitting an unexecuted function_call. Added a test asserting the natural-loop follow-up request still carries tools while the retry does not, with identical input. (On the stopWhen path the resolved request already had tools stripped, so this is a no-op there.) Suite green: 8/8 in the file, 261/261 unit.

...rest,
stream: true,
};

const newResult = await betaResponsesSend(
this.options.client,
{
responsesRequest: newRequest,
},
this.options.options,
);

if (!newResult.ok) {
throw newResult.error;
}

const value = newResult.value;
if (isEventStream(value)) {
const followUpStream = new ReusableReadableStream(value);

if (this.turnBroadcaster) {
return this.pipeAndConsumeStream(followUpStream, turnNumber);
}

return consumeStreamForCompletion(followUpStream);
}
if (this.isNonStreamingResponse(value)) {
return value;
}
throw new Error('Unexpected response type from API');
}

/**
* Resolve async functions in the request for a given turn context.
* Extracts non-function fields and resolves any async parameter functions.
Expand All @@ -1306,15 +1377,22 @@ export class ModelResult<
if (hasAsyncFunctions(this.options.request)) {
return resolveAsyncFunctions(this.options.request, context);
}
// Already resolved, extract non-function fields
// Filter out stopWhen and state-related fields that aren't part of the API request
// Already resolved, extract non-function fields.
// Strip ALL client-only fields — keep this list in sync with
// `clientOnlyFields` in async-params.ts, which handles the async path.
// (`sharedContextSchema` is absent here: call-model.ts destructures it
// before the request reaches ModelResult.)
const {
stopWhen: _,
state: _s,
requireApproval: _r,
approveToolCalls: _a,
rejectToolCalls: _rj,
context: _c,
onTurnStart: _ots,
onTurnEnd: _ote,
allowFinalResponse: _afr,
strictFinalResponse: _sfr,
...rest
} = this.options.request;
return rest as ResolvedCallModelInput;
Expand Down Expand Up @@ -1894,6 +1972,8 @@ export class ModelResult<
);

if (!this.options.tools?.length || !hasToolCalls) {
// No tool work: keep hard throw on empty/invalid final output.
this.validateFinalResponse(currentResponse);
this.finalResponse = currentResponse;
await this.markStateComplete();
return;
Expand All @@ -1908,6 +1988,7 @@ export class ModelResult<
}

if (!this.hasExecutableToolCalls(toolCalls)) {
this.validateFinalResponse(currentResponse);
this.finalResponse = currentResponse;
await this.markStateComplete();
return;
Expand Down Expand Up @@ -2119,8 +2200,30 @@ export class ModelResult<
await this.saveResponseToState(currentResponse);
}

// Validate and finalize
this.validateFinalResponse(currentResponse);
// Validate and finalize. Mini-class models intermittently return an
// empty final turn after a successful tool round (the tool call was
// the answer). Retry once, then tolerate empty output so a completed
// run isn't reported as failure — unless `strictFinalResponse` is set.
const canTolerateEmptyFinal =
this.allToolExecutionRounds.length > 0 && this.options.strictFinalResponse !== true;
const isEmptyOutput =
Array.isArray(currentResponse.output) && currentResponse.output.length === 0;

if (canTolerateEmptyFinal && isEmptyOutput) {
const turnNumber = this.allToolExecutionRounds.length + 1;
currentResponse = await this.retryCurrentRequest(turnNumber);
// Persist the retried response like every other response in the
// loop — otherwise stateful conversations silently lose the final
// turn's content on resume.
await this.saveResponseToState(currentResponse);
}
Comment on lines +2212 to +2219

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Retry response after an empty final output is not persisted to conversation state

The retried response is not saved to state (retryCurrentRequest at packages/agent/src/lib/model-result.ts:2192) unlike every other API response in the tool loop, so stateful conversations lose the final turn's content.

Impact: When using state persistence, the retry response is silently dropped from the conversation history, causing data loss on resume.

Missing saveResponseToState call after retry

Every other API response in executeToolsIfNeeded is persisted via saveResponseToState — see packages/agent/src/lib/model-result.ts:1945 (initial response), packages/agent/src/lib/model-result.ts:2091 (follow-up responses), and packages/agent/src/lib/model-result.ts:2178 (final response after stopWhen). However, after the retry at line 2192, saveResponseToState is never called before markStateComplete at line 2202. This means the retry response (which becomes this.finalResponse) is not recorded in the conversation state.

Suggested change
if (canTolerateEmptyFinal && isEmptyOutput) {
const turnNumber = this.allToolExecutionRounds.length + 1;
currentResponse = await this.retryCurrentRequest(turnNumber);
}
if (canTolerateEmptyFinal && isEmptyOutput) {
const turnNumber = this.allToolExecutionRounds.length + 1;
currentResponse = await this.retryCurrentRequest(turnNumber);
await this.saveResponseToState(currentResponse);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const allowEmptyOutput =
canTolerateEmptyFinal &&
Array.isArray(currentResponse.output) &&
currentResponse.output.length === 0;

this.validateFinalResponse(currentResponse, allowEmptyOutput);
this.finalResponse = currentResponse;
await this.markStateComplete();
})();
Expand Down
Loading
Loading