通过 WSS 让 Server 安全调用 Desktop 本地工具(下01):Ticket、人工确认、幂等与断线对账

1. 下篇要加什么

1.1 产品形态

上篇做完后,DevMind 已经是一个完整的对话应用:会话列表、按轮显示问答、审批、编辑重跑、停止后继续。但 AI 能用的工具都在 Server 上(加法、查时间、读一段内置文本),它看不到用户电脑上的任何东西。

下篇让 AI 能读用户电脑上的项目:

  • 用户问"列出我工作区根目录的文件,再读一下 README.md",AI 先调用 workspace_list_files,再调用 workspace_read_file,拿到内容后回答。
  • 列文件只返回文件名,直接执行;读文件会把文件内容交给模型,所以 Desktop 会弹一个确认框,用户点"允许一次"才读。
  • 只能访问一个事先指定的目录(工作区)。绝对路径、../ 跳出去、指向外面的符号链接、.env、.git、私钥这类敏感文件,在弹确认框之前就直接拒绝,不打扰用户。
  • 页面顶部显示"本机工具:已连接 · 工作区 /Users/...",用户知道 AI 这次能不能用本机工具。

关键的一点:Agent 跑在 Server 上,文件在用户电脑上。 Server 要调用 Desktop 上的工具,得有一条 Server 能主动发消息给 Desktop 的通道。这条通道就是本篇的 WSS。它不是一个"远程 Shell":Server 只能调用 Desktop 白名单里的结构化工具,参数要过 Schema 校验,路径要过工作区检查,内容进模型之前要用户点头,同一个调用无论收到几次只执行一次,断线后还能对上账。

本篇只做两个只读工具:workspace.list_files(列目录)和 workspace.read_file(读文本文件)。写文件、执行命令这类有副作用的工具放到 v0.2 以后,到时候还要再叠加上篇的审批中断。

1.2 用户能看到、能做的操作

操作 会发生什么 规则
打开 Desktop 页面顶部显示"本机工具:连接中 → 已连接" Desktop 主动连 Server,不开放任何端口;断了会自动重连
让 AI 列出工作区的文件 直接执行,工具卡片显示结果 list_files 是 LOW 风险,不需要确认;.env、.git 这类名字不会出现在结果里
让 AI 读一个文件 弹出确认框,显示工具名和完整参数 read_file 是 MEDIUM 风险,必须确认;只能点"允许一次"或"拒绝",不能改参数
在确认框里点拒绝,或者 60 秒不理 不读文件;AI 收到"用户拒绝",自己决定怎么跟用户说 超时按拒绝处理
AI 想读工作区外面的文件或敏感文件 不弹确认框,直接拒绝 越界检查在确认之前,不用让用户判断一个本来就不允许的请求
确认框还在时点"停止" 确认框立刻关闭,这一轮标为"已停止" 和上篇的停止一样,后台的模型和工具都停下
确认后、结果传回之前断网或 Server 重启 结果先存在 Desktop 本地,重新连上后补发,Server 对上账 Server 不会自动重新执行这个调用
没开 Desktop,或者在浏览器里单独调试页面 照常问答;AI 调用本机工具会收到"没有绑定设备"或"设备离线" 没有本机工具也能用
同一台电脑开了两个 Desktop 后连上的那个生效,先连的那个显示"已在另一个窗口连接",不再重连 避免两个实例互相抢连接

几个词,文档里统一这样用:

  • 设备 :一台电脑上装的一份 Desktop。第一次启动时生成 device_id,存在本机,之后不变。
  • 工作区 :本机工具唯一能访问的目录,开发期写在 .env 里。
  • Ticket:Desktop 建立 WSS 连接用的一次性门票,有效期 60 秒,用一次就作废。
  • 工具调用 :模型的一次"我要调用某个工具"的意图,对应 Server 上的一行 tool_call。
  • 下发 :Server 把一个工具调用发给 Desktop 一次,对应一行 tool_execution。同一个工具调用可能因为超时、断线被下发不止一次,但 Desktop 只执行一次。
  • 确认 :Desktop 本地弹框问用户"允许读这个文件吗"。注意和上篇的审批区分:审批是 Agent 图停下来、在对话里显示卡片,属于 Server;确认是工具执行前在本机弹框,属于 Desktop。本篇的两个只读工具只需要确认,不需要审批。

2. 页面上多了什么

2.1 界面

下图是 Desktop 的界面,红框是本篇新加的部分:

  • 顶部状态:设备是否连上了 Server,工作区是哪个目录。鼠标移上去能看到设备 ID。
  • 工具卡片:上篇已经有了,但当时工具结果其实传不到前端(原因见 7.3 节),卡片一直显示"执行中"。本篇修好后,执行完会显示"已完成"和结果。
  • 确认框:Main 进程收到需要确认的调用时弹出。它不属于某个会话,叠在整个页面上面。

2.2 Desktop 代码的结构

上篇只写了 Renderer。本篇的主要代码在 Electron 的 Main 进程里:

文件 :apps/devmind-desktop/src/

bash 复制代码
main/
├── index.ts                      Electron 入口:创建窗口,启动和停止 Desktop Runtime
├── env.d.ts                      Main 进程能读到的 MAIN_VITE_ 环境变量的类型
└── desktop/                      本篇新增:本机工具的全部逻辑
    ├── runtime.ts                装配入口:读配置 → 工具注册表 → 执行记录 → 确认管理器 → WSS 客户端
    ├── config.ts                 读 MAIN_VITE_ 配置;首次启动生成设备 ID
    ├── wss-client.ts             申请 Ticket、建立 WSS、处理工具调用、补发结果、心跳和重连
    ├── protocol.ts               WSS 消息的 Zod 校验和类型;本机工具的类型 LocalTool
    ├── tools.ts                  工具注册表(白名单):list_files、read_file
    ├── path-policy.ts            路径策略:把相对路径解析成工作区内的真实路径
    ├── validation.ts             用 Ajv 按 JSON Schema 校验参数;稳定序列化
    ├── execution-store.ts        本地执行记录:按 tool_call_id 保证只执行一次,结果保留到 Server 确认
    ├── confirmation-manager.ts   确认框:把请求发给 Renderer,等用户回答
    └── __tests__/                vitest 单元测试
