Skip to content

Instantly share code, notes, and snippets.

@acmerfight
Last active July 31, 2026 09:07
Show Gist options
  • Select an option

  • Save acmerfight/03e5817ea1bcf6e6ce032935c53c62f2 to your computer and use it in GitHub Desktop.

Select an option

Save acmerfight/03e5817ea1bcf6e6ce032935c53c62f2 to your computer and use it in GitHub Desktop.
Pi Agent、AskUser 与 shouldStopAfterTurn:从 Pipeline 启动到 JSONL 恢复(基于源码核对)

Pi Agent、AskUser 与 shouldStopAfterTurn

从最小代码讲清楚:AskUser 如何让一个 Moxt Pipeline 在完整 turn 结束后停止,以及 Pi 候选 PR 实际补了什么。

核对基线

文中会明确区分:

当前事实      当前 main 已经这样运行
候选 PR       Pi fork 中已有,但尚未合入 upstream
目标方案      Moxt 后续建议实现,尚未进入 main

先看 30 秒版本

先用一个布尔值理解核心逻辑:

let waitingForUser = false

AskUser 成功后,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、Agent 和 Turn

一个 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:

部署到测试环境还是生产环境?

第一步: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。

第二步:Moxt 当前如何识别 AskUser

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',
    // ...
}

第三步:当前 main 如何停止

当前 Moxt main 随后执行:

opts.requestStop()
return { terminate: true }

requestStop 当前被接成:

agentRef.abort()

所以当前事实是:

AskUser 成功
    │
    ▼
记录 user_input_required
    │
    ├── agent.abort()
    └── ToolResult terminate = true

这能阻止下一次模型请求,但使用了强制中断语义。

AskUser 真正需要表达的是:

当前 turn 正常收尾以后,不要开始下一个 turn。


Pi 底层已经有正确的安全停止点

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
}

事实来源:agent-loop.ts#L215-L259

因此 shouldStopAfterTurn

  • 不打断正在进行的模型流;
  • 不取消正在执行的 Tool;
  • 不改变 AssistantMessage 的 stop reason;
  • 在 ToolResult 进入 Agent 上下文以后执行;
  • 在下一次模型请求以前执行;
  • 返回 true 时不会继续轮询 steering/follow-up queue。

Moxt 当前没有配置 prepareNextTurn,所以这条路径中 turn_end 后会直接进入停止判断。


Pi 候选 PR 补了什么

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。

() => true 不是默认值

候选 PR 测试中出现:

new Agent({
    shouldStopAfterTurn: () => true,
})

意思是:

第一个 turn 完成
    → 回调永远返回 true
    → Agent 停止

这是为了测试 Hook 转发,不是默认配置。

默认不传:

new Agent({
    // shouldStopAfterTurn 未配置
})

不会改变 Agent 原来的行为。


Moxt 推荐的最小接线

以下是 目标方案,不是当前 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 安全停止

为什么不再依赖 terminate: true

Pi 的 terminate 是 ToolResult 级提示。

只有同批 所有 ToolResult 都设置 terminate: true,Tool batch 才要求停止:

function shouldTerminateToolBatch(finalizedCalls) {
    return (
        finalizedCalls.length > 0 &&
        finalizedCalls.every(
            finalized => finalized.result.terminate === true
        )
    )
}

事实来源:agent-loop.ts#L582-L584

例如模型同一 turn 调用:

AskUser:terminate = true
ReadFile:terminate 未设置

every(...) = false

它不能稳定表达 Moxt 的业务规则:

只要 AskUser 成功,当前 Pipeline 就应该等待用户。

对比:

方案 结果
只用 terminate: true 混合 Tool batch 不保证停止
terminate 与 Hook 同时使用 能工作,但有两套停止规则
只用 shouldStopAfterTurn 单一停止规则,语义最准确

因此目标方案使用 shouldStopAfterTurn 作为唯一停止机制。


Agent 返回后,真实 interruption 链路

不是 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 时必须继续满足这条测试。


AskUser checkpoint 的真实顺序

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 的内存实例。


候选 Pi PR 的测试证明了什么

候选 PR 的测试:

  1. 第一次模型响应调用 noop Tool;
  2. shouldStopAfterTurn 返回 true
  3. 预先准备一个“不应该执行”的第二次模型响应;
  4. 执行 agent.prompt("start")
  5. 断言模型请求只有一次;
  6. 断言 Hook 看到:
expect(requestCount).toBe(1)
expect(sawAbortSignal).toBe(true)
expect(callbackContextRoles).toEqual([
    "user",
    "assistant",
    "toolResult",
])

测试来源:agent.test.ts#L707-L749

它证明的是通用 Pi Agent 能力:

Assistant tool call 已完成
ToolResult 已进入 Agent 上下文
Hook 收到当前 run 的 AbortSignal
第二次模型请求没有发生

它没有单独证明 Moxt AskUser 集成已经完成;Moxt 还需要自己的测试。


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 继续
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment