fix(agent): default final-answer directive for bare allowFinalResponse: true (DEV-658) - #68
Conversation
…Response: true Bare `allowFinalResponse: true` stripped tools on the forced final turn but gave the model no signal that this was its last turn. Models that emit tool-call syntax as text (e.g. GLM) would attempt another tool call and leak unparsed `<tool_call>...` text into the final content instead of producing a real answer (DEV-658). - `true` now appends DEFAULT_FINAL_RESPONSE_DIRECTIVE (exported) as the final user message - a non-empty string still overrides the wording - `''` appends no message (the previous bare-true behavior) Adds an e2e regression test with the DEV-658 repro shape (GLM + unhelpful search tool + stepCountIs(1)); verified to fail 3/3 on the legacy no-directive path and pass 3/3 with the directive. Fixes DEV-658
mattapperson
left a comment
There was a problem hiding this comment.
allowFinalResponse should not strip tools it should just set tool_choice: 'none',. removing the tools busts cache.
allowFinalResponse: true should be the default.
Codifies the review convention requested on #68: any PR changing the public API must include a consumer-perspective code example in the PR description and in its changeset (so it flows into CHANGELOG.md). Brings this PR's changeset into compliance.
|
Added |
…allowFinalResponse on Review feedback from @mattapperson on #68: 1. The forced final turn no longer strips `tools`/`toolChoice`/ `parallelToolCalls` — it keeps the request intact and forces `toolChoice: 'none'`. Stripping the tools block invalidated the prompt-cache prefix; forbidding calls preserves it. Same change on the empty-final retry path. 2. `allowFinalResponse` now defaults to ON. Omitted or `true` appends DEFAULT_FINAL_RESPONSE_DIRECTIVE; a non-empty string overrides the wording; '' appends nothing; `false` opts out of the final turn. Changeset bumped to minor: runs that previously ended on a halted tool-call turn now make one additional request by default.
|
Both addressed in a1df1c6:
Default-on — Changeset bumped to minor since runs that previously ended on a halted tool-call turn now make one extra request by default — |
| const allowFinalResponse = this.options.allowFinalResponse; | ||
| const finalResponseEnabled = | ||
| allowFinalResponse === true || typeof allowFinalResponse === 'string'; | ||
| const finalResponseEnabled = allowFinalResponse !== false; |
There was a problem hiding this comment.
📝 Info: Default-on final turn adds an extra model request for existing stopWhen users
finalResponseEnabled = allowFinalResponse !== false (packages/agent/src/lib/model-result.ts:3211) makes the forced final turn happen by default whenever stopWhen halts mid-executable-tool-call. This is explicitly called out in the PR/changeset as an intentional minor bump, but it is worth confirming that no in-repo stateful/resume flows depend on the old behavior of ending on the halted tool-call turn. I checked the mcp package and found no stopWhen/allowFinalResponse usage, and the all-manual-tool-call path is still gated by hasExecutableToolCalls, so only executable-tool halts are affected.
Was this helpful? React with 👍 or 👎 to provide feedback.
* ci(release): dispatch the Python and Go ports on publish (HOP C) The Python and Go ports of @openrouter/agent track this repo as their reference spec, but nothing told them when a version shipped. Both were generated by hand against 0.7.2 and have sat a minor version behind since — missing HooksManager, versioned state serialization, and the #61-#68 fixes. Adds a HOP C dispatch beside the existing HOP B monorepo dispatch. On a real publish, python-agent and go-agent each receive openrouter-agent-published and open a PR porting the delta. Their pipelines gate the result on a mechanical verifier plus a behavioral parity eval before advancing sync state, so a bad port cannot land silently. Details worth noting: - Sends the release TAG (@openrouter/agent@X.Y.Z), not a branch, so a port reproduces the exact published tree rather than whatever main drifted to. - continue-on-error: the packages are already on npm when this runs, so a failed dispatch must not turn a successful release red. It warns instead, and names the manual recovery path. - !cancelled() so a failed HOP B dispatch doesn't also skip the ports. - Partial failure is tolerated: one port failing still dispatches the other. Reuses the same GH_TOKEN PAT as HOP B, which additionally needs contents:write on OpenRouterTeam/python-agent and OpenRouterTeam/go-agent. Also documents the full publish fan-out in the changeset-versioning skill, since a breaking callModel change now produces port PRs in two other repos. Companion PRs: OpenRouterTeam/python-agent#19, OpenRouterTeam/go-agent#1 * fix(release): address review findings on HOP C dispatch - Use -f (--raw-field) for all dispatch payload fields: -F treats values starting with @ (the release tag) as filenames, which made every port dispatch fail before the request was sent - Push release tags on the manual mode=publish path so the dispatched ref actually exists on the remote - Restore set -e; the gh api call sits in an if-condition and is already -e-exempt, so the rest of the script stays strict - Pass source_run_url via env like VERSION instead of inline ${{ }} Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(release): fall back to commit SHA when the release tag is not on origin Address review findings from cortex and Devin on the HOP C dispatch: - Make the manual-publish tag push non-fatal. It runs after packages are already on npm, so a rejected push (tag protection, or a re-run where the tag exists at a different commit) turned a successful publish red and, because the HOP B/C steps carry no status guard, skipped both dispatches. - Verify the tag is on origin before dispatching it, falling back to the run's commit SHA. Previously the manual path could dispatch a ref the port repos cannot resolve, making them fail on checkout instead of degrading. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: LukasParke <luke@openrouter.ai>
Fixes DEV-658
Problem
When
stopWhenhalted the loop mid-tool-call, the forced final turn strippedtools/toolChoice/parallelToolCallsand appended no signal that this was the model's last turn. Two defects:contentas unparsed<tool_call>…text and gets treated as the final answer. Router PR #29461 worked around it by passing a directive string; every consumer using baretruewas still exposed.toolsblock invalidated the prompt-cache prefix on the final request.Additionally, the final turn was opt-in — the common failure mode (run ends on a half-finished tool call) was the default.
Change
allowFinalResponse: falseopts out; omitted/truebehave identically.toolChoice: 'none'— preserves the prompt-cache prefix. Same on the empty-final retry path (retryCurrentRequest).trueappendsDEFAULT_FINAL_RESPONSE_DIRECTIVE(exported) as the final user message; a non-empty string overrides the wording;''appends nothing.JSDoc, README, and changeset updated. Changeset bumped to minor: runs that previously ended on a halted tool-call turn now make one additional model request by default.
API example
Tests
Unit (
allow-final-response.test.ts,tool-terminal-empty-final.test.ts): final request keepstoolsand forcestoolChoice: 'none'(overriding the caller's'required'); default-on path (option omitted) makes the final turn with the directive; string override,'', andfalsecontracts pinned; retry path asserts tools kept +toolChoice: 'none'.e2e regression (
call-model.test.ts): DEV-658 repro shape —z-ai/glm-5.2, aweb_searchtool returning deliberately unhelpful results,stopWhen: stepCountIs(1), option omitted (default path under test). Asserts non-empty final text, no leaked tool-call syntax, zero structuredfunction_callitems.Discrimination check (probed live before writing the test): legacy no-directive path leaked 3/3 runs; with the directive 0/3.
Verification
allowFinalResponseblock passes against the live API (4/4, incl. GLM regression on the default path)OPENROUTER_API_KEYrepo secret (was previously skip-passing)Maintenance
Adds
.agents/skills/public-api-examples/SKILL.md: public-API-changing PRs must carry a consumer-perspective code example in the PR description and changeset.Follow-up (not this PR)
SERVER_TOOLS_FINAL_RESPONSE_DIRECTIVEworkaround once this shipsmodel_result.py_build_final_request,model_result.gosendFinalResponseRequest) — same fix shape applies