Skip to content

Code Mode plugin tools cannot be called natively: a name-validating server returns the call as text (duplicate of #50312) #58778

Closed as not planned

Description

@aifeifei798

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 no tool 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.

Activity

  1. opencode-agent commented on Oct 24, 2026

    @opencode-agent
    Contributor

    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.

    Related issues, ordered by likelihood:

  2. aifeifei798 commented on Oct 25, 2026

    @aifeifei798
    Author

    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 —

    {"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 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

    1. 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.
    2. 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).
    3. 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.

    Action on my side

  3. 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions