Uh oh!
There was an error while loading. Please reload this page.
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: 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.paperview
commented
Jul 18, 2026
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.
Automated PR Cleanup Thank you for contributing to opencode. Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions. This PR was closed because it matched the following cleanup criteria:
PRs created within the last month are not affected by this cleanup. If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate. Thanks again for taking the time to contribute. |
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