first commit
This commit is contained in:
642
explainers/agent-loop-and-execution.html
Normal file
642
explainers/agent-loop-and-execution.html
Normal file
File diff suppressed because one or more lines are too long
620
explainers/cloud-agent-loop-film.html
Normal file
620
explainers/cloud-agent-loop-film.html
Normal file
@@ -0,0 +1,620 @@
|
||||
<!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>
|
||||
497
explainers/compute-state-separation.html
Normal file
497
explainers/compute-state-separation.html
Normal file
@@ -0,0 +1,497 @@
|
||||
<!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 > 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 > exp</code> 应该是 <code>now >= 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>
|
||||
444
explainers/run-agent-loop-lab.mjs
Normal file
444
explainers/run-agent-loop-lab.mjs
Normal file
@@ -0,0 +1,444 @@
|
||||
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?.();
|
||||
}
|
||||
Reference in New Issue
Block a user