浏览器里的 AI agent 工具调用:从前端按钮到 microVM PTY 的完整链路
目录
- 为什么一次「看目录」不只是一次命令执行
- 链路全景:从 React 到 guest-agent
- 前端:按钮、SSE、WS 与 typed client 的边界
- 后端:Axum 把聊天流和工具执行拆开
- sandbox:为什么是 vsock,不是 TCP
- PTY:为什么不是直接 exec
- 错误与回流:stdout、exit、error 都是协议
- 业界正在做什么
- 一个可复用的工程框架
为什么一次「看目录」不只是一次命令执行
用户在浏览器 chat 输入「帮我看一下当前目录」,表面上只是 pwd && ls。但在 agent 产品里,它同时穿过四个边界:浏览器进程、Rust server、sandbox runtime、microVM guest。每一层都可能丢事件、阻塞、错认工作目录,或者把错误包装成一段模型看不懂的文本。
真正难的不是执行命令,而是让一次工具调用具备这些性质:用户能看到流式反馈;模型拿到结构化结果;命令运行在隔离环境;刷新页面后状态还能恢复;超时、退出码、stderr 都能被解释;交互式 shell 和一次性命令不能互相污染。
这里还有一个容易被忽略的产品问题:用户以为自己在和「一个助手」对话,但系统实际由很多异步参与者组成。LLM 可能还在思考,SSE 已经断线;命令可能已经退出,消息缓存还没 flush;sandbox 可能刚被重建,旧的 PTY tab 还在尝试写 stdin;用户切换 agent 后,工作目录、技能开关、模型参数都要跟着换。如果这些边界没有清晰协议,最后出现的 bug 往往不是「命令跑错了」,而是「结果跑对了,但出现在错误的会话、错误的窗口、错误的工作目录」。
| 难点 | 表面现象 | 工程解法 |
|---|---|---|
| 事件先后 | POST 成功但前端没收到首个 token | 先建 SSE 订阅,再触发 send |
| 执行边界 | 模型命令误跑在 host | executor 显式携带 sandbox handle |
| 工作目录 | 多轮 Bash cwd 漂移 | 执行后回读 pwd -P 并更新状态 |
| 页面刷新 | terminal 输出丢失 | PTY registry 保存 history 并支持 attach |
| 错误表达 | stderr 被当普通文本 | exit/error/stderr 分帧再映射 ToolOutput |
┌──────────────┐ POST /chat/send ┌──────────────┐
│ Browser Chat │ ───────────────────▶ │ Axum AI API │
│ React + SSE │ ◀── data: AgentEvent │ ChatService │
└──────┬───────┘ └──────┬───────┘
│ │ tool: Bash
│ WS: app events / terminal ▼
│ ┌──────────────┐
│ │ AgentSandbox │
│ │ per-agent cwd│
│ └──────┬───────┘
│ │ spawn_shell
│ ▼
│ ┌──────────────┐
└────────────────────────────▶ │ microVM guest│
│ vsock + PTY │
└──────────────┘
Agent 工程的本质,是把「模型想做一件事」变成跨进程、跨协议、跨权限边界的可恢复事务。
链路全景:从 React 到 guest-agent
当前代码里,AI chat 的入口主要在 packages/web/src/apps/ai-chat/。AiChatContext.tsx 维护 agent、conversation、panel 状态;useSendMessage.ts 把编辑器内容、附件和模型选择组装成发送输入;useChatSSE.ts 负责先连 /api/apps/ai/chat/{conversationId}/events,再 POST /api/apps/ai/chat/send,避免服务端广播时前端还没订阅。
服务端路由集中在 packages/rust-server/src/apps/ai/router.rs:stream::chat_events 提供 SSE,stream::chat_send 触发 ChatService::stream_completion。工具层落在 packages/rust-server/src/apps/ai/tools/,其中 Bash 工具最终会走 LocalToolExecutor::exec_bash_streaming;正常情况下 sandbox always-on,executor 进入 AgentSandbox::exec,由 tokimo-package-sandbox 的 Auto backend 决定 Linux CH / bwrap 等实际路径。只有当 sandbox boot 进入 cached Failed state 时,chat 才按 6432e4ab 的策略退回 host execution,避免每次请求都卡 multi-second timeout;/api/apps/ai/sandbox/restart 用于清掉 failed runtime 并重试。
这篇文章按「理想的一次看目录工具调用」来讲链路,但会保留当前代码的真实边界:chat send 不是 generated client 的 mutation,而是 authFetch 手写;packages/web/src/lib/api/ 这个旧路径不存在,真实 typed client 在 generated/rust-api/;packages/tokimo-guest-agent/ 也不是独立 package,而是 sandbox 包里的 binary。写技术随笔最怕把架构图画得很顺,代码却不是这么跑的,所以这里宁可把这些不那么优雅的过渡态写出来。
graph TD
A[ChatInput / Send button] --> B[useSendMessage]
B --> C[useChatSSE]
C --> D[authFetch /api/apps/ai/chat/send]
C --> E[SSE /api/apps/ai/chat/:id/events]
D --> F[Axum stream::chat_send]
E --> G[React Query message cache]
F --> H[ChatService::stream_completion]
H --> I[BashTool]
I --> J[LocalToolExecutor]
J --> K{sandbox runtime healthy?}
K -->|yes| L[AgentSandbox::exec]
K -->|boot failed / Failed cached| M[host process fallback]
L --> N[tokimo-package-sandbox Auto backend]
N --> O[tokimo-sandbox-init / guest bash]
R[/api/apps/ai/sandbox/restart] --> K
一次调用的时序条带
Browser Axum Agent runtime Sandbox host Guest
| | | | |
|--SSE open-->| chat_events | subscribe bus | |
|<--initial---| | | |
|--POST------>| chat_send | stream_completion | |
| |------------------->| model/tool loop | |
| | | BashTool | |
| | |------------------->| spawn_shell |
| | | |--vsock JSON--->|
| | | |<--frames-------|
|<--SSE data--|<-------------------| AgentEvent | |
这条链路不是单向 RPC,而是「触发通道 + 事件通道 + 执行通道」三条线并行协作。
前端:按钮、SSE、WS 与 typed client 的边界
前端有两类网络通道。第一类是 AI chat 自己的 SSE:useChatSSE.ts 用 authFetch(rustUrl(...)) 手动读取 response body,解析 data: 行,再用 applySSEEvent patch React Query cache。这里没有直接使用浏览器原生 EventSource,因为代码需要精确控制「先订阅、再发送」以及 AbortController 生命周期。
第二类是全局 WS:packages/web/src/system/events/ws.tsx 定义 JSON envelope:{ type, data, reqId, error };useJobEvents.ts 明确把 app-wide 的 job_update、person_scraped、download:progress 放到 WS 上,并替代旧 SSE。也就是说,chat token 和工具事件偏 SSE,系统级广播偏 WS。
typed client 的真实位置不是旧路径 packages/web/src/lib/api/,而是生成目录 packages/web/src/generated/rust-api/。例如 ai.ts 里有 api.ai.agents.*、api.ai.conversations.*、api.ai.messages.*、sandbox status、terminal URL 等。chat send 当前仍是手写 authFetch,但 agent/conversation/message 的 CRUD 已经由 generated client 承担。
这个边界值得保留:不是所有请求都应该强行塞进同一个 client 抽象。普通 CRUD 追求类型、缓存键和失效策略一致;chat stream 追求连接时序、增量解析和取消语义可控。把二者分开,反而能让「控制面」和「数据面」各自简单,也方便后续把 stream 协议单独演进,不牵动普通查询接口,并保持可测试、可回放、可追踪。
| 通道 | 当前用途 | 代码位置 | 为什么这样分 |
|---|---|---|---|
| POST | 触发一次用户消息 | useChatSSE.ts → /chat/send |
请求有明确成功/失败 |
| SSE | assistant/message/tool 增量 | chat_events + ConversationJournal |
单向流、易恢复、适合文本增量 |
| WS | 全局事件、terminal、job | system/events/ws.tsx、terminal_ws.rs |
双向、可复用、适合交互 |
| generated API | CRUD / 查询 | generated/rust-api/ai.ts |
类型稳定、React Query 集成 |
graph LR
subgraph React
Input[ChatInput]
Hook[useSendMessage]
SSE[useChatSSE]
Cache[React Query Cache]
WS[WsProvider]
end
Input --> Hook --> SSE
SSE -->|POST trigger| Send[/chat/send/]
SSE -->|read stream| Events[/chat/:id/events/]
Events --> Cache
WS --> Jobs[job_update etc]
API[generated rust-api] --> Cache
前端不是「发一个请求等结果」,而是在维护一个可断线重连的 agent 运行投影。
后端:Axum 把聊天流和工具执行拆开
router.rs 里有三组关键路由:/api/apps/ai/chat/send 触发运行;/api/apps/ai/chat/{conversationId}/events 输出 SSE;/api/apps/ai/agents/{id}/terminal/ws 给用户打开同一个 sandbox 内的交互式终端。stream.rs 的 chat_send 把 user、conversation、provider、model、attachments 和 sandbox_runtime 一起传进 ChatService::stream_completion。
工具执行上下文由 ServerToolExt 描述。它把 workspace 级资源(DB、HTTP、ConversationJournal、MCP、sandbox manager)和 per-agent 资源(agent id、cwd、模型参数、tool whitelist、sandbox handle)拆开。这样 DispatchAgent、Skill、session memory extractor 都能继承同一个逻辑 sandbox,而不是复制一个可能已经死掉的 Arc。
sequenceDiagram
participant U as Browser
participant A as Axum stream.rs
participant C as ChatService
participant T as BashTool
participant E as LocalToolExecutor
participant S as AgentSandbox
participant G as sandbox-init / guest bash
U->>A: POST /api/apps/ai/chat/send
A->>C: stream_completion(input, sandbox_runtime, bus)
C-->>U: SSE AgentEvent conversation:start
C->>T: model requests Bash
T->>E: exec_bash(args)
E->>S: exec(cmd, timeout)
S->>G: spawn argv=[/bin/bash,-lc,cmd]
G-->>S: stdout/stderr/exit/error frames
S-->>E: ExecResult
E-->>T: ToolOutput
C-->>U: SSE message/tool_result/end
Axum 层只负责把运行接上轨道,真正的复杂度在「事件 journal」和「工具 executor」之间。
sandbox:为什么是 vsock,不是 TCP
tokimo-package-sandbox 是跨平台 sandbox 包,公开 Sandbox、ShellOpts、Event、JobId 等 API。Linux 现在不应只写 Cloud Hypervisor:默认是 Auto backend,先探测 CH,失败时回退到 bubblewrap。CH 分支仍通过 Cloud Hypervisor hybrid-vsock UDS 连接 guest;bwrap fallback 则用 namespaces + socketpair 承载同一套 init / RPC / netstack 语义。对 Bash tool 来说,关键变化是 guest 里应有真 /bin/bash,同时有大量 busybox applet symlinks 补齐常用命令。
Guest 侧并没有独立的 packages/tokimo-guest-agent/,而是 packages/tokimo-package-sandbox 里的 binary:src/bin/tokimo-guest-agent/。main.rs 默认在 1024 开 one-shot RPC,在 1025 开 PTY;server.rs 读一行 JSON request,返回多行 JSON frames;exec.rs 定义 Spawn/Ping/QueryMount;pty.rs 用 forkpty() 处理交互式会话。
| 维度 | vsock | TCP |
|---|---|---|
| 地址模型 | host/guest CID + port | IP + port |
| 暴露面 | 不进入普通网络栈 | 容易被路由、防火墙、代理影响 |
| microVM 语义 | 天然 host↔guest 通道 | 需要额外网络配置 |
| 安全边界 | 不需要给 guest 开公网/局域网入口 | 需要认真处理监听地址和 ACL |
| 当前代码 | ch/rpc.rs hybrid-vsock UDS + CONNECT |
不是主路径 |
graph TD
Host[Sandbox host process] --> UDS[Cloud Hypervisor vsock UDS]
UDS -->|CONNECT 1024| RPC[guest-agent RPC listener]
UDS -->|CONNECT 1025| PTY[guest-agent PTY listener]
RPC --> Spawn[Command::output]
PTY --> Forkpty[forkpty + exec]
Spawn --> Frames[stdout/stderr/exit/error]
Forkpty --> PtyFrames[stdin/resize/stdout/exit]
vsock 的价值不是更快,而是把「只允许宿主机和 guest 说话」变成默认拓扑。 但这句话只适用于 VM backend:Linux CH、macOS VZ、Windows Hyper-V 分别对应 AF_VSOCK / virtio-vsock / HvSocket。Linux bwrap fallback 不走 vsock,而是用 socketpair 承载同一类 frame / RPC 语义;文中后续提到“vsock”时,应理解为 VM backend 的 transport,而不是所有 backend 的唯一传输。
PTY:为什么不是直接 exec
一次性工具调用和交互式终端是两种协议。GuestRpc::spawn_command_with_options 每次连接只发一个 spawn request,guest 用 Command::output() 等命令结束后再返回 stdout/stderr/exit。这适合「帮我看目录」这种短命令,因为模型需要的是完整结果。
但用户打开 terminal 或 agent 运行交互式程序时,直接 exec 不够。TTY 程序会检查是否有终端、需要窗口尺寸、需要 Ctrl-C、需要 resize、会输出 ANSI 控制序列。AgentSandbox::open_pty 传 ShellOpts { pty: Some((rows, cols)), argv, env, cwd };terminal_ws.rs 再把浏览器 WS 的 binary/text 输入转成 write_stdin 和 resize_shell,把 sandbox Event::Stdout 广播给所有 attach 的浏览器 tab,并保留 scrollback。
| 维度 | one-shot exec | PTY |
|---|---|---|
| 生命周期 | 请求结束即结束 | 长连接,直到 shell 退出 |
| stdin | 基本不交互 | 持续双向输入 |
| 输出 | stdout/stderr/exit 结果 | ANSI 字节流 + exit |
| 窗口尺寸 | 无意义 | rows/cols + SIGWINCH |
| 适合 | pwd && ls、测试命令 |
shell、REPL、top、vim 类程序 |
| 当前实现 | GuestRpc::spawn_command_with_options |
open_pty_with_options + forkpty() |
PTY 不是「exec 多一个参数」,而是一条活的终端电路:
Browser xterm
│ key bytes / resize
▼
terminal_ws.rs
│ PtyInput::Data / Resize
▼
Sandbox::write_stdin / resize_shell
│ VM: vsock JSON frame / bwrap: socketpair frame
▼
sandbox-init pty.rs
│ forkpty master fd
▼
/bin/bash -l(Bash tool 语义)或 ShellOpts 指定的交互式 shell ❓
exec 给模型结果,PTY 给用户现场;把两者混成一个接口,最后会两边都难用。
错误与回流:stdout、exit、error 都是协议
错误不能只靠字符串。当前链路里至少有三种错误:请求错误(POST 失败,前端 onError)、运行错误(AgentEvent 里的 conversation/message/turn error)、sandbox 错误(Event::Error { fatal, message } 或 guest Response::Error { msg })。这里需要删除 caller-owned JobId 的叙述:该 race fix 已被 b797c2975 revert,exec() 回到 spawn-then-subscribe。项目组判断原始竞态并不存在,因为事件发布路径要经过 guest fork+exec / vsock / HCS pump,远晚于 host 侧 synchronous insert;反而是只在 ChBackend 实现 spawn_shell_with_id、其他 backend 走 trait 默认 fallback,导致 Windows/macOS/bwrap 丢弃 job_id,最终让 Bash tool 在 Windows 卡 120s。
这个 revert 的教训比 race 本身更重要:跨平台 sandbox 的 API 不能只在一个 backend 上实现,然后依赖 trait 默认实现“看起来编译通过”。如果 API 改动影响 job id、事件订阅、stdin/stdout 语义或 host-exec response,就必须让 CH、bwrap、macOS、Windows 全部显式实现或显式报错;静默 fallback 会把 bug 推迟到最难调的平台上爆炸。
输出回流也分层:guest-agent 把 stdout/stderr/exit/error 变成 JSON lines;ch/backend.rs 映射成 tokimo_package_sandbox::Event;AgentSandbox::exec 收集成 ExecResult;Bash tool 格式化成 ToolOutput;ChatService 再投影成前端可消费的 AgentEvent。对于 chat,前端以 SSE patch message cache;对于 terminal,WS 直接推原始字节给浏览器。
| 对比项 | SSE | WS |
|---|---|---|
| 方向 | server → browser | 双向 |
| chat 增量 | 很适合,浏览器只读 | 也能做,但需要自定义重连语义 |
| terminal | 不适合 stdin/resize | 适合二进制输入和输出 |
| 当前代码 | useChatSSE.ts、stream::chat_events |
WsProvider、terminal_ws.rs |
| 失败恢复 | 重新连接并通过初始状态补齐 | 需要业务自己处理 session/history |
好的 agent 运行时不会隐藏错误,而是让每一层都能保留自己的错误类型和恢复策略。
业界正在做什么
OpenAI Code Interpreter 的路线是「托管沙箱 + 文件挂载 + 结果工件」,用户不关心底层 VM,但工具调用被严格关在会话环境里。Cursor agent 更像「IDE 内本地代理」,强调 workspace 上下文、diff、终端和用户确认。Claude Code 则把 terminal、文件系统、任务子代理和工具权限做成 CLI 里的长期会话,重点是把工程项目当运行现场,而不是把 prompt 当唯一上下文。
Tokimo 这条链路更接近「浏览器桌面 OS 里的 agent runtime」:前端是窗口和消息投影,后端是 agent orchestrator,sandbox 是可共享但 per-agent 隔离的执行层。它不是复制某一家产品,而是在浏览器、Rust server、microVM 三者之间明确切边界。
| 系统 | 用户界面 | 执行位置 | 强项 |
|---|---|---|---|
| OpenAI Code Interpreter | Chat | 托管沙箱 | 工件和数据分析闭环 |
| Cursor agent | IDE | 本地 workspace/终端 | 代码上下文和编辑体验 |
| Claude Code | CLI | 本机工程目录 | 长任务、工具权限、子任务 |
| Tokimo | Browser desktop | Rust server + microVM sandbox | Web UI、窗口系统、隔离执行统一 |
业界共识正在形成:强 agent 不是一个模型接口,而是一套带 UI、状态、沙箱和权限的运行时。
一个可复用的工程框架
如果把这条链路抽象出来,我们会得到一个四层框架:UI projection、run orchestration、tool execution、isolation transport。UI projection 负责把运行状态变成用户可理解的消息、spinner、terminal;run orchestration 负责模型循环、工具调度、取消和恢复;tool execution 负责 cwd、env、timeout、输出裁剪;isolation transport 负责把命令送进受控环境。
graph TB
L1[UI Projection\nReact Query / SSE / WS] --> L2[Run Orchestration\nChatService / ConversationJournal]
L2 --> L3[Tool Execution\nBashTool / LocalToolExecutor]
L3 --> L4[Isolation Transport\nAgentSandbox / Sandbox / vsock]
L4 --> L5[Guest Runtime\nguest-agent / exec / PTY]
L5 --> L4
L4 --> L3
L3 --> L2
L2 --> L1
这也是为什么「帮我看一下当前目录」值得被认真拆解。它不是 demo,而是 agent 产品最小但完整的工程切片:一次用户意图,被模型转成工具调用,被 server 约束成执行计划,优先被 sandbox backend 执行,再被事件流还原成用户能看懂的状态。只有当 sandbox boot 已进入 cached Failed state 时,chat 才退回 host execution;这是一条可用性 fallback,不应被写成常规执行路径。
Framework-level 结论:浏览器里的 AI agent 工具调用,应该被设计成事件驱动的分层运行时,而不是从按钮一路硬连到 shell 的远程命令。
版权属于:一名宅。
本文链接:https://zhaiyiming.com/archives/87.html
转载时须注明出处及本声明