Skip to content
6 changes: 4 additions & 2 deletions docs/部署.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ docker compose start remote-computer

## 电脑代理的发件箱

#47 提过发件箱没有大小上限。#91(PR #103)补上了单帧上限:可靠上行进 `ReliableOutbox.append` 之前按 `AGENT_FRAME_MAX_BYTES`(16 MiB,`packages/shared/src/protocol.ts`)截短,截不下的拒收,不再因为一帧超限把整个发件箱卡死(`packages/computer-agent/src/frame-limit.ts`、`reliable-transport.ts`)。
#47 提过发件箱没有大小上限。#91(PR #103)先补上了单帧上限:可靠上行进 `ReliableOutbox.append` 之前按 `AGENT_FRAME_MAX_BYTES`(16 MiB,`packages/shared/src/protocol.ts`)截短,截不下的拒收,不再因为一帧超限把整个发件箱卡死(`packages/computer-agent/src/frame-limit.ts`、`reliable-transport.ts`)。

帧数和总字节数仍然没有上限,也没有淘汰策略,总量上限拆到 #115 跟进。控制面只认连续的序号,删掉中间的帧,后面的帧全成缺号(`reliable-transport.ts` 里旧帧截短那段的注释)。发件箱在 `<dataDir>/.agent/outbox/`,控制面确认之后就压缩掉已确认的帧。派活走下行,断线期间不会有新 run 派进来。电脑长期离线时要看的是这个目录的大小,在服务器上就是 `bots` 卷下的 `.agent/outbox/`。
#115 补上了总量上限:未确认帧按 journal 里那一行的字节数累计,超过 128 MiB 就从最早的帧开始原位收短,收到 96 MiB 以下为止(`OUTBOX_LIMITS`,`reliable-transport.ts`)。只收展示用的输出:`bot_text_done.text` 截到 64 KiB、`agent_error.detail` 截到 4 KiB,末尾写「电脑离线太久,积压的输出太多,后面的已截掉」;`bot_tool_call` 的工具卡按单帧上限同样的规则收短。`run_done`、审批、交接、各种 offer、牌桌这些代表决定和结局的报文一帧不动,超了也照收。超限期间新来的 `work_append` 拒收,代理日志打一行「发件箱积压已有 N 字节,work_append 不进发件箱」,工作记录镜像收到 `invalid` 停流。run 照常收尾,但被拒那一轮的工作记录没上传,下一轮运行器按服务器上的快照重建会话,Bot 丢掉那一轮的会话记忆。停流之后,同一个 run 里剩下的有副作用的工具调用(读和写工作区以外的动作,比如跑命令、写工作区外、发布、发消息)会被道围栏挡下,工具结果是「工作记录没有确认,这一步先不做」。群里会出现一条「工作记录停止上传」的系统消息(文案由 #132 跟进)。收完还超(剩下的都收不动)就照收,代理日志打一行「发件箱收短后仍有 N 字节」。每次收短日志打一行汇总:收了几帧、从多少字节到多少字节。旧版代理留下的超量积压在代理启动时按同一套收短。

不删帧,因为控制面只认连续的序号,删掉中间的帧,后面的帧全成缺号;原位换掉不碍去重,已处理过的序号控制面只回确认,没处理过的就处理换上去的那一条。发件箱在 `<dataDir>/.agent/outbox/`,控制面确认之后就压缩掉已确认的帧。派活走下行,断线期间不会有新 run 派进来,结局类报文靠它自己涨满 128 MiB 要几十万个 run,所以实际占用不会远超上限。电脑长期离线时要看的是这个目录的大小,在服务器上就是 `bots` 卷下的 `.agent/outbox/`,日志里出现「发件箱积压超过上限」就说明有输出被收短了。
16 changes: 15 additions & 1 deletion packages/computer-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

电脑代理以协议 3 完成握手。首次启动生成 UUID,完整落盘到 `<dataDir>/.agent/install-id` 后才发 hello;重连和进程重启复用它。hello 同时携带运行器导出的 Pi `CURRENT_SESSION_VERSION`。每条可靠上行先写入 `<dataDir>/.agent/outbox/` 并 fsync,再通过当前连接发送;welcome 的累计确认和后续 `uplink_ack` 会清理已确认帧。重连或进程重启会按原 stream 和序号补发未确认帧。

