重构分享站:统一说明文字、合并行业对比页、移除旧 explainers

- 页面从 11 页缩减为 9 页,三个独立产品源码页合并为一张行业对比表
- 四个演示页说明文字重写:每页说清和上一级的区别、机制为什么相同
- 开场从 AiWork vs Codex 具体现象切入,工具页从 fs.readFileSync 引入契约层
- Loop 基本逻辑页说明这是真实可跑的原型,解释 await runTool 卡住的是什么
- 结尾页改为「收件箱统一三条路径」,回扣到 AiWork 的立即插入
- 新增 CompareSlide 组件,对比 AiWork / Codex / Claude Code / OpenAI Responses API
- 删除 ProductSlide 配置式组件,改为独立页面
- 关键事件卡片加标题标签,补 mode-tabs 激活态和 compare-table 样式
- 删除 explainers/ 下四个旧文件
- 更新 README.md 和 async-agent-lab/README.md
This commit is contained in:
魏天炜
2026-11-12 16:24:22 +08:00
parent 5dabea54ab
commit 6011f501aa
12 changed files with 208 additions and 2309 deletions

View File

@@ -1,8 +1,10 @@
# Agent Loop # Agent Loop
一份关于 Agent 如何调用工具、等待后台任务和接收用户答复的分享与演示项目。主讲稿从最基础的 Agent Loop 讲起,并提供同步 Bash、后台 Bash、阻塞提问和异步提问四个可运行的案例。 约 30 分钟的内部技术分享:Agent 为什么能边问边做。从一个 15 行的真实 Agent 原型出发,通过四个递进级别说明工具调用的返回时机如何决定 Loop 能做什么。
使用 Node.js 24 和 pnpm。在 `async-agent-lab` 中复制 `.env.example` 为 `.env.local`,配置 `EASTMONEY_API_KEY`,然后运行: ## 启动
需要 Node.js 24 和 pnpm。复制 `async-agent-lab/.env.example` 为 `.env.local`,填入 `EASTMONEY_API_KEY`,然后:
```sh ```sh
cd async-agent-lab cd async-agent-lab
@@ -10,4 +12,20 @@ pnpm install --frozen-lockfile
pnpm start pnpm start
``` ```
打开 [本地主讲稿](http://127.0.0.1:8791/),在案例页切换源码讲解和现场运行。更多说明见 [Agent Loop 实验室](async-agent-lab/README.md)。 打开 [http://127.0.0.1:8791/](http://127.0.0.1:8791/),用左右方向键或底部按钮翻页,点「全屏」进入投屏模式。
## 分享结构
| 页面 | 内容 |
| --- | --- |
| 开场 | 从 AiWork 与 Codex 的具体差异引入,说明问题的根源 |
| Agent Loop 基本逻辑 | 15 行真实原型,解释 `await runTool` 卡住的是什么 |
| 四个基础工具 | 从 `fs.readFileSync` 引入工具契约的作用 |
| 第 1 级:同步 Bash | 建立基线,观察工具等待期间请求数为 0 |
| 第 2 级:后台 Bash | 只加一个参数,工具先还回执,Loop 继续运行 |
| 第 3 级:阻塞提问 | 等的从进程换成人,机制完全一样 |
| 第 4 级:异步提问 | 把「发问题」和「拿答案」拆开,和第 2 级路径相同 |
| 行业对比 | AiWork / Codex / Claude Code / OpenAI Responses API 各自的处理方式 |
| 收件箱统一三条路径 | 后台进程、子 Agent、用户答复走同一个机制,回到 AiWork |
更多说明见 [async-agent-lab/README.md](async-agent-lab/README.md)。

View File

@@ -1,28 +1,37 @@
# Agent 为什么能边问边做 # Agent 为什么能边问边做
本地 HTML 主讲稿。讲解页先介绍 Agent Loop 和基础工具。四张案例页均可切换「源码与说明」与「现场运行」,运行模式左侧显示对话与问答,右侧以角色标签展示事件轨迹。页面顺序见[分享方案](docs/presentation-plan.md)。 约 30 分钟内部技术分享的演示站。四个递进级别,每级只改一件事,每次都能在请求时序里看到变化的那个点。
## 启动 ## 启动
使用 Node.js 24 和 pnpm。在 `.env.local` 中配置 `EASTMONEY_API_KEY`。本机已有配置时直接启动。 需要 Node.js 24 和 pnpm。复制 `.env.example` 为 `.env.local`,填入 `EASTMONEY_API_KEY`:
```bash ```bash
cd /Users/appgroup/Documents/ChatGPT/super-agent-loop/async-agent-lab
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm start pnpm start
``` ```
打开 [本地主讲稿](http://127.0.0.1:8791/)。用左右方向键、底部按钮或目录翻页。内容按窗口宽度排版,源码、候选项和轨迹随页面滚动。点「全屏」进入投屏模式。打开详情后用 Esc 关闭。演示页可独立启动、停止或重跑,切到源码对照页再返回时会保留当前运行和答复草稿。 打开 [http://127.0.0.1:8791/](http://127.0.0.1:8791/)。用左右方向键或底部按钮翻页,点「全屏」进入投屏模式。
- **同步 Bash**:沿 `tool_call_id` 从 Git 结果追到下一次请求。 ## 四个演示级别
- **后台 Bash**:沿 `job_1` 看启动回执、进程退出和完成通知。点请求事件可以展开对应的 `user` 消息及实际序列化请求体。
- **阻塞提问**:让问题卡停几秒,观察请求数。提交后展开下一次请求,核对原 `tool` 消息里的答案。 每个演示页可以切换「源码与说明」和「现场运行」两种模式。运行时左侧显示对话和问答交互,右侧显示事件轨迹。
- **异步提问**:先等到「继续取证」亮起,再提交下周计划。候选默认全选,取消若干项后提交。点「打开 weekly-report.md」查看磁盘上实际写出的周报。
- **第 1 级·同步 Bash**:沿 `tool_call_id` 从 Bash 返回追到下一次请求,观察两次请求之间的空白。
- **第 2 级·后台 Bash**:沿 `job_1` 看启动回执、进程退出、完成通知进入请求。测试跑着,模型继续工作。
- **第 3 级·阻塞提问**:让问题卡停几秒,观察请求数不动。提交后核对请求里的 `tool` 消息。
- **第 4 级·异步提问**:先别答,看取证请求继续出现。答完两道题后打开 `weekly-report.md`,核对两份答案各自进了哪段。
## 取证与网关 ## 取证与网关
取证用上海时间的本周工作日范围,截止到运行时或周五结束。读取 Codex 和 OpenCode 的本地会话数据库及 DSH 会话缓存,按 Git common directory 归并 worktree。默认以本机 AiWork 仓库的 `git config user.email` 过滤作者。可通过 `AIWORK_REPO` 指定 AiWork 路径,通过逗号分隔的 `WEEKLY_AUTHOR_EMAILS` 指定已确认的作者邮箱。Bash 支持本场演示用的 `weekly-evidence`、`git log` 和 `git show <sha>`。每次启动的独立目录位于 `.runs/`,周报由 `write` 写入该次运行目录。 取证用上海时间的本周工作日范围。读取 Codex 和 OpenCode 的本地会话数据库及 DSH 会话缓存,按 Git common directory 归并 worktree。默认以本机 AiWork 仓库的 `git config user.email` 过滤作者。可通过 `AIWORK_REPO` 指定路径,通过逗号分隔的 `WEEKLY_AUTHOR_EMAILS` 指定作者邮箱。每次运行的独立目录在 `.runs/`,周报写入该次运行目录。
服务监听本机回环地址。请求走东财 AI 网关的 OpenAI Chat Completions 流式接口,模型为 `DeepSeek-V4.1-Flash-1017`。模型窗口 1,000,000,最大输出 384,000,支持 text/image。本场使用文本工具调用。 请求走东财 AI 网关,模型为 `DeepSeek-V4.1-Flash-1017`(OpenAI Chat Completions 流式接口)。网关对单个令牌限速 50 次/分钟,不要并发开多个演示。
开发时可分别运行 `pnpm server` 与 `pnpm dev`(Vite 5178 代理至 8791)。运行 `pnpm typecheck` 检查类型。此前的订单原型见[原型讲稿](docs/share.md)。 ## 开发
```bash
pnpm server # 仅启动后端(端口 8791)
pnpm dev # 仅启动 Vite 开发服务器(端口 5178,代理至 8791)
pnpm typecheck
```

View File

@@ -199,9 +199,12 @@ export function Demo({ scenario, config, available, active }: { scenario: Scenar
<div className="status" role="status"><span className={`signal-dot ${running ? 'live' : terminal && current.state === 'done' ? 'success' : ''}`} />{status}</div> <div className="status" role="status"><span className={`signal-dot ${running ? 'live' : terminal && current.state === 'done' ? 'success' : ''}`} />{status}</div>
<span className="request-count">请求 <b>{request?.n ?? 0}</b></span> <span className="request-count">请求 <b>{request?.n ?? 0}</b></span>
</header> </header>
<div className="signals" aria-label="关键事件"> <div className="signals-wrapper">
{signalsNow.map(({ label, event }) => <button key={label} className={`signal ${event ? 'seen' : ''}`} <span className="signals-label">这一级的关键时刻</span>
disabled={!event} onClick={() => open(event)}><span>{label}</span><time>{event ? time(event.t) : '—'}</time></button>)} <div className="signals" aria-label="关键事件">
{signalsNow.map(({ label, event }) => <button key={label} className={`signal ${event ? 'seen' : ''}`}
disabled={!event} onClick={() => open(event)}><span>{label}</span><time>{event ? time(event.t) : '—'}</time></button>)}
</div>
</div> </div>
{error || current?.state === 'error' ? <div className="trace-error" role="alert">{error || current?.note}</div> : null} {error || current?.state === 'error' ? <div className="trace-error" role="alert">{error || current?.note}</div> : null}
{(report || current?.summary) && <div className="trace-output"> {(report || current?.summary) && <div className="trace-output">

View File

@@ -2,40 +2,40 @@ import type { ScenarioId } from '../../../live/types.ts';
const lessons: Record<ScenarioId, { intro: string; steps: [string, string][]; observation: string }> = { const lessons: Record<ScenarioId, { intro: string; steps: [string, string][]; observation: string }> = {
sync: { sync: {
intro: '先用一份消息历史保存任务。每次请求模型,它可能给出文本,也可能要求执行工具。运行时把工具结果追加到历史,再请求一次模型,这个反复执行的过程就是最小 Agent Loop。', intro: '这是四个场景的起点和基线。整个 Loop 在 agent.ts 里是 15 行代码:请求模型、依次等工具结果、追加历史、循环。await runTool 是真正的阻塞——不只是这个工具调用卡住了,而是整个 Loop 停在这里等 Promise resolve,收件箱里的消息也进不来,下一次 callModel() 发不出去。先用这个版本建立基准,之后每一步只改一件事。',
steps: [ steps: [
['把上下文交给模型', 'callModel(history) 发送当前历史和工具定义。模型返回的 tool_calls 描述工具名称、参数和调用 ID。'], ['把上下文交给模型', 'callModel(history) 发送当前消息历史和工具定义。模型返回 tool_calls,里面有工具名称、参数和一个 call_id。这个 call_id 是后面对账的钥匙——它把这次调用和之后的结果串起来。'],
['执行工具并等待结果', '运行时按工具名找到 Bash 执行函数。await runTool 在命令退出前保持等待,这时 Loop 还没有发起下一次模型请求。'], ['在 await 处停住', '运行时按工具名找到 Bash 执行函数,把参数传过去。await runTool 在命令退出前让这段 Loop 代码挂在这里。Node.js 事件循环没被卡死,网页仍然响应,但 Loop 的下一步发不出去。'],
['追加结果,继续一轮', '用 role: tool 和原 tool_call_id 把结果接回历史。模型在下一次请求中读到 Git 提交,再形成总结。本例在成功取证后要求模型给出文本答复。'], ['结果追回历史,继续一轮', '命令退出后,用 role: "tool" 和原来那个 call_id 把结果追加进历史。模型在下一次请求里读到 Git 提交,可以给出汇总。两次请求之间的时间差,就是 Bash 运行的时间。'],
], ],
observation: '运行时留意请求 #1 与请求 #2 之间的 Bash 返回。工具调用和返回通过同一个 call_id 对应。', observation: '找到请求 #2 里 tool_calls 中的 call_id,再找请求 #3 里 role 为 tool 的那条消息——同一个 id。两次请求之间的空白就是 Bash 在运行的时间,这段时间里 Loop 什么都没做。',
}, },
background: { background: {
intro: '跨仓库取证由一个本地子进程执行。Bash 工具启动它后先返回 job_id,Loop 便可以请求模型整理周报结构。进程在自己的生命周期里继续工作。', intro: '和第 1 级代码完全一样,只给 bash 加了一个参数 run_in_background。工具改成先返回 job_id 回执,Loop 拿到回执就结束了这一步,马上发下一次模型请求。子进程在自己的生命周期里继续工作,退出时运行时把结果包成一条消息放进收件箱,步骤边界再交给模型。Loop 代码没变,改变的只是这个工具什么时候返回。',
steps: [ steps: [
['回执结束当前工具调用', 'startBackground 返回 accepted 和 job_id。此时模型知道工作已经启动,具体的提交证据仍要等进程完成。'], ['回执结束当前工具调用', 'startBackground 启动子进程后立刻返回 {job_id, status: "running"}。这就是这次工具调用的全部结果——回执,不是最终输出。Loop 拿到回执,这一步就结束了,可以继续请求模型。'],
['完成结果进入收件箱', '进程退出时,运行时记录退出码和输出,并投递带 job_id 的通知。这个结果可以在 Loop 忙碌时先排队。'], ['进程退出,结果进收件箱', '子进程退出时,运行时拿到退出码和最后 25 行输出,包成 <background_job_done> 消息放进收件箱。用户插话走同一个收件箱,优先级规则也一样。'],
['下一次请求接收结果', 'callModel 发请求前把收件箱加入历史。如果 Loop 已在等待,收件箱中的新消息会唤醒它。'], ['步骤边界取走结果', '每次步骤结束,Loop 把收件箱清空追加进历史。如果 Loop 此时已经空闲,收件箱里有东西就唤醒新的一轮,不需要轮询。'],
], ],
observation: '按 job_1 依次观察启动回执、进程退出、模型请求携带完成通知。取证很快时,三个事件仍有明确的先后顺序。', observation: '跟一个 job_1:先找工具结果里的启动回执,再找进程退出事件,最后找携带 background_job_done 的模型请求。取证命令很快时,三个时间点依然有先后顺序,不会重叠。',
}, },
blocking: { blocking: {
intro: '这个场景先询问下周计划。question 工具打开网页问题卡后,等待浏览器提交答案。等待发生在工具调用内部,因此本次调用还没有结果可以交回模型。', intro: '把等待对象从子进程换成了人,机制完全一样:await answer 挂住了这次 question 工具调用,Loop 停在等工具结果的地方,收件箱里的消息也进不来。这就是 AiWork 今天的 question 工具在做的事——OpenCode 源码里的 Deferred.await。答案没提交,Promise 就不 resolve,模型请求数停在 1。',
steps: [ steps: [
['创建等待中的问题', 'openQuestion 返回问题信息和一个 Promise。运行时用问题 ID 保存等待关系,让浏览器答复能找到它。'], ['创建等待中的问题', 'openQuestion 把问题信息发到浏览器,返回一个 Promise 和问题 ID。运行时把 ID 和这个 Promise 保存起来,等浏览器的回答过来时能找到它。'],
['等待用户回答', 'await answer 暂停当前工具的执行。网页仍能操作,但这条 Loop 要等工具返回才能继续请求模型。'], ['在 await 处停住,等人', 'await answer 让这次工具调用挂在这里。等的不再是子进程退出,而是用户点提交。等待期间,Loop 和第 1 级的 Bash 等待一模一样——什么都做不了。'],
['答案作为工具结果返回', '浏览器提交后解除 Promise。原 question 调用得到用户答案,再用同一个 tool_call_id 写入历史,随后开始周报取证。'], ['答案作为工具结果返回', '浏览器提交后 Promise resolve,答案就是这次 question 调用的工具结果,用原来的 tool_call_id 追加进历史。这之后 Loop 才继续查证据,和第 1 级 Bash 退出之后继续一模一样。'],
], ],
observation: '留出几秒答题时间。问题卡待答期间,请求数应停在 1;提交后再观察请求 #2 中的 tool 消息。', observation: '答题前,右侧请求数保持在 1。故意多等几秒。提交后,找请求 #2 里的 tool 消息——里面带着 question 调用的 call_id 和你输入的答案文本。',
}, },
async: { async: {
intro: '异步 question 打开问题卡后立即返回 accepted 和 question_id。Loop 得到回执,便继续收集本周证据。用户的答案稍后作为一条新的消息进入收件箱。', intro: '把「发问题」和「拿答案」拆成两件事,走的是和第 2 级后台 Bash 完全相同的路径:工具先返回回执,答案之后作为新消息进收件箱,步骤边界再交给模型。Loop 在等答案的这段时间可以继续取证。对 Loop 来说,问题的答复和子进程的退出通知没有区别,都是收件箱里的一条消息。',
steps: [ steps: [
['先问,再继续独立工作', 'ask 返回问题 ID,collect 随即定位活跃仓库并核查 Git。下周优先事项尚未确定,也可以收集本周已经发生的工作。'], ['发问,立刻还回执', 'ask 返回 {accepted: true, question_id: "q_1"},这就是这次工具调用的全部结果。问题卡照常显示,但 Loop 已经拿到回执,可以继续下一步取证,不用等你回答。'],
['把后来的答复接回历史', '浏览器提交的答案带有 question_id。运行时将它加入下一次模型请求,模型可据此识别它回答的是哪一道题。'], ['答复进收件箱,带问题 ID', '你提交后,服务端把 <question_reply question_id="q_1"> 包成一条 user 消息放进收件箱。模型在下一次请求里看到这条消息,能认出这是哪道题的答案。'],
['在依赖答案的步骤汇合', '候选工作项归纳完成后再请讲者选择。waitForAnswer 等齐两项决策,write 才将选中的本周工作和下周计划写入周报。'], ['在依赖答案的步骤汇合', '候选工作项归纳完成后,waitForAnswer 等齐两项回答,write 才把本周选中的工作和下周计划写入周报。两份回答的内容各自出现在周报对应的段落里,证明答案真的被用上了。'],
], ],
observation: '先让问题卡保持待答,观察取证对话继续出现。随后答复计划、确认保留项,并核对写出的 weekly-report.md。', observation: '先让问题卡保持待答,观察右侧取证对话继续出现、请求数继续增加。再提交下周计划,然后勾选本周保留项。最后打开周报,核对两次回答各自出现在哪一段。',
}, },
}; };
@@ -51,24 +51,23 @@ export function Lesson({ scenario }: { scenario: ScenarioId }) {
export function LoopLesson() { export function LoopLesson() {
return <section className="slide explain loop-lesson"> return <section className="slide explain loop-lesson">
<div className="slide-heading"><h1>最基础的 Agent Loop 是什么?</h1> <div className="slide-heading"><h1>最基础的 Agent Loop 是什么?</h1>
<p>一个围绕消息历史重复执行的程序,让模型根据工具的真实结果决定下一步。</p></div> <p>这不是伪代码。下面这段 15 行的循环接的是真实的 DeepSeek 网关,跑出来的请求和返回都是真的。OpenCode、Codex、AiWork 用的 Agent 都是同一种结构的变体——区别只在工具怎么返回、Loop 怎么等。</p></div>
<div className="reading-grid"> <div className="reading-grid">
<article className="lesson"> <article className="lesson">
<h2>模型负责提出下一步,运行时负责执行</h2> <h2>模型提出下一步,运行时执行</h2>
<p>用户提出任务后,运行时把任务、已有对话和工具定义发给模型。模型可能直接回答,也可能返回一个工具调用,例如让 Bash 查询 Git 提交。</p> <p>把任务、已有对话和工具定义发给模型。模型可能直接给文本答复,也可能返回一个 tool_calls,里面带着工具名、参数和一个 call_id。运行时按工具名查到对应的执行函数(就是普通的 async 函数),把参数传进去,拿到结果。</p>
<p>收到工具调用后,程序找到对应的执行函数,把参数交给它。执行结果连同调用 ID 一起追加到消息历史,模型才能在下一次请求中使用这些新信息。</p> <p>结果连同原来那个 call_id 一起,以 role: "tool" 的形式追加进消息历史。下一次请求模型时,模型就能读到这份结果,决定下一步。只要模型还想要工具,这个循环就继续;模型给出纯文本时,这一轮就结束。</p>
<p>只要模型继续请求工具,这个过程就继续重复。模型给出最终文本时,这一轮可以结束。如果之后有后台结果或用户答复到达,运行时可以再次启动一轮。</p> <h2>"等待"到底发生在哪里</h2>
<h2>“等待”发生在哪里?</h2> <p>关键就在 await runTool()。这是一个普通的 await,Node.js 事件循环没被卡死——网页、本地服务、收件箱照常工作。但是 Loop 的代码停在这一行,下一个 callModel() 发不出去。同步工具要等命令跑完才返回;异步工具先还回执,Loop 就能继续。后面四个案例,改的都是这一处什么时候 resolve。</p>
<p>同步工具会在执行完成后返回,因此 Loop 的下一步要等待它。异步工具先返回启动回执,运行时再通过后续消息交付结果。接下来的四个案例逐步展示这两种时序。</p>
</article> </article>
<div className="loop-steps"> <div className="loop-steps">
{[ {[
['用户任务与消息历史', '历史保存模型已经知道的任务、对话和工具结果。'], ['用户任务与消息历史', '历史里保存任务、已发生的对话和已拿到的工具结果。每次请求把它整包发给模型。'],
['请求模型', '把消息历史和可调用的工具定义发送给模型。'], ['请求模型', '把消息历史和这次能用的工具定义一起发送。模型返回文本或 tool_calls。'],
['执行模型请求的工具', '按工具名分发,将参数传给真实的执行函数。'], ['执行模型请求的工具', '按工具名查到执行函数,传参数进去。这里是唯一会卡住的地方——同步工具要等结果,异步工具先还回执。'],
['结果追加回历史', '保留调用 ID,再请求模型,直到这一轮结束。'], ['结果追加回历史', '保留 call_id,以 role: "tool" 追加进历史,再请求模型。模型不再要工具时这一轮结束。'],
].map(([title, body], i) => <div key={title}><span className="loop-step-number">{i + 1}</span><section><h3>{title}</h3><p>{body}</p></section></div>)} ].map(([title, body], i) => <div key={title}><span className="loop-step-number">{i + 1}</span><section><h3>{title}</h3><p>{body}</p></section></div>)}
<p className="loop-return">工具结果回到第 2 步,形成 Loop。</p> <p className="loop-return">工具结果回到第 2 步,形成 Loop。后台结果和用户答复也一样,都从收件箱进入第 2 步。</p>
</div> </div>
</div> </div>
</section>; </section>;

View File

@@ -1,24 +1,24 @@
import { useState } from 'react'; import { useState } from 'react';
import { Code } from './Code.tsx'; import { Code } from './Code.tsx';
import { codexAsync, opencodeQuestion, opencodeShell, readExecution, toolContract } from './content.ts'; import { readExecution, toolContract } from './content.ts';
import type { Source } from './content.ts';
export function Intro({ next }: { next: () => void }) { export function Intro({ next }: { next: () => void }) {
return <section className="intro slide" aria-labelledby="intro-title"> return <section className="intro slide" aria-labelledby="intro-title">
<div className="intro-copy"> <div className="intro-copy">
<h1 id="intro-title">Agent 为什么能<span>边问边做</span></h1> <h1 id="intro-title">Agent 为什么能<span>边问边做</span></h1>
<p>问题卡还在等答复,Agent 已经继续查询代码。这次分享从最基础的 Agent Loop 讲起,逐步观察工具何时返回,以及稍后到达的结果怎样被模型接收。</p> <p>AiWork 里问一个问题,模型就停下来等你回答,这期间什么都不做。Codex 不是这样——问题卡还挂着,取证的请求已经发出去了。这个差别不来自框架有多复杂,只来自工具调用什么时候返回。</p>
<button className="button primary intro-next" onClick={next}>开始分享 <span aria-hidden="true">→</span></button> <p>这次分享从一个 15 行的真实 Agent 原型开始,把机制一步步拆开。四个级别,每级只改一件事,每次都能看到请求时序真正变化的那个点。</p>
<button className="button primary intro-next" onClick={next}>开始 <span aria-hidden="true">→</span></button>
</div> </div>
<div className="intro-figure" aria-label="问题仍待回答,Agent 同时在取证"> <div className="intro-figure" aria-label="问题仍待回答,Agent 同时在取证">
<div className="intro-ask"><span className="signal-dot" /> 问题待答 <code>q_1</code> <div className="intro-ask"><span className="signal-dot" /> 问题待答 <code>q_1</code>
<strong>下周优先推进什么?</strong> <strong>下周优先推进什么?</strong>
</div> </div>
<div className="intro-flow" aria-hidden="true"><span /><i /><span /></div> <div className="intro-flow" aria-hidden="true"><span /><i /><span /></div>
<div className="intro-work"><span className="signal-dot" /> Agent 正在取证 <code>请求 #2</code> <div className="intro-work"><span className="signal-dot" /> Agent 正在取证 <code>请求 #3</code>
<strong>从本地会话定位活跃仓库</strong> <strong>从本地会话定位活跃仓库</strong>
</div> </div>
<div className="intro-note">AiWork 的阻塞提问 · OpenCode 的后台 Shell · Codex 的异步提问</div> <div className="intro-note">问题挂着,取证继续——今天 AiWork 做不到,这次分享讲为什么</div>
</div> </div>
</section>; </section>;
} }
@@ -26,8 +26,8 @@ export function Intro({ next }: { next: () => void }) {
export function ToolsSlide() { export function ToolsSlide() {
const [execution, setExecution] = useState(false); const [execution, setExecution] = useState(false);
return <section className="slide explain" aria-labelledby="tools-title"> return <section className="slide explain" aria-labelledby="tools-title">
<div className="slide-heading"><h1 id="tools-title">先给 Agent 四个基础工具</h1><p>模型说要调用什么,运行时执行,再用调用 ID 交回结果。</p></div> <div className="slide-heading"><h1 id="tools-title">先给 Agent 四个基础工具</h1><p>在 Node.js 里直接 fs.readFileSync 就能读文件——但 Agent 里需要多一层契约,让模型能用名字描述它想要什么。</p></div>
<p className="section-prose">工具定义告诉模型它能做什么,以及需要提供哪些参数。执行函数负责实际读取文件或启动命令。模型返回一次 read 调用后,运行时会把文件内容作为工具结果送回;调用 ID 让模型知道这份结果属于之前的哪一次请求。</p> <p className="section-prose">平时写 Node.js 脚本,直接 import fs 调同步接口就行,代码知道要读哪个文件。Agent 的问题是:模型不能直接调函数,它只能输出文本。工具契约(名称 + JSON Schema)是模型和运行时之间的协议——模型在返回里写 <code>read({"{"}"path":"docs/plan.md"{"}"}</code>,运行时解析出工具名和参数,调真正的执行函数,拿到结果再连同调用 ID 一起发回模型。对 Loop 来说,不管工具里面是 fs 读文件、子进程还是网络请求,外面的流程都一样:调用 → 等结果 → 追加历史。</p>
<div className="tools-grid"> <div className="tools-grid">
<div className="tool-example"> <div className="tool-example">
<div className="tabs" aria-label="read 的工具契约"> <div className="tabs" aria-label="read 的工具契约">
@@ -50,56 +50,98 @@ export function ToolsSlide() {
</section>; </section>;
} }
export function ProductSlide({ kind }: { kind: 'opencode-shell' | 'opencode-question' | 'codex' }) { const compareRows: { product: string; where: string; longCmd: { label: string; detail: string }; question: { label: string; detail: string }; note: string }[] = [
const content: Record<typeof kind, { title: string; subtitle: string; sources: Source[]; note: React.ReactNode }> = { {
'opencode-shell': { product: 'AiWork',
title: 'OpenCode 把退出结果写回会话', where: 'OpenCode 1.18.18',
subtitle: '后台分支先回执,退出后用 sessions.synthetic 交付。', longCmd: { label: '后台 Shell(V2 实验性)', detail: 'V2 有后台分支,超过前台等待预算自动转后台,退出结果用 sessions.synthetic 写回会话。旧版同步等待。' },
sources: opencodeShell, question: { label: '阻塞等待', detail: 'question.ask 通过 Deferred.await 挂住工具调用,网页 reply 触发 Deferred.succeed 才解除。等待期间 Loop 不动,请求数停在当前值。' },
note: <>开启实验选项后,超过前台等待预算的同一进程可以转到后台,复用完成通知。 <a href="https://github.com/anomalyco/opencode/pull/63024" target="_blank" rel="noreferrer">OpenCode #63024 ↗</a></>, note: 'AiWork 今天用的是第 3 级。Loop 能接收「立即插入」的消息,但只要 question 还没 resolve,模型就看不到。',
}, },
'opencode-question': { {
title: 'AiWork 的问题卡在哪里等', product: 'Codex CLI',
subtitle: 'OpenCode 1.18.18 发布问题事件,再等同一个 Deferred 被解除。', where: 'rust-v0.167.1',
sources: opencodeQuestion, longCmd: { label: '超时转后台', detail: 'exec_command 超过前台等待后返回 process_id,进程继续运行。退出事件作为独立消息送回原 turn。' },
note: <>网页答复通过 <code>requestID</code> 找回等待中的调用。</>, question: { label: '异步回执', detail: 'request_user_input_async 把问题发到界面后立即返回 {"accepted":true}。用户答复带 questionItemId 作为 user 消息进入下一次请求,模型可以继续工作。' },
}, note: 'Codex 是第 4 级的直接对照。测试快照里能看到:回执在请求 N,用户答复在请求 N+2,中间已有模型输出。',
codex: { },
title: 'Codex 把答复作为用户消息送回', {
subtitle: '提问先结算,后续请求用 questionItemId 关联用户答复。', product: 'Claude Code',
sources: codexAsync, where: 'Anthropic SDK',
note: <>右侧是上游测试记录的模型请求。<code>accepted</code> 回执和用户答复分别出现。</>, longCmd: { label: 'Ctrl+B 转后台', detail: '前台工具超过预算自动转后台,也可以手动 Ctrl+B。后台任务有独立生命周期,完成后通知原会话。' },
}, question: { label: '阻塞回调', detail: 'SDK 的 AskUserQuestion 走 canUseTool 回调,本质是同步等用户操作。等待期间 Loop 挂起,和 OpenCode 的 Deferred.await 类似。' },
}; note: '长命令处理和 Codex 类似,提问机制和 OpenCode 类似。两种能力各自独立。',
const { title, subtitle, sources, note } = content[kind]; },
const explanation = { {
'opencode-shell': '左侧后台分支先注册完成通知,然后返回后台回执。右侧通知逻辑会等待任务结束,拿到退出状态和输出,再把它们写入发起任务的会话。这与刚才演示中的 job_id、进程退出事件和收件箱投递一一对应。', product: 'OpenAI Responses API',
'opencode-question': 'ask 发布问题后,通过 Deferred.await 等待答案。reply 根据 requestID 找到这个等待对象,再用 Deferred.succeed 提交答案。因此问题卡的交互虽然发生在网页上,模型的工具调用仍要等这一步解除后才能完成。', where: 'API 层',
'codex': '工具源码先向界面发出带问题的消息,再立即返回 accepted。右侧测试快照显示模型已经继续写了一段草稿,用户的回答随后作为 role 为 user 的新消息出现。questionItemId 将这份回答关联回原来的问题。', longCmd: { label: '原生异步工具', detail: '工具定义里加 async: true,API 层管理生命周期。结果作为后续的 function_call_output 绑回原 call_id,不走普通消息通道。' },
}[kind]; question: { label: '同一机制', detail: '用户输入工具也用 async: true。答案通过 function_call_output 绑回,不会被模型伪造(结果不走消息通道)。' },
return <section className="slide explain" aria-labelledby="product-title"> note: '把异步性做进 API 协议里,从根本上解决了模型伪造通知的问题。代价是绑定了特定 API。',
<div className="slide-heading"><h1 id="product-title">{title}</h1><p>{subtitle}</p></div> },
<p className="section-prose">{explanation}</p> ];
<div className="compare-grid">{sources.map((source) => <Code key={source.file} source={source} />)}</div>
<p className="speaker-note">{note}</p> export function CompareSlide() {
const [expanded, setExpanded] = useState<string | null>(null);
return <section className="slide explain" aria-labelledby="compare-title">
<div className="slide-heading">
<h1 id="compare-title">行业怎么处理这两个问题</h1>
<p>长命令不能同步等,提问不能阻塞 Loop——各家的方案殊途同归,底层逻辑都是一样的。</p>
</div>
<p className="section-prose">把四个场景跑完之后,再看这张表就会清楚:每个产品做的无非是把「发起」和「拿结果」在不同层次拆开。拆的越早、越深,Loop 能重叠的工作就越多。OpenAI 把它做进了 API 协议,其他产品在 SDK 或运行时层面实现。AiWork 目前在第 3 级,机制上离第 4 级只差把 question 的 await 提前 resolve。</p>
<div className="compare-table" role="table" aria-label="各产品异步机制对比">
<div className="ct-head" role="row">
<div role="columnheader">产品</div>
<div role="columnheader">长命令处理</div>
<div role="columnheader">用户提问</div>
</div>
{compareRows.map((row) => {
const key = row.product;
const open = expanded === key;
return <div key={key} className={`ct-row ${open ? 'ct-row-open' : ''}`} role="rowgroup">
<div className="ct-row-main" role="row">
<div role="cell" className="ct-product">
<strong>{row.product}</strong>
<span>{row.where}</span>
</div>
<button role="cell" className={`ct-cell ${open ? 'active' : ''}`} onClick={() => setExpanded(open ? null : key)} aria-expanded={open}>
<span className="ct-label">{row.longCmd.label}</span>
</button>
<button role="cell" className={`ct-cell ${open ? 'active' : ''}`} onClick={() => setExpanded(open ? null : key)} aria-expanded={open}>
<span className="ct-label">{row.question.label}</span>
</button>
</div>
{open && <div className="ct-detail" role="row">
<div role="cell" />
<div role="cell" className="ct-detail-cell">
<p>{row.longCmd.detail}</p>
</div>
<div role="cell" className="ct-detail-cell">
<p>{row.question.detail}</p>
</div>
</div>}
<div className="ct-note" role="row"><div role="cell" /><div role="cell" className="ct-note-text">{row.note}</div></div>
</div>;
})}
</div>
</section>; </section>;
} }
export function ReturnSlide() { export function ReturnSlide() {
return <section className="slide explain return-slide" aria-labelledby="return-title"> return <section className="slide explain return-slide" aria-labelledby="return-title">
<div className="slide-heading"><h1 id="return-title">后台工作如何回到 Loop</h1><p>运行时拿到结果,按 ID 归属,在下一次可用的模型请求里交付。</p></div> <div className="slide-heading"><h1 id="return-title">收件箱统一了三条路径</h1><p>后台进程、子 Agent、用户答复——对 Loop 来说都是同一件事:一条消息在步骤边界进入历史。</p></div>
<p className="section-prose">后台工作可以发生在本地子进程、另一个 Agent,或远端服务中。运行时负责保存任务与会话的对应关系,收到完成结果后将它排入收件箱。Loop 在可以接收新消息的边界取走这些结果,追加到历史后再请求模型。后台任务的生命周期和模型的每次请求因此可以交错推进。</p> <p className="section-prose">这次原型里,后台 Bash 的退出通知、异步提问的用户答复、用户插话,走的是同一个收件箱,进入模型的时机规则也完全一样:Loop 活跃时等到步骤边界取走,Loop 空闲时收件箱一有东西就唤醒新的一轮。这意味着把机制做对了一次,三种来源就都能用。</p>
<div className="return-diagram" aria-label="本地进程、子 Agent、远端任务将结果交给运行时,随后回到模型"> <div className="return-diagram" aria-label="本地进程、子 Agent、远端任务将结果交给运行时,随后回到模型">
<div className="return-sources"> <div className="return-sources">
<div><strong>本地进程</strong><span>退出码与输出</span></div> <div><strong>本地进程</strong><span>退出码与输出,带 job_id</span></div>
<div><strong>子 Agent</strong><span>独立 Loop 结束后报告</span></div> <div><strong>子 Agent</strong><span>独立 Loop 结束后报告</span></div>
<div><strong>远端任务</strong><span>轮询结果或收到回调</span></div> <div><strong>远端任务</strong><span>轮询或回调,带任务 ID</span></div>
</div> </div>
<div className="diagram-arrow" aria-hidden="true">→</div> <div className="diagram-arrow" aria-hidden="true">→</div>
<div className="return-node"><strong>运行时收件箱</strong><span>用任务 ID 找回发起会话</span></div> <div className="return-node"><strong>运行时收件箱</strong><span>用 ID 找回发起会话,步骤边界投递</span></div>
<div className="diagram-arrow" aria-hidden="true">→</div> <div className="diagram-arrow" aria-hidden="true">→</div>
<div className="return-node last"><strong>模型下一步</strong><span>活跃时接入,空闲时唤醒</span></div> <div className="return-node last"><strong>模型下一步</strong><span>活跃时接入,空闲时唤醒新一轮</span></div>
</div> </div>
<p className="speaker-note">AiWork 的「立即插入」也要等可接收新消息的边界。若 Loop 还在等阻塞式 question,先由答复解除等待。</p> <p className="speaker-note">回到 AiWork:「立即插入」能把消息收进来,但只要 question 工具还在 await answer,Loop 就不动,消息得等答复解除那个 await 之后才被模型看到。从第 3 级到第 4 级的差别,就是把这个 await 提前 resolve——把「发问题」和「拿答案」拆成两件事。</p>
</section>; </section>;
} }

View File

@@ -158,12 +158,10 @@ export const pages = [
{ slug: 'start', title: '为什么能边问边做' }, { slug: 'start', title: '为什么能边问边做' },
{ slug: 'loop', title: 'Agent Loop 的基本逻辑' }, { slug: 'loop', title: 'Agent Loop 的基本逻辑' },
{ slug: 'tools', title: '四个基础工具' }, { slug: 'tools', title: '四个基础工具' },
{ slug: 'sync', title: '同步 Bash' }, { slug: 'sync', title: '第 1 级:同步 Bash' },
{ slug: 'background', title: '后台 Bash' }, { slug: 'background', title: '第 2 级:后台 Bash' },
{ slug: 'opencode-shell', title: 'OpenCode 后台 Shell' }, { slug: 'blocking', title: '第 3 级:阻塞提问' },
{ slug: 'blocking', title: '阻塞提问' }, { slug: 'async', title: '第 4 级:异步提问' },
{ slug: 'opencode-question', title: 'AiWork 的问答实现' }, { slug: 'compare', title: '行业对比:各家怎么处理' },
{ slug: 'async', title: '异步提问' }, { slug: 'return', title: '收件箱统一了三条路径' },
{ slug: 'codex', title: 'Codex 的问答实现' },
{ slug: 'return', title: '后台结果如何回到 Loop' },
] as const; ] as const;

View File

@@ -4,7 +4,7 @@ import type { ScenarioId, ScenarioInfo } from '../../../live/types.ts';
import { LoopLesson } from './Lesson.tsx'; import { LoopLesson } from './Lesson.tsx';
import { Demo } from './Demo.tsx'; import { Demo } from './Demo.tsx';
import { Dialog } from './Detail.tsx'; import { Dialog } from './Detail.tsx';
import { Intro, ProductSlide, ReturnSlide, ToolsSlide } from './Slides.tsx'; import { CompareSlide, Intro, ReturnSlide, ToolsSlide } from './Slides.tsx';
import { pages } from './content.ts'; import { pages } from './content.ts';
import { getConfig } from './live.ts'; import { getConfig } from './live.ts';
import './stage.css'; import './stage.css';
@@ -80,7 +80,7 @@ function App() {
{slug === 'start' && <Intro next={() => go(page + 1)} />} {slug === 'start' && <Intro next={() => go(page + 1)} />}
{slug === 'loop' && <LoopLesson />} {slug === 'loop' && <LoopLesson />}
{slug === 'tools' && <ToolsSlide />} {slug === 'tools' && <ToolsSlide />}
{(slug === 'opencode-shell' || slug === 'opencode-question' || slug === 'codex') && <ProductSlide kind={slug} />} {slug === 'compare' && <CompareSlide />}
{slug === 'return' && <ReturnSlide />} {slug === 'return' && <ReturnSlide />}
{scenarioIds.map((scenario) => <div className="demo-page" key={scenario} hidden={slug !== scenario}> {scenarioIds.map((scenario) => <div className="demo-page" key={scenario} hidden={slug !== scenario}>
<Demo scenario={scenario} config={config?.find((info) => info.id === scenario)} available={Boolean(config)} active={slug === scenario} /> <Demo scenario={scenario} config={config?.find((info) => info.id === scenario)} available={Boolean(config)} active={slug === scenario} />

View File

@@ -431,6 +431,39 @@ body { line-height: 1.7; }
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; } *, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
} }
.signals-wrapper { border-bottom: 1px solid var(--line); }
.signals-label { display: block; padding: 8px 13px 0; font-size: 11px; font-weight: 600; color: var(--terracotta); text-transform: uppercase; letter-spacing: .06em; }
.signals { border-bottom: 0; }
/* Compare table */
.compare-table { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.ct-head { display: grid; grid-template-columns: 160px 1fr 1fr; gap: 0; background: #f5f4f0; border-bottom: 1px solid var(--line); }
.ct-head > div { padding: 10px 16px; font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: .04em; text-transform: uppercase; }
.ct-head > div:not(:last-child) { border-right: 1px solid var(--line); }
.ct-row { border-bottom: 1px solid var(--line); }
.ct-row:last-child { border-bottom: 0; }
.ct-row-main { display: grid; grid-template-columns: 160px 1fr 1fr; }
.ct-product { display: flex; flex-direction: column; gap: 3px; padding: 14px 16px; border-right: 1px solid var(--line); justify-content: center; }
.ct-product strong { font-size: 14px; font-weight: 600; }
.ct-product span { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.ct-cell {
display: flex; align-items: center; gap: 8px; padding: 14px 16px;
background: transparent; border: 0; border-right: 1px solid var(--line);
text-align: left; cursor: pointer; transition: background var(--transition);
}
.ct-cell:last-child { border-right: 0; }
.ct-cell:hover { background: #f7f2eb; }
.ct-cell.active { background: var(--tint); }
.ct-label { font-size: 14px; font-weight: 500; color: var(--ink); line-height: 1.4; }
.ct-detail { display: grid; grid-template-columns: 160px 1fr 1fr; background: #faf7f3; border-top: 1px solid #ebe0d6; }
.ct-detail > div:first-child { border-right: 1px solid var(--line); }
.ct-detail-cell { padding: 12px 16px; border-right: 1px solid var(--line); }
.ct-detail-cell:last-child { border-right: 0; }
.ct-detail-cell p { font-size: 13px; line-height: 1.75; color: var(--muted); margin: 0; }
.ct-note { display: grid; grid-template-columns: 160px 1fr; border-top: 1px dashed #e0d6cb; }
.ct-note > div:first-child { border-right: 1px solid var(--line); }
.ct-note-text { grid-column: span 1; padding: 8px 16px 12px; font-size: 12px; line-height: 1.7; color: var(--muted); border-left: 3px solid var(--terracotta); margin: 8px 16px; padding: 4px 10px; border-radius: 0; background: transparent; }
.section-prose { max-width: 86ch; font-size: 16px; line-height: 1.85; color: var(--muted); } .section-prose { max-width: 86ch; font-size: 16px; line-height: 1.85; color: var(--muted); }
.deck-foot { flex-wrap: wrap; } .deck-foot { flex-wrap: wrap; }

File diff suppressed because one or more lines are too long

View File

@@ -1,620 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="用一段可暂停的动画,看云端 Agent Loop 如何通过本机 daemon 读仓库,以及 Mac 离线后两种架构的区别。">
<title>云端 Agent Loop 怎么读本机仓库?</title>
<style>
:root {
color-scheme: light;
--paper: #f3f5f1;
--ink: #172b36;
--muted: #445a62;
--line: #cbd7d4;
--screen: #0c222c;
--cloud: #a5d2ff;
--local: #a2e6be;
--paused: #ffd29a;
--light: #f4f9fc;
--subtle: #c0d0d6;
--mono: "IBM Plex Mono", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 16px/1.62 "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
h1, h2, h3, p, pre, dl, dd { margin: 0; }
h1, h2, h3 { line-height: 1.22; }
h1 { font-size: clamp(2rem, 5.6vw, 4.05rem); letter-spacing: -.048em; text-wrap: balance; }
h1 span { white-space: nowrap; }
h2 { font-size: clamp(1.45rem, 3.2vw, 2.05rem); letter-spacing: -.025em; }
h3 { font-size: 1.05rem; }
a { color: #165984; text-underline-offset: .2em; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .45; }
:focus-visible { outline: 3px solid #3787bc; outline-offset: 3px; }
.wrap { width: min(100% - 2rem, 1100px); margin-inline: auto; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.heading { padding-block: clamp(1.7rem, 4.4vw, 3.35rem) 1.1rem; }
.heading p { margin-top: .6rem; color: var(--muted); }
.player { border-radius: 14px; overflow: clip; background: var(--screen); color: var(--light); }
.controls {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: .65rem .9rem;
padding: .7rem clamp(.7rem, 2.3vw, 1.2rem);
border-bottom: 1px solid #35505b;
}
.play {
min-width: 6rem;
min-height: 44px;
padding: .45rem .65rem;
border: 0;
border-radius: 7px;
background: var(--cloud);
color: var(--screen);
font-weight: 750;
}
.seek {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: .55rem;
}
.seek input { width: 100%; min-width: 0; accent-color: var(--cloud); cursor: pointer; }
.seek output { font: .75rem var(--mono); white-space: nowrap; color: var(--subtle); }
.chapters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .45rem;
padding: .65rem clamp(.7rem, 2.3vw, 1.2rem);
}
.chapters button {
min-height: 44px;
padding: .35rem .4rem;
border: 1px solid #35505b;
border-radius: 7px;
background: #122e3a;
color: var(--subtle);
font-size: .86rem;
font-weight: 650;
}
.chapters button[aria-pressed="true"] { border-color: var(--cloud); color: var(--cloud); }
.chapters button:hover { background: #1d3a48; }
.frame { padding: 0 clamp(.7rem, 2.3vw, 1.2rem) .7rem; }
.diagram { position: relative; display: grid; gap: .1rem; }
.wire { position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.zone { padding: .7rem clamp(.45rem, 2.1vw, 1.15rem) .75rem; border: 1px solid #2e4b58; border-radius: 9px; background: #112d3a; }
.zone.mac { background: #112f2c; border-color: #30544b; }
.zone-label { margin-bottom: .48rem; color: var(--cloud); font-size: .79rem; font-weight: 700; }
.mac .zone-label { color: var(--local); }
.actors { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(.65rem, 5vw, 4.65rem); }
.actor {
z-index: 2;
min-width: 0;
min-height: 76px;
display: flex;
flex-direction: column;
justify-content: center;
gap: .17rem;
padding: .4rem clamp(.3rem, 1.5vw, .8rem);
border: 1px solid #466372;
border-radius: 7px;
background: #173544;
overflow-wrap: anywhere;
}
.mac .actor { background: #193a35; border-color: #41665a; }
.actor .name { font-size: clamp(.78rem, 2.15vw, 1.08rem); font-weight: 750; line-height: 1.28; }
.actor .state { color: var(--subtle); font-size: clamp(.69rem, 1.5vw, .87rem); line-height: 1.35; }
.actor[data-active] { border-color: var(--cloud); box-shadow: inset 0 0 0 1px var(--cloud); }
.mac .actor[data-active] { border-color: var(--local); box-shadow: inset 0 0 0 1px var(--local); }
.actor[data-active="receiver"] { background: #234b5e; }
.mac .actor[data-active="receiver"] { background: #245446; }
.channel {
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
min-height: 48px;
color: var(--subtle);
font-size: .75rem;
}
.channel::before { content: ""; position: absolute; inset-inline: 0; top: 50%; border-top: 1px dashed #57707b; }
.channel span { position: relative; z-index: 2; grid-column: 3; justify-self: end; padding-inline: .4rem; background: var(--screen); text-align: right; }
.caption {
display: grid;
gap: .55rem;
align-items: start;
padding: .9rem clamp(.7rem, 2.3vw, 1.2rem) 1.05rem;
border-top: 1px solid #35505b;
background: #112c39;
}
.caption h2 { font-size: clamp(1.18rem, 2.5vw, 1.65rem); }
.caption p { margin-top: .3rem; color: var(--subtle); font-size: .94rem; }
.payload {
padding: .55rem .65rem;
border: 1px solid #436471;
border-radius: 7px;
color: var(--cloud);
background: var(--screen);
font: .82rem/1.55 var(--mono);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.payload[data-tone="local"] { color: var(--local); }
.payload[data-tone="wait"] { color: var(--paused); }
.comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(.45rem, 2.3vw, 1rem);
}
.side { display: grid; align-content: start; gap: .55rem; min-width: 0; padding: .65rem; border: 1px solid #3f5864; border-radius: 9px; background: #112d3a; }
.side h3 { min-height: 2.55em; font-size: clamp(.9rem, 2.4vw, 1.1rem); }
.compare-state { padding: .5rem; min-height: 3rem; border-radius: 6px; background: #193848; color: var(--light); font-size: clamp(.77rem, 1.65vw, .93rem); line-height: 1.4; }
.compare-state[data-state="off"] { color: var(--paused); background: #473a2c; }
.compare-state[data-state="online"] { color: var(--local); background: #23443b; }
.compare-state[data-state="waiting"] { color: var(--paused); border: 1px dashed #997653; background: #302f2b; }
.compare-state strong { display: block; }
.compare-state span { display: block; margin-top: .12rem; }
.compare-state.reveal { animation: arrive .35s ease-out both; }
@keyframes arrive { from { opacity: .5; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.answers { display: grid; gap: 1.25rem; padding-block: 1.6rem; }
.answers p { margin-top: .5rem; color: var(--muted); }
.answers strong { color: var(--ink); }
.sources { padding-block: .8rem 2rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .84rem; }
.sources a { display: inline-block; margin: .3rem .75rem 0 0; }
@media (min-width: 720px) {
.wrap { width: min(100% - 3.5rem, 1100px); }
.caption { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 1.2rem; }
.comparison { gap: 1rem; }
.side { padding: 1rem; min-height: 16.7rem; }
.side h3 { min-height: 0; }
.answers { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 395px) {
.wrap { width: calc(100% - 1rem); }
.chapters button { font-size: .75rem; }
.actor { min-height: 70px; }
.controls { gap: .5rem; }
.seek { grid-template-columns: minmax(0, 1fr); gap: 0; }
.seek output { text-align: end; }
}
@media (prefers-reduced-motion: reduce) {
.compare-state.reveal { animation: none; }
}
</style>
</head>
<body>
<main class="wrap">
<header class="heading">
<h1>云端 Agent Loop <span>怎么读本机仓库?</span></h1>
<p>跟着一次读文件请求走。最后让 Mac 合盖,看两种架构分别停在哪里。</p>
</header>
<section class="player" aria-label="Agent Loop 动画">
<div class="controls" id="controls" hidden>
<button class="play" id="play" type="button">▶ 播放</button>
<div class="seek">
<label class="sr-only" for="seek">播放进度</label>
<input id="seek" type="range" min="0" value="0" step="0.1">
<output for="seek" id="clock"></output>
</div>
</div>
<nav class="chapters" id="chapters" aria-label="动画章节" hidden>
<button type="button" data-chapter="0" aria-pressed="true">云端怎么跑</button>
<button type="button" data-chapter="1" aria-pressed="false">怎么读仓库</button>
<button type="button" data-chapter="2" aria-pressed="false">Mac 合盖以后</button>
</nav>
<div class="frame">
<div class="diagram" id="diagram" aria-label="云端与开发机的部署位置">
<div class="zone">
<p class="zone-label">云端</p>
<div class="actors">
<div class="actor" id="model"><span class="name">模型 API</span><span class="state">V4 Flash 推理</span></div>
<div class="actor" id="loop"><span class="name">Agent Loop</span><span class="state">组请求、派工具</span></div>
<div class="actor" id="session"><span class="name">Session</span><span class="state">持久化记录</span></div>
</div>
</div>
<div class="channel"><span id="channel">云端 / 本机边界</span></div>
<div class="zone mac">
<p class="zone-label">你的 Mac</p>
<div class="actors">
<div aria-hidden="true"></div>
<div class="actor" id="daemon"><span class="name">执行器 daemon</span><span class="state">接请求、跑工具</span></div>
<div class="actor" id="repo"><span class="name">Git 工作区</span><span class="state">源码和未提交改动</span></div>
</div>
</div>
<canvas class="wire" id="wire" aria-hidden="true"></canvas>
</div>
<div class="comparison" id="comparison" aria-label="Mac 离线后的并排比较" hidden>
<section class="side" aria-labelledby="local-title">
<h3 id="local-title">本地 Loop</h3>
<div class="compare-state" data-state="off" id="local-loop"></div>
<div class="compare-state" data-state="off" id="local-files"></div>
<div class="compare-state" id="local-outcome"></div>
</section>
<section class="side" aria-labelledby="cloud-title">
<h3 id="cloud-title">云端 Loop + 本机执行</h3>
<div class="compare-state" data-state="online" id="cloud-loop"></div>
<div class="compare-state" data-state="off" id="cloud-files"></div>
<div class="compare-state" id="cloud-outcome"></div>
</section>
</div>
</div>
<div class="caption">
<div><h2 id="shot-title">云端 Loop 接到开发需求</h2><p id="shot-text">你要给订单列表加状态筛选。云端进程读回 Session,准备下一次模型请求。</p></div>
<pre class="payload" id="payload">Session → Agent Loop
任务需求 + 已有会话记录</pre>
</div>
<p class="sr-only" id="notice" aria-live="polite"></p>
</section>
<section class="answers" aria-label="两个问题的答案">
<article>
<h2>工具和信道各管什么?</h2>
<p><strong>工具定义要做什么,信道负责把请求送过去。</strong>模型给出 <code>read_file</code> 和路径。云端 Loop 把它封成工具请求,发到这台 Mac 的 daemon。daemon 读本地磁盘,再沿连接把文件内容和调用 ID 回给 Loop。原仓库留在本机,返回的文件内容可以进入云端 Session 和下一次模型请求。</p>
</article>
<article>
<h2>只有这台 Mac,值得搬吗?</h2>
<p><strong>如果所有工作都依赖这台 Mac,而且你通常在开着电脑时才用 Agent,本地 Loop 更直接。</strong>云端能在你关终端或换设备后接收消息、回答已保存的结果、等待 Mac 重连。要在 Mac 离线时继续跑代码,得有另一台在线的执行机,且它得有任务所需的代码版本和环境。</p>
</article>
</section>
<footer class="sources">
<p>动画按 DSH §5 的部署设想演绎。WebSocket 是信道举例。此前实测使用真实 V4 Flash 网关,Loop 和 daemon 则在同一台 Mac 上以独立进程通过 HTTP 通信。动画时间为讲解节奏。</p>
<a href="agent-loop-and-execution.html">查看真实请求、进程号和 Git diff</a>
<a href="https://telegra.ph/Super-agent-%E8%AE%BE%E8%AE%A1%E6%8E%A2%E7%B4%A2%E8%BF%88%E5%90%91%E6%9B%B4%E8%87%AA%E4%B8%BB%E7%9A%84-Agent-%E7%B3%BB%E7%BB%9F-10-31-2">DSH 设计文章 §5(中文镜像)</a>
</footer>
</main>
<script>
(() => {
const shots = [
{
duration: 4.6, chapter: 0, route: ["session", "loop"],
title: "云端 Loop 接到开发需求", arrival: "装载会话",
text: "你要给订单列表加状态筛选。云端调度启动一个 Loop 进程,它读回 Session,准备下一次模型请求。",
payload: "Session → Agent Loop\n任务需求 + 已有会话记录"
},
{
duration: 4.5, chapter: 0, route: ["loop", "model"],
title: "Loop 组请求,模型做推理", arrival: "收到消息和工具定义",
text: "云端 Loop 把需求、会话消息和可用工具的定义发给 V4 Flash。Loop 和模型 API 各自运行。",
payload: "Agent Loop → 模型 API\nmessages + tools"
},
{
duration: 4.6, chapter: 0, route: ["model", "loop"],
title: "模型决定先读一个文件", arrival: "收到 read_file",
text: "V4 Flash 返回结构化的工具调用。此时只有函数名和参数,文件还没被读取。",
payload: "模型 API → Agent Loop\nread_file({ path: \"orders.mjs\" })"
},
{
duration: 5.2, chapter: 1, route: ["daemon", "loop"], link: true, tone: "local",
title: "Mac 先连上设备信道", arrival: "设备已登记",
text: "工具下发前,Mac 上的 daemon 已主动连到云端,并登记设备身份。这里用一条双向 WebSocket 举例。",
payload: "Mac daemon → 云端设备入口\n建立已鉴权的双向连接"
},
{
duration: 5, chapter: 1, route: ["loop", "daemon"], link: true,
title: "Loop 把工具请求发给这台 Mac", arrival: "收到路径和调用 ID",
text: "Loop 根据任务绑定的设备和工作区,经云端设备入口找到连接,把工具名、路径和调用 ID 发给 daemon。",
payload: "Agent Loop → 本机 daemon\n{ id: \"call-1\", tool: \"read_file\", path: \"orders.mjs\" }"
},
{
duration: 4.4, chapter: 1, route: ["daemon", "repo"], link: true, tone: "local",
title: "daemon 在本地磁盘上执行读取", arrival: "读取 orders.mjs",
text: "daemon 校验工作区范围,在 Mac 上打开 orders.mjs。它读的是当前工作区,包括尚未提交的内容。",
payload: "本机 daemon → Git 工作区\nfs.readFile(工作区内的 orders.mjs)"
},
{
duration: 4.8, chapter: 1, route: ["daemon", "loop"], link: true, tone: "local",
title: "文件内容沿同一条信道回云端", arrival: "收到源码",
text: "daemon 带着同一个调用 ID 返回源码。Git 仓库仍在 Mac,读出的文件内容现在也到了云端。",
payload: "本机 daemon → Agent Loop\ncall-1 · export function filterOrders(orders) { … }"
},
{
duration: 4.6, chapter: 1, route: ["loop", "model"], link: true,
title: "Loop 把工具结果放进下一次请求", arrival: "收到 tool_result",
text: "模型看见本机读出的源码,才能决定怎么改。这就完成了一轮「请求模型 → 执行工具 → 把结果给模型」。",
payload: "Agent Loop → 模型 API\nmessages + tool_result(源码)"
},
{
duration: 4.7, chapter: 1, route: ["loop", "daemon"], link: true, repo: "写入后有未提交改动",
title: "写代码和跑测试也走这个信道", arrival: "写入后启动测试",
text: "Loop 下发补丁和测试命令,daemon 在原工作区写入并启动测试。代码改动和工具进程都在 Mac。",
payload: "Agent Loop → 本机 daemon\nwrite_file / start_tests"
},
{
duration: 4.8, chapter: 1, route: ["loop", "session"], link: true, repo: "改动仍未提交",
title: "测试结果回传后,Session 记下结果", arrival: "保存退出码和摘要",
text: "daemon 把测试输出回传给 Loop。云端保存退出码和摘要,以后可以从记录里查到这次测试。",
payload: "Agent Loop → Session\n退出码 0 · 3 个测试通过"
},
{
duration: 5.6, chapter: 2, compare: "closed", tone: "wait",
title: "现在把 Mac 合盖",
text: "两种架构都碰不到这台 Mac 的工作区。本地 Loop 随电脑暂停,云端 Loop 还可以收到新消息。",
payload: "本地工作区离线\n两边都读不到当前文件"
},
{
duration: 6, chapter: 2, compare: "question", tone: "wait",
title: "从手机问:测试过了吗?再看一眼 diff",
text: "云端可以从 Session 回答「3 个已通过」。当前 diff 只能等 Mac 重连。本地 Loop 则要等 Mac 恢复才能处理。",
payload: "查以前的结果 · 云端可以\n读现在的文件 · 等 Mac 重连"
},
{
duration: 5.1, chapter: 2, compare: "resume", tone: "local",
title: "Mac 重连,云端把待办接着做",
text: "工作区恢复可达。云端收到设备上线事件,派发等着的 git diff,把结果回到手机。本地会话也可以恢复工作。",
payload: "设备重连 → 下发待办 → 读取本机 diff"
},
{
duration: 6.7, chapter: 2, compare: "verdict", tone: "local",
title: "只有这台 Mac,收益主要在任务管理",
text: "日常都是开着 Mac 改这一个仓库,本地 Loop 更直接。云端的价值是保持任务入口在线,或把可迁移的工作交给另一台有代码和环境的执行机。",
payload: "单机交互开发 · 本地更简单\n跨设备接续和调度 · 云端有用"
}
];
const byId = id => document.getElementById(id);
const diagram = byId("diagram");
const comparison = byId("comparison");
const canvas = byId("wire");
const ctx = canvas.getContext("2d");
const slider = byId("seek");
const chapters = [...document.querySelectorAll("[data-chapter]")];
const actors = [...document.querySelectorAll(".actor")];
const defaults = Object.fromEntries(actors.map(actor => [actor.id, actor.querySelector(".state").textContent]));
const quietMotion = matchMedia("(prefers-reduced-motion: reduce)");
let duration = 0;
const starts = shots.map(shot => {
const start = duration;
duration = Math.round((duration + shot.duration) * 10) / 10;
return start;
});
const chapterStarts = chapters.map((_, chapter) => starts[shots.findIndex(shot => shot.chapter === chapter)]);
let time = 0;
let shotIndex = -1;
let playing = false;
let frameId = 0;
let lastFrame = 0;
let geometry = null;
let arrived = false;
function compareState(id, title, description, status) {
const element = byId(id);
const label = document.createElement("strong");
const detail = document.createElement("span");
label.textContent = title;
detail.textContent = description;
element.dataset.state = status;
element.replaceChildren(label, detail);
if (!quietMotion.matches && playing) {
element.classList.remove("reveal");
requestAnimationFrame(() => element.classList.add("reveal"));
}
}
function renderComparison(scene) {
const result = scene === "question";
if (scene === "verdict") {
compareState("local-loop", "单机交互开发", "你开着 Mac,Agent 跟着你改代码。", "online");
compareState("local-files", "原工作区", "直接使用本机源码、改动和工具链。", "online");
compareState("local-outcome", "本地 Loop 更直接", "会话和工具都跟着开发机。", "online");
compareState("cloud-loop", "任务入口持续在线", "换到手机,也能接收任务、查询已有结果。", "online");
compareState("cloud-files", "依赖 Mac 的任务", "Mac 离线时等待重连。", "waiting");
compareState("cloud-outcome", "有其他在线执行机", "它有匹配的代码和环境时,可以接手对应任务。", "online");
return;
}
if (scene === "resume") {
compareState("local-loop", "Mac 上的 Loop", "电脑唤醒,可以恢复本地会话", "online");
compareState("local-files", "Mac 工作区", "在线,可以读当前文件", "online");
compareState("local-outcome", "本地操作", "在本地会话发起读取。", "online");
compareState("cloud-loop", "云端 Loop + Session", "收到设备重新上线事件", "online");
compareState("cloud-files", "Mac 工作区", "daemon 执行挂起的 git diff", "online");
compareState("cloud-outcome", "待办已完成", "当前 diff 回到手机。", "online");
return;
}
compareState("local-loop", "Mac 上的 Loop", "电脑休眠,Loop 暂停", "off");
compareState("local-files", "Mac 工作区", "离线,当前文件不可访问", "off");
compareState("local-outcome", "新消息",
result ? "等 Mac 恢复,才能处理测试和 diff 的追问。" : "等 Mac 恢复后处理。", "waiting");
compareState("cloud-loop", "云端 Loop + Session", "在线,可以接消息、查历史", "online");
compareState("cloud-files", "Mac 工作区", "离线,当前文件同样不可访问", "off");
compareState("cloud-outcome", result ? "3 个测试已通过" : "新消息",
result ? "当前 diff:等待 Mac 重连。" : "先接收消息,依赖文件的动作排队。", "waiting");
}
function updateGeometry() {
if (diagram.hidden || !ctx) return;
const area = diagram.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
canvas.width = Math.round(area.width * scale);
canvas.height = Math.round(area.height * scale);
ctx.setTransform(scale, 0, 0, scale, 0, 0);
const nodes = Object.fromEntries(actors.map(actor => {
const bounds = actor.getBoundingClientRect();
return [actor.id, {
left: bounds.left - area.left, right: bounds.right - area.left,
top: bounds.top - area.top, bottom: bounds.bottom - area.top,
x: (bounds.left + bounds.right) / 2 - area.left,
y: (bounds.top + bounds.bottom) / 2 - area.top
}];
}));
geometry = { width: area.width, height: area.height, nodes };
}
function ports(from, to) {
const a = geometry.nodes[from];
const b = geometry.nodes[to];
if (Math.abs(a.y - b.y) > 30) {
return a.y < b.y ? [[a.x, a.bottom], [b.x, b.top]] : [[a.x, a.top], [b.x, b.bottom]];
}
return a.x < b.x ? [[a.right, a.y], [b.left, b.y]] : [[a.left, a.y], [b.right, b.y]];
}
function line(start, end, color, width, dashed = false) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dashed ? [4, 4] : []);
ctx.beginPath();
ctx.moveTo(...start);
ctx.lineTo(...end);
ctx.stroke();
ctx.setLineDash([]);
}
function arrow(tip, angle, color, size) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(...tip);
ctx.lineTo(tip[0] - size * Math.cos(angle - .45), tip[1] - size * Math.sin(angle - .45));
ctx.lineTo(tip[0] - size * Math.cos(angle + .45), tip[1] - size * Math.sin(angle + .45));
ctx.closePath();
ctx.fill();
}
function draw(shot, progress) {
if (!geometry || !shot.route || !ctx) return;
ctx.clearRect(0, 0, geometry.width, geometry.height);
if (shot.link) {
const [top, bottom] = ports("loop", "daemon");
line(top, bottom, "#7494a1", 2, true);
}
const [start, end] = ports(...shot.route);
const color = shot.tone === "local" ? "#a2e6be" : "#a5d2ff";
line(start, end, "#587888", 2);
const travel = quietMotion.matches ? 1 : Math.max(.08, Math.min(1, progress / .72));
const x = end[0] - start[0];
const y = end[1] - start[1];
const length = Math.hypot(x, y);
const angle = Math.atan2(y, x);
const tip = [start[0] + x * travel, start[1] + y * travel];
line(start, tip, color, 3);
arrow(tip, angle, color, 8);
if (travel < 1) {
const size = Math.min(20, length * .45);
const offset = Math.max(size / 2, Math.min(length - size / 2, travel * length));
const center = [start[0] + offset * Math.cos(angle), start[1] + offset * Math.sin(angle)];
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(center[0] - size / 2, center[1] - size / 2, size, size, 4);
ctx.fill();
if (size >= 14) {
ctx.fillStyle = "#0c222c";
ctx.font = "700 11px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("··", center[0], center[1]);
}
}
const complete = travel >= 1;
if (complete !== arrived) {
arrived = complete;
const receiver = byId(shot.route[1]);
if (complete) receiver.dataset.active = "receiver";
else delete receiver.dataset.active;
const idle = receiver.id === "repo" && shot.repo ? shot.repo : defaults[receiver.id];
receiver.querySelector(".state").textContent = complete ? shot.arrival : idle;
}
}
function activate(index, announce) {
shotIndex = index;
const shot = shots[index];
byId("shot-title").textContent = shot.title;
byId("shot-text").textContent = shot.text;
const payload = byId("payload");
payload.textContent = shot.payload;
payload.dataset.tone = shot.tone || "";
chapters.forEach((button, position) => button.setAttribute("aria-pressed", String(position === shot.chapter)));
diagram.hidden = Boolean(shot.compare);
comparison.hidden = !shot.compare;
if (shot.compare) {
renderComparison(shot.compare);
} else {
byId("channel").textContent = shot.link ? "双向设备信道" : "云端 / 本机边界";
for (const actor of actors) {
delete actor.dataset.active;
actor.querySelector(".state").textContent = actor.id === "repo" && shot.repo ? shot.repo : defaults[actor.id];
}
byId(shot.route[0]).dataset.active = "sender";
arrived = false;
updateGeometry();
}
if (announce) byId("notice").textContent = shot.title;
}
function timestamp(seconds) {
const whole = Math.floor(seconds + .001);
return Math.floor(whole / 60) + ":" + String(whole % 60).padStart(2, "0");
}
function render(position, announce = false) {
time = Math.max(0, Math.min(duration, position));
const index = starts.findLastIndex(start => start <= time);
if (index !== shotIndex) activate(index, announce);
slider.value = time;
byId("clock").textContent = timestamp(time) + " / " + timestamp(duration);
const progress = (time - starts[index]) / shots[index].duration;
draw(shots[index], progress);
}
function pause() {
playing = false;
cancelAnimationFrame(frameId);
byId("play").textContent = time >= duration ? "↻ 重播" : "▶ 播放";
}
function tick(now) {
if (!playing) return;
render(time + (now - lastFrame) / 1000);
lastFrame = now;
if (time >= duration) pause();
else frameId = requestAnimationFrame(tick);
}
byId("play").addEventListener("click", () => {
if (playing) {
pause();
return;
}
if (time >= duration) render(0, true);
playing = true;
byId("play").textContent = "Ⅱ 暂停";
lastFrame = performance.now();
frameId = requestAnimationFrame(tick);
});
slider.addEventListener("input", () => {
render(Number(slider.value), true);
if (playing) lastFrame = performance.now();
else pause();
});
chapters.forEach((button, index) => button.addEventListener("click", () => {
render(chapterStarts[index], true);
if (playing) lastFrame = performance.now();
else pause();
}));
new ResizeObserver(() => {
updateGeometry();
render(time);
}).observe(diagram);
slider.max = duration;
byId("controls").hidden = false;
byId("chapters").hidden = false;
render(0);
})();
</script>
</body>
</html>

View File

@@ -1,497 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>计算与状态分离:一次改 bug 的全过程</title>
<style>
:root {
--bg: #f6f7f9;
--card: #ffffff;
--ink: #1f2328;
--muted: #656d76;
--line: #d8dee4;
--compute: #2f6fde;
--compute-bg: #eaf1fd;
--state: #8a4dd6;
--state-bg: #f3ecfc;
--local: #1a7f4b;
--local-bg: #e6f4ec;
--warn: #b35900;
--warn-bg: #fff4e5;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.65 -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
main { max-width: 1180px; margin: 0 auto; padding: 28px 24px 60px; }
h1 { font-size: 26px; margin: 0 0 6px; }
h2 { font-size: 19px; margin: 36px 0 12px; }
.lead { color: var(--muted); margin: 0 0 4px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; background: #eef1f4; padding: 1px 5px; border-radius: 4px; }
.note { background: var(--warn-bg); border-left: 4px solid var(--warn); padding: 10px 14px; border-radius: 6px; margin: 14px 0; font-size: 15px; }
/* 三种布局对比 */
.layouts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.layout { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
.layout h3 { font-size: 16px; margin: 0 0 2px; }
.layout .sub { color: var(--muted); font-size: 13px; margin-bottom: 10px; }
.tier { border-radius: 8px; padding: 8px 10px; margin-top: 8px; font-size: 14px; }
.tier b { display: block; font-size: 12px; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
.tier.cloud { background: #eef2f7; }
.tier.mine { background: var(--local-bg); }
.chip { display: inline-block; padding: 1px 8px; margin: 3px 4px 0 0; border-radius: 999px; font-size: 13px; background: #fff; border: 1px solid var(--line); }
.chip.c { border-color: var(--compute); color: var(--compute); }
.chip.s { border-color: var(--state); color: var(--state); }
.chip.l { border-color: var(--local); color: var(--local); }
.chip.x { border-style: dashed; color: var(--muted); }
.layout .verdict { font-size: 14px; margin-top: 10px; color: var(--muted); }
.layout.pick { border: 2px solid var(--ink); }
/* 舞台 */
.stage { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px; }
.you {
display: flex; align-items: center; gap: 10px;
border: 1px dashed var(--line); border-radius: 8px; padding: 8px 12px; margin-bottom: 12px; font-size: 15px;
transition: background .25s, border-color .25s;
}
.you.on { background: #fff8d6; border-color: #d4a72c; }
.you .who { font-weight: 600; }
.zones { display: grid; grid-template-columns: 1fr 1.15fr 1.15fr; gap: 12px; }
.zone { border-radius: 10px; padding: 12px 14px; min-height: 300px; border: 2px solid transparent; transition: border-color .25s, box-shadow .25s; }
.zone h3 { margin: 0; font-size: 16px; }
.zone .where { font-size: 12px; color: var(--muted); margin-bottom: 10px; }
.zone.compute { background: var(--compute-bg); }
.zone.state { background: var(--state-bg); }
.zone.local { background: var(--local-bg); }
.zone.on.compute { border-color: var(--compute); box-shadow: 0 0 0 4px rgba(47,111,222,.12); }
.zone.on.state { border-color: var(--state); box-shadow: 0 0 0 4px rgba(138,77,214,.12); }
.zone.on.local { border-color: var(--local); box-shadow: 0 0 0 4px rgba(26,127,75,.12); }
.zone.off { filter: grayscale(1); opacity: .55; }
.item { background: #fff; border-radius: 6px; padding: 6px 9px; margin-bottom: 6px; font-size: 14px; border: 1px solid rgba(0,0,0,.06); }
.item.new { animation: pop .6s ease; outline: 2px solid #d4a72c; }
.item.gone { text-decoration: line-through; color: var(--muted); background: transparent; border-style: dashed; }
.item small { display: block; color: var(--muted); font-size: 12px; }
.empty { color: var(--muted); font-size: 14px; font-style: italic; padding: 6px 0; }
@keyframes pop { from { transform: scale(.96); } to { transform: scale(1); } }
.log { list-style: none; padding: 0; margin: 0; max-height: 640px; overflow-y: auto; }
.log li { display: flex; gap: 8px; font-size: 13.5px; background: #fff; border-radius: 6px; padding: 5px 8px; margin-bottom: 5px; border: 1px solid rgba(0,0,0,.06); }
.log li .n { color: var(--state); font-weight: 600; min-width: 26px; }
.log li .t { color: var(--muted); min-width: 64px; }
.log li.new { outline: 2px solid #d4a72c; }
.flow { margin: 14px 0 0; font-size: 14px; color: var(--muted); }
.flow span { color: var(--ink); font-weight: 600; }
/* 解说区 */
.explain { display: grid; grid-template-columns: 1.6fr 1fr; gap: 14px; margin-top: 14px; }
.box { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.box h4 { margin: 0 0 6px; font-size: 17px; }
.box p { margin: 6px 0; }
.box.cli { background: #fafbfc; }
.box.cli h4 { font-size: 14px; color: var(--muted); font-weight: 600; }
.box.cli p { font-size: 14.5px; }
.cloudline { margin-top: 8px; font-size: 14px; background: #eef2f7; border-radius: 6px; padding: 6px 10px; }
.controls { display: flex; align-items: center; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
button {
font: inherit; font-size: 15px; padding: 7px 16px; border-radius: 8px; border: 1px solid var(--line);
background: #fff; cursor: pointer;
}
button.primary { background: var(--ink); color: #fff; border-color: var(--ink); }
button:disabled { opacity: .4; cursor: default; }
.dots { display: flex; gap: 6px; flex: 1; justify-content: center; }
.dot { width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--line); background: #fff; font-size: 12px; padding: 0; }
.dot.on { background: var(--ink); color: #fff; border-color: var(--ink); }
.dot.done { background: #d0d7de; }
.hint { font-size: 13px; color: var(--muted); }
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.summary .box h4 { font-size: 15px; }
.summary ul { margin: 6px 0 0; padding-left: 18px; font-size: 14.5px; }
.takeaway { margin-top: 16px; font-size: 17px; background: var(--card); border: 2px solid var(--ink); border-radius: 10px; padding: 14px 18px; }
.src { font-size: 13px; color: var(--muted); margin-top: 26px; }
.src a { color: var(--muted); }
@media (max-width: 900px) {
.layouts, .zones, .explain, .summary { grid-template-columns: 1fr; }
.zone { min-height: 0; }
}
</style>
</head>
<body>
<main>
<h1>计算与状态分离:跟着一次改 bug 走一遍</h1>
<p class="lead">需求:<b>“登录令牌在过期的那一秒还能用,修一下,并跑回归测试。”</b></p>
<p class="lead">下面一步步看:哪些东西在云端、云端在做什么、你的电脑上留着什么。</p>
<div class="note">
先澄清一个容易混的点:<b>模型推理本来就在云端</b>(你今天用 Claude Code、Codex 也是调云端模型 API)。这里说的“计算”指的是 <b>Agent Loop 本身</b>:拼上下文、调模型、分发工具调用、记录结果。它今天跑在你的电脑上,文章要把它搬走。
</div>
<h2>先看全景:三种摆法</h2>
<div class="layouts">
<div class="layout">
<h3>① 今天的本地 CLI</h3>
<div class="sub">Claude Code / Codex CLI / OpenCode 的常见用法</div>
<div class="tier cloud"><b>云端</b><span class="chip x">只有模型 API</span></div>
<div class="tier mine"><b>你的电脑</b>
<span class="chip c">Agent Loop 进程</span>
<span class="chip s">会话记录文件</span>
<span class="chip l">仓库 / 凭据 / 工具链</span>
</div>
<div class="verdict">三样东西都在一台机器上。合盖、断网、关终端,Agent 就停了。</div>
</div>
<div class="layout">
<h3>② 全搬上云(云端沙箱)</h3>
<div class="sub">文章 §5.2 提到的第一种常见做法</div>
<div class="tier cloud"><b>云端</b>
<span class="chip c">Agent Loop</span>
<span class="chip s">会话数据库</span>
<span class="chip l">沙箱:拉代码、重建环境</span>
</div>
<div class="tier mine"><b>你的电脑</b><span class="chip x">基本不参与</span></div>
<div class="verdict">一直在线,但你没提交的改动、本地凭据、内网服务,沙箱都拿不到,或者同步成本很高。</div>
</div>
<div class="layout pick">
<h3>③ 文章方案:§5.1 + §5.2</h3>
<div class="sub">计算、状态、执行环境三者分开</div>
<div class="tier cloud"><b>云端</b>
<span class="chip c">Agent Loop(无状态,可随时换)</span>
<span class="chip s">会话数据库</span>
</div>
<div class="tier mine"><b>你的电脑</b>
<span class="chip l">仓库 / 凭据 / 工具链</span>
<span class="chip l">轻量守护进程</span>
</div>
<div class="verdict">Loop 在云上一直在线,环境留在原地,工具调用穿过守护进程远程执行。<b>下面演示的就是这一种。</b></div>
</div>
</div>
<h2>逐步演示(方案 ③)</h2>
<div class="stage">
<div class="you" id="you"><span class="who">你</span><span id="youText">网页 / IM / CLI,都只是“发消息、看回复”的窗口</span></div>
<div class="zones">
<div class="zone compute" id="zCompute">
<h3>云端 · 计算</h3>
<div class="where">跑 Agent Loop 的容器,按需借用,用完即还</div>
<div id="computeBody"></div>
</div>
<div class="zone state" id="zState">
<h3>云端 · 状态(Session)</h3>
<div class="where">数据库里的只追加事件日志,唯一需要长期保存的东西</div>
<ul class="log" id="logBody"></ul>
</div>
<div class="zone local" id="zLocal">
<h3>你的电脑 · 执行环境</h3>
<div class="where">代码、改动、凭据、依赖都留在这里</div>
<div id="localBody"></div>
</div>
</div>
<div class="flow" id="flow"></div>
<div class="explain">
<div class="box">
<h4 id="title"></h4>
<p id="desc"></p>
<div class="cloudline" id="cloudline"></div>
</div>
<div class="box cli">
<h4>对照:如果是今天的本地 CLI</h4>
<p id="cli"></p>
</div>
</div>
<div class="controls">
<button id="prev">← 上一步</button>
<div class="dots" id="dots"></div>
<button id="next" class="primary">下一步 →</button>
</div>
<div class="hint" style="text-align:center;margin-top:6px">也可以用键盘 ← → 切换</div>
</div>
<h2>走完一遍,东西都在哪</h2>
<div class="summary">
<div class="box">
<h4 style="color:var(--compute)">云端 · 计算:什么都没留</h4>
<ul>
<li>前后用过 A、B、C 三个实例,都已经回收</li>
<li>它们内存里的东西都能从日志重建,丢了也没关系</li>
</ul>
</div>
<div class="box">
<h4 style="color:var(--state)">云端 · 状态:完整的事件日志</h4>
<ul>
<li>你的需求、模型每一步的决定</li>
<li>工具结果:读到的代码片段、diff 摘要、测试输出摘要</li>
<li>任务状态:等待设备、已完成</li>
</ul>
</div>
<div class="box">
<h4 style="color:var(--local)">你的电脑:真正干活的地方</h4>
<ul>
<li>两个文件的改动,<b>没提交</b>,等你 review</li>
<li>凭据、内网依赖、工具链,从来没离开过</li>
<li>测试是在这里真实跑的</li>
</ul>
</div>
</div>
<div class="takeaway">
一句话:<b>计算可以随时丢,因为状态存在别处;环境不用搬,因为工具调用可以远程执行。</b><br>
<span style="font-size:15px;color:var(--muted)">§5.1 让 Agent 不再依附某个进程,§5.2 让它不再依附某台机器。</span>
</div>
<p class="src">
依据:DeepSeek Harness《Super-agent 设计探索》第 5 节(<a href="https://xitter.cf/DeepSeekHarness/article/2116857257245987236">英文版镜像</a>),以及 Anthropic《<a href="https://www.anthropic.com/engineering/managed-agents">Scaling Managed Agents: Decoupling the brain from the hands</a>》中的 <code>wake(sessionId)</code>、<code>getSession(id)</code>、<code>emitEvent(id, event)</code> 接口。这是文章的架构设想,不代表 DeepSeek Harness 或公开原型已经实现;步骤中的事件、文件名和测试数量都是示意。
</p>
</main>
<script>
const STEPS = [
{
title: "1. 你提出需求",
desc: "你在网页或 IM 里发出需求。这条消息<b>先写进 Session</b>,而不是直接交给某个正在运行的进程。此时云端没有任何 Loop 在跑。",
cloud: "上云的:你的这条消息。",
cli: "消息直接进入你终端里那个进程的内存,同时追加到本机的会话记录文件里。进程和记录都在你的电脑上。",
on: ["you", "state"],
you: "发出:“登录令牌在过期那一秒还能用,修一下,并跑回归测试。”",
flow: "你 → <span>云端状态</span>",
compute: [],
computeEmpty: "空闲:没有实例在跑。计算是按需借用的。",
events: [["用户", "修令牌过期边界的 bug,并跑回归"]],
local: [
{ t: "仓库 auth-service", s: "main 分支,工作区干净" },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:在线", s: "只负责执行命令、返回结果,不保存会话" }
]
},
{
title: "2. 拉起一个 Loop 实例",
desc: "云端发现 Session 里有还没处理的事件,就分配一个<b>无状态容器</b>来跑 Agent Loop,记为实例 A。A 读取事件日志,拼出模型请求。模型的第一个决定是先读一下令牌校验的代码。",
cloud: "实例 A 的内存里只有从日志重建出来的消息列表,随时可以丢掉。",
cli: "本地 CLI 的 Loop 就是你启动的那个进程,一直开着,不需要“拉起”。代价是关掉终端它就没了。",
on: ["state", "compute"],
flow: "<span>云端状态</span> → <span>云端计算(实例 A)</span> → 模型 API",
compute: [
{ t: "实例 A · 运行中", s: "从日志重建上下文 → 调模型", isNew: true }
],
events: [["模型", "先读 src/auth/token.ts"]],
local: "same"
},
{
title: "3. 读代码:工具调用穿到你的电脑上",
desc: "代码在你的电脑上,<b>云端没有仓库副本</b>。实例 A 把 <code>read_file</code> 发给本地守护进程,守护进程读出文件返回,结果写进 Session。读到第 42 行:<code>if (now &gt; exp) reject()</code>。",
cloud: "上云的:<b>读到的那段代码</b>会作为工具结果进入 Session。“环境留在本地”不等于“代码一行都不上云”,模型需要看到的片段还是会上去。",
cli: "效果一样:文件也是在本地读的,读到的内容也会发给云端模型 API。区别只在于 Loop 和会话记录也在本地。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地守护进程</span> → <span>云端计算</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 运行中", s: "等待本地返回工具结果" }],
events: [["工具调用", "read_file src/auth/token.ts"], ["工具结果", "第 42 行:if (now > exp) reject()"]],
local: [
{ t: "仓库 auth-service", s: "main 分支,工作区干净" },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:执行 read_file", s: "执行完即忘,不记会话", isNew: true }
]
},
{
title: "4. 改代码:改动只落在本地",
desc: "模型判断边界写错了:<code>now &gt; exp</code> 应该是 <code>now &gt;= exp</code>。<code>edit</code> 在你的电脑上执行,文件被改了,但<b>没有提交,也没有推送</b>。",
cloud: "上云的:这次修改的 diff 摘要(一行变更)。完整的工作区仍然只在本地。",
cli: "一样:改动也只在本地。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地:修改文件</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 运行中", s: "" }],
events: [["工具调用", "edit token.ts:> 改成 >="], ["工具结果", "1 行变更"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)", isNew: true },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:在线", s: "" }
]
},
{
title: "5. 启动回归测试(异步)",
desc: "<code>pnpm test auth</code> 要跑一分钟,还要连内网服务,只能在你的电脑上跑。守护进程在本地启动测试,<b>立刻返回 job-17</b>,Loop 的这一步就结束了,不用一直等着。",
cloud: "上云的:“job-17 已启动”这条记录。测试进程本身在本地。",
cli: "同步执行的话,这一分钟里 Loop 一直卡在等结果。用后台任务(例如 Claude Code 的 run_in_background)才能先返回。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地:启动测试</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 空闲", s: "这一步做完了,没有别的事" }],
events: [["工具调用", "后台运行 pnpm test auth"], ["工具结果", "已启动 job-17"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "测试正在用它们" },
{ t: "进程:pnpm test auth", s: "job-17 · 运行中", isNew: true }
]
},
{
title: "6. 云端实例被回收(或崩溃)",
desc: "实例 A 闲着,被回收了。换成机器故障、版本发布、扩缩容,结果也一样。<b>A 内存里的东西全丢了,但没关系</b>:该记的都已经在 Session 里。你电脑上的测试照常在跑。",
cloud: "这一步就是“计算与状态分离”的核心:丢掉计算,什么都不会丢。",
cli: "本地 CLI 这时如果进程退出,它启动的后台测试一般也会被清理(Claude Code 文档写明退出时会自动清理后台任务),测试结果没有地方可以回。",
on: ["compute"],
flow: "<span>云端计算</span>:实例 A 消失。Session 和本地都不受影响",
compute: [{ t: "实例 A", s: "已回收", gone: true }],
events: [],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "进程:pnpm test auth", s: "job-17 · 仍在运行" }
]
},
{
title: "7. 测试跑完,新实例接上",
desc: "测试结束,守护进程上报“job-17 退出码 0,24 个通过”,这条结果写进 Session,成为一个新事件。云端发现新事件,拉起<b>实例 B</b>:<code>wake(sessionId)</code> → 读出完整日志 → 从最后一个事件接着推理。模型决定再补一个“恰好过期”的测试用例。",
cloud: "实例 B 和 A 不是同一个进程,但 Session 是同一个。B 知道的一切都来自日志。",
cli: "本地 CLI 要“接着跑”,只能是同一个进程还活着;进程挂了就得手动 resume,而且后台测试的结果多半已经丢了。",
on: ["local", "state", "compute"],
flow: "<span>本地:测试结束</span> → <span>云端状态</span> → <span>云端计算(实例 B)</span> → 模型 API",
compute: [
{ t: "实例 A", s: "已回收", gone: true },
{ t: "实例 B · 运行中", s: "wake(sessionId) → 读日志 → 调模型", isNew: true }
],
events: [["后台完成", "job-17 退出码 0,24 个通过"], ["模型", "补一个“恰好过期”的测试用例"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "进程:pnpm test auth", s: "job-17 · 已结束,24 个通过", isNew: true }
]
},
{
title: "8. 你合上了笔记本",
desc: "实例 B 要在本地新增测试文件,但你的电脑离线了,工具调用发不出去。任务被标记为“<b>等待设备上线</b>”,实例 B 也被回收。没有东西丢失,也没有东西在空转。",
cloud: "上云的:“等待设备”这个任务状态。计算区又空了。",
cli: "本地 CLI 合盖等于整个 Agent 暂停。进程被挂起,打开后正在进行的网络请求可能已经超时报错。",
on: ["local", "state"],
offLocal: true,
flow: "<span>本地:离线</span> → <span>云端状态</span>:记录“等待设备”",
compute: [],
computeEmpty: "空闲:实例 B 已回收,没有实例在等。",
events: [["工具调用", "edit token.spec.ts → 等待设备上线"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "守护进程:离线", s: "电脑已合盖", isNew: true }
]
},
{
title: "9. 第二天打开电脑:自动续上",
desc: "守护进程重新连上,云端发现有任务在等这台设备,就拉起<b>实例 C</b>,执行挂起的那次工具调用:新增测试,再跑一遍回归。",
cloud: "文章原话的意思:个人设备重连后,云端 Agent 会自动开始并完成需要在这台设备上跑的任务。",
cli: "本地 CLI 没有“等设备”这个概念,因为 Loop 和设备是同一个东西:设备在,Loop 才在。",
on: ["local", "state", "compute"],
flow: "<span>本地:重新上线</span> → <span>云端状态</span> → <span>云端计算(实例 C)</span> → <span>本地:改文件、跑测试</span>",
compute: [{ t: "实例 C · 运行中", s: "读日志,执行挂起的工具调用", isNew: true }],
events: [["工具结果", "新增 1 个测试用例"], ["后台完成", "pnpm test auth:25 个通过"]],
local: [
{ t: "仓库 auth-service", s: "token.ts、token.spec.ts 已修改(未提交)", isNew: true },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "守护进程:在线", s: "" }
]
},
{
title: "10. 交付:汇报给你,改动留在本地",
desc: "模型总结修了什么、怎么验证的,回复给你。代码改动还在你的电脑上,等你 review、提交。实例 C 回收,这次任务结束。",
cloud: "最后留在云端的,只有这份事件日志。",
cli: "结果差不多,但前提是这期间你的电脑一直开着、终端一直没关。",
on: ["state", "you", "compute"],
you: "收到:“已修复 token.ts 第 42 行的边界判断,新增 1 个用例,回归 25 个全部通过。改动未提交,请 review。”",
flow: "<span>云端计算</span> → <span>云端状态</span> → 你",
compute: [{ t: "实例 C", s: "已回收", gone: true }],
events: [["回复你", "已修复,新增 1 个用例,25 个通过,改动未提交"]],
local: [
{ t: "仓库 auth-service", s: "token.ts、token.spec.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "从未离开过这台电脑" },
{ t: "守护进程:在线", s: "空闲" }
]
}
];
// 本地状态 "same" 表示沿用上一步
for (let i = 0; i < STEPS.length; i++) {
if (STEPS[i].local === "same") {
STEPS[i].local = STEPS[i - 1].local.map(x => ({ t: x.t, s: x.s }));
}
}
const $ = id => document.getElementById(id);
let cur = 0;
function itemHTML(x) {
const cls = ["item"];
if (x.isNew) cls.push("new");
if (x.gone) cls.push("gone");
return `<div class="${cls.join(" ")}">${x.t}${x.s ? `<small>${x.s}</small>` : ""}</div>`;
}
function esc(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function render() {
const st = STEPS[cur];
$("title").innerHTML = st.title;
$("desc").innerHTML = st.desc;
$("cloudline").innerHTML = st.cloud;
$("cli").innerHTML = st.cli;
$("flow").innerHTML = "这一步的数据流:" + st.flow;
$("you").classList.toggle("on", st.on.includes("you"));
$("youText").textContent = st.you || "网页 / IM / CLI,都只是“发消息、看回复”的窗口";
$("zCompute").classList.toggle("on", st.on.includes("compute"));
$("zState").classList.toggle("on", st.on.includes("state"));
$("zLocal").classList.toggle("on", st.on.includes("local"));
$("zLocal").classList.toggle("off", !!st.offLocal);
$("computeBody").innerHTML = st.compute.length
? st.compute.map(itemHTML).join("")
: `<div class="empty">${st.computeEmpty || "空闲"}</div>`;
$("localBody").innerHTML = st.local.map(itemHTML).join("");
// 事件日志:累积
let n = 0, html = "";
for (let i = 0; i <= cur; i++) {
for (const [type, text] of STEPS[i].events) {
n++;
html += `<li class="${i === cur ? "new" : ""}"><span class="n">#${n}</span><span class="t">${type}</span><span>${esc(text)}</span></li>`;
}
}
$("logBody").innerHTML = html || `<li><span class="t">(空)</span></li>`;
const log = $("logBody");
log.scrollTop = log.scrollHeight;
$("prev").disabled = cur === 0;
$("next").disabled = cur === STEPS.length - 1;
[...$("dots").children].forEach((d, i) => {
d.className = "dot" + (i === cur ? " on" : i < cur ? " done" : "");
});
}
STEPS.forEach((_, i) => {
const b = document.createElement("button");
b.className = "dot";
b.textContent = i + 1;
b.title = STEPS[i].title;
b.onclick = () => { cur = i; render(); };
$("dots").appendChild(b);
});
$("prev").onclick = () => { if (cur > 0) { cur--; render(); } };
$("next").onclick = () => { if (cur < STEPS.length - 1) { cur++; render(); } };
document.addEventListener("keydown", e => {
if (e.key === "ArrowRight") $("next").click();
if (e.key === "ArrowLeft") $("prev").click();
});
render();
</script>
</body>
</html>

View File

@@ -1,444 +0,0 @@
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import {
appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
realpathSync, renameSync, rmSync, writeFileSync
} from "node:fs";
import { createServer } from "node:http";
import { createRequire } from "node:module";
import { homedir, tmpdir } from "node:os";
import { basename, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
const self = fileURLToPath(import.meta.url);
const provider = "em-coding-messages";
const modelName = "DeepSeek-V4-Flash";
const fixtureFiles = ["orders.mjs", "orders.test.mjs"];
const request = `给这个订单列表加状态筛选。filterOrders(orders) 仍返回全部,传入 "pending" 或 "shipped" 时只返回匹配项,保持顺序。保留原测试和 before 钩子,用两个独立的 test(...) 分别测试 pending 和 shipped。
先用 read_file 看 orders.mjs 和 orders.test.mjs,再用 write_file 修改。最后调用 start_tests 启动真实测试,收到任务 ID 后简短汇报已启动并结束当前轮;完成事件稍后投递。`;
const followUp = "请报告刚收到的测试结果。再用 repo_diff 读取这两个文件此刻的真实工作区差异。如果工具返回 EXECUTOR_OFFLINE,就说明差异需要等设备重连,然后结束本轮。";
const [action = "record", firstArgument, phase] = process.argv.slice(2);
function recorder(directory, lane) {
return (kind, summary, data = null, route = [lane, lane]) => {
appendFileSync(join(directory, "events.jsonl"), JSON.stringify({
at: Date.now(), pid: process.pid, lane, kind, route, summary, data
}) + "\n");
};
}
function fixture(directory) {
const repo = join(directory, "repo");
mkdirSync(repo, { recursive: true });
writeFileSync(join(repo, "orders.mjs"), `export function filterOrders(orders) {
return orders;
}
`);
writeFileSync(join(repo, "orders.test.mjs"), `import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { before, test } from "node:test";
import { setTimeout } from "node:timers/promises";
import { filterOrders } from "./orders.mjs";
const orders = [
{ id: 101, status: "pending" },
{ id: 102, status: "shipped" },
{ id: 103, status: "pending" },
];
// The lab releases this gate after observing the Loop process state.
before(async () => {
const gate = process.env.AGENT_LAB_TEST_GATE;
if (!gate) return;
const deadline = Date.now() + 60_000;
while (!existsSync(gate)) {
assert(Date.now() < deadline, "Lab did not release the test gate");
await setTimeout(50);
}
});
test("defaults to all orders", () => {
assert.deepEqual(filterOrders(orders), orders);
});
`);
writeFileSync(join(repo, "notes.txt"), "Release notes\n");
git(repo, ["init", "-q"]);
git(repo, ["add", "."]);
git(repo, ["-c", "user.name=Agent Loop Lab", "-c", "user.email=lab@example.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "Order list fixture"]);
appendFileSync(join(repo, "notes.txt"), "Uncommitted local release note\n");
return repo;
}
function git(repo, args) {
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function snapshot(repo) {
return {
status: git(repo, ["status", "--short"]),
diff: git(repo, ["diff", "--", ...fixtureFiles]),
notes: readFileSync(join(repo, "notes.txt"), "utf8")
};
}
function makeExecutor(directory) {
const repo = join(directory, "repo");
const log = recorder(directory, "executor");
let job;
return async ({ id, name, input }) => {
log("tool.execute", "执行器开始 " + name, { id, name, input });
if (name === "read_file" || name === "write_file") {
if (!fixtureFiles.includes(input.path)) throw Error("Path outside the fixture allowlist");
const path = join(repo, input.path);
if (name === "read_file") return readFileSync(path, "utf8");
if (typeof input.content !== "string" || input.content.length > 8000) throw Error("Invalid file content");
writeFileSync(path, input.content);
log("git.snapshot", "写入后 Git 工作区变化", snapshot(repo));
return "Wrote " + input.path;
}
if (name === "repo_diff") return snapshot(repo).diff;
if (name !== "start_tests") throw Error("Unknown fixture tool");
if (job) return JSON.stringify(job);
const child = spawn(process.execPath, ["--test", "--test-reporter=tap", "orders.test.mjs"], {
cwd: repo, env: { PATH: process.env.PATH, AGENT_LAB_TEST_GATE: join(directory, "release-tests") },
stdio: ["ignore", "pipe", "pipe"]
});
job = { jobId: "orders-tests-1", pid: child.pid, status: "running", command: "node --test --test-reporter=tap orders.test.mjs" };
let stdout = "";
let stderr = "";
child.stdout.on("data", chunk => { stdout += chunk; });
child.stderr.on("data", chunk => { stderr += chunk; });
child.on("close", (code, signal) => {
const completed = { ...job, status: "finished", code, signal, stdout, stderr };
log("test.finish", "本机测试进程退出", completed);
writeFileSync(join(directory, "test-result.json"), JSON.stringify(completed));
});
log("test.start", "本机启动 Node 测试进程", job);
return JSON.stringify(job);
};
}
async function serve(directory) {
const log = recorder(directory, "executor");
const execute = makeExecutor(directory);
const server = createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/tool") {
res.writeHead(404).end();
return;
}
try {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const call = JSON.parse(Buffer.concat(chunks));
log("rpc.receive", "daemon 收到工具请求", call, ["loop", "executor"]);
const text = await execute(call);
log("rpc.send", "daemon 回传工具结果", { id: call.id, name: call.name, text }, ["executor", "loop"]);
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ text }));
} catch {
res.writeHead(400).end(JSON.stringify({ text: "Fixture tool rejected the request" }));
}
});
const portFile = join(directory, "port");
server.listen(existsSync(portFile) ? Number(readFileSync(portFile, "utf8")) : 0, "127.0.0.1");
await once(server, "listening");
log("executor.start", "本机 daemon 监听 loopback", { port: server.address().port });
writeFileSync(portFile, String(server.address().port));
process.send({ ready: true });
}
function sanitizedPayload(payload) {
const { model, max_tokens, stream, system, messages, tools } = payload;
const scrub = value => {
if (Array.isArray(value)) return value.filter(item => !["thinking", "redacted_thinking"].includes(item?.type)).map(scrub);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value)
.filter(([key]) => !["signature", "metadata"].includes(key))
.map(([key, child]) => [key, scrub(child)]));
};
return scrub({ model, max_tokens, stream, system, messages, tools });
}
async function runLoop(directory, mode) {
const piCli = realpathSync(execFileSync("which", ["pi"], { encoding: "utf8" }).trim());
const piModule = new URL("../index.js", pathToFileURL(piCli));
const { Type } = await import(pathToFileURL(createRequire(piModule).resolve("typebox")).href);
const {
createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, SettingsManager
} = await import(piModule.href);
const log = recorder(directory, "loop");
const repo = join(directory, "repo");
const sessions = join(directory, "state");
const config = join(homedir(), ".pi", "agent");
const sdkDir = join(directory, "pi-sdk");
mkdirSync(sdkDir, { recursive: true });
mkdirSync(sessions, { recursive: true });
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: false }, retry: { enabled: false }
});
const modelRuntime = await ModelRuntime.create({
authPath: join(sdkDir, "auth.json"),
modelsPath: join(config, "models.json"),
modelsStorePath: join(sdkDir, "models-store.json"),
allowModelNetwork: false
});
const model = modelRuntime.getModel(provider, modelName);
assert(model, "Configured V4 Flash model is unavailable");
const resourceLoader = new DefaultResourceLoader({
cwd: sdkDir, agentDir: sdkDir, settingsManager,
noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
systemPromptOverride: () => "You are a coding agent in an isolated demo repository. Follow the requested tool sequence and report observed results. Do not invent outputs.",
extensionFactories: [pi => {
pi.on("before_provider_request", event => {
log("model.request", "Pi 组装并发送 Anthropic Messages 请求", sanitizedPayload(event.payload), ["loop", "gateway"]);
});
pi.on("after_provider_response", event => {
log("model.http", "收到网关 HTTP 响应头", { status: event.status }, ["gateway", "loop"]);
});
}]
});
await resourceLoader.reload();
const rpc = mode !== "baseline";
const execute = rpc ? null : makeExecutor(directory);
const definitions = [
["read_file", "Read a file from the working tree", Type.Object({ path: Type.String() })],
["write_file", "Replace the contents of a file in the working tree", Type.Object({ path: Type.String(), content: Type.String() })],
["start_tests", "Start the repository's Node test command and return its job ID immediately", Type.Object({})],
["repo_diff", "Read current git diff for orders.mjs and orders.test.mjs", Type.Object({})]
];
const customTools = definitions.map(([name, description, parameters]) => defineTool({
name, label: name, description, parameters,
execute: async (id, input) => {
const call = { id, name, input };
log("tool.dispatch", "Loop 派发 " + name, call, ["loop", "executor"]);
let text;
let resultOrigin = "executor";
if (rpc) {
const port = readFileSync(join(directory, "port"), "utf8");
try {
const response = await fetch("http://127.0.0.1:" + port + "/tool", {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(call),
signal: AbortSignal.timeout(5000)
});
({ text } = await response.json());
} catch (error) {
const code = error.cause?.code || error.name;
log("rpc.offline", "Loop 无法连到本机 daemon", { id, name, code }, ["loop", "executor"]);
text = "EXECUTOR_OFFLINE: current workspace could not be read (" + code + ")";
resultOrigin = "loop";
}
} else {
text = await execute(call);
}
log("tool.result", resultOrigin === "executor" ? "工具结果交回 Loop" : "Loop 把连接失败转换成工具结果",
{ id, name, text }, [resultOrigin, "loop"]);
return { content: [{ type: "text", text }], details: {} };
}
}));
const receipt = join(directory, "session-path");
const restored = existsSync(receipt);
// In the split topology Pi's cwd does not point to the repository.
const cwd = rpc ? sdkDir : repo;
const sessionManager = restored
? SessionManager.open(readFileSync(receipt, "utf8"), sessions, cwd)
: SessionManager.create(cwd, sessions);
const { session } = await createAgentSession({
cwd, agentDir: sdkDir, resourceLoader, settingsManager, modelRuntime, sessionManager,
model, thinkingLevel: "off", noTools: "builtin", customTools
});
log(restored ? "session.restore" : "loop.start",
restored ? "新 Loop 进程恢复同一个 Session" : "启动 Pi SDK Loop", {
sessionId: session.sessionId, existingMessages: session.messages.length, mode
}, restored ? ["session", "loop"] : ["loop", "loop"]);
session.subscribe(event => {
if (event.type !== "message_end" || event.message.role !== "assistant") return;
const { stopReason, usage, content } = event.message;
log("model.reply", "模型返回 " + stopReason, {
stopReason, usage, content: content.filter(block => ["text", "toolCall"].includes(block.type))
}, ["gateway", "loop"]);
});
const prompt = async (text, origin = "user") => {
log("turn.input", "Loop 接收新输入", { text }, [origin, "loop"]);
await session.prompt(text);
assert.notEqual(session.messages.at(-1)?.stopReason, "error", "Pi reported a provider error");
assert.notEqual(session.messages.at(-1)?.stopReason, "aborted", "Pi run was aborted");
assert(session.sessionFile && existsSync(session.sessionFile), "Pi session was not persisted");
writeFileSync(receipt, session.sessionFile);
const entries = sessionManager.getEntries();
log("session.save", "Pi Session JSONL 已落盘", {
file: basename(session.sessionFile), sessionId: session.sessionId, entries: entries.length,
roles: entries.filter(entry => entry.type === "message").map(entry => entry.message.role)
}, ["loop", "session"]);
};
try {
if (mode === "baseline" || mode === "initial") {
await prompt(request);
if (mode === "baseline") {
writeFileSync(join(directory, "release-tests"), "");
await prompt(await completionMessage(directory) + "\n\n" + followUp, "controller");
}
} else if (mode === "offline") {
await prompt(await completionMessage(directory) + "\n\n" + followUp, "controller");
} else if (mode === "reconnect") {
await prompt("设备已重连。继续上轮挂起的请求,调用 repo_diff,返回当前实际 diff 摘要。", "controller");
}
} finally {
session.dispose();
log("loop.exit", "Loop 主动退出", { mode });
}
}
async function until(predicate, label, timeout = 70_000) {
const deadline = Date.now() + timeout;
while (!predicate()) {
if (Date.now() >= deadline) throw Error("Timed out: " + label);
await delay(50);
}
}
async function completionMessage(directory) {
const resultFile = join(directory, "test-result.json");
await until(() => existsSync(resultFile), "test completion");
const result = JSON.parse(readFileSync(resultFile, "utf8"));
return "本机完成事件 " + JSON.stringify({ jobId: result.jobId, exitCode: result.code, stdout: result.stdout });
}
function startChild(command, directory, mode = "") {
const child = spawn(process.execPath, [self, command, directory, mode], {
env: { ...process.env, PI_OFFLINE: "1" },
stdio: ["ignore", "ignore", "inherit", "ipc"]
});
const watchdog = setTimeout(() => child.kill(), 120_000);
const done = once(child, "exit").then(([code, signal]) => {
clearTimeout(watchdog);
return { code, signal, pid: child.pid };
});
return { child, done };
}
async function childLoop(directory, mode) {
const result = await startChild("loop", directory, mode).done;
assert.equal(result.code, 0, "Pi Loop child failed in phase " + mode);
return result;
}
async function startExecutor(directory) {
const execution = startChild("executor", directory);
await Promise.race([
once(execution.child, "message"),
execution.done.then(() => { throw Error("Executor exited before startup"); })
]);
return execution;
}
async function recordRun(root, mode) {
const directory = join(root, mode);
const repo = fixture(directory);
const log = recorder(directory, "controller");
log("git.snapshot", "实验开始时的工作区", snapshot(repo), ["executor", "executor"]);
let daemon;
try {
if (mode === "baseline") {
await childLoop(directory, "baseline");
} else {
daemon = await startExecutor(directory);
const loop = await childLoop(directory, "initial");
const events = readEvents(directory);
const test = events.find(event => event.kind === "test.start");
assert(test?.data?.pid, "Model did not launch the tests");
assert(!existsSync(join(directory, "test-result.json")), "Tests finished before observing Loop exit");
process.kill(test.data.pid, 0);
log("controller.observed", "确认 Loop 已退出,本机测试 PID 仍存活", {
loopPid: loop.pid, executorPid: daemon.child.pid, testPid: test.data.pid, testGate: "released after observation"
});
writeFileSync(join(directory, "release-tests"), "");
await completionMessage(directory);
daemon.child.kill();
const stopped = await daemon.done;
log("executor.stop", "停止 daemon,注入设备离线", stopped, ["executor", "executor"]);
await childLoop(directory, "offline");
assert(readEvents(directory).some(event => event.kind === "rpc.offline" && event.data.name === "repo_diff"),
"Model did not attempt to read diff while offline");
daemon = await startExecutor(directory);
await childLoop(directory, "reconnect");
}
const final = snapshot(repo);
log("git.snapshot", "实验完成后的工作区", final, ["executor", "executor"]);
assert(final.diff.includes("orders.mjs") && final.diff.includes("orders.test.mjs"), "Model did not change both files");
const test = JSON.parse(readFileSync(join(directory, "test-result.json"), "utf8"));
assert.equal(test.code, 0, "Fixture tests failed");
const passes = Number(test.stdout.match(/^# pass (\d+)$/m)?.[1]);
assert(passes >= 3, "Expected at least three passed test cases; recorded " + passes);
assert(final.notes.includes("Uncommitted local release note"), "Unrelated local edit changed");
const events = readEvents(directory);
const requests = events.filter(event => event.kind === "model.request");
const responses = events.filter(event => event.kind === "model.http");
assert(requests.length && requests.length === responses.length && responses.every(event => event.data.status === 200),
"Expected one successful gateway HTTP response per observed request");
const instances = events.filter(event => ["loop.start", "session.restore"].includes(event.kind));
assert.equal(new Set(instances.map(event => event.data.sessionId)).size, 1, "Session ID changed");
assert.equal(new Set(instances.map(event => event.pid)).size, mode === "split" ? 3 : 1, "Unexpected Loop instances");
assert(events.some(event => event.kind === "tool.result" && event.data.name === "repo_diff" && event.data.text === final.diff),
"Model did not read the final diff");
return { mode, events };
} finally {
if (daemon?.child.exitCode === null && daemon.child.signalCode === null) {
daemon.child.kill();
await daemon.done;
}
}
}
function readEvents(directory) {
return readFileSync(join(directory, "events.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line));
}
async function record(output) {
assert(output, "Usage: node run-agent-loop-lab.mjs record <trace.json|existing lab HTML>");
const root = mkdtempSync(join(tmpdir(), "agent-loop-lab-"));
try {
const runs = [];
for (const mode of ["baseline", "split"]) {
console.error("Running " + mode + " with Pi SDK and " + modelName);
runs.push(await recordRun(root, mode));
console.error("Verified " + mode + " (" + runs.at(-1).events.length + " observed events)");
}
const trace = { capturedAt: new Date().toISOString(), model: modelName, protocol: "Anthropic Messages", runs };
if (output.endsWith(".html")) embedTrace(trace, output);
else writeFileSync(output, JSON.stringify(trace, null, 2));
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function embedTrace(trace, htmlPath) {
const html = readFileSync(htmlPath, "utf8");
const section = /(<script type="application\/json" id="trace-data">)[\s\S]*?(<\/script>)/;
assert(section.test(html), "HTML lacks the lab trace-data script");
const serialized = JSON.stringify(trace).replaceAll("<", "\\u003c");
const temporary = htmlPath + "." + randomUUID() + ".tmp";
try {
writeFileSync(temporary, html.replace(section, (_, start, end) => start + serialized + end), { flag: "wx" });
renameSync(temporary, htmlPath);
} finally {
rmSync(temporary, { force: true });
}
}
try {
if (action === "record") await record(firstArgument);
else if (action === "embed") embedTrace(JSON.parse(readFileSync(firstArgument, "utf8")), phase);
else if (action === "executor") await serve(firstArgument);
else if (action === "loop") {
await runLoop(firstArgument, phase);
process.disconnect?.();
} else throw Error("Unknown lab action");
} catch (error) {
console.error(error.message);
process.exitCode = 1;
process.disconnect?.();
}