preload/
└── index.ts                      只把几个受控动作暴露给页面:读设备信息、订阅确认请求、回答确认
renderer/src/
├── desktop/
│   ├── ToolConfirmationDialog.tsx    确认框
│   └── desktop-runtime.ts            读设备 ID、订阅连接状态;浏览器里调试时没有本机工具
├── hooks/useTurnRunner.ts            提问时带上 device_id(上篇改)
└── agent/devmind-api.ts              createTurn() 多一个 deviceId 参数(上篇改)

为什么放在 Main 而不是 Renderer:Renderer 是加载网页内容的地方,安全边界和浏览器差不多,不应该拿到文件系统权限;Main 是 Node.js 进程,可以读文件、持有设备身份、写执行记录。Renderer 只能通过 preload 暴露的几个函数回答"允许"或"拒绝",碰不到 fs,也碰不到 WSS 连接。

3. 新加的东西,在后端是什么

3.1 对照表

看到的 / 用到的 存在哪 ID 说明
这台电脑上的 Desktop Desktop 本地 userData/device.json device_id 第一次启动生成,提问时随 POST /api/turns 发给 Server,写进 agent_run.device_id
一张连接门票 Server 进程内存(已用过的 jti) JWT 的 jti 60 秒有效,只能用一次
一条 WSS 连接 Server 进程内存 ConnectionManager connection_id 重连一次就是一条新连接;Server 重启后全部消失
模型的一次工具调用 tool_call 表 tool_call_id 新表。保存参数、状态、结果,是 Server 侧的审计事实
一次下发 tool_execution 表 id,加 attempt_no 新表。超时或断线后再下发一次,就多一行
Desktop 上的执行记录 Desktop 本地 desktop-tool-executions.json tool_call_id 保证同一个调用只执行一次;结果留到 Server 确认收到为止
确认框 Main 进程内存 confirmationId 只标识这一次弹框,不代替 tool_call_id

和上篇的表接在一起:

ruby 复制代码
执行 run(上篇)                      agent_run.device_id 决定这次执行能调用哪台电脑
└── 工具调用 tool_call                 模型的一个意图(0:N)
    └── 下发 tool_execution            发给 Desktop 一次(1:N),通常只有 1 次

3.2 为什么"工具调用"和"下发"要分开

和上篇"轮"与"执行"分开是同一个道理:用户关心的是"AI 读了 README.md,结果是什么",而后台可能发了不止一次。

这次调用经历了什么 tool_call tool_execution
正常执行 1 行,succeeded 1 行,succeeded
用户点了拒绝 1 行,rejected 1 行,rejected
等结果时 Desktop 断线,重连后补发了结果 1 行,先 unknown,补发后 succeeded 1 行,先 unknown,补发后 succeeded
审批恢复时图把这个节点重放了一遍,又走到这个调用 1 行,已经 succeeded,直接用保存的结果 不新增
等结果超时,后来又被重新下发 1 行 2 行,attempt_no 是 1、2;Desktop 第二次直接返回第一次的结果

只有一张表的话,要么一个调用出现好几次结果,要么"发过几次、每次怎么结束的"被覆盖掉,没法排查。

3.3 tool_call_id:为什么是 graph_thread_id 加模型给的 ID

幂等的前提是:同一个调用,无论走到这里几次,ID 都一样;不同的调用,ID 一定不同 。 本篇的 tool_call_id 是:

ini 复制代码
tool_call_id = graph_thread_id + ":" + 模型生成的 tool call id
例如          019a...c3:call_00_Xy7k
  • 模型生成的 tool call id 保存在存档(Checkpoint)里的那条 AIMessage 上。LangGraph 从存档恢复、重放一个节点时,读到的还是同一条 AIMessage,ID 不变。
  • 不能在工具节点里 uuid4() 随机生成:上篇讲过,LangGraph 从 interrupt() 恢复时会把整个节点重新执行一遍,随机 ID 会变。
  • 前面拼上 graph_thread_id:模型生成的 ID 只在一次对话里不重复,不同存档链上可能撞上。

不同情况下 ID 怎么变:

场景 执行 存档链 tool_call_id 会不会再执行一次
WSS 消息重发、Desktop 断线重连 不变 不变 不变 不会,Desktop 按本地记录返回
Server 重新下发 unknown、timed_out 的调用 不变 不变 不变 不会,Desktop 按本地记录返回
Server 已经保存了结果,又遇到同一个调用 任意 不变 不变 不会,Server 直接用保存的结果
审批恢复(上篇场景 3) 新建 复用 未完成的调用不变 不会
编辑或重试(上篇场景 4) 新建 新建 新的 会,这是用户要求的新一次回答

run_id 只作为审计信息发给 Desktop,不参与 ID:审批恢复会换一个新的执行,但存档链不变,调用也不该重来。

3.4 状态

Server 上 tool_call.status 的流转:

状态 意思 是不是最终结论
created、dispatched 已落库、已发出,还没有结果 否
succeeded、failed、rejected、cancelled Desktop 回报的结果 是,之后的结果不再覆盖
timed_out Server 等太久,放弃等待并通知 Desktop 取消 否,迟到的结果会覆盖它
unknown 发出去之后断了,不知道 Desktop 有没有执行 否,Desktop 补发的结果会覆盖它

Desktop 本地记录的状态是 running 加上面那些结果。running 表示已经拿到执行权、正在确认或执行。Desktop 在执行途中被关掉,下次启动时把 running 改成 unknown 补发给 Server,不会自动重新执行。

4. Server 和 Desktop 怎么通信:WSS

4.1 为什么是 WSS,为什么 Desktop 连 Server

用户的电脑通常在家里的路由器、公司内网或者 NAT 后面,Server 没法主动连过来,也不应该要求用户把本机端口暴露出去。所以连接只能由 Desktop 主动发起。

一次本地工具调用也不是简单的"一问一答":Server 发请求,Desktop 可能要等用户确认,执行完回传结果,Server 还可能中途取消,双方都需要心跳、重连后对账。这需要一条长期保持、双向都能发消息的连接。WebSocket 正好是这样的连接,加上 TLS 就是 WSS。轮询也能做,但要不停地空请求,而且"Server 要取消"这种消息只能等下一次轮询才送到。

