feat(session): expose toolChoice via PromptInput and agent config#37678
feat(session): expose toolChoice via PromptInput and agent config#37678paperview wants to merge 632 commits into
Conversation
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
Co-authored-by: Dax Raad <826656+thdxr@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
The internal LLM layer already accepts toolChoice ("auto" | "required" |
"none") end to end (aisdk.ts:421, session/llm.ts:47/234/319, native
runtime + request, per-provider lowerings). The only missing edges were:
- HTTP: PromptInput did not accept a toolChoice field, so external
orchestrators calling POST /session/:id/message or
/session/:id/prompt_async could not set it per turn.
- Config: agent v2 config did not accept tool_choice, so an unset value
was silently dropped and providers always received "auto".
This wires the missing edges:
1. Add optional toolChoice to PromptInput. When present, stored on a
session-keyed in-memory Map for the duration of the turn (avoids
changing SessionV1.User schema, which would have downstream decoding
knock-on effects). Cleared on loop exit; not persisted across daemon
restart (next turn falls back to agent config or the default).
2. Add tool_choice to the v1 agent config schema and KNOWN_KEYS
allowlist, and map it onto AgentInfo.toolChoice in the agent loader.
3. At the LLM request-build site in SessionPrompt.prompt (line ~1298),
replace the hard-coded ternary with:
pendingToolChoice.get(sessionID)
?? agent.toolChoice
?? (format.type === "json_schema" ? "required" : undefined)
Behavior when unset is unchanged: toolChoice stays undefined for regular
turns, "required" for json_schema output. Backwards compatible.
Motivation: models like Kimi K3 that under-invoke tools under "auto"
default cannot currently be forced to call MCP tools through the daemon.
This is especially painful for the runtime MCP feature (anomalyco#37308) where
external orchestrators mediating structured flows have no way to say
"this turn must produce a tool call."
Related dead PRs (both closed by 1-month auto-cleanup, not rejection):
- anomalyco#14090 (feature request, closed as completed but never landed)
- anomalyco#32521, anomalyco#32518 (parallel fix PRs, both auto-closed)
- anomalyco#32465 (still-open bug report)
See anomalyco#37672 for the full write-up.
Not addressed in this PR (deliberate scope):
- Legacy /api/session/:id/prompt (goes through SessionPending.admit +
the core SessionRunner) does not propagate toolChoice through the
pending queue. That's a separate wiring effort and is out of scope
here; direct v2 endpoints (/session/:id/message,
/session/:id/prompt_async) do carry it correctly.
- SessionV1.User schema was left unchanged on purpose; the session-map
route avoids invalidating persisted messages and share subscribers.
Deeper finding while trying to verify end-to-endI've been trying to prove this patch works end-to-end via a runtime MCP flow (external orchestrator posts `/mcp` to add a bot-scoped MCP entry, then hits `/api/session/:id/prompt` for Kimi to invoke it). Two independent architecture splits blocked verification, and are worth calling out here — they aren't caused by this patch, but they mean the patch only fixes half of what a runtime-MCP-driven "force tool call" flow needs. 1) Two MCP services, not one.
Runtime-added MCP entries (via the endpoint from #37308) land in the opencode service, but the SessionRunner uses the core service — so those tools never reach `registry.materialize` and never appear in the LLM request's tools list. Verified by watching `sync-bot-63` show "connected" in the daemon MCP status (queried with the same `?directory=`) while Kimi's assistant response enumerated exactly the built-ins (`read/write/edit/grep/glob/shell/websearch/webfetch/question/skill/subagent/execute`) with no MCP entry. 2) Two prompt code paths, not one.
The second path also hard-codes `toolChoice`, at `model-request.ts:90`: So even after this PR lands, orchestrators using the legacy `/api/session/:id/prompt` route (which is currently the working streaming path for external clients — the direct v2 endpoints emit a "share subscriber failed: Expected string, got undefined" on message.updated and never produce an assistant reply on the current v2-test build) still get `toolChoice: undefined`. Suggested follow-up scope (either as a review comment I can address in this PR, or as separate issues):
Happy to take any/all of these as follow-up PRs if there's interest, but wanted to flag the shape of it here in case a maintainer already has a preferred design (the runner is under active refactor). The core PR still stands as-is — it's the correct fix for the direct-endpoint code path, and it's the one this PR's title/description claims. |
Additions supporting the sync_server chatbot bridge against our patched opencode daemon (paperview/opencode:paperview-stable): - New OpencodeEx.MCP module: `status/2`, `add/4`, `disconnect/2`, `connect/2`. Wraps the runtime MCP endpoints introduced in anomalyco/opencode#37308. Accepts `directory:` keyword for workspace-scoped operations (`?directory=...`). - `OpencodeEx.Session.create/2` accepts `directory:` for scoping the session to a specific instance. Runtime MCP entries added with the same directory become visible to that session's tool registry. - `OpencodeEx.Session.prompt/4` accepts `tool_choice:` ("auto" | "required" | "none"). Serialized as `toolChoice` on the request body. Silently ignored by stock upstream (unknown field on legacy endpoint); honored on our patched fork via anomalyco/opencode#37678. Backward-compatible: default paths and bodies unchanged when the new options are unset.
Issue for this PR
Refs #37672 (my consolidation issue), closes #32465 (the still-open bug),
picks up the work from #32521 / #32518 (both closed by 1-month
auto-cleanup, not rejection).
Type of change
What does this PR do?
The internal LLM layer already accepts
toolChoice("auto" | "required" | "none") end to end (packages/core/src/aisdk.ts:421,packages/opencode/src/session/llm.ts:47,234,319, native runtime + request, per-provider lowerings). Two edges of the wire were missing:PromptInputdid not accept atoolChoicefield, so external orchestrators callingPOST /session/:id/messageorPOST /session/:id/prompt_asynccould not set it per turn.tool_choice, so an unset value was silently dropped and providers always received"auto".This wires the missing edges:
toolChoicetoPromptInput. When present, stored on a session-keyed in-memoryMapfor the duration of the turn — this avoids changing the persistedSessionV1.Userschema (an earlier version of this patch persistedtoolChoiceon the user message, which brokeMessageV2.Event.Updateddecoders inshare-next.ts). The map is cleared on loop exit; not persisted across daemon restart (next turn falls back to agent config or the default).tool_choiceto the v1 agent config schema andKNOWN_KEYSallowlist, and map it ontoAgentInfo.toolChoicein the agent loader.SessionPrompt.prompt(line ~1298), replace the hard-coded ternaryBehavior when unset is unchanged.
toolChoicestaysundefinedfor regular turns,"required"forjson_schemaoutput. Fully backwards compatible.Motivation
Models like Kimi K3 that under-invoke tools under
"auto"default cannot currently be forced to call MCP tools through the daemon. Reproduction (my case): a chatbot orchestrator adds an MCP entry withupdate_fieldvia runtime MCP (#37308), asks Kimi K3 "record my color as blue via update_field," and Kimi answers in prose without invoking the tool. Under"required"the daemon would force the call.This is especially painful for the runtime MCP feature — external orchestrators mediating structured flows (form-fill, tool-mediated mutation) have no way to say "this turn must produce a tool call."
Related dead PRs (both closed by 1-month auto-cleanup, not by review):
How did you verify your code works?
bun turbo typecheck --filter=@opencode-ai/schema --filter=@opencode-ai/core --filter=opencode— clean.POST /session/:id/prompt_asyncwith{"toolChoice":"BOGUS", ...}→ returns 400 withExpected "auto" | "required" | "none", got "BOGUS". Same call with"required"→ 204.agent.build.tool_choice: "required"in~/.config/opencode/opencode.jsonc, verify agent config parses cleanly and loader picks up the value (agentAgentInfo.toolChoice === "required"at request build).Tests I did not run: I didn't add unit tests in this PR — I'd like a signal from a maintainer on the session-map approach vs. persisting
toolChoiceon the user message before investing in tests, since either shape ships different guarantees on daemon-restart behavior.Scope / not addressed
POST /api/session/:id/promptendpoint (goes throughSessionPending.admit+ coreSessionRunner) does not propagatetoolChoicethrough the pending queue. That's a separate wiring effort and is out of scope here; direct v2 endpoints do carry it correctly. Filing a follow-up issue if this lands and there's demand.SessionV1.Userschema is left unchanged on purpose — the session-map route avoids invalidating persisted messages and downstream share subscribers that decode existing shapes.Screenshots / recordings
Not a UI change.
Checklist