Pi 的问答插件,不只是一个弹窗:拆解 ask_user_question 的实现
从源码拆解 Pi Agent 的 `@juicesharp/rpiv-ask-user-question` 插件:说明模型何时会向用户提问、提示词如何影响提问频率,以及工具注册、校验、状态机、跨宿主降级和结果回灌的完整实现。
Pi 的问答插件,不只是一个弹窗:拆解 ask_user_question 的实现
最近我在自己的 Pi Agent 环境里安装了 @juicesharp/rpiv-ask-user-question。它解决的问题很直接:当 Agent 遇到真正需要人来决定的事情时,不要继续猜,而是暂停执行,把问题、选项和取舍整理成一个结构化问卷。
最开始我以为它只是给 Pi 的 ctx.ui.select() 套了一层界面。把源码完整看过一遍之后,我发现自己低估了它。这个插件真正实现的是一套“模型发起问题、人类完成决策、Agent 恢复执行”的小型交互协议,里面包含工具注册、运行时校验、状态机、终端渲染、RPC 降级、失败语义和结果回灌。
本文基于我本机实际安装的 Pi Agent
0.84.3和@juicesharp/rpiv-ask-user-question2.7.1分析。插件迭代很快,后续版本的模块和行为可能变化。
先说结论
它的完整调用链可以压缩成下面这张图:
用户给 Pi 一个任务
↓
模型发现存在无法自行确定的决策
↓
模型调用 ask_user_question
↓
TypeBox schema + 运行时业务校验
↓
根据宿主能力选择 TUI / RPC / Headless 路径
↓
用户选择、输入、添加备注或取消
↓
QuestionnaireResult
↓
生成模型可读文本 + 结构化 details
↓
Pi 把 tool result 放回上下文
↓
模型带着用户答案继续执行
这里最重要的一点是:插件不会自己判断什么时候提问,也不会再调用一次模型生成问题。 它只是向当前模型提供一个工具。是否调用、问什么、给哪些选项,仍然由正在执行任务的模型决定。
这也是 Pi Extension 的核心价值:不修改 Agent 主循环,只增加一个模型可调用、宿主可渲染、结果可回传的能力。
模型为什么知道可以问我
插件入口很简单,最终只做两件事:
export default function (pi: ExtensionAPI) {
registerAskUserQuestionTool(pi);
registerAskUserQuestionReconciler(pi);
}
第一件事是通过 pi.registerTool() 注册 ask_user_question。工具定义里除了参数 schema,还有三类直接提供给模型的信息:
description:完整说明工具能做什么;promptSnippet:在系统提示词里出现的一行摘要;promptGuidelines:什么时候应该问、一次最多问几个、怎样组织选项。
换句话说,模型不是因为插件扫描到了“需求不明确”才弹窗,而是因为工具描述告诉它:遇到无法继续的真实决策时,可以调用这个工具。
这两者差别很大。前者需要插件理解整段对话并自行判断,后者沿用 Agent 原本的工具调用机制。插件没有额外模型请求,也不需要 API Key,更不会偷偷增加一轮推理成本。
决策过程:不是规则引擎,而是模型选择工具
ask_user_question 没有关键词触发器,也没有一段规则代码在后台计算“当前需求的歧义分数”。真正的决策过程是:
Pi 构建系统提示词和可用工具列表
↓
模型读取用户需求、项目上下文和 ask_user_question 的工具说明
↓
模型判断是否缺少无法自行确定的信息
↓
需要人类决策 → 调用 ask_user_question
可以从代码确认或风险很低 → 继续执行
因此,一次提问通常需要同时满足四个条件:
- 当前宿主真的能提供交互界面,工具处于 active 状态;
- 工具的
description、promptSnippet和promptGuidelines建议模型在这种情况下提问; - 用户提示词、
AGENTS.md或其他项目指令没有要求模型自行决定; - 当前模型判断这个信息确实不能从已有上下文中可靠得到。
这不是完全确定性的规则。同一个需求换一个模型、换一种措辞或换一份系统提示词,是否调用问答工具都可能不同。插件负责提供能力和边界,最后的工具选择仍然是模型推理的一部分。
通常会提问的场景
第一类是存在多个合理方案,并且选错会改变架构或产生明显返工。例如“给 API Client 增加缓存”看起来很明确,但内存缓存、SQLite 和 Redis 会带来完全不同的部署方式、一致性和失效策略。模型可以分析取舍,却不能替用户决定产品约束。
第二类是用户偏好无法从代码推断。后台页面应该是高密度表格还是卡片布局,是否优先适配移动端,操作流程是否允许增加一步确认,这些都不是阅读仓库就能得到的事实。
第三类是破坏性、不可逆或影响兼容性的决策,例如是否删除旧字段、清空历史数据、废弃旧 API、覆盖部署环境或强制迁移配置。这里询问用户的成本,远低于做错后的恢复成本。
第四类是任务范围本身不明确。一句“重构认证模块”可能只是整理代码,也可能意味着更换 Token 格式、修改数据库、替换登录协议和删除旧客户端兼容。范围没有确定之前,实施计划没有可靠边界。
第五类是源码里存在互相矛盾但都合理的设计。例如一个分支明确废弃某个接口,另一个更新的提交又强化了它,而且双方都有测试。这已经不是机械合并,而是在决定最终产品行为,模型不应该悄悄选择其中一边。
通常不应该提问的场景
只要答案能够通过代码、配置、文档或测试确认,Agent 就应该先自己调查。例如项目使用 npm 还是 pnpm、数据库是 PostgreSQL 还是 SQLite、测试命令是什么、状态管理采用什么方案、现有接口返回哪些字段,这些都是仓库事实,不是用户偏好。
低风险实现细节通常也不值得打断用户。变量怎么命名、小函数如何拆分、是否复用项目已有 helper,只要现有代码已经形成明确模式,Agent 应该沿用它。
我认为比较合理的边界是:
只询问会影响架构、数据兼容、产品行为、安全性或用户体验的真实决策。能从代码确认的事情自己查,普通实现细节自行决定。
问得太少,Agent 会在关键地方自作主张;问得太多,Agent 又会退化成一个每走一步都需要确认的遥控器。这个插件真正需要控制的不是“能不能问”,而是提问的成本是否小于错误假设的成本。
用户提示词也会影响提问频率
用户可以在任务里明确要求 Agent 更主动地提问:
实现过程中,如果遇到影响架构、数据兼容或用户体验的决策,
先使用 ask_user_question 问我,不要自行假设。
能从代码和文档确认的内容自己调查。
也可以限制它不要频繁打断:
只询问无法从代码确认、且选择错误会产生明显返工的决策。
普通实现细节自行决定,不要逐步向我确认。
如果当前任务不适合交互,也可以直接说明:
不要向我提问。缺少细节时优先沿用项目现有模式,
并在最终结果中列出你的假设。
这些指令会和插件默认的 promptGuidelines 一起进入模型上下文。用户指令越具体,模型越容易判断什么是真正需要人决定的问题。
可以修改默认提问策略
插件允许通过下面的配置文件覆盖工具描述和使用准则:
~/.config/rpiv-ask-user-question/config.json
例如,可以把默认策略收紧到架构和兼容性决策:
{
"guidance": {
"promptSnippet": "Ask before making consequential assumptions",
"promptGuidelines": [
"Use ask_user_question only for decisions affecting architecture, compatibility, data, security, or user experience.",
"Do not ask questions whose answers can be found in code, tests, configuration, or documentation.",
"Group related questions into one ask_user_question call instead of asking them separately."
]
}
}
guidance.description 会完整替换工具描述,promptSnippet 会替换系统提示中的摘要,promptGuidelines 会替换默认规则,不会自动与默认值合并。这些字段在工具注册时读取,所以修改后需要重启 Pi 才会生效。
运行环境也会限制是否提问
很多交互式工具只考虑正常终端,却忽略了 pi -p、JSON 输出和 CI 这类无 UI 环境。如果模型看得到工具,但调用后永远只能失败,它就可能反复尝试,或者把“界面没显示”误解成“用户拒绝回答”。
这个插件用了两层保护。
第一层发生在每次 before_agent_start。插件检查 ctx.hasUI,没有 UI 就从 active tool 列表里移除 ask_user_question;UI 恢复后,再把工具加回来。
if (!ctx.hasUI && hasTool) {
pi.setActiveTools(active.filter(name => name !== "ask_user_question"));
} else if (ctx.hasUI && !hasTool) {
pi.setActiveTools([...active, "ask_user_question"]);
}
第二层放在工具的 execute() 里。即使未来 Pi 改变了事件顺序,或者某个宿主报告了错误的能力,工具仍会返回明确的 no_ui 错误。
我很喜欢这个处理。它不是“出错后给一句提示”,而是尽量不让不可能成功的动作进入模型的选择空间。对 Agent 工具来说,这通常比增加重试更有效。
问卷首先是一份严格协议
模型调用工具时,发送的参数大致长这样:
{
questions: [
{
question: "缓存应该放在哪里?",
header: "缓存介质",
multiSelect: false,
options: [
{
label: "内存缓存",
description: "实现简单,但进程重启后数据消失",
preview: "可选的 Markdown 预览"
},
{
label: "Redis",
description: "支持多实例共享,但增加基础设施依赖"
}
]
}
]
}
TypeBox schema 先负责结构约束:
- 一次只能提交 1 到 4 个问题;
- 每个问题必须有 2 到 4 个选项;
header最长 16 个字符;- 选项
label最长 60 个字符; multiSelect决定单选还是多选;preview可以携带 Markdown、代码、配置或 ASCII 布局。
进入 execute() 后,validateQuestionnaire() 再做业务校验,包括重复问题、重复选项和保留标签冲突。
这里有三个不能由模型自己创建的标签:
Other
Type something.
Next
Type something. 和 Next 是插件运行时追加的控制行。前者让用户始终可以输入选项之外的答案,后者用于提交多选结果。插件禁止模型自己创建同名选项,是为了保证显示文本和交互语义不会冲突。
这看起来只是一个小细节,但它体现了一个很重要的工具设计原则:模型负责提供领域数据,运行时负责提供交互控制。 不要让模型同时定义内容和协议按钮。
它不是一串 select(),而是一台状态机
完整 TUI 路径使用的是 ctx.ui.custom(),并在底部创建一个占满宽度的 overlay。真正复杂的部分不在边框和颜色,而在状态管理。
问卷的核心状态包含:
interface QuestionnaireState {
currentTab: number;
optionIndex: number;
inputMode: boolean;
notesVisible: boolean;
answers: ReadonlyMap<number, QuestionAnswer>;
multiSelectChecked: ReadonlySet<number>;
customDraftsByTab: ReadonlyMap<number, string>;
notesByTab: ReadonlyMap<number, string>;
submitChoiceIndex: number;
notesDraft: string;
collapsed: boolean;
}
键盘输入不会直接修改组件,而是先经过 routeKey(),转换成一个判别联合类型的 Action:
nav
confirm
toggle
multi_confirm
tab_switch
notes_enter
notes_exit
submit
cancel
toggle_collapsed
接着,纯函数 reduce(state, action, context) 计算下一份状态,并返回一个封闭的 Effect[]。Effect 才负责真正的副作用,例如修改输入框、打开外部编辑器、隐藏 overlay 或调用 done(result) 结束问卷。
按键
↓
routeKey
↓
QuestionnaireAction
↓
reduce
↓
新状态 + Effect[]
↓
执行副作用
↓
selectors / props adapter
↓
重新渲染组件
这是一种很典型的 Elm/Redux 式单向数据流。它的好处不是代码看起来“架构化”,而是焦点、草稿、备注、多选和 Tab 切换都只有一份可信状态。
如果把这些逻辑分散到每个组件里,很快就会出现这种问题:选项列表认为当前是第 2 项,预览面板认为当前是第 3 项;切换 Tab 后答案还在,但输入草稿丢了;备注编辑器已经关闭,焦点却仍然停在编辑器上。
这个插件用 reducer 和 selector 把这些状态同步问题集中解决了。
自定义答案为什么可以保留草稿
Type something. 不是一次性的 input()。插件在内部使用 Pi TUI 的 Editor 作为“无外壳编辑器”,把它的文本、光标和多行编辑能力嵌进选项行。
因此它天然支持:
- 多行文本;
- 粘贴和光标移动;
Shift+Enter换行;Ctrl+U清空;Ctrl+G打开 Pi 配置的外部编辑器。
每个问题的草稿保存在 customDraftsByTab。用户可以先输入一半,切换到别的选项或别的问题,再回来继续写。只有确认后,草稿才变成 kind: "custom" 的正式答案。
备注则单独保存在 notesByTab。这也是一个很合理的设计:有备注,不代表已经回答问题。如果把备注直接塞进 answers,Submit 页就可能把“只写了备注、没有做选择”的问题错误地标记为已完成。
多问题问卷还会额外创建一个 Submit Tab。它负责汇总已经回答的内容、列出未回答的问题,并允许添加全局备注。未回答项会显示警告,但不会强制阻止部分提交。
折叠问卷比想象中麻烦
这个插件支持按 Ctrl+] 暂时折叠问卷,让用户查看被 overlay 遮住的对话记录。
问题在于:Pi TUI 不会把键盘输入发送给已经隐藏的 overlay。如果只是调用 setHidden(true),用户就再也没有机会按同一个快捷键把它打开。
插件的处理方式是额外注册一个原始终端输入监听器。问卷隐藏后,由这个监听器捕获折叠快捷键,再调用 session 的 toggleCollapsedExternal() 恢复 overlay。
它还处理了 Kitty keyboard protocol 的按下、长按重复和释放事件,确保一次按键只切换一次。如果宿主没有原始终端输入能力,插件不会真的隐藏 overlay,而是退化成底部一行提示,确保仍然有办法恢复。
这是我看源码时最喜欢的细节之一。它说明一个 UI 功能是否可靠,不取决于正常路径能不能画出来,而取决于进入特殊状态后有没有可逆路径。
Preview 也不是简单把 Markdown 放右边
单选问题可以为每个选项提供 preview。插件会渲染 Markdown,并根据终端宽度选择布局:
- 终端和问卷面板都至少 100 列时,选项与预览左右分栏;
- 宽度不足时,预览堆叠到选项下方;
- 没有任何 preview 时,完全不创建预览视觉区域;
- 正在编辑自定义答案时,选项区临时恢复全宽。
左右分栏的宽度也不是固定五五开。它会测量跨 Tab 的最长选项标签和 preview 内容,再计算一个稳定的左栏宽度,避免切换问题时整个界面左右抖动。
渲染层同样不直接读取其他组件。QuestionnairePropsAdapter 从统一状态计算每个组件的 props,然后分别推给 TabBar、OptionList、MultiSelect、PreviewPane 和 SubmitPicker。
这让状态层与显示层保持了清晰边界:状态决定“现在是什么”,组件只负责“应该怎么画”。
同一个工具如何兼容 TUI、RPC 和无界面模式
Pi 不只运行在终端里。RPC/ACP 宿主可能有自己的原生对话框,却不能渲染 ctx.ui.custom() 组件。
插件因此维护了三条路径:
| 环境 | 行为 |
|---|---|
| 交互式 Pi TUI | 完整 Tab、备注、Preview 和 Submit 页 |
| RPC / ACP 宿主 | 依次调用宿主的 select() 和 input() |
| 无 UI 模式 | 从模型工具列表移除 ask_user_question |
RPC 路径会主动放弃一部分能力。它没有 Tab 页和统一 Review,没有问题备注,也没有左右预览。多选会退化成输入 1,3 这样的编号列表,自定义文本仍然保留。
这里最值得学习的不是“功能降级”,而是结果协议没有跟着分叉。无论答案来自完整 TUI 还是 RPC 对话框,最后都会得到同一种 QuestionnaireResult,再进入同一个 response envelope。
上层模型不需要知道答案是从哪种界面收集的。
用户答案怎样回到模型
用户完成问卷后,插件返回标准 Pi tool result:
{
content: [
{
type: "text",
text: "User has answered your questions: ..."
}
],
details: {
answers: [
{
questionIndex: 0,
question: "缓存应该放在哪里?",
kind: "option",
answer: "Redis"
}
],
cancelled: false
}
}
这里同时保留了两种表达:
content是给模型直接阅读的自然语言;details是给 Pi 会话、其他插件和未来恢复逻辑使用的结构化数据。
答案有三种 kind:
option:选择一个预设选项;custom:填写自定义文本;multi:提交多个选项。
如果选择项带有 preview,选中的 preview 会回写到模型可见结果里。问题备注和全局备注也会追加进去。模型拿到 tool result 后,Pi 的 Agent loop 会自然进入下一轮推理,继续原来的任务。
取消则统一返回:
User declined to answer questions
而“用户拒绝”和“界面根本没有显示”会使用不同语义。后者返回 no_custom_ui、session_load_failed 等错误,并明确告诉模型不要把它当成拒绝,而应该改用普通聊天文本提问。
这种区分非常关键。Agent 系统里,失败原因如果表达不清,模型就会基于错误事实继续行动。
延迟加载和模块缓存容错
完整 TUI 渲染图的导入成本大约是 560ms,所以插件没有在启动时同步加载所有 UI 模块,而是在第一次工具调用时动态导入 QuestionnaireSession。
同时,它会在启动约两秒后用一个 unref() 的计时器预热模块图。这样既不阻塞 Pi 启动,也不会让计时器阻止进程退出。
源码里还专门处理了一个很具体的问题:如果包管理器在 Pi 运行期间替换了依赖目录,jiti 可能缓存一个加载失败的模块命名空间。插件会检测 QuestionnaireSession 是否真的存在,并分别返回:
session_load_failed
stale_module_cache
后者明确要求重启 Pi,因为当前进程里的模块缓存已经无法安全恢复。
这类代码通常不会出现在功能演示里,却决定了插件在长期运行时是不是可靠。
我认为它做得好的地方
第一,它没有重新发明 Agent 主循环。 模型仍然通过标准 tool call 发起问题,Pi 仍然通过标准 tool result 恢复执行。
第二,工具可见性跟宿主能力绑定。 无法交互时直接从工具列表移除,比让模型调用后失败更干净。
第三,状态机与组件分离。 routeKey → reducer → effect → selector → render 这条路径让复杂交互仍然可推理。
第四,失败语义足够精确。 用户取消、没有 UI、宿主不支持 custom UI、动态模块加载失败,不会被压成同一个“失败”。
第五,核心结果协议跨宿主保持一致。 TUI 可以做得很丰富,RPC 可以适当降级,但模型拿到的答案结构不变。
还可以继续改进的地方
它不是没有代价。
Pi Extension 拥有当前用户的完整进程权限,这不是浏览器里的低权限扩展。安装任何第三方 Pi 包之前,都应该检查来源和代码。
另外,preview 和自定义答案目前没有明显的严格长度上限。选中的 preview 会进入模型可见 tool result,极端情况下可能快速消耗上下文。自然语言 envelope 对引号和复杂换行的表达也不如 details 严谨。
外部编辑器命令使用空格拆分参数,对带空格路径和复杂引号的支持有限。备注快捷键 n 也还是插件内部约定,不像其他导航键那样完全跟随 Pi keybinding 配置。
这些问题不影响它的核心思路,但如果我要把同样的架构用于更大规模的企业 Agent,我会继续补充输入长度预算、结果截断、完整键位配置和更严格的序列化策略。
这套设计真正解决了什么
表面上看,ask_user_question 只是让 Pi 弹出一个选择框。实际上,它把一个很重要的人机协作问题变成了明确协议:Agent 可以自主执行,但遇到不可替代的人类决策时,必须进入一个可观察、可取消、可恢复的阻塞状态。
我现在越来越觉得,Agent 工具最难的不是“再加一个能力”,而是把能力边界定义清楚。什么时候工具应该出现,什么时候必须消失;失败代表用户拒绝,还是界面从未展示;中间状态能不能恢复;不同宿主是否得到同一种结果。
@juicesharp/rpiv-ask-user-question 值得看的地方,正是这些边界。
它不是一个简单的弹窗插件,而是一套构建在 Pi Extension API 之上的小型人机决策运行时。对准备开发 Pi 插件、终端 UI 或 Agent 人工审批流程的人来说,这个实现很值得读一遍。
安装与相关链接
安装插件:
pi install npm:@juicesharp/rpiv-ask-user-question
相关资料: