Skip to content

浏览器里的 AI agent 工具调用:从前端按钮到 microVM PTY 的完整链路 #71

@tinymins

Description

@tinymins

浏览器里的 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.rsstream::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.tsauthFetch(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_updateperson_scrapeddownload: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.tsxterminal_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.rschat_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 包,公开 SandboxShellOptsEventJobId 等 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/QueryMountpty.rsforkpty() 处理交互式会话。

维度 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_ptyShellOpts { pty: Some((rows, cols)), argv, env, cwd }terminal_ws.rs 再把浏览器 WS 的 binary/text 输入转成 write_stdinresize_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::EventAgentSandbox::exec 收集成 ExecResult;Bash tool 格式化成 ToolOutput;ChatService 再投影成前端可消费的 AgentEvent。对于 chat,前端以 SSE patch message cache;对于 terminal,WS 直接推原始字节给浏览器。

对比项 SSE WS
方向 server → browser 双向
chat 增量 很适合,浏览器只读 也能做,但需要自定义重连语义
terminal 不适合 stdin/resize 适合二进制输入和输出
当前代码 useChatSSE.tsstream::chat_events WsProviderterminal_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 的远程命令。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions