Skip to content

[BUG]: SDK hangs when string prompt generates more than ~50 tool calls with hooks enabled #779

@Don-Falkone

Description

@Don-Falkone

Description

query() with a string prompt and PreToolUse hooks deadlocks once the internal 100 message buffer fills up. Each tool call puts ~2 messages into the buffer, so around 50 tool calls is enough to trigger it.
Hooked tools (Bash, Skill) getting denied after the CLI times out on hook callbacks. But the real problem is that the whole session stalls.

Root cause

For string prompts, _internal/client.py:141 awaits wait_for_result_and_end_input() before receive_messages() starts:

# _internal/client.py:130-147
if isinstance(prompt, str):
    await chosen_transport.write(json.dumps(user_message) + "\n")
    await query.wait_for_result_and_end_input()   # blocks until "result" arrives

async for data in query.receive_messages():        # buffer drain starts here

Meanwhile _read_messages() keeps reading from CLI stdout and pushing into the 100 slot anyio channel (query.py:107-109). After ~50 tool calls the channel is full and _message_send.send() at line 236 blocks. Now _read_messages can't read anything else from stdout, including the "result" message that wait_for_result_and_end_input needs.

The _read_messages finally block (query.py:254) does call _first_result_event.set(), but it never runs because we're stuck inside the try. And even if it ran, it also tries to send() to the same full buffer.

For exmaple, the AsyncIterable path avoids all of this because it spawns stream_input() as a background task (client.py:144), so receive_messages() starts immediately.

Reproduction

Note: Use a big file instead of test.py

import asyncio
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher, ResultMessage, query

async def allow_hook(input_data, tool_use_id, ctx):
    return {"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "allow",
        "permissionDecisionReason": "allowed",
    }}

async def main():
    steps = [f"{i}. Read file test.py from line {(i-1)*15+1} with limit 15" for i in range(1, 55)]
    steps.append("55. Run Bash command: pwd")
    prompt = "Do ONE tool call per step, never parallel:\n" + "\n".join(steps)

    async for message in query(
        prompt=prompt,
        options=ClaudeAgentOptions(
            model="claude-opus-4-6",
            permission_mode="dontAsk",
            hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[allow_hook])]},
        ),
    ):
        if isinstance(message, ResultMessage):
            print(message.result)

asyncio.run(main())

Expected: all 55 steps complete including pwd.

Actual: after ~50 Reads the session deadlocks. CLI times out on the Bash hook and falls back to dontAsk denial: "Permission to use Bash has been denied because Claude Code is running in don't ask mode."

Workaround

Bump the queue size

import anyio
from claude_agent_sdk._internal import query as _query_mod

_orig_init = _query_mod.Query.__init__
def _patched_init(self, *args, **kwargs):
    _orig_init(self, *args, **kwargs)
    self._message_send, self._message_receive = anyio.create_memory_object_stream[dict](
        max_buffer_size=10_000,
    )
_query_mod.Query.__init__ = _patched_init

Suggested fix

Spawn wait_for_result_and_end_input() as a background task for string prompts, same as the AsyncIterable path already does:

if isinstance(prompt, str):
    await chosen_transport.write(json.dumps(user_message) + "\n")
    query.spawn_task(query.wait_for_result_and_end_input())

Environment

  • claude-agent-sdk 0.1.52
  • Bundled CLI 2.1.87
  • Python 3.12
  • anyio (latest)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions