用户异步交互

在 Session 内处理审批、问题与等待用户响应的执行

Interaction 表示当前 Turn 的执行需要用户参与。第一阶段支持 approval 与 question;它们使用同一套生命周期、查询和响应 API。

Interaction 是 Assistant Message 的 Part,不是新的顶层 Message,也不会创建新 Turn。 当 Interaction 来自 Tool 时,Tool 与 Interaction 在同一个 Assistant revision 中原子更新:

创建:Tool ready -> waiting-user + Interaction pending
响应:Interaction resolved + Tool running / failed
结束:Interaction cancelled + Tool failed

订阅并响应

const unsubscribe = session.subscribe((mutation) => {
  if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
  const interaction = (mutation.part.interactions ?? []).find(
    (item) => item.status === "pending",
  );
  if (!interaction) return;

  render_interaction(interaction);
});

await session.respond({
  interaction_id,
  response: {
    type: "approval",
    outcome: "resolved",
    payload: { decision: "approved" },
  },
});

subscribe() 只报告已经持久化的状态。respond() 先原子提交 Interaction 与关联 Tool, 成功后才恢复原执行。请求不存在、已经终结或响应类型不匹配时会抛出明确错误。

查询 pending Interaction

const pending = await session.interactions();

for (const request of pending) {
  if (request.type === "approval") {
    render_approval(request);
  } else {
    render_questions(request.payload.questions);
  }
}

approval 请求同时携带 response_schema,声明响应必须满足的结构;Shell 审批与 Tool 审批共用同一份默认 schema,可直接从 SDK 导入 SESSION_APPROVAL_RESPONSE_SCHEMA (要求 decision 为 approved 或 denied)。

Question 支持 text、single_select 与 multi_select。回答必须覆盖请求中的全部 question_id:

await session.respond({
  interaction_id,
  response: {
    type: "question",
    outcome: "resolved",
    payload: { answers: [
      { question_id: "region", value: "cn" },
      { question_id: "features", value: ["search", "files"] },
    ] },
  },
});

模型主动提问

ask_question 是可选 Tool,不会由 Agent 默认注册。需要该能力时由调用方显式传入:

import { Agent } from "@downcity/agent";
import { Workspace } from "@downcity/city";
import { AskQuestionsTool } from "@downcity/agent/tools";

const agent = new Agent({
  id: "repo-helper",
  tools: {
    ask_question: AskQuestionsTool,
  },
});
const workspace = new Workspace({
  id: "project",
  path: "/path/to/project",
});

模型缺少会实质影响结果的信息时,返回标准 Tool Call:

{
  "title": "选择部署区域",
  "questions": [
    {
      "question": "需要部署到哪个区域?",
      "type": "single_select",
      "options": [
        { "value": "cn", "label": "中国" },
        { "value": "us", "label": "美国" }
      ]
    }
  ]
}

questions 中每个问题都必须显式提供 type,可选值为 text、single_select 和 multi_select。question_id 由 Session 自动生成;选择题必须提供至少一个同时包含 value 和 label 的 options,文本题不需要 options。

Session 会把该 Tool 与 Question Interaction 原子提交为 waiting-user / pending。调用方 使用上面的 session.respond(...) 提交完整回答后,Tool Result 返回:

{
  "status": "resolved",
  "answers": [{ "question_id": "question:generated-id", "value": "cn" }]
}

模型随后在同一个 Turn 的下一 Step 继续执行。普通 assistant 文本中的问句不会进入该 生命周期:它会正常结束当前 Turn,用户的下一条消息会创建新 Turn。

普通 Tool 审批

需要用户许可的 AgentTool,在自己的执行里向 Session 的审批端口请求审批:

import { define_agent_tool } from "@downcity/type";
import { z } from "zod";