可靠上行进发件箱之前按整帧序列化后的字节数对 `AGENT_FRAME_MAX_BYTES` 检查(`src/frame-limit.ts`),发件箱重开时对未确认帧也按同样规则原位改写,序号不变。控制面收到超限帧以 1009 关连接,代理重连重发同一帧又被关,整台电脑的发件箱就卡死了,所以超限只能在入队和重放前处理:`bot_text_done.text` 和 `agent_error.detail` 按 UTF-8 字节截尾并注明「内容过长」,不切开多字节字符;`bot_tool_call` 只是展示,`view.raw` 换成一句说明字节数的话,命令、路径、预览、参数各截到 4 KiB。`approval_request` 不一样,用户是看着卡决定批不批的,截掉的尾巴照样会执行,所以只换 `view.raw`、截写入预览,命令、说明、路径、工具连接、工具名和参数一个字不动。截完仍超限、带 runId 的帧原位换成同一 runId 的 `agent_error`(`runner_failed`),控制面替这个 run 收尾,代理本地 `cancel` 它,挂着的审批随之按过期结算,工具不执行;审批请求的说明写「审批请求过长,没法完整给你看,这一步没有执行」。`work_append` 由运行器先按字节切批,到这里还超限就回 `invalid` 让镜像停流。`mcp_discovered` 是 bare 报文,schema 已把结果限在 1 MiB,不经这道检查。
可靠上行进发件箱之前按整帧序列化后的字节数对 `AGENT_FRAME_MAX_BYTES` 检查(`src/frame-limit.ts`),发件箱重开时对未确认帧也按同样规则原位改写,序号不变。控制面收到超限帧以 1009 关连接,代理重连重发同一帧又被关,整台电脑的发件箱就卡死了,所以超限只能在入队和重放前处理:`bot_text_done.text` 和 `agent_error.detail` 按 UTF-8 字节截尾并注明「内容过长」,不切开多字节字符;`bot_tool_call` 只是展示,超过 4 KiB 的 `view.raw` 换成一句说明字节数的话,命令、路径、预览、参数各截到 4 KiB。`approval_request` 不一样,用户是看着卡决定批不批的,截掉的尾巴照样会执行,所以只换超过 4 KiB 的 `view.raw`、截写入预览,命令、说明、路径、工具连接、工具名和参数一个字不动。截完仍超限、带 runId 的帧原位换成同一 runId 的 `agent_error`(`runner_failed`),控制面替这个 run 收尾,代理本地 `cancel` 它,挂着的审批随之按过期结算,工具不执行;审批请求的说明写「审批请求过长,没法完整给你看,这一步没有执行」。`work_append` 由运行器先按字节切批,到这里还超限就回 `invalid` 让镜像停流。`mcp_discovered` 是 bare 报文,schema 已把结果限在 1 MiB,不经这道检查。

`.agent/computer.json` 记录传输状态所属的 `computerId`;换电脑、旧版无归属状态或状态文件损坏时,代理把旧 outbox、下行游标和归属信息移入 `.agent/transport-quarantine-<uuid>/`,并从新流和游标 0 开始。隔离不移动 `.agent/install-id`,安装标识属于这份 dataDir,不随换电脑重置。服务器 welcome 的确认序号低于本地记录时,也会隔离旧状态并主动重连新流。

