# ask_user:Python 工具定义与前端解析示例
本文以当前 Java 实现的 AskUserTool.java 和 React 前端 web/src/main.tsx 为契约来源,说明如何用 Python 定义同一个 ask_user 工具,以及浏览器如何解析并提交回答。
ask_user不是普通同步工具:模型调用它后,Agent 运行暂停,后端通过 SSE 发出结构化表单,用户提交后,原工具调用得到ToolResult,Agent 从下一轮推理继续。
# 1. Python 示例定义
下面的 Python 代码只负责向模型暴露工具 schema。实际 Web 运行时,执行器不应伪造用户答案;应由交互协调器在执行前拦截该工具调用。
from typing import Any
ASK_USER_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "问题标题",
},
"description": {
"type": "string",
"description": "用户需要知道的补充说明",
},
"mode": {
"type": "string",
"enum": [
"single",
"multiple",
"text",
"number",
"date",
"directory",
"toggle",
],
"description": "决定前端渲染何种控件",
},
"options": {
"type": "array",
"description": "single、multiple、toggle 模式的选项",
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "稳定提交值"},
"label": {"type": "string", "description": "展示名称"},
"description": {"type": "string", "description": "展示说明"},
"recommended": {"type": "boolean", "description": "推荐项"},
},
"required": ["id", "label"],
},
},
"allowCustom": {
"type": "boolean",
"description": "是否允许用户填写选项之外的内容",
},
"submitLabel": {
"type": "string",
"description": "提交按钮文案",
},
},
"required": ["title", "mode"],
}
def ask_user_tool_definition() -> dict[str, Any]:
return {
"name": "ask_user",
"description": (
"当任务缺少必要信息时发起结构化追问。"
"支持 single、multiple、text、number、date、directory、toggle;"
"收到用户结果后继续任务。"
),
"parameters": ASK_USER_SCHEMA,
}
def ask_user_fallback(_: dict[str, Any]) -> dict[str, Any]:
"""CLI 等没有交互协调器的运行时的明确降级结果。"""
return {
"content": [{"type": "text", "text": "ask_user 需要在 Web 交互界面中完成。"}],
"details": {},
"is_error": True,
}
模型实际调用示例:
{
"name": "ask_user",
"arguments": {
"title": "请选择执行方式",
"description": "不同方式会影响接下来的工具权限。",
"mode": "single",
"options": [
{
"id": "safe",
"label": "先分析",
"description": "只阅读并给出方案",
"recommended": true
},
{
"id": "implement",
"label": "直接实现",
"description": "开始修改工作区"
}
],
"allowCustom": false,
"submitLabel": "继续"
}
}
options[].id 是稳定契约,后端和模型应依据它判断选择;label、description 可以调整展示文案,不应作为业务判断值。
# 2. 后端交互协议
当前 Java 链路为:
模型 ToolCall(ask_user)
→ UserInputCoordinator 拦截
→ 保存 AgentState.pendingInteraction
→ SSE user_input_required
→ POST /api/chat/continue
→ ToolResult 回灌 AgentLoop
→ 下一轮模型推理
后端补充两项运行字段:
id:请求 ID,前端提交时作为requestId;toolCallId:关联模型最初的工具调用和 Trace。
SSE 事件:
event: user_input_required
data: {
"id": "ask_...",
"toolCallId": "call_...",
"title": "请选择执行方式",
"mode": "single",
"options": [{ "id": "safe", "label": "先分析" }]
}
用户提交:
POST /api/chat/continue
Content-Type: application/json
{
"sessionId": "sess-xxx",
"requestId": "ask_...",
"selections": ["safe"],
"customInput": "",
"cancelled": false
}
刷新恢复查询:
GET /api/interactions/pending?sessionId=sess-xxx
返回 pending 时,resumable: true 代表该会话状态仍可继续。提交时后端按持久化的 requestId 和 toolCallId 写入 ToolResult,再启动下一段 AgentLoop;因此不依赖原 JVM 中保留 Future。
# 3. React 前端解析
前端的 stream(...) 使用 fetch 读取 SSE,并在解析到 userinputrequired 时检查最小必需字段:
if (
name === 'user_input_required' &&
typeof data.id === 'string' &&
typeof data.toolCallId === 'string' &&
typeof data.title === 'string' &&
typeof data.mode === 'string'
) {
onInput(data as InputRequest)
}
对应 TypeScript 类型:
type InputOption = {
id: string
label: string
description?: string
recommended?: boolean
}
type InputRequest = {
id: string
toolCallId: string
title: string
description?: string
mode: 'single' | 'multiple' | 'text' | 'number' | 'date' | 'directory' | 'toggle'
options?: InputOption[]
allowCustom?: boolean
submitLabel?: string
resumable?: boolean
}
UserInputCard 的渲染规则:
mode | 控件 |
|---|---|
| --- | --- |
single | radio;只保留一个 selection |
multiple | checkbox;可提交多个 selection |
toggle | 选项控件;至少选择一项才能提交 |
text | 文本输入框 |
number | input[type=number] |
date | input[type=date] |
directory | 文本输入框,提示目录相对路径 |
allowCustom: true 时,非文本模式额外显示“其他说明”输入框。提交时,组件统一调用:
onSubmit(selections, customInput, cancelled)
应用层的 submitUserInput(...) 再调用 /api/chat/continue。如果 resumable === false,卡片只展示“服务已重启,请重新发起任务”,不会发送一个必然失败的继续请求。
# 4. Python 运行时接入要点
如果将来有 Python 版 AgentLoop,建议保留三条边界:
- 工具定义与交互执行分离:Python schema 只暴露给模型;暂停/恢复交给
UserInputCoordinator等运行时组件。 - 按会话持久化表单,不持久化 Future:保存
requestId、toolCallId、payload、过期时间;Future 仅是当前进程的等待机制。 - 不要把服务重启后重新运行称为“恢复”:真正恢复要求把工具调用、模型上下文和 AgentLoop 的暂停点序列化为状态机。
# 5. 源码定位
- Java schema:
AskUserTool.java - 暂停、保存与回灌:
UserInputCoordinator.java - HTTP/SSE 路由:
WebMain.java - 组件与 SSE 解析:
web/src/main.tsx - 待答持久化模型:
AgentState.java
# 6. 后续接入鲁班智能体的清单
这套方案接入鲁班时,应作为一个平台级结构化交互能力,而不是某个业务 Skill 自己阻塞读取 stdin。 业务 Skill 只负责在需要信息时调用 ask_user;鲁班 Agent 负责把它转成 SSE、持久化待答状态、接收用户回答并恢复执行。
# 6.1 接入位置
Skill / Agent 产生 ask_user tool call
→ 鲁班工具执行分发层拦截
→ 会话状态保存 pending interaction
→ 原聊天 SSE 输出 user_input_required
→ Web / App 渲染表单
→ 鲁班 continue 接口接收答案
→ 同一 toolCallId 写入 tool result
→ Agent 继续下一轮
不要把 ask_user 直接作为普通 Python 函数执行。否则 Worker 会阻塞、SSE 不会有可渲染的交互事件,且用户答案无法与原工具调用可靠关联。
# 6.2 后端待办
- 注册工具:在鲁班的工具注册表中增加
ask_user,schema 采用本文第 1 节;确保它被当前 Skill 的工具白名单显式允许。 - 模型可见性:确认每一轮发给模型的动态 tool schema 中确实含有
ask_user。工具被注册并不等于当前 Agent 一定可见。 - 执行拦截:在工具分发层识别
ask_user,不进入 Python executor;生成requestId,保存sessionId、toolCallId、payload、过期时间。 - SSE 契约:向原聊天流输出
userinputrequired;事件必须包含requestId/id、toolCallId、sessionId与结构化表单 payload。 - 继续接口:提供
continueAPI,按sessionId + requestId做一次性消费;拒绝跨会话提交、重复提交和过期提交。 - 工具结果回灌:把
{ selections, customInput, cancelled }写成原toolCallId的 ToolResult,再驱动模型的下一轮。 - 状态恢复:先支持刷新后的待答卡查询;若要支持服务重启后精确续跑,单独实现可序列化的 Agent 暂停状态机,不能仅依赖内存 Future。
- 审计:链路记录
sessionId、requestId、toolCallId、发起/回答/超时/取消时间、选择结果摘要;不要在常规日志中记录敏感自由输入全文。
# 6.3 前端待办
- SSE 识别
userinputrequired,按第 3 节做最小字段校验后渲染卡片。 - 页面刷新或重新进入会话时调用 pending-interaction 查询接口,恢复未完成卡片。
- 表单提交后将卡片变成只读回执,显示用户选择、提交时间与“继续执行中”状态。
- 支持取消和超时的明确回执;超时不应默默消失。
- 如果后端返回不可续跑状态,提示“服务已重启,请重新发起”,不要向用户假称任务仍在继续。
# 6.4 验收用例
| 场景 | 必须观察到的证据 |
|---|---|
| --- | --- |
| 单选追问 | 原始 SSE 中有 userinputrequired;提交后同一 toolCallId 有 ToolResult,模型继续回复 |
| 多选 + 自定义输入 | selections[] 与 customInput 原样进入工具结果;前端只读回执正确显示 |
| 取消 | ToolResult 标记取消,原任务停止,不再循环追问 |
| 重复提交 | 第二次提交被拒绝,且不会产生第二次工具结果 |
| 跨会话提交 | 被拒绝,不影响原会话 |
| 页面刷新 | pending 查询能恢复卡片;提交后正常继续 |
| 服务重启 | 明确验证是“精确续跑”还是“仅展示不可续跑卡片”,不能混为一谈 |
鲁班接入后,验证必须保留真实请求、完整原始 SSE、服务端 tool/trace 日志和实际 ToolResult;仅看到模型说“已向用户提问”不构成链路成功。