Skip to content

subagent: finish:"error" / MALFORMED_FUNCTION_CALL with empty content is reported to the parent as a successful completion #52378

Open

Description

@rebelliard

Description

Summary

OpenCode reports a failed subagent run to the parent as a successful one.

A Task subagent's last assistant message ended with finish:"error", rawFinish:"MALFORMED_FUNCTION_CALL", content:[], and 0 output tokens. OpenCode then set the child session to idle with outcome:"succeeded", and the parent got state="completed" with "Subagent completed without a text response." The parent cannot tell a provider failure from a real empty answer. In a review panel, an orchestrator can read that as a clean review. This happened 3 times in one session.

Expected Behavior

  • The child ends in an error state (for example idle outcome:"failed", or the existing error outcome), with the provider error attached.
  • The parent receives state="error", and the tool result carries the finish reason (MALFORMED_FUNCTION_CALL) and the provider error text.
  • Optional: retry the failed step once automatically. This failure may be transient (see Frequency below).

Actual Behavior

Evidence from ~/.local/share/opencode/opencode.db (sqlite3 -readonly). I removed prompts and file contents. All other fields are as stored, except providerState, which is trimmed.

Child ses_f0ca68c11ffescNxFG4bNp3AjF, last assistant message (seq 33, 2026-09-30 19:25:35):

{
  "agent": "prosecutor-tests",
  "model": { "id": "vmc/adlc-prosecutor-tests", "providerID": "vercel", "variant": "default" },
  "content": [],
  "finish": "error",
  "rawFinish": "MALFORMED_FUNCTION_CALL",
  "providerState": {
    "routing": {
      "originalModelId": "google/gemini-3.8-flash",
      "finalProvider": "google",
      "modelAttempts": [
        {
          "canonicalSlug": "google/gemini-3.8-flash",
          "success": false,
          "providerAttempts": [
            {
              "provider": "google",
              "success": false,
              "error": "stream finished with error: MALFORMED_FUNCTION_CALL"
            }
          ]
        }
      ]
    }
  },
  "tokens": { "input": 3771, "output": 0, "reasoning": 37, "cache": { "read": 20366, "write": 0 } }
}

Next event (seq 36, 19:25:37):

{ "time": { "created": 1790789137877 }, "outcome": "succeeded" }

Parent (root session), synthetic message seq 2311:

<subagent sessionID="ses_f0ca68c11ffescNxFG4bNp3AjF" state="completed" description="P5 panel 2 tests">
Subagent completed without a text response.
</subagent>

The same pattern appears in two other child sessions:

Child session Last assistant seq finish / rawFinish Output tokens Idle outcome Parent message
ses_f0ca68c11ffescNxFG4bNp3AjF 33 (19:25:35) error / MALFORMED_FUNCTION_CALL 0 succeeded (seq 36) seq 2311, state="completed"
ses_f0ca60ab7ffe7Tp09wDsPvc6Yq 40 (19:26:14) error / MALFORMED_FUNCTION_CALL 0 succeeded (seq 43) seq 2355, state="completed"
ses_f0c89759cffe0N4N0oDTX5gp4M 33 (19:57:20) error / MALFORMED_FUNCTION_CALL 0 succeeded (seq 36) seq 2686, state="completed"

session_v2.idle_outcome is succeeded for all three. Each child made 4–5 valid tool calls before the failing step.

Query used:

sqlite3 -readonly ~/.local/share/opencode/opencode.db "select seq, data from session_message where session_id='<child>' and type in ('assistant','idle') order by seq desc limit 2"