Expand All @@ -15,3 +15,17 @@ prompt 的 `lane` 原样交给运行器。`cancel{runId}` 停单条运行;`can
本机 MCP 的 `mcp_discover` 不经 Pi 会话或 Bot 串行队列,由运行器独立探测模块处理,`mcp_discovered` 只回固定枚举或操作表。两种报文都不落盘;探测断线或停机时取消,旧连接结果不回到新连接。

附件上传和发布下载在实际请求时读取最近 config 的 hub 和网关 key。config 尚未下发时收到附件发布,返回固定错误「发布出错:附件配置尚未下发」,不连接浏览器;这与实际下载失败的「发布出错:配图从白泽下载失败,请稍后重试」分开。

## 发件箱上限

电脑长期离线时未确认的上行会一直累积,所以发件箱有总量上限(`OUTBOX_LIMITS`,`src/reliable-transport.ts`)。量的是未确认帧在 journal 里那一行的字节,不按条数:占磁盘和内存的是字节,一条 `bot_text_done` 可能比一千条 `run_done` 还大。累计字节数在入队、确认、收短时增减,不每次重量整队。

每次 flush 写盘之前,积压超过 128 MiB 就从最早的未确认帧往后原位收短,收到 96 MiB 以下为止(`trimBacklog`,`src/frame-limit.ts`)。还没落盘的帧直接写收短后的,只有已落盘的帧被换掉才重写 journal:剩下的都收不动时,要是每批新帧都整份重写,每批就要写一遍上限那么大的文件。收哪些:

- `bot_text_done.text` 截到 64 KiB,`agent_error.detail` 截到 4 KiB,末尾写「…(电脑离线太久,积压的输出太多,后面的已截掉)」。
- `bot_tool_call.view` 用单帧上限的 `compactView` 收短。不到 4 KiB 的原始参数留着,收过的帧再收不会变小,也就不再换。
- 其余报文原样:`run_done`、`approval_request`、`approval_abandoned`、`handoff`、`handoff_accept`、各种 offer、`poker_*` 代表决定和结局,超了也照收。审批卡不收,用户是看着卡批的。

收不小的帧不换,收过的帧不再收。帧不删:控制面只认连续的序号,删一条后面全成缺号;原位换、序号不变和单帧上限是同一个办法,控制面处理过的序号只回确认,没处理过的处理换上去的那条,所以控制面不需要知道发件箱满过。新来的 `work_append` 会让总量超过上限时直接拒收,走 `OversizedUplink`,代理日志打一行拒收原因,镜像回 `invalid` 停流。停流有代价:run 照常收尾,但被拒那一轮的工作记录没上传,下一轮按服务器快照重建会话,Bot 丢掉那一轮的会话记忆;同一个 run 里剩下的有副作用的工具调用会被 `bot-runner/src/lane-fence.ts` 的 `beforeEffect` 挡下(镜像停流后 `flush` 一直回 `invalid`,见 `work-mirror.ts` 的 `#flush`),读和写工作区不受影响;群里出现一条「工作记录停止上传」的系统消息(文案由 #132 跟进)。单帧超限的 `work_append` 也是同样的后果。收完还超就照收,日志打一行当前字节数。冷启动打开发件箱时同样执行一遍,处理旧版代理留下的超量 journal。

