Skip to content

feat(session): expose toolChoice via PromptInput and agent config#37678

Open
paperview wants to merge 632 commits into
anomalyco:devfrom
paperview:feat/tool-choice-http-and-config
Open

feat(session): expose toolChoice via PromptInput and agent config#37678
paperview wants to merge 632 commits into
anomalyco:devfrom
paperview:feat/tool-choice-http-and-config

Conversation

@paperview

Copy link
Copy Markdown

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

  • Bug fix
  • New feature

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:

  • HTTP. PromptInput did not accept a toolChoice field, so external orchestrators calling POST /session/:id/message or POST /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 — this avoids changing the persisted SessionV1.User schema (an earlier version of this patch persisted toolChoice on the user message, which broke MessageV2.Event.Updated decoders in share-next.ts). The map is 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
    toolChoice: format.type === "json_schema" ? "required" : undefined,
    with
    toolChoice:
      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. 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 with update_field via 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.
  • Boot patched daemon on 4100, POST /session/:id/prompt_async with {"toolChoice":"BOGUS", ...} → returns 400 with Expected "auto" | "required" | "none", got "BOGUS". Same call with "required" → 204.
  • Set agent.build.tool_choice: "required" in ~/.config/opencode/opencode.jsonc, verify agent config parses cleanly and loader picks up the value (agent AgentInfo.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 toolChoice on the user message before investing in tests, since either shape ships different guarantees on daemon-restart behavior.

Scope / not addressed

  • The legacy POST /api/session/:id/prompt endpoint (goes through SessionPending.admit + 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 do carry it correctly. Filing a follow-up issue if this lands and there's demand.
  • SessionV1.User schema 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

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

thdxr and others added 30 commits July 10, 2026 02:51
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>
simonklee and others added 25 commits July 17, 2026 16:45
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.
@paperview

Copy link
Copy Markdown
Author

Deeper finding while trying to verify end-to-end

I'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.

  • `packages/opencode/src/mcp/index.ts` is `@opencode/MCP` — the target of the runtime `POST /mcp` endpoint. Instance-scoped via `InstanceState`.
  • `packages/core/src/mcp/index.ts` is `@opencode/v2/MCP` — what the core `SessionModelRequest.prepare` reads. Its `runtime` map is initialized from config `documents` at layer construction time only (`index.ts:184-192`), and I couldn't find a runtime-add code path into it.

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.

  • `packages/opencode/src/session/prompt.ts` (`SessionPrompt.prompt`) — reached by `POST /session/:id/message` and `POST /session/:id/prompt_async`. This is the file this PR patches.
  • `packages/core/src/session/runner/llm.ts` + `packages/core/src/session/model-request.ts` (`SessionRunner` + `SessionModelRequest.prepare`) — reached by `POST /api/session/:id/prompt` via `SessionPending.admit`.

The second path also hard-codes `toolChoice`, at `model-request.ts:90`:
```ts
toolChoice: stepLimitReached ? "none" : undefined,
```

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):

  1. Mirror the same three-way precedence at `packages/core/src/session/model-request.ts:90`, threading toolChoice from `Session.prompt` (core service) via a new field on `SessionPending` through to `PrepareInput`.
  2. Bridge runtime-added MCP entries from `@opencode/MCP` into `@opencode/v2/MCP`, so tools registered via feat(core): add runtime MCP controls #37308's endpoint reach `SessionModelRequest.prepare.executableTools`.
  3. Fix the v2 direct endpoints' "Expected string, got undefined" on message.updated so orchestrators can migrate off the legacy `/api` path.

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.

paperview added a commit to paperview/opencode_ex that referenced this pull request Jul 18, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

opencode Agent config is silently ignored — opencode always sends (1.17.7)

8 participants