Skip to main content
对话与会话

审批与用户输入

Agent 在执行任务时,有两种情况需要用户参与:
  1. Agent 想执行一个工具,需要用户确认是否允许。
  2. Agent 缺少必要信息,需要用户回答一个问题。
当宿主应用使用 canUseTool(Python 为 can_use_tool)承接用户交互时,这两种请求都会进入同一个回调。你的应用需要显示确认框或问题,并把用户的选择返回给 SDK。
用户看到的内容典型工具用户需要做什么应用返回什么
工具审批BashWrite、MCP 工具是否允许本次操作原始或审核后的工具输入
澄清问题AskUserQuestion问题的实际答案questionsanswers
无论 prompt 使用单消息输入还是流式输入,都可以配置这个回调。一次性 query() 也能显示工具确认和澄清问题。

canUseTool 何时触发

Agent 请求使用工具后,SDK 会先检查你的权限配置,然后出现以下三种结果:
  • 已经允许:工具直接执行,不调用 canUseTool
  • 已经禁止:工具直接被拒绝,不调用 canUseTool
  • 需要用户确认:SDK 调用 canUseTool,等待你的应用返回允许或拒绝。
AskUserQuestion 与普通工具不同:它需要用户提供真实答案。已经配置 canUseTool / can_use_tool 时,只要没有明确禁止用户交互,SDK 就会调用回调来获取答案。 因此,canUseTool 看不到所有工具调用,不能用来记录完整的工具执行日志。要观察每次工具调用,请使用 hooks。权限配置的检查顺序见权限控制 如果希望应用处理动态确认或 AskUserQuestion,必须配置 canUseTool / can_use_tool,或者使用外部 permission prompt tool。运行时发出权限请求而 SDK 没有对应回调时,SDK 会以失败关闭(fail closed)的方式报告错误,工具不会自动执行。

配置回调

下面只展示回调的配置方式。示例中的 showApprovalDialog / show_approval_dialog 代表宿主应用自己实现的确认界面,不是 SDK 提供的函数。可直接运行的终端版本见本文后面的完整示例
import { accessTokenFromEnv, query } from '@qoder-ai/qoder-agent-sdk';

query({
  prompt: '读取发布配置并生成 changelog;写文件前先请求批准。',
  options: {
    auth: accessTokenFromEnv(),
    permissionMode: 'default',
    allowedTools: ['Read'],
    async canUseTool(toolName, input, context) {
      const approved = await showApprovalDialog({
        toolName,
        input,
        title: context.title,
        description: context.description,
        signal: context.signal,
      });

      if (!approved) {
        return {
          behavior: 'deny',
          message: '用户拒绝了本次操作。',
          toolUseID: context.toolUseID,
        };
      }

      return {
        behavior: 'allow',
        updatedInput: input,
        toolUseID: context.toolUseID,
      };
    },
  },
});
TypeScript 的 context.signalAbortSignal,Python 的 context.signalasyncio.Event | None。如果任务已经取消,你的应用应关闭确认框并结束回调。TypeScript 示例返回 toolUseID,用于标记当前处理的是哪一次工具调用。 如果确认界面由你的应用提供,使用 canUseTool / can_use_tool。如果已有外部 permission prompt tool,使用 permissionPromptToolName / permission_prompt_tool_name。两种方式不能同时配置。

返回审批结果

允许一次

允许工具执行时,返回工具参数:
return {
  behavior: 'allow',
  updatedInput: input,
  toolUseID: context.toolUseID,
};
updatedInput / updated_input 是工具最终收到的参数。你可以原样返回,也可以在允许前删除危险选项、限制文件路径或修改其他字段。修改时应按照该工具的参数格式进行校验。

拒绝

拒绝时,请提供简短原因。Agent 可以根据原因换一种做法,或者向用户说明为什么没有执行:
return {
  behavior: 'deny',
  message: '不允许在生产环境执行该命令。',
  toolUseID: context.toolUseID,
};
如果拒绝后还要立即停止整个任务,可以设置 interrupt: true(Python 为 interrupt=True)。

本会话始终允许

context.suggestions 可能包含 SDK 建议保存的权限规则。用户选择“本会话始终允许”时,把这些建议放进允许结果:
return {
  behavior: 'allow',
  updatedInput: input,
  updatedPermissions: context.suggestions,
  toolUseID: context.toolUseID,
};
这些规则可以让同类操作在当前会话中不再重复询问。完整的 PermissionUpdate 类型和自定义规则方式见会话内更新权限