Additional Context

  • Frequency: 3 out of 5 invocations of this subagent in one session. The same model worked earlier that day.
  • Impact: the parent retried by hand, then switched to a different model. The cost was small (≈ $0.12), but an empty "completed" result looks like "no findings" to a reviewing orchestrator.
  • Workaround: tell the orchestrator to treat an empty subagent reply as a failed run, retry once, then switch to another model.
  • rawFinish is mapped in packages/opencode/src/session/llm/ai-sdk.ts. The error seems to get lost where finish:"error" becomes the child's idle outcome or the parent's subagent state.
  • Provider: vercel (AI Gateway). Virtual model vmc/adlc-prosecutor-tests → google/gemini-3.8-flash, routed to Google.
  • Install/channel: latest (Homebrew anomalyco/tap/opencode-v2, plus OpenChamber's bundled opencode-cli, driven via opencode serve). Shell: /bin/zsh.

Possibly related. These issues also report a failed stream that reaches the parent as an empty success, but none of them covers v2 with finish:"error" / MALFORMED_FUNCTION_CALL:

Plugins

@adlc/opencode, opencode.browser, openchamber-agent-tool

OpenCode version

Sessions recorded by v2.0.18 (session_v2.version, OpenChamber's bundled opencode-cli). The same machine also has Homebrew opencode-v2 v2.0.20. I have not checked whether 2.0.19 or 2.0.20 changed this code path.

Steps to reproduce

Intermittent. It depends on the model sending a malformed tool call.

  1. Configure a subagent pinned to a Gemini Flash model (here, through the Vercel AI Gateway).
  2. From a parent session, invoke it with the Task tool, with a tool-heavy brief (read or grep over a large diff).
  3. After several valid tool steps, the provider stream sometimes ends with stream finished with error: MALFORMED_FUNCTION_CALL.
  4. Look at the parent: the task result is state="completed" with "Subagent completed without a text response."

Screenshot and/or share link

No response

Operating System

macOS (Darwin 25.6.0 arm64)

Terminal

xterm-256color (sessions driven through OpenChamber via opencode serve)

Activity

  1. github-actions commented on Sep 30, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #42725: Reports the same core behavior: a child/provider error is lost and the parent receives an empty successful-looking task result.
    • #38866: Subagent stream errors surface to the parent as an empty task result.
    • #40527: A failed final response stream leaves the parent with an empty result and no surfaced error.
  2. r266-tech commented on Sep 30, 2026

    @r266-tech
    Contributor

    Confirmed the downstream failure on v2 at f46fa72 (package version 2.0.20), using the real session runner, job lifecycle and temporary database with the existing tool-subagent.test.ts harness. This is a synthetic provider-event reproduction, not a live Vercel/Gemini reproduction.

    Replace the completionIt fixture's TestLLM.text(...) fallback with:

    TestLLM.complete({
      reason: { normalized: "error", raw: "MALFORMED_FUNCTION_CALL" },
      usage: { inputTokens: 0, outputTokens: 0 },
    })

    For both foreground and background subagent calls, the assistant persists finish: "error", rawFinish: "MALFORMED_FUNCTION_CALL", empty content and no error field. The child outcome is succeeded, the job is completed, and the parent gets the empty completed result. Changing only the normalized reason to "stop" gives the expected successful empty-response control (2 controls pass; both error-outcome assertions fail).

    The relevant seam is packages/core/src/session/runner/step.ts: a successfully ended stream with finish === "unknown" synthesizes a failure, but explicit "error" does not. It reaches Outcome.Completed without record.failure; subagent-job.ts then accepts the assistant because message.error is absent. Empty text alone should remain a valid control.

    AI-assisted investigation and test execution. Sharing the reproduction here for the existing assignee; no parallel PR opened.

  3. tpischke commented on Oct 1, 2026

    @tpischke

    Same class of failure with finish: "length", on a local OpenAI-compatible provider (OpenCode 2.0.21).

    A subagent's last assistant message was cut by the request's output limit while still in its reasoning block: the message holds one reasoning part and no text part, finish is length. OpenCode set the child session to idle with outcome: "succeeded" and the parent received "Subagent completed without a text response."

    Seen three times in one session on 2026-10-01: two review subagents (32,768 output tokens each, all reasoning) and one implementer subagent that had left uncommitted edits. In all three the parent could only infer the failure from the empty result; it could not see the finish reason, so it could not tell a truncated turn from a subagent that had nothing to say. For the implementer it guessed "tooling failure" and re-dispatched with the same brief.

    So the expected behaviour described above would cover this case too: a last message that ends with finish: "length" and no text should end the child with a failed outcome, and the tool result should carry the finish reason.

  4. r266-tech commented on Oct 1, 2026

    @r266-tech
    Contributor

    Confirmed the length case on current v2 at 0f9f0a9 (2.0.21), using the real runner/job/database and synthetic TestLLM events. No live provider calls.

    The existing tool-subagent.test.ts completion fixture can use this fallback:

    TestLLM.complete(
      { reason: { normalized: "length" }, usage: { outputTokens: 32768 } },
      LLMEvent.reasoningStart({ id: "r" }),
      LLMEvent.reasoningDelta({ id: "r", text: "unfinished analysis" }),
      LLMEvent.reasoningEnd({ id: "r" }),
    )

    In both foreground and background cases, the assistant retains reasoning and finish: "length", but no error; the child is succeeded, the job is completed, and the parent receives “Subagent completed without a text response.” The completion notification also omits the finish reason.

    Changing only the normalized finish to stop passes both controls. Empty stop controls also pass; the earlier error reproduction still fails. Overall: 4 controls pass, 4 expected-failure-outcome assertions fail. The runner's step.ts still special-cases only unknown after a successfully ended stream. A fix limited to explicit provider errors would therefore leave this truncation case uncovered.

    AI-assisted investigation and test execution; sharing with the existing assignee.

  5. rebelliard commented on Oct 25, 2026

    @rebelliard
    Author

    Still happens on current dev (32b5701), and #54713 fixes it. I ran its tests on dev with and without the step.ts change: 2 fail without, 13/13 pass with. It keeps error terminal with no retry, which works for me.

    Root cause is step.ts#L171-L172. After a cleanly ended stream, only finish === "unknown" becomes a failure. An explicit error finish completes normally, and subagent-job.ts#L58 then accepts the message because message.error is undefined.

    The query above now finds 9 of these in my database: 6 on 2.0.18, 1 on 2.0.20, 2 on 2.0.22, last on 2026-10-08. None on 2.0.25 or 2.0.32, but routing metadata isn't stored on those versions, so I can't tell whether that alias was still hitting Gemini Flash.

    @tpischke's finish: "length" case is a different path. dev already handles it in llm.ts#L299-L328: two continuations, then an output-limit step failure. I have no length cases locally, so I didn't test it.

  6. cep207 commented on Oct 27, 2026

    @cep207

    Worth noting we observed the finish: "error" + empty-content variant in a main (non-subagent) turn too, on Desktop 2.1.4 / CLI 1.19.4 with opencode-go: assistant message with content: [], finish: "error", settled without any visible output or error surfaced to the user. Same missing validation as #55378 (the finish: "stop" variant is far more frequent here — 3/625 messages over 2 days). This suggests the guard in #54713 would benefit from covering the main agent path and both finish reasons, not just the subagent → parent reporting.

  7. r266-tech commented on Oct 27, 2026

    @r266-tech
    Contributor

    @cep207 I checked #54713 at 4b1fedc63011a3ce58b33f1036e19e99c16bf56a: its error guard is in the shared SessionStep.attempt, with no subagent condition. The primary request path also calls it through SessionRunner.drain. So the patch appears to cover main-agent finish: "error" too, although the added regression tests exercise subagents.

    finish: "stop" does not enter that guard. The empty/reasoning-only stop behavior in #55378 therefore remains a separate case; merging #54713 should not be taken as resolving it.

    AI-assisted source review only; I have not run a new main-agent reproduction or live provider test.

  8. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I can reproduce this on 2.0.18 and on the latest dev (f0160a1).

    The script below sets up a fake AI SDK provider that acts like the Vercel AI Gateway response you saw. The parent calls the subagent tool, and the subagent's only step ends with finish reason error / MALFORMED_FUNCTION_CALL, no content and 0 output tokens. The result matches your database records:

    • The child's assistant message is stored with finish:"error", rawFinish:"MALFORMED_FUNCTION_CALL", content:[] and no error.
    • The child idles with outcome:"succeeded".
    • In the foreground, the parent's tool result is state="completed" with "Subagent completed without a text response." With background: true, the parent gets the same synthetic <subagent ... state="completed"> message you quoted.

    For comparison, the same Gemini MALFORMED_FUNCTION_CALL response through OpenCode's native Google provider is handled correctly. The child fails with "Gemini stopped with MALFORMED_FUNCTION_CALL" and the parent sees an error. So the problem is in how a finish reason of error from AI SDK-based providers is handled. Your vercel provider used that path in 2.0.18.

    One caveat: on current dev, @ai-sdk/gateway is mapped to a native Vercel AI Gateway implementation, so your exact Vercel route may already behave differently there. I haven't verified that. The general bug is still on dev for AI SDK providers.

    Related: #50250 (v2 session idles silently after a turn with no output), and the v1 reports #38866, #40527 and #37852.

    Reproduction script (run.sh)
    #!/usr/bin/env bash
    # Reproduce anomalyco/opencode#52378: a subagent whose step ends with finish "error"
    # (rawFinish MALFORMED_FUNCTION_CALL, no content) from an AI SDK provider is reported
    # to the parent as a successful completion (child idle "succeeded", parent state="completed").
    # Self-contained: writes a fake AI SDK provider, an isolated config, and a DB dump helper.
    # Usage: ./run.sh                       (released `opencode` on PATH, e.g. @opencode/cli@2.0.18)
    #        OC_DEV=/path/to/opencode ./run.sh (from a source checkout)
    set -uo pipefail
    R=/workspace/repro
    mkdir -p $R/proj $R/home/.config/opencode
    git -C $R/proj init -q 2>/dev/null || true
    cat > $R/fake-provider.mjs <<'JS'
    // Minimal AI SDK (LanguageModelV3) provider standing in for a gateway that routes to Gemini.
    // Parent turn 1 (subagent tool offered, no tool result yet): call the subagent tool (background if BG=1).
    // Parent later turns: plain text. Child (no subagent tool): stream finishes with
    // finishReason { unified: "error", raw: "MALFORMED_FUNCTION_CALL" } and no content, like Gemini via AI SDK.
    const usage = (o) => ({
      inputTokens: { total: 100, noCache: 100, cacheRead: 0, cacheWrite: 0 },
      outputTokens: { total: o, text: o, reasoning: 0 },
    })
    const stream = (parts) =>
      new ReadableStream({ start(c) { for (const p of parts) c.enqueue(p); c.close() } })
    export function createFake() {
      const languageModel = (modelId) => ({
        specificationVersion: "v3",
        provider: "fake",
        modelId,
        supportedUrls: {},
        async doGenerate() { throw new Error("not supported") },
        async doStream(options) {
          const tools = (options.tools ?? []).map((t) => t.name)
          const sub = tools.find((n) => n === "subagent" || n === "task")
          const hasResult = JSON.stringify(options.prompt).includes('"tool-result"')
          let parts
          if (sub && !hasResult) {
            parts = [
              { type: "stream-start", warnings: [] },
              { type: "tool-call", toolCallId: "call_1", toolName: sub,
                input: JSON.stringify({ agent: "general", description: "P5 panel 2 tests", prompt: "Review the tests", background: process.env.BG === "1" }) },
              { type: "finish", finishReason: { unified: "tool-calls", raw: "STOP" }, usage: usage(5) },
            ]
          } else if (sub) {
            parts = [
              { type: "stream-start", warnings: [] },
              { type: "text-start", id: "t" }, { type: "text-delta", id: "t", delta: "Parent: got the subagent result." }, { type: "text-end", id: "t" },
              { type: "finish", finishReason: { unified: "stop", raw: "STOP" }, usage: usage(5) },
            ]
          } else {
            parts = [
              { type: "stream-start", warnings: [] },
              { type: "finish", finishReason: { unified: "error", raw: "MALFORMED_FUNCTION_CALL" }, usage: usage(0) },
            ]
          }
          return { stream: stream(parts) }
        },
      })
      const p = (id) => languageModel(id)
      p.languageModel = languageModel
      p.chat = languageModel
      return p
    }
    JS
    cat > $R/q.ts <<'TS'
    import { Database } from "bun:sqlite"
    const db = new Database(process.argv[2] ?? "/workspace/repro/home/.local/share/opencode/opencode.db", { readonly: true })
    for (const r of db.query("select session_id, seq, type, data from session_message where type in ('synthetic','assistant','idle') order by rowid").all() as any[])
      console.log(r.session_id, r.seq, r.type, String(r.data).slice(0, 600))
    try { for (const r of db.query("select id, idle_outcome from session_v2").all()) console.log(JSON.stringify(r)) } catch (e) { console.log(String(e)) }
    TS
    cat > $R/home/.config/opencode/opencode.json <<'JSON'
    {
      "model": "fake/gemini-flash-via-gateway",
      "providers": {
        "fake": {
          "name": "Fake gateway",
          "env": ["FAKE_API_KEY"],
          "package": "aisdk:file:///workspace/repro/fake-provider.mjs",
          "models": { "gemini-flash-via-gateway": { "name": "Gemini Flash via gateway" } }
        }
      }
    }
    JSON
    rm -rf $R/home/.local
    export HOME=$R/home XDG_CONFIG_HOME=$R/home/.config XDG_DATA_HOME=$R/home/.local/share FAKE_API_KEY=x
    cd $R/proj
    if [ -n "${OC_DEV:-}" ]; then
      OC="bun --conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js $OC_DEV/packages/cli/src/index.ts"
    else OC=opencode; fi
    $OC --version
    for bg in 0 1; do
      echo "=== background=$bg ==="
      if [ -n "${OC_DEV:-}" ]; then
        export OPENCODE_PASSWORD=repro-pass OPENCODE_SERVER_PASSWORD=repro-pass
        BG=$bg $OC serve --port 4999 > $R/serve-bg$bg.log 2>&1 &
        SP=$!; sleep 8
        timeout 90 $OC run --server http://127.0.0.1:4999 --auto --format json "Run the review subagent" > $R/out-bg$bg.jsonl 2> $R/err-bg$bg.log
        sleep 2; kill $SP; wait $SP 2>/dev/null
      else
        BG=$bg timeout 90 $OC run --standalone --auto --format json "Run the review subagent" > $R/out-bg$bg.jsonl 2> $R/err-bg$bg.log
      fi
      sleep 1
    done
    bun $R/q.ts "$(ls $R/home/.local/share/opencode/opencode*.db | head -1)"

    Run it: bash run.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions