问题
Worker 通过 Claude Agent SDK(headless)驱动 agent,但 SDK 模式下 agent 调用 AskUserQuestion(以及其它需要交互式宿主的工具,如 plan 模式外的 ExitPlanMode)时无法把问题回传给人类,导致该 tool 调用直接 报错(is_error / 1 failed),整个 turn 失败。
现实场景(飞书上观察到):agent 在 turn 中途想跟用户确认几个点 → 触发 AskUserQuestion → 在 headless worker 上没有任何宿主能应答这个交互请求 → tool 报错,用户只看到一个红色 error,问题也没送达。
注:worker 跑在 Mac mini / 服务器上,没有 TTY、没有交互式 Claude Code TUI,纯 SDK headless。
根因(代码定位)
packages/cli/src/session/runner/:
query.ts — SDK 启动选项写死 permissionMode: 'bypassPermissions',且没有传 canUseTool 回调:
const options: Options = {
cwd: config.worktreePath,
permissionMode: planMode ? 'plan' : 'bypassPermissions',
// ❌ 无 canUseTool;❌ 无 disallowedTools
...
}
stream.ts — SDK 消息流是单向的:for await (message of messages) → emitEvent('sdk_event', message)。没有任何从人类回灌进正在进行的 turn 的通道。
turn.ts — turn 模型是「请求/应答」:一个 turn 跑到结束,人类的回复是下一个新 turn(resume),无法在 turn 中途暂停等人回答。
因此 AskUserQuestion 这种「turn 中途需要人应答、并把结果作为 tool_result 接着往下跑」的工具,在当前架构里无处落地,只能报错。
SDK 能力核对(@anthropic-ai/claude-agent-sdk@0.3.167,worker 实际安装版本)
Options.canUseTool?: CanUseTool,签名为 (toolName, input, opts) => Promise<PermissionResult>,是 async —— 宿主可以在回调里 park 住、等人类应答后再 resolve,这正是「turn 中途暂停等输入」所需的钩子。
- SDK 注释明确:交互式 ask 路径通过
can_use_tool 的 control_request 浮出。
Options.disallowedTools?: string[] —— 可直接屏蔽某个工具,模型会改用别的方式(纯文本提问)。
AskUserQuestionInput:1–4 个问题,每题 2–4 个选项(label + description + 可选 preview),SDK 自动追加 "Other"。AskUserQuestionOutput 回结构化的选择结果。
已有的可复用基础设施(重要)
baton 已经有 server→worker 的反向控制通道:worker daemon 通过 SSE 订阅 /workers/me/stream(daemon.ts),runner.ts 已经在处理 user_message 和 interrupt(/abort Esc 会 abort 在飞的 turn)。也就是说「把人类的答案推回给正在 park 的 worker」所需的传输层已经存在,只差一个新的事件类型 + 在 turn 内 park/resolve 的逻辑。Web 端也已经能渲染 AskUserQuestion(截图里就是 web 解析出来的 AskUserQuestion questions:[{...)。
方案(分两步,先不实现)
Track A — 立即止血(低成本,无架构改动)
在 query.ts 的 Options 里加 disallowedTools: ['AskUserQuestion'](必要时连同未接入的交互工具一起),并在 system prompt 追加一句「你运行在 headless 中继环境,需要确认时请用纯文本在回复里提问,不要使用 AskUserQuestion」。
- 效果:模型不再调用会报错的工具,转而把问题作为普通文本写进 assistant 消息 —— 这条路径在当前 turn 模型里已经完全跑通(人类在 web/飞书读到 → 回复 → 触发新 turn)。截图里的 error 立刻消失。
- 代价:失去「结构化选项 + 点选」的体验,只有纯文本问答。
- 适合:作为 hotfix 先上,挡住报错。
Track B — 正式打通交互式问答(中等成本,真正的体验)
把 AskUserQuestion(及未来的权限 ask)经由 baton 已有的人类通道做成真正的「中途提问 → 人点选 → 同一 turn 继续」:
- 拦截:两种实现路径,二选一
- (a) 在
query.ts 传 canUseTool 回调拦截交互请求;注意需评估 bypassPermissions 下 canUseTool 是否被触发,可能要把权限模式从 bypass 调整为可触发 ask 的模式。
- (b) 更稳:用一个同名 in-process MCP 工具覆盖
AskUserQuestion,工具体内 emit 事件 + 阻塞等人类答案,返回 AskUserQuestionOutput。这条路绕开权限模式耦合,行为可控。
- 浮出:拦截后 emit 一个新事件
question_pending(携带结构化 questions/options),持久化,并把 session 置为子状态 awaiting_input。
- 渲染:web 渲染选项点选器(已有 AskUserQuestion 渲染基础);飞书/钉钉渲染成带序号的列表,回数字即可。
- 回灌:人类的答案经一个新 endpoint → server 走已有的
/workers/me/stream 把 question_answer 事件推给正在 park 的 worker → resolve 掉 canUseTool/MCP 工具的 Promise(返回结构化结果)→ 同一个 in-flight turn 继续往下跑,无需 resume。
- 超时/看门狗:
turn.ts 现在有 30min turn ceiling 看门狗,park 等人期间要么把 ceiling 调长、要么在 awaiting_input 期间暂停看门狗,避免「人还没回就被 abort」;同时 awaiting_input 不能被 idle-reaper 当成空闲回收。
建议:先上 Track A 止血,再排期 Track B(优先考虑 (b) MCP 覆盖方案,耦合最小、最可控)。
验收(待实现时)
相关文件
packages/cli/src/session/runner/query.ts(SDK options:permissionMode / 缺 canUseTool / disallowedTools)
packages/cli/src/session/runner/stream.ts(单向消息流)
packages/cli/src/session/runner/turn.ts(turn 生命周期 / 看门狗 / abort)
packages/cli/src/session/runner.ts + packages/cli/src/worker/daemon.ts(已有 server→worker SSE 控制通道:user_message / interrupt)
本 issue 由 rooma worker 经大鱼指派记录,仅记录方案、暂不实现,等待排期与确认。
问题
Worker 通过 Claude Agent SDK(headless)驱动 agent,但 SDK 模式下 agent 调用
AskUserQuestion(以及其它需要交互式宿主的工具,如 plan 模式外的ExitPlanMode)时无法把问题回传给人类,导致该 tool 调用直接 报错(is_error/1 failed),整个 turn 失败。现实场景(飞书上观察到):agent 在 turn 中途想跟用户确认几个点 → 触发
AskUserQuestion→ 在 headless worker 上没有任何宿主能应答这个交互请求 → tool 报错,用户只看到一个红色 error,问题也没送达。根因(代码定位)
packages/cli/src/session/runner/:query.ts— SDK 启动选项写死permissionMode: 'bypassPermissions',且没有传canUseTool回调:stream.ts— SDK 消息流是单向的:for await (message of messages) → emitEvent('sdk_event', message)。没有任何从人类回灌进正在进行的 turn 的通道。turn.ts— turn 模型是「请求/应答」:一个 turn 跑到结束,人类的回复是下一个新 turn(resume),无法在 turn 中途暂停等人回答。因此
AskUserQuestion这种「turn 中途需要人应答、并把结果作为 tool_result 接着往下跑」的工具,在当前架构里无处落地,只能报错。SDK 能力核对(
@anthropic-ai/claude-agent-sdk@0.3.167,worker 实际安装版本)Options.canUseTool?: CanUseTool,签名为(toolName, input, opts) => Promise<PermissionResult>,是 async —— 宿主可以在回调里 park 住、等人类应答后再 resolve,这正是「turn 中途暂停等输入」所需的钩子。can_use_tool的control_request浮出。Options.disallowedTools?: string[]—— 可直接屏蔽某个工具,模型会改用别的方式(纯文本提问)。AskUserQuestionInput:1–4 个问题,每题 2–4 个选项(label + description + 可选 preview),SDK 自动追加 "Other"。AskUserQuestionOutput回结构化的选择结果。已有的可复用基础设施(重要)
baton 已经有 server→worker 的反向控制通道:worker daemon 通过 SSE 订阅
/workers/me/stream(daemon.ts),runner.ts已经在处理user_message和interrupt(/abortEsc 会 abort 在飞的 turn)。也就是说「把人类的答案推回给正在 park 的 worker」所需的传输层已经存在,只差一个新的事件类型 + 在 turn 内 park/resolve 的逻辑。Web 端也已经能渲染AskUserQuestion(截图里就是 web 解析出来的AskUserQuestion questions:[{...)。方案(分两步,先不实现)
Track A — 立即止血(低成本,无架构改动)
在
query.ts的Options里加disallowedTools: ['AskUserQuestion'](必要时连同未接入的交互工具一起),并在 system prompt 追加一句「你运行在 headless 中继环境,需要确认时请用纯文本在回复里提问,不要使用 AskUserQuestion」。Track B — 正式打通交互式问答(中等成本,真正的体验)
把
AskUserQuestion(及未来的权限 ask)经由 baton 已有的人类通道做成真正的「中途提问 → 人点选 → 同一 turn 继续」:query.ts传canUseTool回调拦截交互请求;注意需评估bypassPermissions下canUseTool是否被触发,可能要把权限模式从 bypass 调整为可触发 ask 的模式。AskUserQuestion,工具体内 emit 事件 + 阻塞等人类答案,返回AskUserQuestionOutput。这条路绕开权限模式耦合,行为可控。question_pending(携带结构化 questions/options),持久化,并把 session 置为子状态awaiting_input。/workers/me/stream把question_answer事件推给正在 park 的 worker → resolve 掉canUseTool/MCP 工具的 Promise(返回结构化结果)→ 同一个 in-flight turn 继续往下跑,无需 resume。turn.ts现在有 30min turn ceiling 看门狗,park 等人期间要么把 ceiling 调长、要么在 awaiting_input 期间暂停看门狗,避免「人还没回就被 abort」;同时awaiting_input不能被 idle-reaper 当成空闲回收。建议:先上 Track A 止血,再排期 Track B(优先考虑 (b) MCP 覆盖方案,耦合最小、最可控)。
验收(待实现时)
AskUserQuestion报错;需要确认时以纯文本提问,人类可在飞书/web 正常回复并继续。相关文件
packages/cli/src/session/runner/query.ts(SDK options:permissionMode/ 缺canUseTool/disallowedTools)packages/cli/src/session/runner/stream.ts(单向消息流)packages/cli/src/session/runner/turn.ts(turn 生命周期 / 看门狗 / abort)packages/cli/src/session/runner.ts+packages/cli/src/worker/daemon.ts(已有 server→worker SSE 控制通道:user_message/interrupt)