Skip to content

plugin: parallel tool calls surface the first execute.before hook error for all failures #50331

Closed

Description

@wienans

Summary

When a tool.hook("execute.before") plugin hook throws for several tool calls issued in one parallel batch, every failing call surfaces the first thrown error's message. The distinct errors thrown by the other calls' hooks are discarded. Non-blocked calls in the same batch are unaffected. This misleads both the agent and the user about which tool call was rejected and why.

Environment

  • opencode version: 2.0.11
  • OS: Windows_NT 10.0.26200 (win32 x64)
  • Terminal: TERM=xterm-256color (TERM_PROGRAM unset)
  • Shell: Git bash (shell: "C:\Program Files\Git\bin\bash.exe" in config); ComSpec is cmd.exe
  • Install/channel: latest (autoupdate: notify)
  • Active plugins: local project plugin under .opencode/plugins/ (auto-discovered) + @plannotator/opencode@latest (global)

Reproduction

Minimal plugin (.opencode/plugins/hook-error-repro.js; run npm i @opencode/plugin inside .opencode/ if the package is not resolvable yet):

import { Plugin } from "@opencode/plugin"

export default Plugin.define({
  id: "hook-error-repro",
  async setup(ctx) {
    await ctx.tool.hook("execute.before", (event) => {
      const cmd = event.input?.command ?? ""
      if (cmd.includes("alpha-block")) throw new Error("HOOK BLOCK ALPHA")
      if (cmd.includes("beta-block")) throw new Error("HOOK BLOCK BETA")
    })
  },
})
  1. Have the model issue two shell tool calls in one parallel batch (two tool-use blocks in a single assistant message):
    • call A: echo alpha-block
    • call B: echo beta-block
  2. Observe the two tool results.

Expected: A fails with HOOK BLOCK ALPHA, B fails with HOOK BLOCK BETA.

Actual: both results fail with HOOK BLOCK ALPHA. The second hook's distinct error never reaches the tool result. The same happens with any mix: with N parallel calls where multiple hooks throw, all failures show the first throw's message. A benign call in the same batch (e.g. echo ok) executes normally, so error attribution is per-call for successes but not for hook failures.

To verify that the plugin really throws distinct errors (i.e. the loss happens in core, not in the plugin), append a random nonce to each thrown message and log the hook inputs server-side:

  • the hook received the correct, distinct command for each call,
  • the plugin threw two different errors (two different nonces),
  • both tool results still displayed the nonce of the first throw.

Expected Behavior

Each failing tool call in a parallel batch surfaces the error thrown by its own execute.before hook invocation, the same way natural execution failures are attributed per call (two parallel calls to a missing file return two distinct "File not found" errors — only hook errors collapse).

Actual Behavior

All failing tool calls in the batch display the first hook error's message. Observed consistently across multiple batches (first-error-wins, not last-error-wins).

Additional Context

  • Found while testing a tool call guarding plugin (a local project plugin that blocks dangerous shell commands): with four parallel shell calls matching four different block patterns, every result reported the first command's name and reason. Confirmed with an instrumented plugin that per-call hook inputs and thrown errors were correct, so the collapse is in core's parallel hook-error attribution (observed on the server->tool-result path; not distinguished whether the latch is in the server or the client result pipeline).
  • Not specific to shell: any tool gated by execute.before should be affected; verified with shell in this session.
  • Workaround status: none found at plugin level (throwing distinct errors does not help; message content is overwritten, not deduplicated).

Activity

  1. 1052326311 commented on Sep 21, 2026

    @1052326311
    Contributor

    Reproduced on current v2 (b8aa08f26) through the Promise plugin adapter and session runner: two hooks throw ALPHA/BETA, but both persisted tool results contain ALPHA; a successful sibling still runs. I am preparing a narrow fix to retain each failed call's own cause during settlement, with coverage for the next model request and unchanged decline/interruption behavior.

  2. graphenn commented on Oct 13, 2026

    @graphenn

    Still reproduces on opencode 2.0.27 (Linux x64), with a project plugin that throws distinct execute.before errors for blocked shell commands — same shape as the original report:

    • two blocked shell calls in one parallel batch → both results carry the first thrown error's message; the second call's own message is lost
    • a single blocked call in its own batch → its own correct message
    • native tool failures in the same batch (two reads of missing files) → attributed per call, unaffected (tool.execution type)
    • non-blocked calls in the same batch execute normally; every blocked call is still blocked, so gating itself stays per-call — only the surfaced message duplicates

    Verbatim evidence (two blocked calls A and B in one batch; message excerpts are from a Chinese-UI guard plugin, the matched command excerpt is the relevant part):

    • A bin/gcube dev-local enable → ...(命中:bin/gcube dev-local enable) (correct)
    • B bin/gcube obs mirror --tier smoke --instance dev-local --yes → error message also shows (命中:bin/gcube dev-local enable) (A's excerpt)

    No plugin-side workaround found: the hook computes a fresh, distinct message per invocation (verified by construction), and the duplication happens after the hook throws.

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