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")
- 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.