anthropics / anthropics/claude-agent-sdk-typescript

Streaming-input `query()` closes stdin after the top-level turn's first `result`, permanently breaking `canUseTool` for a still-running background subagent ("Tool permission request failed: Error: Stream closed")

Offen
#376 3 Kommentare 3 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
bug
Vorherrschende Sprache
Shell
Sterne
1.8k
Forks
226
PR-Merge-Kennzahlen
Keine gemergten PRs in 30 T.

Beschreibung

## Summary

When `query()` is called with a **streaming-input `prompt`** (an `AsyncIterable` that yields exactly one message and then completes) **and** a `canUseTool` callback, the SDK closes its write side of the child CLI process's stdin (`transport.endInput()`) as soon as the **first** `result` SDK message is observed — even if the top-level turn dispatched a **background subagent** (the CLI's native async-agent / `Task`-style tool) that is still running and will still need to make `canUseTool`-gated tool calls.

Once stdin is closed, the CLI process sets a process-wide `inputClosed` flag. From that point on, **every** subsequent `can_use_tool` control request — from the subagent, or from anything else in the same CLI process — is rejected synchronously before it is even sent, and the tool call is denied with:

```
Tool permission request failed: Error: Stream closed
```

(or `AbortError: Stream closed` on newer builds — see "Version note" below). This is **permanent for the life of the process** — a later `query.setPermissionMode(...)` call still round-trips successfully (it doesn't need `canUseTool`), but it does **not** reopen the closed channel, and no further subagent tool call ever succeeds again.

## Minimal reproduction

No custom tooling required — this reproduces with the CLI's own built-in subagent-spawning tool (surfaced to the model as `Agent`) and a single-message streaming prompt.

```ts
import { query } from '@anthropic-ai/claude-agent-sdk'

async function* promptStream(text: string) {
yield { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null }
}

const prompt =
'Use the Task tool to spawn ONE general-purpose subagent. Give the subagent this exact instruction: ' +
'"Step 1: Read the file /etc/hostname with the Read tool. Step 2: after that finishes, Read the file ' +
'/etc/os-release with the Read tool. Step 3: then write one short sentence summarizing what you found ' +
'in both files as your final answer. Do these steps in order, one at a time, and do nothing else." ' +
'Do not do anything else yourself — just spawn the subagent, wait for it, and relay its final answer.'

const q = query({
prompt: promptStream(prompt),
options: {
settingSources: [],
permissionMode: 'default',
allowedTools: [],
model: 'haiku',
canUseTool: async (toolName, input, options) => {
console.log('canUseTool fired', toolName, options.agentID)
return { behavior: 'allow', updatedInput: input }
},
},
})

for await (const msg of q) {
// observe messages
}
```

### Observed (SDK 0.3.205, CLI 2.1.205 — bundled binary)

```
[t+5013ms] assistant tool_use name=Agent
[t+5042ms] tool_result: "Async agent launched successfully. ..." <- launch is fire-and-forget, returns immediately
[t+6885ms] canUseTool #1 FIRED (subagent's first Read — agentID populated)
[t+6892ms] tool_result: /etc/hostname content <- subagent's 1st tool call SUCCEEDS
[t+6965ms] RESULT (top-level turn's OWN result — "I'll wait for it to complete...")
[t+9080ms] subagent's 2nd Read -> tool_result is_error=true: "Tool permission request failed: Error: Stream closed"
[t+11654ms] retry -> same error
[t+15593ms] retry -> same error
[t+19028ms] task_notification: subagent finished, having failed step 2 permanently
```

Reproduced again on **SDK 0.3.209 / CLI 2.1.209** (haiku model), with a 3rd tool call (a `Bash` call, not just `Read`) also dying the same way after the channel closed — confirming the closure is process-wide, not per-tool or per-agent:

```
[t+17441ms] RESULT (top-level turn's own result)
[t+18885ms] tool_result is_error=true: "Tool permission request failed: AbortError: Stream closed"
[t+20936ms] tool_result is_error=true: "Tool permission request failed: AbortError: Stream closed"
[t+23238ms] tool_result is_error=true: "Tool permission request failed: AbortError: Stream closed" (Bash tool)
```

### It's a race, not a deterministic failure

Running the **identical** shape multiple times, the subagent's tool calls sometimes all succeed — when the subagent happens to finish before the top-level turn's own `result` fires. This makes the bug timing-dependent and easy to miss in quick manual testing, but very easy to hit in production whenever the top-level prompt is engineered to "stop immediately" after dispatching the subagent (which naturally minimizes how long it takes for `result` to fire, tipping the race toward failure).

## Root cause (traced in the SDK's own source, `sdk.mjs`, both 0.3.205 and 0.3.209 — byte-identical in this path)

`Query.streamInput()`:

```js
async streamInput(e){
try {
let t = 0;
for await (let r of e) {
t++;
if (this.abortController?.signal.aborted) break;
await this.transport.write(JSON.stringify(r) + "\n");
}
if (t > 0 && this.hasBidirectionalNeeds()) {
await this.waitForFirstResult();
}
this.transport.endInput(); // closes the CLI child process's stdin
} catch (t) { if (!(t instanceof AbortError)) throw t; }
}

hasBidirectionalNeeds() {
return this.sdkMcpTransports.size > 0
|| (this.hooks !== undefined && Object.keys(this.hooks).length > 0)
|| this.canUseTool !== undefined
|| this.onElicitation !== undefined
|| this.onUserDialog !== undefined
|| this.getOAuthToken !== undefined
|| this.getHostAuthToken !== undefined;
}
```

`waitForFirstResult()` resolves on the session's very first `result`-type SDK message — i.e. as soon as the **top-level** conversational turn finishes (its own model output stops / no more pending top-level tool calls), which is exactly what happens right after a fire-and-forget "launch a background subagent" tool call returns. It has **no concept of an outstanding background task/subagent** that will keep needing the control channel after that point.

`endInput()` (in the process transport) does `this.processStdin.end()` — a genuine EOF on the child CLI process's stdin.

On the CLI side, its own read loop over `this.input` sets a **process-wide** flag once it reaches EOF:

```js
// CLI 2.1.209 (matches 2.1.205 in shape)
async *readSdkMessages() {
...
this.inputClosed = true;
for (let r of this.pendingRequests.values())
r.reject(new BS("Tool permission stream closed before response received"));
}
```

And the control-request sender used for **every** `can_use_tool` request (top-level or subagent, keyed only by `agent_id` inside the same shared queue) checks that flag synchronously, before attempting to enqueue anything:

```js
// CLI 2.1.209
async sendRequest(e, t, r, n = randomUUID()) {
let o = { type: "control_request", request_id: n, request: e };
if (this.inputClosed) throw new BS("Stream closed");
if (r?.aborted) throw new bc("Request aborted");
...
}
```

```js
// CLI 2.1.205 — identical structural check, plain Error class
async sendRequest(e, t, r, n = randomUUID()) {
let o = { type: "control_request", request_id: n, request: e };
if (this.inputClosed) throw Error("Stream closed");
if (r?.aborted) throw Error("Request aborted");
...
}
```

The thrown error propagates into `createCanUseTool`'s wrapper, which converts ANY thrown error from the permission round-trip into a `deny` decision surfaced to the tool caller as:

```js
catch (y) {
let _ = `Tool permission request failed: ${y}`;
...
return denyWith({ behavior: "deny", message: _, toolUseID: i }, ...);
}
```

There is no retry, no re-check of whether the flag might later clear (it never does — `inputClosed` is one-way for the life of the process), and no code path reopens the write side of stdin. A caller flipping `setPermissionMode(...)` mid-run round-trips fine (that control request/response pair travels over the *read* side, stdout, which is unaffected), but it does not touch `inputClosed`, so it cannot recover the channel.

## Version note (0.3.205 → 0.3.209)

The control-channel logic itself (`streamInput`, `hasBidirectionalNeeds`, `endInput`, the CLI's `inputClosed` guard) is **unchanged** between SDK 0.3.205 (bundles CLI 2.1.205) and SDK 0.3.209 (bundles CLI 2.1.209) — confirmed by a byte-level diff of the relevant functions in `sdk.mjs`. The only change found in this exact path is cosmetic: CLI 2.1.205 throws a plain `Error("Stream closed")`; CLI 2.1.209 throws `new BS("Stream closed")` where `BS extends bc extends Error` and `bc`'s constructor sets `this.name = "AbortError"` — so the surfaced message text changed from `Error: Stream closed` to `AbortError: Stream closed`. The underlying permanent-failure behavior is identical.

## Why this matters

Any caller that (a) uses streaming input specifically to unlock the `Query` control surface (`setPermissionMode`, `interrupt`, etc. — which the SDK's own typings gate behind streaming input), (b) supplies `canUseTool` so it can gate tool calls instead of using `permissionMode: 'bypassPermissions'`, and (c) dispatches a subagent/background task from the top-level turn — will intermittently (and, with a "stop immediately after dispatch" prompt, almost deterministically) lose the ability to gate ANY further tool call for the rest of the process, with a misleading "Stream closed" message that gives no indication of the actual cause.

## Client-side workaround (verified)

The bug is fully avoided by **never letting the input iterable complete while the run is in flight**. Because `endInput()` is only reached *after* `streamInput()`'s `for await` loop finishes, keeping the async generator suspended (yield the message, then `await` a deferred you resolve only once the run's own terminal signal has been observed) means `waitForFirstResult()`/`endInput()` are never reached, `inputClosed` is never set, and the permission channel stays open for the whole run.

Verified on SDK 0.3.205, 3/3 runs of the reproduction shape above: with a held-open generator, the subagent's 2nd (and later) `canUseTool` requests fire and succeed *after* the top-level turn's own `result` (the exact moment that closes the channel in the one-shot case), zero "Stream closed" errors. Releasing the hold once the final relayed `result` is observed lets the generator return and the process exit cleanly (~200–260 ms later, no lingering child, exit 0).

```ts
// instead of a one-shot generator that returns immediately:
async function* heldPrompt(text: string, done: Promise) {
yield { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null }
await done // resolve this only after observing the run's terminal signal
}
```

This is a mitigation, not a substitute for a fix: it requires every caller who uses `canUseTool` with a background subagent to know to hold input open, and it is easy to get wrong (the deferred must resolve on *every* exit path or the process never exits).

## Suggested fix directions (not prescriptive)

- `streamInput()`'s `waitForFirstResult()` heuristic should account for outstanding background tasks/subagents (e.g. track `task_started`/`task_notification` state and only call `endInput()` once no backgrounded task is still outstanding), or
- expose an explicit opt-out / explicit `Query` method to keep the control channel open until the caller says otherwise (rather than inferring "done" from the first top-level `result`), or
- at minimum, surface a clearer error than "Stream closed" when a `can_use_tool` request is rejected purely because of this heuristic, so callers can distinguish "your callback is broken" from "the SDK closed the channel out from under a background task."

## Environment

- `@anthropic-ai/claude-agent-sdk` 0.3.205 and 0.3.209 (both tested, same behavior)
- Bundled CLI 2.1.205 and 2.1.209 respectively (both tested, same behavior; only the thrown error's `.name` differs)
- Linux x64, Node 24

Beitragsleitfaden

Für dieses Repository ist kein Beitragsleitfaden indexiert

Bewertung

Dieses Issue wurde noch nicht bewertet.

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.