acp: a tool call with empty arguments is never surfaced to the client — both its start and its result are silently dropped
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)
"""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)
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:
# 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:
# 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:
- A tool that takes no arguments. This is the clean, provider-independent case. The model emits a
tool_useopener withinput: {}and no argument deltas follow, sochunk["args"]is""on the opener and never grows.langchain-anthropicsurfaces this as atool_call_chunkwhoseargsis the empty string (Anthropic sends the opener withinput: {}and emits noinput_json_deltaevents for empty input). Any user-registered zero-arg tool —get_status(),list_open_prs(), arefresh()action — trips this. - Proxy / parser re-chunking that strips arguments. The same streaming-assembly brittleness documented in #6302 applies:
BerriAI/litellm#39796corrupts parallel tool-call chunks (empty id/name, fused/dropped argument deltas), which can leave a call's accumulatedargs_strempty 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 aToolMessagearrives for a truthytool_call_idthat is not inactive_tool_calls, synthesize the missingtool_callstart 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
{}. Defaultargs_strto"{}"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)
Source: langchain-ai/deepagents