github / github/copilot-cli

SDK sendAndWait hangs indefinitely in tool-call loops once cumulative tool-result payload crosses a threshold

未關閉
#2,911 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
area:tools
主要語言
Shell
星號
11.2k
分支
1.9k
平均合併
14 小時 16 分鐘
30 天內合併 PR
6

描述

---

## Summary

Using `@github/copilot-sdk` to drive a sequential tool-call loop, the SDK
silently hangs after a few tool iterations once tool result payloads grow
past a small threshold. The hang is wedged inside the model call:
`assistant.turn_start` fires, then **no further events ever arrive** —
no `assistant.message`, no `assistant.turn_end`, no `session.error`.

The `timeoutMs` parameter to `session.sendAndWait(opts, timeoutMs)` is
**ignored** while wedged. The only recovery is killing the process or
applying an outer `AbortController` / `Promise.race` timeout.

This is reproducible with a ~120 line script using only `@github/copilot-sdk`,
no other dependencies, no dehydration / resume, no real tools.

## Affected

- `@github/copilot-sdk` (latest, as bundled with current Copilot CLI)
- Models confirmed: `claude-sonnet-4.6`, `claude-haiku-4.5`, `gpt-5.4`
- Both `claude-opus-4.7` (older) and current `claude-opus-4.6` series
- Node `v24.14.0` on macOS 14, also reproduces in containerized Linux

Claude is more sensitive (smaller threshold) but GPT wedges identically
once payloads × turn count get larger. **Not model-specific.**

## Threshold matrix (clean repro, single fresh session, sequential tool loop)

| Model | 500 B per call | 2 KB per call | 5 KB per call |
|---|---|---|---|
| `claude-sonnet-4.6` | ✅ 20/20 in 58 s | ❌ Hung at call **12** | ❌ Hung at call **4** |
| `gpt-5.4` | ✅ 20/20 in 81 s | ✅ 20/20 in 169 s | ❌ Hung at call **9** |

`N=20` shards, single tool, model is told to call shard=1..N then return the sum.
Tool returns in <1 ms with a synthetic JSON `{ shard, value, padding: "x".repeat(...) }`.

When wedged: `[HANG] elapsed=473916ms` was observed while still hung — i.e. the
SDK held the request open ~8 minutes despite a 180 s `sendAndWait` timeout
parameter.

## Symptom (event trace, anonymized)

```
[HH:MM:SS] >>> turn_start #4
[HH:MM:SS] msg len=0
[HH:MM:SS] tool#3 shard=3 value=52
[HH:MM:SS] <<< turn_end (chars total: 0)
[HH:MM:SS] >>> turn_start #5
← hangs here, indefinitely
← no assistant.message
← no assistant.turn_end
← no session.error
← sendAndWait timeout ignored
```

## Minimal reproduction (~50 lines, no extra deps)

```js
import { CopilotClient, approveAll, defineTool } from "@github/copilot-sdk";

const N = 20;
const PAYLOAD = 5000; // bytes per tool return
const MODEL = "claude-sonnet-4.6";

const stepTool = defineTool("get_step_data", {
description:
`Fetch the next data shard. Call shard=1, then 2, ... until ${N}, ` +
`then output ONE message with the sum. Don't summarize between calls.`,
parameters: {
type: "object",
properties: { shard: { type: "integer" } },
required: ["shard"],
},
handler: async ({ shard }) => JSON.stringify({
shard,
value: ((shard * 17) % 100) + 1,
padding: "x".repeat(PAYLOAD - 60),
}),
});

const client = new CopilotClient({
autoStart: true,
githubToken: process.env.GITHUB_TOKEN,
});
await client.start();

const session = await client.createSession({
model: MODEL,
tools: [stepTool],
onPermissionRequest: approveAll,
systemPrompt:
"You are a sequential data fetcher. Call get_step_data with shard=1, " +
"then shard=2, ..., then output ONE final message with the sum and stop.",
});