WSS 只解决"安全传输、双向通信",不解决"谁能调、能调什么、会不会重复执行"。这些靠本篇的 Ticket、设备绑定、白名单、参数校验、路径策略、确认、幂等记录和对账一层层保证,任何一层都不能因为"已经用了 WSS"而省掉。

注意,WSS 只用来让 Server 调用 Desktop 的工具。对话的执行过程仍然走上篇的 POST /api/agent SSE,两条连接互不相干。

4.2 和同类桌面 Agent 的区别

上篇对比过 Codex 和 Claude Code:它们的 Agent 进程就跑在用户电脑上,读文件就是 Agent 进程自己调一个函数,确认也只是进程内的一个提示,不需要网络通道。

DevMind 的 Agent 跑在 Server 上,所以要多出一条"Server → Desktop"的通道,并且要额外处理两件本机 Agent 不用操心的事:

  • 身份:连上来的 Desktop 是谁、属于哪个用户。本机 Agent 天然就是当前用户。
  • 网络不可靠:请求发出去之后连接断了,Server 不知道 Desktop 有没有执行。本机函数调用不存在这个问题。

本篇大部分篇幅都在处理这两件事。

4.3 接口和消息一览

HTTP 接口:

接口 谁调 做什么
POST /api/desktop/connection-ticket Desktop Main 用当前用户身份换一张一次性 Ticket,body 是 {"device_id": ...}
WS /api/desktop/ws?ticket=... Desktop Main 建立 WSS 连接
POST /api/turns(上篇改) Renderer 多一个可选的 device_id,写进这一轮的执行
POST /api/desktop/dev/tool-calls 开发者 只在开发模式开放:不经过模型,直接对一个执行调用 Desktop 工具,用来单独测通道

WSS 上的消息。所有消息都带 event_id(这条消息的 ID,排查用)和 sequence(发送方在这条连接上递增的序号,接收方拒绝倒退的序号):

方向 type 作用
Desktop → Server device.register 连上后的第一条消息:设备 ID、进程实例 ID、版本
Server → Desktop device.registered 注册成功,返回 connection_id
Desktop → Server tool.registry.sync 全量同步工具白名单:名字、版本、风险、Schema、超时
Server → Desktop tool.call.request 下发一次工具调用
Desktop → Server tool.call.result 结果:succeeded、failed、rejected、cancelled、timed_out、unknown
Server → Desktop tool.call.ack Server 已经把结果存进数据库,Desktop 不用再补发
Server → Desktop tool.call.cancel 取消一个还没完成的调用
双向 heartbeat 保活:任何一方长时间收不到对方的消息,就断开重连
双向 protocol.error 对方发来一条看不懂的消息

tool.call.result 只上报最终结果,没有"执行中"这种中间状态:Server 收到第一条结果就会当成最终结果。

4.4 一次连接从建立到断开

Desktop 启动后,下面这些事情按顺序发生(细节见 6.3 节场景 0):

为什么 Ticket 要单独申请,而不是建 WSS 时直接带用户身份:浏览器和大多数 WebSocket 客户端在握手时没法带自定义请求头,身份只能放在 URL 里;URL 会被写进各种日志。所以用一张很快过期、用一次就作废 的 Ticket 放在 URL 里,即使被日志记下来也没法再用。本篇还给 uvicorn 的日志加了过滤器,把 ticket= 后面的值换成 ***(见 7.9 节)。

4.5 超时要一层套一层

一次调用里有好几个超时,必须从里到外一层比一层长,否则会出现"Server 已经放弃了,Desktop 才开始执行":

超时 默认值 在哪 到时间会怎样
确认超时 60 秒 Desktop confirmation-manager.ts 按拒绝处理
执行超时 每个工具自己声明,本篇都是 10 秒 Desktop tools.ts 用 AbortSignal 中止执行
expires_at 现在 + Server 等待时间 − 执行超时 − 5 秒 请求里的字段 Desktop 在确认前、确认后各查一次,过期就拒绝
Server 等待时间 120 秒 .env 的 DESKTOP_TOOL_TIMEOUT_SECONDS 记为 timed_out,通知 Desktop 取消
ToolRegistry 外层超时 Server 等待时间 + 10 秒 main.py 传给 create_langgraph_runtime() M1 写的 ToolRegistry 默认 10 秒就杀掉工具,用户还没来得及点确认,所以 Desktop 工具单独放宽

expires_at 的意思是"过了这个时间就别开始执行了"。用户在确认框前发了一会儿呆,点"允许"的时候已经过期,Desktop 也不会执行,因为 Server 那边可能已经不等了。

5. 新加的模块

5.1 架构图

蓝线是对话(上篇的两步走),橙线是 Server 下发工具调用,绿线是 Desktop 回传结果,紫色虚线是 Main 和页面之间的 IPC:

  • 左边是上篇的对话链路:Renderer 两步走,Server 的适配器运行 LangGraph 图。本篇只在图的工具节点里多了两个 Desktop 工具。
  • 中间是 Server 新加的 desktop/ 包 :Desktop 工具被调用时,DesktopToolService 先把这次调用写进 tool_call,再通过 ConnectionManager 找到这台设备的连接、发出去、等结果。
  • 右边是 Desktop 的 Main 进程 :DesktopWssClient 收到请求后,按固定顺序层层检查,需要确认就通过 IPC 让 Renderer 弹框,执行后把结果发回去。

5.2 模块和它们存在的理由

Server 新加的 desktop/ 包,分层方式和上篇的 runs/ 一样:

层 文件 做什么 不做什么
协议模型 models.py WSS 上每种消息的 Pydantic 模型,收到的消息先过校验 不含逻辑
Ticket tickets.py 签发和消费一次性 JWT 不认识连接、不写数据库
连接表 connections.py 哪个设备在线、哪个调用在等结果;只存内存 不写数据库
Repository repository.py tool_call、tool_execution 的 SQL 不判断能不能做
Service service.py 一次调用的完整流程:落库 → 下发 → 等结果 → 断线、超时、取消时对账 不写 SQL,不认识 WebSocket
接口 api/desktop.py Ticket 接口、WSS 消息循环、开发调试接口 不含业务规则
模型可见的工具 agent/desktop_tools.py 两个 @tool 函数,只是把调用转给 Service 不做校验

