从最小代码讲清楚:AskUser 如何让一个 Moxt Pipeline 在完整 turn 结束后停止,以及 Pi 候选 PR 实际补了什么。
- 核对日期:2026-07-31
- Pi upstream
main:ab366ebe94cacd419d986be454f12b1b9913aaca - Pi 候选 PR:
acmerfight/pi#1,HEAD0859da59 - 对应 issue:
earendil-works/pi#7299 - Moxt
main:ac931af505ce49203071d3e0720864eb250b7a96
文中会明确区分:
当前事实 当前 main 已经这样运行
候选 PR Pi fork 中已有,但尚未合入 upstream
目标方案 Moxt 后续建议实现,尚未进入 main
先用一个布尔值理解核心逻辑:
let waitingForUser = falseAskUser 成功后,Moxt 把它改成 true:
// 教学用伪代码,不是当前源码中的真实函数名。
function afterToolCall({ result }) {
if (isSuccessfulAskUser(result)) {
waitingForUser = true
}
}Agent 在完整 turn 结束后读取它:
function shouldStopAfterTurn() {
return waitingForUser
}组装 Agent:
const agent = new Agent({
afterToolCall,
shouldStopAfterTurn,
})执行过程:
开始:waitingForUser = false
│
▼
AskUser 成功
│
▼
afterToolCall 写入 true
│
▼
本 turn 的 Tool 和 ToolResult 全部完成
│
▼
Agent 调用 shouldStopAfterTurn()
│
▼
读取到 true
│
▼
Agent 停止,不再请求下一次模型
生产代码保存的不是简单布尔值,而是包含原因的对象:
let interruption: MoxtAskUserInterruption | null = null对应关系:
教学版 生产版
waitingForUser = false interruption = null
waitingForUser = true interruption = {
kind: "user_input_required",
toolCallId: "tool-123",
...
}
return waitingForUser return interruption !== null
┌────────────────────┐
│ AskUser Tool │
│ 把问题发送给用户 │
└─────────┬──────────┘
│ 返回“发送成功”的 ToolResult
▼
┌──────────────────────────────────────┐
│ Moxt Host │
│ │
│ Adapter:把结果解释为 │
│ user_input_required │
│ │
│ Pipeline:保存 checkpoint、处理终态 │
└──────────────────┬───────────────────┘
│ 提供停止判断
▼
┌────────────────────┐
│ Pi Agent │
│ 在安全边界停止 │
└────────────────────┘
Moxt Adapter 和 Moxt Pipeline 不是两个独立宿主,只是 Moxt 内部的两项职责。
一句话:
AskUser 产生事实,Moxt 解释事实,Pi Agent 执行停止。
边界是:
- AskUser Tool 不持有 Pi Agent 实例。
- Pi Agent 不认识 AskUser、Moxt、Pipeline 或 checkpoint。
- Moxt 本来就是组装 Agent、管理 Pipeline 和持久化 Session 的宿主。
一个 Pipeline
│
├── 恢复 Session JSONL
├── 组装 model / tools / system prompt
└── 创建一个主 Pi Agent
│
├── Turn 1
├── Turn 2
└── Turn N:完成或等待用户
- 一个 Pipeline 正常创建一个主 Agent。
- 一个 Agent 内可以执行多个 turn。
- 一个 turn 大致是“一次模型回复,以及该回复触发的一批 Tool 执行”。
- 一个 Pipeline 也可能按需创建额外的 subagent。
- 用户回答 AskUser 后会创建新的 Pipeline 和新的 Agent。
- 新 Agent 通过 JSONL 恢复历史,不会继续使用旧 Agent 的内存对象。
用户发送:
帮我部署这个项目
模型不知道应该部署到哪里,于是调用 AskUser:
部署到测试环境还是生产环境?
以 Moxt 当前 collectFormInput 的真实 dispatcher 代码为例:
await sendAskUser(args.component_dsl_schema)
return {
[ASK_USER_SENT_KEY]: true,
message: 'Input form sent to user. Waiting for user to fill and submit.',
}其中:
ASK_USER_SENT_KEY === 'ask_user_sent'AskUser Tool 只完成:
1. 通过 HTTP 把问题发给用户
2. 返回“问题发送成功”
它没有直接停止 Agent。
Agent 正常执行 Tool 后会调用宿主配置的 afterToolCall。
Moxt 当前通过 getAskUserResultText(result) 检查 ToolResult 的文本内容。如果解析到:
{
"ask_user_sent": true
}就记录:
interruption = {
kind: 'user_input_required',
toolCallId: toolCall.id,
toolName: toolCall.name,
resultText,
}状态从:
interruption = null变成:
interruption = {
kind: 'user_input_required',
// ...
}当前 Moxt main 随后执行:
opts.requestStop()
return { terminate: true }requestStop 当前被接成:
agentRef.abort()所以当前事实是:
AskUser 成功
│
▼
记录 user_input_required
│
├── agent.abort()
└── ToolResult terminate = true
这能阻止下一次模型请求,但使用了强制中断语义。
AskUser 真正需要表达的是:
当前 turn 正常收尾以后,不要开始下一个 turn。
Pi upstream 的底层 loop 执行顺序是:
完成本批 Tool
│
▼
生成 ToolResult
│
▼
把 ToolResult 加入 Agent 上下文
│
▼
emit turn_end
│
▼
如果配置了 prepareNextTurn,先执行它
│
▼
调用 shouldStopAfterTurn
│
├── true → emit agent_end,返回
└── false → 轮询消息队列并按原规则继续
对应源码:
for (const result of toolResults) {
currentContext.messages.push(result)
newMessages.push(result)
}
await emit({ type: "turn_end", message, toolResults })
const nextTurnSnapshot =
await config.prepareNextTurn?.(nextTurnContext)
if (await config.shouldStopAfterTurn?.({
message,
toolResults,
context: currentContext,
newMessages,
})) {
await emit({ type: "agent_end", messages: newMessages })
return
}因此 shouldStopAfterTurn:
- 不打断正在进行的模型流;
- 不取消正在执行的 Tool;
- 不改变 AssistantMessage 的 stop reason;
- 在 ToolResult 进入 Agent 上下文以后执行;
- 在下一次模型请求以前执行;
- 返回
true时不会继续轮询 steering/follow-up queue。
Moxt 当前没有配置 prepareNextTurn,所以这条路径中 turn_end 后会直接进入停止判断。
Pi 底层 AgentLoopConfig 已经支持 shouldStopAfterTurn,但高层 AgentOptions 没有暴露它,而且 Agent.createLoopConfig() 是私有方法。
改动前:
Moxt
│
▼
new Agent(...)
│
× 无法配置
│
▼
AgentLoopConfig.shouldStopAfterTurn
候选 PR 在 AgentOptions 增加:
shouldStopAfterTurn?: (
context: ShouldStopAfterTurnContext,
signal?: AbortSignal,
) => boolean | Promise<boolean>Agent 保存它:
this.shouldStopAfterTurn = runtimeOptions.shouldStopAfterTurn创建底层 LoopConfig 时转发:
const shouldStopAfterTurn = this.shouldStopAfterTurn
return {
// ...
shouldStopAfterTurn: shouldStopAfterTurn
? async context =>
await shouldStopAfterTurn(context, this.signal)
: undefined,
}候选代码:agent.ts
改动后:
Moxt
│
▼
new Agent({ shouldStopAfterTurn })
│
▼
Agent.createLoopConfig()
│
▼
AgentLoopConfig.shouldStopAfterTurn
PR 没有新增:
- AskUser 专用协议;
- Moxt 业务字段;
- 新 stop reason;
- 命令式
agent.stopAfterTurn(); - JSONL 或持久化格式;
- Session suspension。
候选 PR 测试中出现:
new Agent({
shouldStopAfterTurn: () => true,
})意思是:
第一个 turn 完成
→ 回调永远返回 true
→ Agent 停止
这是为了测试 Hook 转发,不是默认配置。
默认不传:
new Agent({
// shouldStopAfterTurn 未配置
})不会改变 Agent 原来的行为。
以下是 目标方案,不是当前 main。
Moxt 的 createPiAgent() factory 先增加并原样转发这个通用选项:
export interface CreatePiAgentOptions {
// ...
shouldStopAfterTurn?: AgentOptions['shouldStopAfterTurn']
}
const agent = new Agent({
// ...
shouldStopAfterTurn: opts.shouldStopAfterTurn,
})不需要给 controller 新增一个 Pi 专用的 shouldStopAfterTurn() 方法。继续保留两个现有职责即可:
afterToolCall 识别 AskUser,并写入 interruption
getInterruption 读取 interruption
目标 controller 可以简化为:
function createMoxtAskUserInterruption() {
let interruption: MoxtAskUserInterruption | null = null
return {
afterToolCall({ toolCall, result, isError }) {
if (interruption !== null || isError) {
return
}
const resultText = getAskUserResultText(result)
if (resultText === null) {
return
}
interruption = {
kind: 'user_input_required',
toolCallId: toolCall.id,
toolName: toolCall.name,
resultText,
}
// 不调用 agent.abort()
// 不再返回 { terminate: true }
},
getInterruption() {
return interruption
},
}
}Agent 组装处完成唯一接线:
const askUserInterruption =
createMoxtAskUserInterruption()
const agent = createPiAgent({
// ...
afterToolCall:
askUserInterruption.afterToolCall,
shouldStopAfterTurn: () =>
askUserInterruption.getInterruption() !== null,
})这里只有一个停止机制:
AskUser 成功
│
▼
afterToolCall 写入 interruption
│
▼
Pi 完整结束本 turn
│
▼
shouldStopAfterTurn 读取 interruption
│
▼
返回 true,Pi 安全停止
Pi 的 terminate 是 ToolResult 级提示。
只有同批 所有 ToolResult 都设置 terminate: true,Tool batch 才要求停止:
function shouldTerminateToolBatch(finalizedCalls) {
return (
finalizedCalls.length > 0 &&
finalizedCalls.every(
finalized => finalized.result.terminate === true
)
)
}例如模型同一 turn 调用:
AskUser:terminate = true
ReadFile:terminate 未设置
every(...) = false
它不能稳定表达 Moxt 的业务规则:
只要 AskUser 成功,当前 Pipeline 就应该等待用户。
对比:
| 方案 | 结果 |
|---|---|
只用 terminate: true |
混合 Tool batch 不保证停止 |
terminate 与 Hook 同时使用 |
能工作,但有两套停止规则 |
只用 shouldStopAfterTurn |
单一停止规则,语义最准确 |
因此目标方案使用 shouldStopAfterTurn 作为唯一停止机制。
不是 PiMainAgent 直接读取 controller。
真实顺序是:
runPiAgentUntilSettled()
│
├── 只有 promptError === null
│ 才调用 getInterruption()
│
▼
返回 outcome.interruption
│
▼
PiMainAgent 读取 outcome.interruption
│
▼
判断 kind === user_input_required
│
▼
生成 ask_user settlement
这个顺序保护了一个重要约束:
AskUser 卡片发送成功
但 ToolResult 写 JSONL 失败
│
▼
promptError 存在
│
▼
outcome.interruption = null
│
▼
Pipeline 失败
不会错误报告为正常等待用户
当前已有集成测试:
[UC-CHAT-021-S04]
fails the turn when ask-user dispatch succeeds
but its tool result cannot be persisted
后续切换到 Hook 时必须继续满足这条测试。
Agent 安全停止后:
runPiAgentUntilSettled 返回 outcome
│
▼
PiMainAgent 识别 user_input_required
│
▼
resolveCheckpointDecision(... kind: "ask_user")
冻结本地 JSONL,得到 checkpoint candidate
│
▼
生成 PiExecutionSettlement:
{ kind: "ask_user", checkpoint }
│
▼
外层 terminal protocol
上传 checkpoint
│
▼
向 Server 发送 terminal callback
由 Server 提交恢复点/终态
│
▼
Sandbox 可以结束
AskUser 卡片本身更早已经由 Tool 的 HTTP 请求发送给 Server;AskUser 路径不会再发送普通的成功 ResultMessage。
用户选择:
部署到测试环境
Moxt 创建新的 Pipeline 和新的 Agent,从 JSONL 恢复:
用户:帮我部署这个项目
Assistant:
调用 AskUser,询问部署环境
ToolResult:
问题发送成功,等待用户
用户:
部署到测试环境
新的 Agent 从这里继续。
Moxt 延续的是 JSONL Session,不是旧 Agent 的内存实例。
候选 PR 的测试:
- 第一次模型响应调用
noopTool; shouldStopAfterTurn返回true;- 预先准备一个“不应该执行”的第二次模型响应;
- 执行
agent.prompt("start"); - 断言模型请求只有一次;
- 断言 Hook 看到:
expect(requestCount).toBe(1)
expect(sawAbortSignal).toBe(true)
expect(callbackContextRoles).toEqual([
"user",
"assistant",
"toolResult",
])它证明的是通用 Pi Agent 能力:
Assistant tool call 已完成
ToolResult 已进入 Agent 上下文
Hook 收到当前 run 的 AbortSignal
第二次模型请求没有发生
它没有单独证明 Moxt AskUser 集成已经完成;Moxt 还需要自己的测试。
至少需要覆盖:
1. 单个 AskUser
→ ToolResult 完整保存
→ 不发生下一次模型请求
→ settlement 为 ask_user
2. AskUser + 普通 Tool 的混合 batch
→ 所有 Tool 正常完成
→ turn 完整结束
→ 不发生下一次模型请求
3. AskUser 已发送,但 ToolResult 写 JSONL 失败
→ interruption 不得覆盖持久化错误
→ Pipeline 必须失败
4. 没有 AskUser 的普通 turn
→ shouldStopAfterTurn 返回 false
→ 行为保持不变
这些测试通过以后,才能把“目标方案成立”升级为“Moxt 已完成验证”。
截至文首基线:
Pi upstream main
✅ 底层 AgentLoopConfig 有 shouldStopAfterTurn
❌ 高层 AgentOptions 尚未暴露
候选 Pi PR
✅ 高层 AgentOptions 暴露并转发 Hook
✅ Agent 级转发测试已存在
✅ issue 已获得维护者 lgtm
⏳ 尚未向 upstream 创建 PR
Moxt main
✅ AskUser adapter 会记录 user_input_required
✅ 已有 JSONL checkpoint / restore
✅ 已有 AskUser ToolResult 持久化失败测试
⚠️ 当前仍使用 agent.abort() + terminate: true
❌ 尚未接入 Agent.shouldStopAfterTurn
所以当前最准确的结论是:
Pi 候选 PR 只开放高层 Hook;Moxt 目标方案是用该 Hook 替换
agent.abort() + terminate: true,但必须通过 Moxt 集成测试后才能上线。
AskUser Tool
发送问题并返回成功结果
│
▼
Moxt afterToolCall
写入 user_input_required
│
▼
Pi 完整结束本 turn
并生成 ToolResult
│
▼
Pi 调用 shouldStopAfterTurn
│
▼
Moxt 读取 interruption,返回 true
│
▼
Pi Agent 正常 agent_end
│
▼
runner 返回 outcome.interruption
│
▼
Moxt 冻结并上传 JSONL checkpoint
提交 Server terminal callback
│
▼
用户回答后创建新 Pipeline
新 Agent 从 JSONL 继续