浏览器里的 AI agent 工具调用:从前端按钮到 microVM PTY 的完整链路
目录
为什么一次「看目录」不只是一次命令执行
用户在浏览器 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
Loading
一次调用的时序条带
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
Loading
前端不是「发一个请求等结果」,而是在维护一个可断线重连的 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
Loading
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]
Loading
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
Loading
这也是为什么「帮我看一下当前目录」值得被认真拆解。它不是 demo,而是 agent 产品最小但完整的工程切片:一次用户意图,被模型转成工具调用,被 server 约束成执行计划,优先被 sandbox backend 执行,再被事件流还原成用户能看懂的状态。只有当 sandbox boot 已进入 cached Failed state 时,chat 才退回 host execution;这是一条可用性 fallback,不应被写成常规执行路径。
Framework-level 结论:浏览器里的 AI agent 工具调用,应该被设计成事件驱动的分层运行时,而不是从按钮一路硬连到 shell 的远程命令。
浏览器里的 AI agent 工具调用:从前端按钮到 microVM PTY 的完整链路
目录
为什么一次「看目录」不只是一次命令执行
用户在浏览器 chat 输入「帮我看一下当前目录」,表面上只是
pwd && ls。但在 agent 产品里,它同时穿过四个边界:浏览器进程、Rust server、sandbox runtime、microVM guest。每一层都可能丢事件、阻塞、错认工作目录,或者把错误包装成一段模型看不懂的文本。真正难的不是执行命令,而是让一次工具调用具备这些性质:用户能看到流式反馈;模型拿到结构化结果;命令运行在隔离环境;刷新页面后状态还能恢复;超时、退出码、stderr 都能被解释;交互式 shell 和一次性命令不能互相污染。
这里还有一个容易被忽略的产品问题:用户以为自己在和「一个助手」对话,但系统实际由很多异步参与者组成。LLM 可能还在思考,SSE 已经断线;命令可能已经退出,消息缓存还没 flush;sandbox 可能刚被重建,旧的 PTY tab 还在尝试写 stdin;用户切换 agent 后,工作目录、技能开关、模型参数都要跟着换。如果这些边界没有清晰协议,最后出现的 bug 往往不是「命令跑错了」,而是「结果跑对了,但出现在错误的会话、错误的窗口、错误的工作目录」。
pwd -P并更新状态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 进入 cachedFailedstate 时,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这条链路不是单向 RPC,而是「触发通道 + 事件通道 + 执行通道」三条线并行协作。
前端:按钮、SSE、WS 与 typed client 的边界
前端有两类网络通道。第一类是 AI chat 自己的 SSE:
useChatSSE.ts用authFetch(rustUrl(...))手动读取 response body,解析data:行,再用applySSEEventpatch 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 协议单独演进,不牵动普通查询接口,并保持可测试、可回放、可追踪。
useChatSSE.ts→/chat/sendchat_events+ConversationJournalsystem/events/ws.tsx、terminal_ws.rsgenerated/rust-api/ai.tsgraph 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。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()处理交互式会话。ch/rpc.rshybrid-vsock UDS +CONNECTvsock 的价值不是更快,而是把「只允许宿主机和 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每次连接只发一个spawnrequest,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,把 sandboxEvent::Stdout广播给所有 attach 的浏览器 tab,并保留 scrollback。pwd && ls、测试命令GuestRpc::spawn_command_with_optionsopen_pty_with_options+forkpty()exec 给模型结果,PTY 给用户现场;把两者混成一个接口,最后会两边都难用。
错误与回流:stdout、exit、error 都是协议
错误不能只靠字符串。当前链路里至少有三种错误:请求错误(POST 失败,前端
onError)、运行错误(AgentEvent里的 conversation/message/turn error)、sandbox 错误(Event::Error { fatal, message }或 guestResponse::Error { msg })。这里需要删除 caller-ownedJobId的叙述:该 race fix 已被b797c2975revert,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 直接推原始字节给浏览器。useChatSSE.ts、stream::chat_eventsWsProvider、terminal_ws.rs好的 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 三者之间明确切边界。
业界共识正在形成:强 agent 不是一个模型接口,而是一套带 UI、状态、沙箱和权限的运行时。
一个可复用的工程框架
如果把这条链路抽象出来,我们会得到一个四层框架:UI projection、run orchestration、tool execution、isolation transport。UI projection 负责把运行状态变成用户可理解的消息、spinner、terminal;run orchestration 负责模型循环、工具调度、取消和恢复;tool execution 负责 cwd、env、timeout、输出裁剪;isolation transport 负责把命令送进受控环境。
这也是为什么「帮我看一下当前目录」值得被认真拆解。它不是 demo,而是 agent 产品最小但完整的工程切片:一次用户意图,被模型转成工具调用,被 server 约束成执行计划,优先被 sandbox backend 执行,再被事件流还原成用户能看懂的状态。只有当 sandbox boot 已进入 cached Failed state 时,chat 才退回 host execution;这是一条可用性 fallback,不应被写成常规执行路径。
Framework-level 结论:浏览器里的 AI agent 工具调用,应该被设计成事件驱动的分层运行时,而不是从按钮一路硬连到 shell 的远程命令。