为什么连接表单独一层、而且只存内存:在线连接、正在等结果的协程,本来就只在当前进程里有意义,Server 重启后它们全部失效。重启后靠什么接上?靠数据库里的 tool_call 和 Desktop 本地的执行记录,这就是"对账"。

Desktop Main 的模块:

模块 做什么 为什么单独一块
wss-client.ts 连接、收发消息、按顺序处理一次调用 唯一碰网络的地方;不依赖 electron 模块,可以直接在 Node 里测试
tools.ts 工具白名单 加一个工具只改这里
path-policy.ts 路径安全 本地文件访问的最后一道边界,单独测试
validation.ts 参数校验 网络输入一律不可信,Server 校验过也要再校验
execution-store.ts 本地执行记录 幂等和补发都靠它,必须先落盘再执行
confirmation-manager.ts 确认框 唯一碰 Electron IPC 的地方
runtime.ts 装配 和上篇 Server 的 main.py 一样,手动把对象装起来

5.3 文件地图

代码仓库地址: gitee.com/panghuzhuai...

和上篇一样,精读 是要逐行读懂、能自己改的;通读 是知道每个函数做什么;略读 是知道它存在。第二列:下篇 是本篇新写的,下篇改 是之前写的、本篇改过。没列出来的文件本篇没动。

bash 复制代码
apps/devmind-server/
├── migrations/0003_add_desktop_tool_tables.sql   下篇     通读   tool_call、tool_execution 两张表
├── src/devmind_server/
│   ├── main.py                                   下篇改   通读   启动时装配 Desktop 通道
│   ├── core/
│   │   ├── config.py                             下篇改   略读   Ticket 密钥、有效期、等待时间、允许的 Origin
│   │   └── logging.py                            下篇     略读   日志里的 ticket 参数换成 ***
│   ├── api/
│   │   ├── desktop.py                            下篇     精读   Ticket 接口、WSS 消息循环、开发调试接口
│   │   ├── dependencies.py                       下篇改   略读   多两个依赖:Ticket 服务、Desktop 工具服务
│   │   └── router.py                             下篇改   略读   挂上 desktop 路由
│   ├── desktop/                                  下篇
│   │   ├── service.py                                     精读   一次调用:落库 → 下发 → 等结果 → 对账
│   │   ├── connections.py                                 精读   在线连接、等结果的协程
│   │   ├── repository.py                                  通读   两张表的 SQL
│   │   ├── tickets.py                                     通读   一次性 JWT
│   │   └── models.py                                      略读   协议消息模型
│   ├── agent/
│   │   ├── desktop_tools.py                      下篇     通读   模型可见的 workspace_list_files、workspace_read_file
│   │   ├── tool_registry.py                      下篇改   通读   透传 config、单独放宽超时、按 ToolCall 格式调用
│   │   └── langgraph_runtime/
│   │       ├── graph.py                          下篇改   略读   工具节点接收 config
│   │       └── factory.py                        下篇改   略读   注册 Desktop 工具
│   └── runs/service.py                           下篇改   略读   多一个 get_run_for_user(),开发调试接口用
└── tests/
    ├── test_desktop_wss.py                       下篇     通读   用 TestClient 扮演 Desktop 的集成测试
    └── test_desktop_tickets.py                   下篇     略读   Ticket 单元测试

apps/devmind-desktop/
├── src/main/
│   ├── index.ts                                  下篇改   略读   启动和停止 Desktop Runtime
│   ├── env.d.ts                                  下篇     略读   MAIN_VITE_ 变量的类型
│   └── desktop/                                  下篇
│       ├── wss-client.ts                                  精读   连接、处理调用、补发、心跳、重连
│       ├── execution-store.ts                             精读   本地执行记录
│       ├── path-policy.ts                                 精读   路径策略
│       ├── tools.ts                                       通读   工具白名单
│       ├── confirmation-manager.ts                        通读   确认框的 Main 一侧
│       ├── runtime.ts                                     通读   装配
│       ├── protocol.ts、validation.ts、config.ts           略读   消息校验、参数校验、配置
│       └── __tests__/                                     通读   路径策略、执行记录、WSS 客户端的测试
├── src/preload/index.ts、index.d.ts               下篇改   通读   暴露给页面的受控动作
├── src/renderer/src/
│   ├── desktop/ToolConfirmationDialog.tsx        下篇     通读   确认框
│   ├── desktop/desktop-runtime.ts                下篇     略读   读设备 ID 和连接状态
│   ├── App.tsx、App.css                          下篇改   略读   顶部状态、挂上确认框
│   ├── hooks/useTurnRunner.ts                    下篇改   略读   提问带 device_id
│   └── agent/devmind-api.ts                      下篇改   略读   createTurn() 多一个 deviceId
└── .env.example、package.json                    下篇改   略读   MAIN_VITE_ 配置;ws、ajv、zod、vitest

5.4 用到的库

库 在哪 用来做什么
PyJWT Server 签发和校验 Ticket(HS256)
FastAPI 的 WebSocket Server WSS 路由:accept()、receive_text()、send_json()、close(code)
ws Desktop Main Node 里的 WebSocket 客户端。Electron Main 没有浏览器的 WebSocket 对象
zod Desktop Main 收到的每条 Server 消息先过运行时校验;TypeScript 的类型只在编译期存在
ajv Desktop Main 按工具的 JSON Schema 校验参数
vitest Desktop 单元测试

6. 场景:一次本地工具调用,代码从哪个文件走到哪个文件

这一章按用户实际的操作顺序把代码串起来。例子接着上篇的会话:Desktop 启动并连上 Server;用户问"列出我工作区根目录的文件,然后读一下 README.md";AI 先列文件(直接执行),再读文件(弹确认框);然后看用户拒绝、越界、确认时点停止、断线这几种情况。

每个场景的写法和上篇一样:

  1. 调用链路图 :从上往下是时间顺序。四条泳道是 Renderer、Desktop Main、Server 的接口层、Server 的业务层(desktop/ 包、图和适配器)。备注框写的是这个方法里面依次调用了什么。
  2. 调用明细表:按调用顺序,一行一个方法。
  3. 调用树:Service 在一个事务里依次调用了哪些 Repository 方法。
  4. 数据库变化 和要注意的地方。