重写 journal 只写已落盘的帧(`pending()`),还在 `#writes` 里的帧由下一次 flush 追加,否则同一序号会写两行,重开时被当成损坏隔离。确认之后的压缩原来也有这个问题,一并改了。
16 changes: 12 additions & 4 deletions packages/computer-agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { type AssistantChrome, type AssistantChromeOptions, startAssistantChrome
import { createLiveScreen } from "./live-screen.js";
import { checkLogin, createLogins, defaultLoginPages } from "./login.js";
import { createTerminals, ptySpawner } from "./terminal.js";
import { DownlinkCursor, quarantineTransportState, ReliableOutbox } from "./reliable-transport.js";
import { DownlinkCursor, type OutboxLimits, quarantineTransportState, ReliableOutbox } from "./reliable-transport.js";
import { OversizedUplink } from "./frame-limit.js";
import { loadInstallId } from "./install-id.js";
import { WorkAppendChannel } from "./work-append.js";
Expand Down Expand Up @@ -67,6 +67,7 @@ export interface AgentOptions {
readonly loginTimeoutMs?: number;
/** 测试用断线注入点:持久化完成后、可靠帧发送前调用。 */
readonly beforeReliableSend?: (frame: UplinkFrame) => void;
readonly outboxLimits?: OutboxLimits;
readonly discoverMcp?: typeof discoverMcpTools;
}

Expand Down Expand Up @@ -240,6 +241,7 @@ export function startAgent(options: AgentOptions): AgentHandle {
return { seq: stored.seq, body: frame };
},
send: sendStored,
log,
cancel: (lane, epoch) => runner?.cancel({ lane: { botId: lane.botId, groupId: lane.groupId }, epoch }),
failed: (error) => {
log(`工作记录探针落盘失败:${String(error)}`);
Expand Down Expand Up @@ -386,8 +388,14 @@ export function startAgent(options: AgentOptions): AgentHandle {
await previousOutbox.flush();
if (stopping) return;
try {
await quarantineTransportState(dataDir, "服务器确认序号回退", options.log);
const fresh = await ReliableOutbox.openForComputer(dataDir, options.computerId, options.log, dropRun);
await quarantineTransportState(dataDir, "服务器确认序号回退", log);
const fresh = await ReliableOutbox.openForComputer(
dataDir,
options.computerId,
log,
dropRun,
options.outboxLimits,
);
outbox = fresh.outbox;
downlinkCursor = fresh.downlinkCursor;
resettingTransport = false;
Expand Down Expand Up @@ -644,7 +652,7 @@ export function startAgent(options: AgentOptions): AgentHandle {
}

void Promise.all([
ReliableOutbox.openForComputer(dataDir, options.computerId, options.log, dropRun),
ReliableOutbox.openForComputer(dataDir, options.computerId, log, dropRun, options.outboxLimits),
loadInstallId(dataDir),
])
.then(([{ outbox: openedOutbox, downlinkCursor: openedCursor }, openedInstallId]) => {
Expand Down
31 changes: 25 additions & 6 deletions packages/computer-agent/src/frame-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { AGENT_FRAME_MAX_BYTES, type AgentToControl, type JsonValue, type ToolVi

const CLIPPED = "…(内容过长,后面的已截掉)";
const VIEW_TEXT_MAX_BYTES = 4096;
const BACKLOG_CLIPPED = "…(电脑离线太久,积压的输出太多,后面的已截掉)";
const BACKLOG_TEXT_MAX_BYTES = 64 * 1024;
const BACKLOG_DETAIL_MAX_BYTES = 4 * 1024;

export class OversizedUplink extends Error {}

Expand All @@ -20,19 +23,22 @@ function utf8Prefix(text: string, limit: number): string {
return encoded.subarray(0, end).toString("utf8");
}

const short = (text: string): string =>
utf8Bytes(text) <= VIEW_TEXT_MAX_BYTES
? text
: `${utf8Prefix(text, VIEW_TEXT_MAX_BYTES - utf8Bytes(CLIPPED))}${CLIPPED}`;
const clipTo = (text: string, limit: number, tail: string): string =>
utf8Bytes(text) <= limit ? text : `${utf8Prefix(text, limit - utf8Bytes(tail))}${tail}`;

const short = (text: string): string => clipTo(text, VIEW_TEXT_MAX_BYTES, CLIPPED);

function shortJson(value: JsonValue): JsonValue {
if (typeof value === "string") return short(value);
const text = JSON.stringify(value);
return utf8Bytes(text) <= VIEW_TEXT_MAX_BYTES ? value : short(text);
}

const rawNotice = (view: ToolView): { raw?: string } =>
view.raw === undefined ? {} : { raw: `原始参数有 ${utf8Bytes(JSON.stringify(view.raw))} 字节,太大没有上传` };
function rawNotice(view: ToolView): { raw?: JsonValue } {
if (view.raw === undefined) return {};
const bytes = utf8Bytes(JSON.stringify(view.raw));
return { raw: bytes <= VIEW_TEXT_MAX_BYTES ? view.raw : `原始参数有 ${bytes} 字节,太大没有上传` };
}

/** 工具卡只是展示,哪个字段都可能装着模型给的大参数,所以整份收短。 */
function compactView(view: ToolView): ToolView {
Expand Down Expand Up @@ -73,6 +79,19 @@ function approvalView(view: ToolView): ToolView {
return view.kind === "write" ? { ...view, preview: view.preview.map(short), ...raw } : { ...view, ...raw };
}

export function trimBacklog(body: AgentToControl): AgentToControl | null {
switch (body.type) {
case "bot_text_done":
return { ...body, text: clipTo(body.text, BACKLOG_TEXT_MAX_BYTES, BACKLOG_CLIPPED) };
case "agent_error":
return { ...body, detail: clipTo(body.detail, BACKLOG_DETAIL_MAX_BYTES, BACKLOG_CLIPPED) };
case "bot_tool_call":
return { ...body, view: compactView(body.view) };
default:
return null;
}
}

function shrink(body: AgentToControl, over: number): AgentToControl | null {
// JSON 转义只会让字节变多,按原文字节砍掉 over 就一定够。
const clip = (text: string): string => `${utf8Prefix(text, utf8Bytes(text) - over - utf8Bytes(CLIPPED))}${CLIPPED}`;
Expand Down
Loading
Loading