权限模式如何影响回调

permissionMode / permission_mode 决定这一会话默认如何处理工具请求。它会影响用户是否看到确认框,也会影响 canUseTool 是否被调用。下表假设会话已经配置 canUseTool / can_use_tool,并且 AskUserQuestiontools 中可见:
权限模式普通工具会怎样AskUserQuestion 会怎样
default需要确认的工具会调用回调调用回调,让用户回答
acceptEdits常见文件编辑直接允许;其他工具仍可能要求确认调用回调,让用户回答
plan不执行实际修改;必要时仍可请求确认可以提问,用来确认计划或需求
autoSDK 自动允许或拒绝部分工具,不保证每次都调用回调调用回调,让用户回答
dontAsk没有提前允许的工具直接被拒绝,不显示确认问题也会被拒绝,无法等待用户回答
bypassPermissions / yolo普通工具直接执行,不显示确认仍然调用回调;SDK 不会替用户回答
需要注意:
  • 如果 disallowedTools / disallowed_tools 或 deny 规则禁止了 AskUserQuestion,Agent 就不能向用户提问。
  • 如果显式设置了 tools,需要把 AskUserQuestion 加入列表,否则 Agent 看不到这个工具。
  • bypassPermissionsyolo 只应在受信任环境使用。它们会跳过普通工具确认,但不会替用户回答问题。
  • 如果希望所有尚未允许的操作都显示在自己的确认界面中,通常选择 default,并且不要把这些工具加入 allowedTools / allowed_tools
各模式的完整语义和风险说明见控制默认策略:permissionMode

处理 AskUserQuestion

AskUserQuestion 用于在继续当前任务前询问少量、结构化的问题,例如选择目标环境、实现方案或输出格式。它不是一条新的聊天消息;用户回答后,Agent 会继续当前任务。 canUseTool 收到 toolName === 'AskUserQuestion' 时,回调中的 input 使用下面的数据结构:
type AskUserQuestionRuntimeInput = {
  questions: Array<{
    question: string;
    header: string;
    options: Array<{
      label: string;
      description: string;
      preview?: string;
    }>;
    multiSelect?: boolean;
  }>;
};
  • 一次包含 1–4 个问题。
  • 每个问题包含一个简短 header 和 2–4 个选项。
  • multiSelect: false 或省略时只能选择一项;为 true 时可以选择多项。
  • 你的界面应允许用户输入选项之外的自定义答案。
  • 选项可能携带 preview。TypeScript 可通过 toolConfig.askUserQuestion.previewFormat 指定预览内容按 markdownhtml 解释;Python SDK 当前没有对应的 tool_config 选项。
编写 canUseTool 回调时,请使用这里的 questions / answers 结构,不要按单个 question / answer 字段处理。

返回问题答案

提交答案时仍返回 behavior: 'allow'。这里的 allow 表示“接受这些答案并继续任务”,updatedInput 中需要包含问题和答案:
{
  behavior: 'allow',
  updatedInput: {
    questions: input.questions,
    answers: {
      '部署到哪个环境?': 'Staging',
      '需要启用哪些检查?': '类型检查, 单元测试',
      '还有其他要求吗?': '保持 Node.js 18 兼容',
    },
  },
}
answers 的编码规则如下:
  • 每个 key 必须使用对应问题完整的 question 文本。
  • 单选答案使用选项的 label;自定义答案直接使用用户输入的文本。
  • 多选答案把多个 label 用 , 连接成一个字符串。
  • 如果用户取消问答,返回 deny 并说明原因。不要使用虚构或空白答案继续执行。

完整示例

下面的示例同时处理问题和普通工具确认。实际应用可以把终端输入替换成对话框、Web 页面或审批服务。
import { createInterface } from 'node:readline/promises';
import {
  accessTokenFromEnv,
  query,
  type CanUseTool,
} from '@qoder-ai/qoder-agent-sdk';

type Question = {
  question: string;
  header: string;
  options: Array<{ label: string; description: string }>;
  multiSelect?: boolean;
};

const readline = createInterface({
  input: process.stdin,
  output: process.stdout,
});