路径的写法:Server 文件省略前缀 apps/devmind-server/src/devmind_server/;Desktop Main 文件省略前缀 apps/devmind-desktop/src/main/;Renderer 文件省略前缀 apps/devmind-desktop/src/renderer/src/。例子里的 ID:会话 c1、轮次 t3、执行 r3、存档链 g3、设备 d1、模型给的调用 ID call_1,所以 tool_call_id 是 g3:call_1。

6.1 开始之前:两边启动时装好了哪些对象

Server 和上篇一样,在 main.py 的 lifespan() 里按顺序创建对象;本篇在"组装 LangGraph 的图"之前,多建了 Desktop 通道的三个对象,因为 Desktop 工具要拿着 DesktopToolService 才能工作。Desktop 这边,Electron 启动后由 desktop/runtime.ts 装配 Main 进程里的对象。

文件 · 对象 做什么 谁用它
core/logging.py · install_ticket_redaction() 给 uvicorn 的日志挂过滤器,把 ticket= 后面的值换成 *** 启动时调用一次
desktop/connections.py · ConnectionManager 进程内存里的连接表:按"用户 + 设备"找在线连接,按 tool_call_id 把结果交给正在等的协程 WSS 路由、DesktopToolService
desktop/repository.py · ToolCallRepository tool_call、tool_execution 的 SQL,和上篇共用一个连接池 DesktopToolService
desktop/service.py · DesktopToolService 一次调用的完整流程。启动时还调一次 recover_after_restart():上一个进程发出去、没等到结果的调用,统一标为 unknown Desktop 工具、WSS 路由、开发调试接口
agent/desktop_tools.py · create_desktop_tools(service) 创建模型能看到的两个工具,函数体里只是调 service.call_from_config() create_langgraph_runtime()
desktop/tickets.py · TicketService 签发和消费 Ticket,密钥来自 .env Ticket 接口、WSS 路由
Desktop desktop/runtime.ts · startDesktopRuntime() 读配置 → 工具注册表 → 执行记录 → 确认管理器 → DesktopWssClient,然后 start() 开始连接 main/index.ts 在窗口创建后调用

6.2 全景图:一次本地工具调用经过的全部代码

6.1 是前置条件。下面这张图把一次调用的全过程画在一起:上面一行是 Server,下面一行是 Desktop,中间是 WSS。格子的颜色表示在哪一边、哪一层;标题带 ↓ 的格子展开成下面一排小步骤。

读的时候抓住这几点:

  • 身份全部来自 Server :用户、会话、执行、设备都是适配器从数据库读出来、放进 LangGraph 的 configurable 的。模型只决定"调哪个工具、参数是什么",改不了这些。
  • 先落库,再下发:任何一次下发在数据库里都有记录,Server 崩了也知道发过什么。
  • Desktop 先检查,再占位,再确认,再执行:越界的请求不占位、不弹框;占位之后同一个调用不会再执行第二次。
  • 结果先落库,再回 ack:Desktop 收到 ack 才删掉本地的结果,中间任何一步断了都还能补发。

后面的场景就是在这张图里挑一段走一遍:场景 0 是连接建立;场景 1 是 LOW 工具走完整条链;场景 2 多了确认;场景 3 是各种在 Desktop 被拒绝的情况;场景 4 是取消;场景 5 是断线和重启后的对账。

6.3 场景 0:Desktop 启动,连上 Server

用户打开 Desktop,什么都还没问。

调用链路:Desktop 启动并注册。

顺序 文件 · 方法 做什么
1 main/index.ts 窗口创建之后调 startDesktopRuntime(() => mainWindow)。传的是"取窗口的函数"而不是窗口本身:macOS 上关掉窗口再打开,确认框还能弹到新窗口里
2 desktop/runtime.ts · startDesktopRuntime() 先注册 desktop-runtime:get-device-id 这个 IPC,再等配置加载完。顺序不能反:页面可能比配置更早挂载,先注册的 IPC 返回一个会等配置就绪的 Promise
3 desktop/config.ts · loadDesktopConfig() 读 MAIN_VITE_ 配置,工作区目录不存在就启动失败;设备 ID 从 userData/device.json 读,第一次启动时生成
4 desktop/wss-client.ts · start() 先加载本地执行记录(上次没执行完的标为 unknown),再连网
5 desktop/wss-client.ts · connect() → requestTicket() POST /api/desktop/connection-ticket,带开发期身份头和 device_id
6 api/desktop.py · create_connection_ticket() 用户来自 get_current_user(),不信请求体;TicketService.issue() 签一张 60 秒的 JWT
7 desktop/wss-client.ts · connect() new WebSocket(wsUrl?ticket=...),连上后 onOpen() 发 device.register
8 api/desktop.py · desktop_ws() accept() → 检查 Origin → tickets.consume() → 10 秒内收到 device.register,设备 ID 必须和 Ticket 里的一致
9 desktop/connections.py · ConnectionManager.register() 登记连接;同一设备已有旧连接时,旧连接上等结果的调用立即失败,旧连接以 4409 关闭
10 api/desktop.py · desktop_ws() 回 device.registered,进入消息循环
11 desktop/wss-client.ts · onMessage() 收到 device.registered:状态改为"已连接",同步工具白名单,补发本地还没被确认的结果,开始每 15 秒一次心跳
12 api/desktop.py · desktop_ws() 收到 tool.registry.sync:整体替换这条连接的白名单 connection.tools
13 desktop/runtime.ts → Renderer desktop/desktop-runtime.ts onStatus 回调把状态推给页面,顶部显示"本机工具:已连接"

Server 收到连接后的处理:

scss 复制代码
WS /api/desktop/ws?ticket=eyJ...
└─ api/desktop.py 的 desktop_ws()
    ├─ websocket.accept()                   先接受:之后才能用 4401、4403 这类关闭码告诉 Desktop 为什么被拒
    ├─ 检查 Origin                          带了 Origin 说明是浏览器页面发起的,必须在白名单里,否则 4403
    ├─ TicketService.consume(ticket)        验签、验过期、验用途,jti 用过就 4401
    ├─ 10 秒内读第一条消息                    必须是 device.register,否则 4400
    ├─ device_id 和 Ticket 里的不一致         4403 DEVICE_MISMATCH
    ├─ ConnectionManager.register()          同一设备的旧连接被替换
    ├─ 发 device.registered
    └─ 消息循环:每条消息先过 Pydantic 校验,再检查 sequence 只增不减
        ├─ tool.registry.sync               替换白名单
        ├─ tool.call.result                 先落库,再回 ack(场景 1)
        ├─ heartbeat                        回一个 heartbeat
        └─ 45 秒没收到任何消息               4408 关闭,认为是半开连接
    finally:ConnectionManager.unregister()  只有它还是当前连接时才移除;它上面等结果的调用立即失败

