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.

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

Metadata

Metadata

Assignees

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