function parseAnswer(raw: string, question: Question): string {
  const indexes = raw
    .split(',')
    .map((part) => Number.parseInt(part.trim(), 10) - 1)
    .filter((index) => index >= 0 && index < question.options.length);

  if (indexes.length > 0) {
    const selected = question.multiSelect ? indexes : indexes.slice(0, 1);
    return selected.map((index) => question.options[index].label).join(', ');
  }

  return raw.trim();
}

async function readLine(
  prompt: string,
  signal: AbortSignal,
): Promise<string | null> {
  try {
    return await readline.question(prompt, { signal });
  } catch (error) {
    if (
      signal.aborted ||
      (error instanceof Error && error.name === 'AbortError')
    ) {
      return null;
    }
    throw error;
  }
}

const canUseTool: CanUseTool = async (toolName, input, context) => {
  if (context.signal.aborted) {
    return {
      behavior: 'deny',
      message: '请求已取消。',
      toolUseID: context.toolUseID,
    };
  }

  if (toolName === 'AskUserQuestion') {
    const questions = (input.questions ?? []) as Question[];
    const answers: Record<string, string> = {};

    for (const question of questions) {
      console.log(`\n${question.header}${question.question}`);
      question.options.forEach((option, index) => {
        console.log(`${index + 1}. ${option.label}${option.description}`);
      });

      const hint = question.multiSelect
        ? '输入序号(可用逗号分隔)或自定义答案(/cancel 取消):'
        : '输入序号或自定义答案(/cancel 取消):';

      let answer = '';
      while (!answer) {
        const raw = await readLine(hint, context.signal);
        if (raw === null || raw.trim() === '/cancel') {
          return {
            behavior: 'deny',
            message: '用户取消了问答。',
            toolUseID: context.toolUseID,
          };
        }

        answer = parseAnswer(raw, question);
        if (!answer) {
          console.log('答案不能为空,请重新输入。');
        }
      }

      answers[question.question] = answer;
    }

    return {
      behavior: 'allow',
      updatedInput: { questions, answers },
      toolUseID: context.toolUseID,
    };
  }

  const answer = await readLine(
    `\n是否允许 ${toolName} 使用参数 ${JSON.stringify(input)}?[y/N] `,
    context.signal,
  );

  if (answer === null) {
    return {
      behavior: 'deny',
      message: '请求已取消。',
      toolUseID: context.toolUseID,
    };
  }

  if (answer.trim().toLowerCase() !== 'y') {
    return {
      behavior: 'deny',
      message: `用户拒绝执行 ${toolName}。`,
      toolUseID: context.toolUseID,
    };
  }

  return {
    behavior: 'allow',
    updatedInput: input,
    toolUseID: context.toolUseID,
  };
};

try {
  for await (const message of query({
    prompt: '检查项目并生成发布说明;遇到不确定的发布目标时先询问我。',
    options: {
      auth: accessTokenFromEnv(),
      tools: ['AskUserQuestion', 'Read', 'Write', 'Bash'],
      allowedTools: ['Read'],
      permissionMode: 'default',
      canUseTool,
    },
  })) {
    if (message.type === 'result') {
      console.log(message.subtype);
    }
  }
} finally {
  readline.close();
}
Python 终端示例使用阻塞式 input(),因此任务取消后,要等当前输入返回才能检查 context.signal。Web 页面或桌面应用不应照搬这一限制:监听 context.signal,在信号触发时立即关闭对话框并结束回调。

适用边界

需求推荐能力原因
确认是否执行某个工具canUseTool / can_use_tool可以允许、拒绝,也可以先修改工具参数
Agent 继续任务前需要 1–4 个简短答案AskUserQuestionAgent 提供问题和选项,用户回答后继续当前任务
用户主动追问、补充长文本或改变任务方向流式输入这是一条新的用户消息
需要固定字段、严格校验、文件上传或复杂表单自定义工具应用可以自己定义固定的数据格式和界面
MCP server 主动索取表单或授权信息MCP Elicitation这类请求使用 onElicitation / on_elicitation
记录所有工具调用或统一拦截工具hooks权限控制canUseTool 不会收到已经自动允许或拒绝的调用
不要用 AskUserQuestion 代替多轮对话。用户主动发送的新消息应使用流式输入。也不要用普通文本回复代替工具确认,因为 SDK 需要收到明确的 allow 或 deny 结果。