要注意:

  • accept() 要写在最前面。在 accept() 之前关闭,Starlette 直接回 HTTP 403,Desktop 分不清是 Ticket 过期还是别的原因。
  • Server 不保存"哪个用户有哪些设备"。设备是不是这个用户的,靠 Ticket:Ticket 是用这个用户的身份申请的,里面写着设备 ID。
  • 每次重连都重新申请 Ticket,因为旧的已经用过了。

6.4 场景 1:AI 列出工作区的文件(不需要确认)

用户在会话 c1 里问:"列出我工作区根目录的文件,然后读一下 README.md。"这一节只看第一个工具 workspace_list_files。

调用链路:从提问到 Desktop 执行,再回到模型。

顺序 文件 · 方法 做什么
1 Renderer hooks/useTurnRunner.ts · ask() 和上篇一样先准备再执行,只是 createTurn() 多带一个 deviceId: await getDeviceId()
2 Server runs/service.py · prepare_new_turn() 上篇已有的代码:device_id 写进新建的执行 agent_run.device_id
3 Server agent/ag_ui_adapter.py · stream() 上篇已有:build_run_configurable() 把用户、会话、轮次、执行、存档链、设备放进 configurable,模型改不了这些值
4 Server agent/langgraph_runtime/graph.py · route_after_model() 模型返回一个 workspace_list_files 调用;它不在需要审批的工具里,直接走 tools 节点
5 Server graph.py · execute_tools(state, config) 本篇改:节点多声明一个 config 参数,LangGraph 会把这次运行的配置传进来,再交给 registry.execute(tool_call, config)
6 Server agent/tool_registry.py · ToolRegistry.execute() 本篇改:把模型给的调用 ID 写进 configurable["devmind_tool_call_id"];Desktop 工具的超时放宽到 130 秒;按标准 ToolCall 格式调用工具
7 Server agent/desktop_tools.py · workspace_list_files() 名字里不能有点,所以模型看到的是下划线;转成 service.call_from_config(config, tool_name="workspace.list_files", ...)
8 Server desktop/service.py · DesktopCallContext.from_config() 从 configurable 里读出可信上下文,拼出 tool_call_id = g3:call_1;这次执行没绑定设备就返回 DESKTOP_DEVICE_NOT_SELECTED
9 Server desktop/service.py · call_tool() 设备在线、工具在白名单里 → 在一个事务里写 tool_call 和第一次下发 → 组装请求 → manager.dispatch() 发出去并等结果。见下面的调用树
10 Desktop desktop/wss-client.ts · onMessage() → handleToolCall() 收到请求,按顺序检查:设备 → 白名单和版本 → 参数 → 路径预检 → 有没有过期 → 本地占位(见 8.1 节)
11 Desktop desktop/execution-store.ts · reserve() 在锁里检查这个 tool_call_id 有没有记录,没有就写一条 running 并落盘,然后才执行
12 Desktop desktop/wss-client.ts · execute() → tools.ts · list_files.execute() 10 秒执行超时;读目录,过滤掉敏感名字,最多返回 500 项
13 Desktop execution-store.ts · finish() → wss-client.ts · sendResult() 结果落盘(含完整输出),发 tool.call.result
14 Server api/desktop.py · desktop_ws() → service.py · handle_result() 先 record_result() 写库,再 manager.resolve() 唤醒第 9 步在等的协程,最后回 tool.call.ack
15 Desktop execution-store.ts · markAcked() 删掉本地保存的输出,只留状态;之后重连不会再补发
16 Server call_tool() → ToolRegistry.execute() 返回输出,包成 {"ok": true, "data": ...} 的 ToolMessage 交给模型;同时 ag_ui_langgraph 给前端发 TOOL_CALL_RESULT,工具卡片显示"已完成"

Server 的调用树(第 9 步和第 14 步):

css 复制代码
DesktopToolService.call_tool(context, tool_name="workspace.list_files", arguments={"path": "."})
├─ manager.get(user_id, device_id)                 设备不在线 → DEVICE_OFFLINE,什么都不写
├─ connection.tools["workspace.list_files"]        不在白名单 → TOOL_NOT_AVAILABLE
├─ async with 连接, 事务:
│   ├─ repository.lock_tool_call("g3:call_1")       锁住这次调用(FOR NO KEY UPDATE)
│   ├─ 不存在 → repository.insert_tool_call()       参数和参数哈希一起存下来
│   │  已存在 → 参数哈希不同:IDEMPOTENCY_CONFLICT
│   │           已经 succeeded:直接返回保存的结果,不再下发
│   │           已经 failed / rejected / cancelled:直接返回这个错误
│   │           dispatched / timed_out / unknown:用同一个 ID 再下发一次
│   └─ repository.start_attempt()                  新增一行 tool_execution(attempt_no + 1),tool_call 改为 dispatched
├─ 组装 ToolCallRequest                            带上 expires_at、是否需要确认、执行超时
└─ manager.dispatch(connection, request, timeout=120)
    ├─ 登记一个 Future,等这个 tool_call_id 的结果
    ├─ connection.send(request)                    在发送锁里分配 sequence
    └─ await Future                                ← 由 handle_result() 里的 resolve() 唤醒

WSS 收到 tool.call.result
└─ DesktopToolService.handle_result()
    ├─ async with 连接, 事务:
    │   └─ repository.record_result()               条件 UPDATE:同一用户、同一设备、还不是最终状态才写
    │                                                顺带把最近一次下发的状态也改掉
    ├─ manager.resolve()                            有协程在等就唤醒它;没有(迟到的结果)也没关系
    └─ (回到路由)发 tool.call.ack

数据库变化:

