You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Code Mode plugin tools cannot be called natively: a name-validating server returns the call as text (duplicate of #50312) #58778
Update (2026-10-26): the original "hangs in dispatch" diagnosis was wrong
The turns were not stalled in harness dispatch — nothing was pending. In Code Mode, tools registered through ctx.tool.transform exist only in the execute catalog and are not in the request's tools array, so a model that calls one natively writes the raw template form (<tool_call><function=ctx_stats></function></tool_call>) as text. What happens to that text is the server's decision:
A server that forwards the call anyway (or a model that emits a real tool_calls delta) produces the expected immediate error: No tool named "<name>" is currently available. Please use a tool from the available tool list. — that is the limitation tracked in Plugin tools in catalog cannot be invoked directly: "No tool named …" #50312.
The OpenAI-compatible server used in this report (Strata, serve/server.py, llama.cpp-derived, 127.0.0.1:8080) keeps a <tool_call> naming a tool the request did not declare as ordinary content (same semantics as upstream [QUESTION] How can I know if the session has ended from the server API? #3075). The client therefore creates no tool part, tool.execute.before never fires, no closure runs, and the turn completes normally — no error, no warning, no pending call. In the UI it looks like a dead call block, which is why it was originally reported as a hang.
Evidence is in the follow-up comment: the session store shows the affected turns contain reasoning + text and notool part, and replaying the identical XML against the server yields tool_calls deltas with finish_reason: "tool_calls" when the tool is declared, and finish_reason: "stop" with the XML in content when it is not.
Not related: #58481 (empty-argument streaming) and #53433 (no tool timeout) — finish_reason is always sent and {} arguments stream as "{" then "}".
Canonical tracker for native calls to Code Mode tools: #50312. This issue is a duplicate and can be closed.
Workaround
Call the tools through execute (tools.ctx_<name>(...)), or register them with options: { codemode: false } so they join the native definitions list and are declared in the request — at the cost of losing the single execute gateway.
Original report (retained for the reproduction details)
A local plugin registers ctx_* tools through ctx.tool.transform. A direct native call to one of them never fired tool.execute.before, never ran the tool's execute closure, and never resolved; the turn appeared to stall until the user interrupted. Calling the same tool through Code Mode (execute → tools.ctx_stats()) returned instantly, and the plugin-registered closures behaved correctly.
Environment: OpenCode v2.1.4, Linux Mint 22.3, local OpenAI-compatible server on 127.0.0.1:8080, plugin loaded from a local path via "plugins": ["<abs path>"].
Probes: a counter incremented inside the registered execute closure stayed at 0 for the "stalled" call, and a hook installed on tool.execute.before never fired for it — which is what motivated the (now retracted) pre-dispatch diagnosis.
Thanks for the detailed report and the probes. I couldn't reproduce the hang on 2.1.4.
I loaded a local plugin that registers ctx_stats through ctx.tool.transform. The plugin logs whenever the execute.before hook fires and whenever the tool's execute runs. Then I had a simulated OpenAI-compatible model make a native ctx_stats({}) call:
Default (Code Mode on):execute.before fired for ctx_stats. The call then failed immediately with No tool named "ctx_stats" is currently available. Please use a tool from the available tool list. That error went back to the model and the turn finished normally. Streaming the arguments slowly, one character at a time, gave the same result.
Tool registered with options: { codemode: false }: the native call ran the tool's execute and returned normally.
When Code Mode is on, plugin tools are only offered inside execute. Calling them natively is expected to return that error straight away; #50312 tracks this limitation. If you want them callable natively, register them with options: { codemode: false }.
In 2.1.4, execute.before fires for every native tool call, whatever the tool name. Your probe never saw it fire, so your call probably never got as far as tool dispatch. That suggests it is stuck while the model's tool call is still being streamed and assembled, and may be specific to how your llama.cpp server streams tool calls. These would help narrow it down:
The OpenCode log from a stalled turn: the server/service log around that request, including any warnings.
The raw streamed response from llama.cpp for a turn that stalls, especially the tool_calls chunks and whether a finish_reason is ever sent.
Whether it also stalls with the tools registered as codemode: false, and whether a native call to a built-in tool with empty arguments (e.g. todo or question) behaves the same.
Thanks — you're right, and my "dispatch hangs" conclusion was wrong. I re-ran it with the session store and the server's raw stream side by side. The call never becomes a tool call at all, so there was nothing for dispatch to hang on.
First correction: the endpoint is not llama.cpp
127.0.0.1:8080 is Strata — serve/server.py, a llama.cpp-derived engine exposing an OpenAI-compatible API. So the streaming/parsing behaviour in my report is Strata's, not upstream llama.cpp's.
What the stalled turn actually contains
From OpenCode's own store (~/.local/share/opencode/opencode.db, session ses_e90d79990ffe…, 2026-10-25 02:34:11 local), the assistant message has reasoning + text only — no tool part:
text: 插件和工具全健康(11/11 注册、sentinel 活跃、无 stale session)。再验证直接调用路径是不是也通:
<tool_call>
<function=ctx_stats>
</function>
</tool_call>
time.completed is set and the session emits idle 5 s later. No tool part means no dispatch, which is why execute.before and the closure never fired: there was no call. That session has 10 assistant text parts shaped like this (02:27:50–02:44:31), 9 of them ending the turn with zero tool calls. So it isn't one flaky turn — the model keeps writing the call as text, and the harness correctly treats it as text.
Reproduced against the server
Replaying that exact XML and only varying the request's tools array:
Declared (ctx_stats + shell in tools): 3 tool_calls deltas, finish_reason: "tool_calls", 560 ms —
Undeclared — the Code Mode shape, only shell in tools: 0 tool_calls deltas, finish_reason: "stop", and the XML block arrives in content, byte-identical to the block stored in the text part.
That's the whole bug: in Code Mode the plugin tools live in execute's catalog and are not in the request's tools list, but the model still knows they exist (the wrapper's routing block tells it). Strata keeps a <tool_call> naming a tool the request did not declare as ordinary content (upstream #3075 semantics, serve/frontend.py: "a call counts only for a tool the request declared"). Text in → no tool part → no dispatch → no error. Not #58481, not #53433: finish_reason is always sent and {} arguments stream fine.
Your three asks
Log from the stalled turn: nothing. No warning, no error, request completed in ~700 ms; the only artifact is the text part above. That's exactly why the old probe only ever saw "counters stayed at 0" with no other clue — it was reading a turn that had already finished.
Raw stream: above. finish_reason is always present; {} arguments are streamed as "{" then "}" (also one-character-at-a-time in your harness, presumably the same).
codemode: false / empty-args builtin: not re-run end-to-end on my side, but consistent with yours — with the tool declared the call is a real tool_calls delta, and the binary's snapshot logic (options.codemode !== false → Code Mode catalog only, otherwise No tool named "…" is currently available) matches Plugin tools in catalog cannot be invoked directly: "No tool named …" #50312 exactly. todo/question with empty arguments work fine here.
The wrapper keeps steering the model to tools.ctx_*. options: { codemode: false } would make native calls work, but that means giving up the single execute gateway for 11 tools, so it stays an opt-in rather than a fix.
changed the title [-]plugin: native tool calls to plugin-registered tools stall before dispatch, hanging the turn[/-][+]Code Mode plugin tools cannot be called natively: a name-validating server returns the call as text (duplicate of #50312)[/+]on Oct 25, 2026
Activity
opencode-agent commentedon Oct 24, 2026
Thanks for the detailed report and the probes. I couldn't reproduce the hang on 2.1.4.
I loaded a local plugin that registers
ctx_statsthroughctx.tool.transform. The plugin logs whenever theexecute.beforehook fires and whenever the tool'sexecuteruns. Then I had a simulated OpenAI-compatible model make a nativectx_stats({})call:execute.beforefired forctx_stats. The call then failed immediately withNo tool named "ctx_stats" is currently available. Please use a tool from the available tool list.That error went back to the model and the turn finished normally. Streaming the arguments slowly, one character at a time, gave the same result.options: { codemode: false }: the native call ran the tool'sexecuteand returned normally.When Code Mode is on, plugin tools are only offered inside
execute. Calling them natively is expected to return that error straight away; #50312 tracks this limitation. If you want them callable natively, register them withoptions: { codemode: false }.In 2.1.4,
execute.beforefires for every native tool call, whatever the tool name. Your probe never saw it fire, so your call probably never got as far as tool dispatch. That suggests it is stuck while the model's tool call is still being streamed and assembled, and may be specific to how your llama.cpp server streams tool calls. These would help narrow it down:tool_callschunks and whether afinish_reasonis ever sent.codemode: false, and whether a native call to a built-in tool with empty arguments (e.g.todoorquestion) behaves the same.Related issues, ordered by likelihood:
{}arguments that stall before dispatchreadhas no bound while grep/glob/webfetch do) #53433: no timeout for tool calls, so an unsettled call hangs the stepaifeifei798 commentedon Oct 25, 2026
Thanks — you're right, and my "dispatch hangs" conclusion was wrong. I re-ran it with the session store and the server's raw stream side by side. The call never becomes a tool call at all, so there was nothing for dispatch to hang on.
First correction: the endpoint is not llama.cpp
127.0.0.1:8080is Strata —serve/server.py, a llama.cpp-derived engine exposing an OpenAI-compatible API. So the streaming/parsing behaviour in my report is Strata's, not upstream llama.cpp's.What the stalled turn actually contains
From OpenCode's own store (
~/.local/share/opencode/opencode.db, sessionses_e90d79990ffe…, 2026-10-25 02:34:11 local), the assistant message hasreasoning+textonly — notoolpart:time.completedis set and the session emitsidle5 s later. No tool part means no dispatch, which is whyexecute.beforeand the closure never fired: there was no call. That session has 10 assistanttextparts shaped like this (02:27:50–02:44:31), 9 of them ending the turn with zero tool calls. So it isn't one flaky turn — the model keeps writing the call as text, and the harness correctly treats it as text.Reproduced against the server
Replaying that exact XML and only varying the request's
toolsarray:Declared (
ctx_stats+shellintools): 3tool_callsdeltas,finish_reason: "tool_calls", 560 ms —{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9d3b67…","type":"function","function":{"name":"ctx_stats","arguments":""}}]},"finish_reason":null}]} {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{"}}]},"finish_reason":null}]} {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"}"}}]},"finish_reason":null}]}Undeclared — the Code Mode shape, only
shellintools: 0tool_callsdeltas,finish_reason: "stop", and the XML block arrives incontent, byte-identical to the block stored in the text part.That's the whole bug: in Code Mode the plugin tools live in
execute's catalog and are not in the request'stoolslist, but the model still knows they exist (the wrapper's routing block tells it). Strata keeps a<tool_call>naming a tool the request did not declare as ordinary content (upstream #3075 semantics,serve/frontend.py: "a call counts only for a tool the request declared"). Text in → no tool part → no dispatch → no error. Not #58481, not #53433:finish_reasonis always sent and{}arguments stream fine.Your three asks
finish_reasonis always present;{}arguments are streamed as"{"then"}"(also one-character-at-a-time in your harness, presumably the same).codemode: false/ empty-args builtin: not re-run end-to-end on my side, but consistent with yours — with the tool declared the call is a realtool_callsdelta, and the binary's snapshot logic (options.codemode !== false→ Code Mode catalog only, otherwiseNo tool named "…" is currently available) matches Plugin tools in catalog cannot be invoked directly: "No tool named …" #50312 exactly.todo/questionwith empty arguments work fine here.Action on my side
AGENTS.md(it currently says "harness dispatch hangs") and I'm happy to edit this issue's title/body to point at Plugin tools in catalog cannot be invoked directly: "No tool named …" #50312, or close it.tools.ctx_*.options: { codemode: false }would make native calls work, but that means giving up the singleexecutegateway for 11 tools, so it stays an opt-in rather than a fix.[-]plugin: native tool calls to plugin-registered tools stall before dispatch, hanging the turn[/-][+]Code Mode plugin tools cannot be called natively: a name-validating server returns the call as text (duplicate of #50312)[/+]