feat(agent): allow nextTurnParams to set toolChoice - #114
Conversation
A tool can now return a new `toolChoice` for the following turn, which lets a tool-search tool widen an `allowed_tools` set as it discovers tools. The alternative — rewriting `tools` between turns — changes the request prefix and loses the provider's prompt cache, which is the reason to withhold tools in the first place. `toolChoice` is already a modelled `ResponsesRequest` field, so it needed only the context entry and the key allowlist; `applyNextTurnParamsToRequest` carries it through unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
|
`applyNextTurnParams` merged the computed choice into `resolvedRequest`, but `makeFollowupRequest` then ran `applyForcedToolChoicePolicy`, which rebuilds `toolChoice` purely from `configuredToolChoice`. The tool's value was dropped before the request was sent, so the hook was a no-op in the main loop — only the separate `tool-orchestrator` path preserved it. Route a computed `toolChoice` through `applyResolvedForcedToolChoicePolicy` so it becomes the new configured choice, re-stamping the forced-choice consumption key alongside it and keeping relaxation consistent on later turns. The existing tests exercised `applyNextTurnParamsToRequest` in isolation and so could not see this. Adds a loop-level test that asserts against the dispatched request; it fails without the fix. Co-Authored-By: Claude <noreply@anthropic.com>
|
Fixed in
A computed The root cause of the miss was test placement: the original tests exercised It also pins the property this feature exists for — 1159 tests pass, lint and typecheck clean. |
Status: review resolved,
|
| run | date | e2e-tests | everything else |
|---|---|---|---|
| main | 2026-08-19 06:09 | ✅ | ✅ |
| main | 2026-08-20 06:09 | ❌ | ✅ |
| #110 | 2026-08-20 01:49 | ❌ | ✅ |
| #114 (this) | 2026-08-20 03:17 & 13:00 | ❌ | ✅ |
Same 6 tests every time — 3 in call-model-state.test.ts, 3 in call-model-tools.test.ts, all BadRequestResponseError: Provider returned error, all on openai/gpt-4o-mini multi-turn / parallel-tool paths, always Tests 6 failed | 106 passed | 2 skipped.
Since a main commit that predates this branch reproduces it, nothing in this PR can be the cause. It broke somewhere between 2026-08-19 06:09 and 2026-08-20 01:49, and it is currently blocking every PR in the repo.
I have deliberately not tried to fix it here: it is unrelated to a two-line context-type addition, it needs a live API key to iterate on, and it wants its own PR with whoever owns the e2e suite. Happy to pick it up separately if useful.
main has no required status checks (its ruleset is deletion / non-fast-forward / pull-request-with-0-approvals), so this PR reports MERGEABLE; UNSTABLE here only reflects the non-required e2e job.
What
Adds
toolChoicetoNextTurnParamsContext, so a tool can change which tools the model may call on the following turn.Three lines of behaviour change:
toolChoiceonNextTurnParamsContext(tool-types.ts)buildNextTurnParamsContextreads it off the request'toolChoice'added to theisValidNextTurnParamKeyallowlist — unknown keys are silently dropped, so without this the hook is a no-optoolChoiceis already a modelledResponsesRequestfield, soapplyNextTurnParamsToRequestcarries it through unchanged. No serialization work, no SDK regeneration.Why
This is what a tool-search tool needs. Declare every tool up front, keep the not-yet-needed ones out of reach behind an
allowed_toolschoice, and widen that choice as the model finds what it wants.The alternative — rewriting
toolsbetween turns — changes the request prefix and loses the provider's prompt cache, which is the whole reason to withhold tools rather than send them all. Keepingtoolsbyte-identical and moving onlytool_choicepreserves the cache across a search.A test asserts exactly that:
expect(result.tools).toBe(tools)— same reference, untouched.Notes for review
activeToolsis untouched.pnpm lintandpnpm typecheckpass; 1156 tests pass, no type errors.