表 变化
tool_call 新增 g3:call_1:workspace.list_files,参数 {"path": "."},dispatched → succeeded,result 是文件列表
tool_execution 新增 1 行:第 1 次下发,连接 ID、dispatched → succeeded,耗时

要注意:

  • 第 14 步的顺序是先写库,再唤醒,最后 ack。先 ack 再写库的话,ack 之后 Server 崩了,Desktop 已经删了结果,这次调用的结果就永远丢了。
  • Desktop 工具只在 tools 节点里运行,所以 M3 的那些限制照样生效:一次只能调用一个工具,一次执行最多 16 个工具调用。

6.5 场景 2:AI 读文件,用户点"允许一次"

接着场景 1,模型拿到文件列表后,调用 workspace_read_file,参数 {"path": "README.md"}。

Server 一侧和场景 1 完全一样,区别都在 Desktop:read_file 声明了 requiresConfirmation: true,占位之后、执行之前要弹确认框。

调用链路:读文件,用户点"允许一次"。

顺序 文件 · 方法 和场景 1 的区别
1 Desktop wss-client.ts · handleToolCall() 占位成功后,因为需要确认,调 this.options.confirm(request, controller.signal)
2 Desktop confirmation-manager.ts · request() 生成 confirmationId,记下 resolve,开 60 秒计时,通过 webContents.send('desktop-tool:confirmation-requested') 发给页面
3 Preload index.ts · onConfirmationRequested 把 Main 的消息转给页面注册的回调
4 Renderer desktop/ToolConfirmationDialog.tsx 加进队列,显示工具名、完整参数、过期时间
5 Renderer 用户点"允许一次" → answerConfirmation(id, true) 通过 ipcRenderer.invoke('desktop-tool:confirmation-response') 回到 Main
6 Desktop confirmation-manager.ts 的 ipcMain.handle 找到这个 confirmationId,resolve(approved === true):只有严格的 true 才算允许
7 Desktop wss-client.ts · handleToolCall() 确认返回后再查两件事:有没有被取消、有没有过期(用户可能在过期之后才点)。都没有才执行
8 Desktop tools.ts · read_file.execute() 再走一遍路径策略;文件超过 max_bytes 返回 FILE_TOO_LARGE;读完再按实际字节数检查一次,防止读的时候文件变大

要注意:

  • 确认框只能回答允许或拒绝,不能改参数。用户想读别的文件,应该在对话里告诉 AI,让 AI 发起一个新的调用。
  • 确认发生在 Desktop,不是上篇那种图上的审批中断。Server 这边只是在 call_tool() 里多等了一会儿,所以 ToolRegistry 的外层超时要放宽(4.5 节)。

6.6 场景 3:在 Desktop 被拒绝的几种情况

Desktop 收到请求后的检查顺序是固定的,前面的检查不过,后面的都不做:

顺序 检查 不通过时的错误码 弹不弹确认框 写不写本地记录
1 请求里的设备 ID 是不是本机 DEVICE_MISMATCH 否 否
2 工具在不在白名单、版本对不对 TOOL_NOT_AVAILABLE 否 否
3 参数是否符合 JSON Schema(多一个字段也不行) INVALID_ARGUMENTS 否 否
4 路径预检:绝对路径、跳出工作区、符号链接指向外面、敏感文件、不存在 ABSOLUTE_PATH_DENIED、PATH_OUT_OF_SCOPE、SENSITIVE_PATH_DENIED、PATH_NOT_FOUND 否 否
5 有没有过期 REQUEST_EXPIRED 否 否
6 本地占位:这个 ID 已经有记录 参数不同返回 IDEMPOTENCY_CONFLICT;正在执行就不回复;已经结束就补发保存的结果 否 已有
7 用户确认 拒绝或 60 秒没理:CONFIRMATION_REJECTED 是 是
8 确认后再查:有没有被取消、有没有过期 CANCELLED_BY_SERVER、REQUEST_EXPIRED 已弹过 是

第 1~5 步被拒绝时不写本地记录:这些拒绝没有任何副作用,Server 再发一次也只是再拒绝一次,不需要幂等保护。从第 6 步开始,这个调用"可能被执行",所以必须先占位。

Server 收到 rejected 后,call_tool() 抛出 DesktopToolError(code);ToolRegistry 把错误码原样放进 ToolMessage:

json 复制代码
{"ok": false, "tool": "workspace_read_file", "data": null,
 "error": {"code": "PATH_OUT_OF_SCOPE", "message": "PATH_OUT_OF_SCOPE: path escapes the workspace"}}

模型看到稳定的错误码,自己决定怎么跟用户解释,比如"这个文件在工作区外面,我没有权限读"。实际测试中,用户拒绝后模型的回答是"README.md 的读取被确认环节拒绝,所以没有拿到内容",没有编造文件内容。

路径策略的两个细节(代码见 8.2 节):

  • 先按字面判断越界,再访问文件系统。如果先 realpath 再判断,../secret 存在时返回 PATH_OUT_OF_SCOPE、不存在时返回 PATH_NOT_FOUND,模型就能用错误码探测工作区外面有哪些文件。
  • 越界判断不能只写 relative.startsWith('..'):工作区里一个叫 ..foo 的普通文件也会被误伤。要判断 relative === '..' 或者以 ../ 开头。

6.7 场景 4:确认框还在,用户点了"停止"

AI 要读 LICENSE,确认框弹出来了,用户没点,而是在对话里点了"停止"。

上篇讲过停止的前半段:前端断开 SSE,适配器在 stream() 里捕获取消,对生产者任务调用 task.cancel(),LangGraph 正在执行的节点会收到 CancelledError。本篇接着往下:这个节点正卡在 call_tool() 等 Desktop 的结果。

调用链路:确认框还在时点停止。