session.on((ev) => {
const ts = new Date().toISOString().slice(11, 23);
if (ev.type === "assistant.turn_start") console.log(`[${ts}] turn_start`);
if (ev.type === "assistant.message") console.log(`[${ts}] msg ${ev.data?.content?.slice(0,60)}`);
if (ev.type === "assistant.turn_end") console.log(`[${ts}] turn_end`);
});

// Outer timeout because sendAndWait's own timeout is ignored when wedged
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("HUNG")), 180_000));

try {
const result = await Promise.race([
session.sendAndWait({ prompt: `Fetch all ${N} shards and sum them.` }, 180_000),
timeoutPromise,
]);
console.log("OK:", result?.data?.content);
} catch (e) {
console.log("HUNG:", e.message);
}

await client.stop();
process.exit(0);
```

Save as `loop.mjs`, run `MODEL=claude-sonnet-4.6 GITHUB_TOKEN=ghp_... node loop.mjs`.

To sweep:
```sh
for MODEL in claude-sonnet-4.6 gpt-5.4; do
for PAYLOAD in 500 2000 5000; do
echo "==== $MODEL payload=${PAYLOAD}B ===="
MODEL=$MODEL PAYLOAD=$PAYLOAD node loop.mjs
done
done
```

## Expected behavior

1. The model either streams an `assistant.message` / completes the turn, **or**
2. The SDK surfaces an error event and the `sendAndWait` promise rejects within
the `timeoutMs` parameter the caller passed.

## Actual behavior

Neither happens. The SDK silently waits forever for an upstream stream that
never emits. The `timeoutMs` argument is not enforced when the wedge is
inside the provider stream.

## What we ruled out

- **Single large tool result.** A one-shot test injecting 40 KB / 100 KB /
250 KB / 500 KB base64 tool returns into a fresh session completes
cleanly in 13–22 s on `claude-opus-4.7`. The hang requires a **loop** of
moderately-sized returns.
- **Conversation history size alone.** Resuming a 326-event / 1.8 MB
`events.jsonl` session with a tiny prompt completes in 4 s.
- **Tool handler latency.** Repro tool returns in <1 ms.
- **Model family.** GPT-5.4 wedges at the same symptom, just at higher
payload thresholds than Claude.

## Likely culprits (from the outside)

- Provider streaming adapter not enforcing read deadlines on the upstream
SSE / chunked response.
- A specific tool-use repeat pattern that produces an "empty turn" the
CLI keeps waiting on.
- Cumulative tool-result payload pushing the request close to a
provider-side limit that returns no error and no terminator.

## What would help us

1. An enforced internal read deadline on the upstream model stream that
surfaces a `session.error` instead of waiting forever.
2. The `timeoutMs` parameter to `sendAndWait` actually unsticking the
request (it currently doesn't).
3. A way to pass an `AbortSignal` into `sendAndWait` so callers can cancel.

## Workarounds we use

- Wrap every `sendAndWait` in `Promise.race(sendAndWait, externalTimeout)`
so we at least know it hung.
- Forcibly kill the worker process to release the request and let our
durable-execution layer redeliver the turn.
- Spill large tool returns to blob storage and pass back a small pointer,
to keep cumulative tool-result payload below the wedge threshold.

貢獻指南

開啟貢獻指南

研究方向

Run the provided loop.mjs reproduction with the listed models and payload sizes, then trace session.sendAndWait from the SDK entry point into the upstream model stream. Check how stream termination, read deadlines, and timeoutMs are handled after repeated tool calls. Done means the request completes or emits session.error and rejects within the caller's timeout.

由索引模型根據 Issue 內容生成。

評估

技術堆疊
javascript, node.js
領域
api, backend
Issue 類型
缺陷
難度
4/5
預估耗時
3-5 天
活躍度
冷清
描述清晰度
基本清楚
新手友好度
35/100

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。