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 是稳定契约,后端和模型应依据它判断选择;labeldescription 可以调整展示文案,不应作为业务判断值。

2. 后端交互协议

当前 Java 链路为:

模型 ToolCall(ask_user)
  → UserInputCoordinator 拦截
  → 保存 AgentState.pendingInteraction
  → SSE user_input_required
  → POST /api/chat/continue
  → ToolResult 回灌 AgentLoop
  → 下一轮模型推理

后端补充两项运行字段:

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 代表该会话状态仍可继续。提交时后端按持久化的 requestIdtoolCallId 写入 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控件
------
singleradio;只保留一个 selection
multiplecheckbox;可提交多个 selection
toggle选项控件;至少选择一项才能提交
text文本输入框
numberinput[type=number]
dateinput[type=date]
directory文本输入框,提示目录相对路径

allowCustom: true 时,非文本模式额外显示“其他说明”输入框。提交时,组件统一调用:

onSubmit(selections, customInput, cancelled)

应用层的 submitUserInput(...) 再调用 /api/chat/continue。如果 resumable === false,卡片只展示“服务已重启,请重新发起任务”,不会发送一个必然失败的继续请求。

4. Python 运行时接入要点

如果将来有 Python 版 AgentLoop,建议保留三条边界:

  1. 工具定义与交互执行分离:Python schema 只暴露给模型;暂停/恢复交给 UserInputCoordinator 等运行时组件。
  2. 按会话持久化表单,不持久化 Future:保存 requestIdtoolCallId、payload、过期时间;Future 仅是当前进程的等待机制。
  3. 不要把服务重启后重新运行称为“恢复”:真正恢复要求把工具调用、模型上下文和 AgentLoop 的暂停点序列化为状态机。

5. 源码定位

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 后端待办

  1. 注册工具:在鲁班的工具注册表中增加 ask_user,schema 采用本文第 1 节;确保它被当前 Skill 的工具白名单显式允许。
  2. 模型可见性:确认每一轮发给模型的动态 tool schema 中确实含有 ask_user。工具被注册并不等于当前 Agent 一定可见。
  3. 执行拦截:在工具分发层识别 ask_user,不进入 Python executor;生成 requestId,保存 sessionIdtoolCallId、payload、过期时间。
  4. SSE 契约:向原聊天流输出 userinputrequired;事件必须包含 requestId/idtoolCallIdsessionId 与结构化表单 payload。
  5. 继续接口:提供 continue API,按 sessionId + requestId 做一次性消费;拒绝跨会话提交、重复提交和过期提交。
  6. 工具结果回灌:把 { selections, customInput, cancelled } 写成原 toolCallId 的 ToolResult,再驱动模型的下一轮。
  7. 状态恢复:先支持刷新后的待答卡查询;若要支持服务重启后精确续跑,单独实现可序列化的 Agent 暂停状态机,不能仅依赖内存 Future。
  8. 审计:链路记录 sessionIdrequestIdtoolCallId、发起/回答/超时/取消时间、选择结果摘要;不要在常规日志中记录敏感自由输入全文。

6.3 前端待办

  1. SSE 识别 userinputrequired,按第 3 节做最小字段校验后渲染卡片。
  2. 页面刷新或重新进入会话时调用 pending-interaction 查询接口,恢复未完成卡片。
  3. 表单提交后将卡片变成只读回执,显示用户选择、提交时间与“继续执行中”状态。
  4. 支持取消和超时的明确回执;超时不应默默消失。
  5. 如果后端返回不可续跑状态,提示“服务已重启,请重新发起”,不要向用户假称任务仍在继续。

6.4 验收用例

场景必须观察到的证据
------
单选追问原始 SSE 中有 userinputrequired;提交后同一 toolCallId 有 ToolResult,模型继续回复
多选 + 自定义输入selections[]customInput 原样进入工具结果;前端只读回执正确显示
取消ToolResult 标记取消,原任务停止,不再循环追问
重复提交第二次提交被拒绝,且不会产生第二次工具结果
跨会话提交被拒绝,不影响原会话
页面刷新pending 查询能恢复卡片;提交后正常继续
服务重启明确验证是“精确续跑”还是“仅展示不可续跑卡片”,不能混为一谈

鲁班接入后,验证必须保留真实请求、完整原始 SSE、服务端 tool/trace 日志和实际 ToolResult;仅看到模型说“已向用户提问”不构成链路成功。