#6333·deepagents

acp: a tool call with empty arguments is never surfaced to the client — both its start and its result are silently dropped

Author: Beastmode1470Created Sep 16, 2026Updated Sep 16, 2026
Labelstype:bugpackage:acporg:externalpriority:backlog

Submission checklist

  • This is a bug, not a usage question.
  • I added a clear and descriptive title.
  • I searched existing issues and didn't find this.
  • I can reproduce this with the latest released version.
  • I included a minimal reproducible example and steps to reproduce.

Area (Required)

  • deepagents (SDK)
  • dcode
  • talon
  • acp
  • evals
  • daytona
  • modal
  • quickjs
  • runloop
  • vercel
  • langsmith-sandbox
  • Other / not sure / general

Related Issues / PRs

#5938 (same handler, sibling defect — failed status reported as "completed") #6302 (same class of silent tool-call drop in the SDK offload path, fixed via #6316) #5560 #6331 (auto-closed — this is the same hand-written report, re-filed via the web form)

Reproduction Steps / Example Code (Python)

python
"""MRE for the empty-args tool-call drop in ACPAgent._process_tool_call_chunks.

Drives the real streaming handler with a fake connection that records every
session_update. A no-argument tool call (accumulated args == "") produces zero
updates ‚ the client never learns the call happened.
"""

import asyncio

from deepagents_acp.server import ACPAgent


class RecordingConn:
    def __init__(self):
        self.updates = []

    async def session_update(self, **kwargs):
        self.updates.append(kwargs)


class FakeChunk:
    """Stand-in for an AIMessageChunk carrying tool_call_chunks."""

    def __init__(self, tool_call_chunks):
        self.tool_call_chunks = tool_call_chunks


async def main():
    agent = ACPAgent.__new__(ACPAgent)   # exercise the handler in isolation
    agent._conn = RecordingConn()

    active_tool_calls, accumulator = {}, {}

    # A no-argument tool call. The opener carries id + name; the accumulated
    # argument string is "" because there are no argument tokens to stream.
    chunk = FakeChunk([{"id": "call_1", "name": "get_status", "args": "", "index": 0}])

    await agent._process_tool_call_chunks("sess-1", chunk, active_tool_calls, accumulator)

    print("session_update calls:", len(agent._conn.updates))   # -> 0  (expected: 1)
    print("registered:", active_tool_calls)                     # -> {} (expected: {"call_1": ...})
    assert agent._conn.updates, "no tool_call start was emitted for the no-arg call"


asyncio.run(main())

Error Message and Stack Trace (if applicable)

bash
No exception, no log line. This is a silent omission: the tool runs server-side,
the agent proceeds normally, and the ACP client's transcript simply has a gap where
a tool call and its result should be. The only symptom is a missing UI event.

Description

Problem

ACPAgent._process_tool_call_chunks (deepagents_acp/server.py:602) only starts ‚ai and registers ‚ai a tool call when the accumulated argument string is truthy:

python
# server.py:644
if tool_id and tool_id not in active_tool_calls and args_str:
    try:
        tool_args = json.loads(args_str)
        active_tool_calls[tool_id] = {"name": tool_name, "args": tool_args}
        update = self._create_tool_call_start(tool_id, tool_name, tool_args)
        await self._conn.session_update(...)

The and args_str guard conflates two different states: "arguments haven't finished streaming yet" and "this call has no arguments." The empty string is a valid, terminal value for a zero-argument tool, but the guard treats it as "not ready" and never fires the start.

The insert into active_tool_calls is the load-bearing side effect. It is the single source of truth the result path reads:

python
# server.py:1076-1079 — the tool result handler
if (
    tool_call_id
    and tool_call_id in active_tool_calls          # <- never true for the dropped call
    and active_tool_calls[tool_call_id].get("name") != "edit_file"
):

So one skipped insert drops two client events: the tool_call start and the later tool_call_update result. Same failure shape as #6302 ‚ai a falsy field on the tool-call path quietly swallows output ‚Äî just one layer up, in the ACP streaming adapter rather than the SDK offloader.

Live trigger paths

args_str == "" reaches the start-loop through normal operation:

  1. A tool that takes no arguments. This is the clean, provider-independent case. The model emits a tool_use opener with input: {} and no argument deltas follow, so chunk["args"] is "" on the opener and never grows. langchain-anthropic surfaces this as a tool_call_chunk whose args is the empty string (Anthropic sends the opener with input: {} and emits no input_json_delta events for empty input). Any user-registered zero-arg tool — get_status(), list_open_prs(), a refresh() action — trips this.
  2. Proxy / parser re-chunking that strips arguments. The same streaming-assembly brittleness documented in #6302 applies: BerriAI/litellm#39796 corrupts parallel tool-call chunks (empty id/name, fused/dropped argument deltas), which can leave a call's accumulated args_str empty even when the model intended arguments. That path lands here identically.

Note this is provider-shaped, and I want to be precise about scope: OpenAI-family integrations typically stream "{}" for an empty-argument call, which is truthy and slips past the guard fine. The reliably-broken case is the empty string, which Anthropic produces for no-arg tools. So the bug is real and reachable in everyday use, but it is not universal across every provider — worth stating up front.

Why nothing has surfaced yet (it is silent)

  • No exception is raised and nothing is logged.
  • The agent's own reasoning is unaffected ai it receives the tool result internally; only the client-facing ACP stream loses the events.
  • The gap is easy to misread as "the model just didn't call a tool that turn," so it never gets reported as a bug.

Proposed fix

The naive json.loads(args_str or "{}") is not safe on its own: starting on the opener chunk before a genuinely-argumented call's deltas arrive would emit a start with empty args and then never patch it (once in active_tool_calls, the call is skipped). The guard exists for a reason.

Two ways to fix it correctly:

  • Preferred ‚AI recover at result time. In the result handler (server.py:1076), when a ToolMessage arrives for a truthy tool_call_id that is not in active_tool_calls, synthesize the missing tool_call start from the accumulator (or the message itself) before emitting the result, instead of dropping it. This is robust to every reason a start was missed ‚Äî empty args, a proxy-mangled opener (#39796), a dropped chunk ‚Äî not just this one, and it cannot cause a premature start.
  • Complementary ‚Äî treat a completed empty accumulator as {}. Default args_str to "{}" only once the call is known to be finalized (end-of-stream flush), so no-arg calls start cleanly without racing argumented ones.

I have the MRE above as a failing regression test and a small patch implementing the result-time recovery; happy to open a PR the moment I am assigned.

Environment / System Info

OS: macOS 26.2 (arm64) Python: 3.12 deepagents-acp: 0.0.11 (reproduced against langchain-ai/deepagents main @ 7f9e8ed) provider exhibiting empty args: anthropic (langchain-anthropic)