const delete_file = define_agent_tool({
  description: "删除工作区内的指定文件。",
  input_schema: z.object({
    path: z.string(),
  }),
  execute: async ({ path }, context) => {
    // 审批端口由 Agent 在执行时注入;ask 模式等待用户,always-allow 直接放行。
    const decision = await context.interactions.approval.request({
      turn_id: context.turn_id,
      tool_call_id: context.tool_call_id,
      tool_name: "delete_file",
      payload: { operation: "tool", validated_input: { path } },
    });
    if (!decision.approved) {
      return { success: false, error: "denied" };
    }
    await remove_workspace_file(path);
    return { deleted: path };
  },
});

const agent = new Agent({
  id: "repo-helper",
  model,
  tools: { delete_file },
});
// Session 在创建时选择 Workspace。

审批策略属于 Tool,不属于 Agent。模型产生 Tool Call 后,Agent 先完成 schema 校验, 随后进入 Tool 自己的 execute;Tool 在这里通过审批入口创建 operation: "tool" 的 Approval Interaction,并等待 session.respond(...)。批准后继续执行副作用,拒绝时不再执行。

内核不再有工具级审批钩子:工具自己决定「哪一步需要有人拍板」,Session 决定「这个拍板 怎么回答」。审批模式(ask / always-allow)在审批入口内生效,Tool 不自己读模式; always-allow 时不创建 Interaction,直接返回放行。

interactions.request() 是通用原语,interactions.approval 是审批专用入口。发起任意交互 只需提供语义字段,interaction_id / created_at / source 由原语补齐:

const handle = await session.interactions.request({
  type: "question",
  turn_id,
  tool_call_id,
  payload: { questions },
});
const result = await handle.result;   // 生产者自己解释 payload

审批请求向 UI 提供真实调用信息:

  • source.tool_name 与 source.tool_call_id:实际 Tool 及调用标识。
  • payload.validated_input:schema 校验通过后的结构化输入。
  • payload.tool_description:Tool 定义中的稳定能力说明,可选。
  • payload.model_explanation:模型在当前调用中生成的解释,可选,不能作为安全判断依据。

调用方不需要配置审批 title 或 reason。UI 应根据 Tool、已校验输入和可选模型解释 组织展示内容,不能用客户端生成的摘要替代真实输入。

Shell 审批模式

const current = await session.status();

await session.set({
  security: {
    approval_mode: "always-allow",
  },
});
模式行为
askhost Shell 请求创建 approval Interaction。
always-allow当前 Session 后续 Shell 请求自动批准。

审批模式是 Shell Adapter 的执行策略,不是通用 Interaction 状态。它不改变 sandbox, 不影响其他 Session,也不会处理已经 pending 的 Interaction。

set() 会接受 configured 值,并把一个 SessionCommand 加入 Session 有序输入队列。 当前 Provider 请求与 Tool callback 继续使用已经捕获的策略,新模式在下一个 Session Step 检查点生效。如果当前 Turn 没有下一个 Step,该命令会在下一条 prompt 开始前提交。

通过 status() 读取状态:security.approval_mode 是 configured 值, security.effective_approval_mode 是执行面当前值;两者不同时表示修改仍在排队。Command 成功提交后,Session 会持久化一条 Session configuration updated completed Action。

初始化或恢复审批模式时,可以通过 set() 的第二个参数关闭 Action 和 Mutation;配置仍会正常写入并在检查点生效:

await session.set(
  { security: { approval_mode: "always-allow" } },
  { persist_action: false, publish_mutation: false },
);

相同审批模式的幂等设置不会生成新的配置 Action。审批模式会随 Session metadata 恢复。

UI 实现要点

  • 使用 interaction_id 作为交互卡片稳定 key。
  • 任何带有 source.tool_call_id 的来源,都用该 Tool Call ID 关联 Tool Part。
  • Tool 为 waiting-user 时保持等待态,不要在客户端伪造 running 或成功。
  • resolved、denied、cancelled、failed 都是终态,不能重复响应。Interaction 没有超时:它只能由用户响应,或随 Turn/Session 结束而被取消。
  • Session 切换时清理旧交互 UI,不能把旧 Session 的 interaction_id 发给新 Session。

完整 Part 状态见 Message 与 Part。