-
Notifications
You must be signed in to change notification settings - Fork 9
fix(agent): tolerate empty final response after completed tool rounds #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4c73333
5e4a324
6320116
e25af4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
|
|
@@ -1253,6 +1260,7 @@ export class ModelResult< | |||||||||||||||||||
| input: newInput, | ||||||||||||||||||||
| stream: true, | ||||||||||||||||||||
| }; | ||||||||||||||||||||
| this.resolvedRequest = finalRequest; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const result = await betaResponsesSend( | ||||||||||||||||||||
| this.options.client, | ||||||||||||||||||||
|
|
@@ -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 = { | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
▶ Prompt for agents: In
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in e25af4a — |
||||||||||||||||||||
| ...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. | ||||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||||
|
|
@@ -1908,6 +1988,7 @@ export class ModelResult< | |||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (!this.hasExecutableToolCalls(toolCalls)) { | ||||||||||||||||||||
| this.validateFinalResponse(currentResponse); | ||||||||||||||||||||
| this.finalResponse = currentResponse; | ||||||||||||||||||||
| await this.markStateComplete(); | ||||||||||||||||||||
| return; | ||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( Impact: When using state persistence, the retry response is silently dropped from the conversation history, causing data loss on resume. Missing saveResponseToState call after retryEvery other API response in
Suggested change
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(); | ||||||||||||||||||||
| })(); | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
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
strictFinalResponseoption is sent to the API as an unknown field (resolveRequestForContextatpackages/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
strictFinalResponseis 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 aclientOnlyFieldsset that includesstrictFinalResponse,allowFinalResponse,onTurnStart,onTurnEnd, andsharedContextSchema. The non-async path inresolveRequestForContext(packages/agent/src/lib/model-result.ts:1367-1375) only destructures outstopWhen,state,requireApproval,approveToolCalls,rejectToolCalls, andcontext. The remaining client-only fields (including the newly addedstrictFinalResponse) pass through intothis.resolvedRequestand are sent to the API viabetaResponsesSend.(Refers to lines 1367-1375)
Was this helpful? React with 👍 or 👎 to provide feedback.