顺序 文件 · 方法 做什么
1 Renderer hooks/useTurnRunner.ts · stop() 上篇:agent.abortRun() 断开 SSE
2 Server agent/ag_ui_adapter.py · stream() 上篇:捕获取消 → _stop_producer() 对图的任务调用 cancel()
3 Server desktop/connections.py · dispatch() 正在 await Future,收到 CancelledError;finally 里把这个调用从等待表里删掉
4 Server desktop/service.py · call_tool() 的 except asyncio.CancelledError 在屏蔽取消的作用域里调 _cancel_inflight():tool_call 记为 unknown(错误码 RUN_CANCELLED),发 tool.call.cancel;然后继续抛出取消
5 Desktop wss-client.ts · onMessage() 收到 tool.call.cancel,对这个调用的 AbortController 调 abort('CANCELLED_BY_SERVER')
6 Desktop confirmation-manager.ts 监听到 signal 的 abort:结束等待(按未批准),并发 desktop-tool:confirmation-cancelled 让页面关掉确认框
7 Desktop wss-client.ts · handleToolCall() 确认返回后先查是否已取消:是,记为 cancelled(而不是"用户拒绝"),发结果
8 Server handle_result() unknown 不是最终状态,被 cancelled 覆盖,回 ack

数据库变化:

时刻 tool_call agent_run
点停止之后 unknown,RUN_CANCELLED cancelled,CLIENT_DISCONNECTED(上篇)
Desktop 回报之后 cancelled,CANCELLED_BY_SERVER 不变

要注意:

  • 第 4 步为什么要"屏蔽取消":这时协程正处在取消中,如果收尾的写库和发消息也被取消,tool_call 就会一直停在 dispatched。anyio.CancelScope(shield=True) 保证收尾做完,再把取消继续抛出去。
  • 为什么先记 unknown 而不是直接记 cancelled:Server 发出取消的那一刻,Desktop 可能已经执行完了,结果正在路上。Server 不知道,只能先记"不确定",以 Desktop 之后的回报为准。
  • 取消也可能比请求先到(请求还在做参数校验和路径预检时就收到了取消)。Desktop 用一个 cancelledEarly 集合记下来,占位之后立刻按取消处理。

6.8 场景 5:断线、Server 重启之后的对账

AI 要读 LICENSE,用户还在看确认框时 Server 重启了。用户随后点了"允许一次",Desktop 在本地读完了文件,但连接是断的。

调用链路:断线、Server 重启之后补发结果。

顺序 发生了什么 哪段代码
1 Server 关闭,WSS 断开。call_tool() 正在等的 Future 收到 DeviceDisconnectedError,tool_call 记为 unknown(RESULT_UNKNOWN) connections.py · unregister() → _fail_pending();service.py · call_tool()
2 Desktop 状态变为"未连接,正在重连",按 1、2、4...秒退避重连 wss-client.ts · onClose() → scheduleReconnect()
3 用户点了允许,Desktop 读文件,结果写进本地记录。发送时连接没打开,消息被丢弃,但记录还在 ,acked 是 false wss-client.ts · execute() → finish();send() 连接没打开时直接返回
4 Server 启动。recover_after_restart() 把上个进程留下的 created、dispatched 调用全标为 unknown(SERVER_RESTARTED) service.py · recover_after_restart() → repository.mark_inflight_unknown()
5 Desktop 重新申请 Ticket、连接、注册 同场景 0
6 收到 device.registered 后,补发所有还没被确认的结果 wss-client.ts · onMessage() → store.listUnacked() → sendResult()
7 Server 写库:unknown 被 succeeded 覆盖,最近一次下发也改为 succeeded;回 ack service.py · handle_result() → repository.record_result()
8 Desktop 删掉本地的输出 execution-store.ts · markAcked()

数据库变化:

时刻 tool_call tool_execution(只有 1 行) Desktop 本地记录
断线后 unknown unknown running(还在等确认)
用户允许后 不变 不变 succeeded,有输出,未确认
重连补发后 succeeded,有 result succeeded succeeded,输出已删,已确认

要注意:

  • 这次对话本身已经结束了:断线那一刻,模型拿到的是 RESULT_UNKNOWN 错误,这一轮按上篇的规则收尾。对账补上的是审计记录 ,让 tool_call 反映真实发生的事,不会让已经结束的这一轮"活过来"。
  • 为什么不自动重试:Server 不知道 Desktop 有没有执行。只读工具重复执行没有坏处,但以后的写文件、执行命令重复一次就可能出事。所以规则统一为"结果不确定就记 unknown,等对账"。
  • Desktop 在执行途中被关掉:下次启动 load() 时把 running 改为 unknown(DESKTOP_RESTARTED)补发,也不会自动重新执行。
  • 本地记录在 ack 之后保留 7 天再清理,这期间同一个 tool_call_id 再来,Desktop 仍然能认出来。

6.9 场景对比

场景 弹确认框 tool_call 最终状态 下发次数 模型拿到什么
1 列文件 否 succeeded 1 文件列表
2 读文件,允许 是 succeeded 1 文件内容
3 越界、敏感文件、参数不对 否 rejected 1 错误码
3 用户拒绝或不理 是 rejected,CONFIRMATION_REJECTED 1 错误码
4 确认时点停止 是,随后自动关闭 先 unknown,对账后 cancelled 1 拿不到,这一轮已停止
5 断线后补发 是 先 unknown,对账后 succeeded 1 RESULT_UNKNOWN
Desktop 没开 否 不写 0 DEVICE_OFFLINE
提问时没有设备(浏览器里调试) 否 不写 0 DESKTOP_DEVICE_NOT_SELECTED
相关推荐
阿里云云原生1 小时前
阿里云发布 AgenticOps 全栈能力,让 Agent 成为运维与研发的第一用户
agent
泡海椒1 小时前
JQuick-Excel 实战:FORMAT 管理日期与数字的 Excel 显示
开发语言·python·excel
阿里云云原生1 小时前
阿里云刚发布的 AgentCore 有何不同?
agent
学掌门2 小时前
老司机带你十分钟入门Python!
开发语言·python
xiaozongt19893 小时前
AI代码学习-Function Calling + ReAct
agent·ai编程
YEGE学AI算法3 小时前
Python实现Log-Mel Fbank:分帧、加窗、FFT与Mel滤波器组
python·语音识别·fbank·log-mel·音频特征
甜到心里的蛋糕3 小时前
Playwright 无头浏览器自动发布 CSDN 图文草稿的实践(持久化 profile + 图床直传)
运维·python·playwright·爬虫自动化·博客运营
阿里云云原生3 小时前
从个人生产力到企业生产力,阿里云发布企业级 Agent 平台 AgentCore
agent
jason.zeng@15022074 小时前
(六)Prompt 优化
python·ai·langchain·prompt·ai编程·llama