Files
sundynix-agentix/LOCAL_AGENT_DESIGN.md

270 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# sundynix-agentix · 本地 Agent 执行设计文档
> 版本:2026-07-23
> 定位:在**保留中心化治理**的前提下,让 Agent 能像 Claude Code 那样**操作用户本机的文件、执行命令**——把"大脑/治理"留服务端,把"手/执行"下放到客户端。
> 配套文档:`ARCHITECTURE_DESIGN.md`(架构总览)、`EINO_ADOPTION.md`(编排引擎)、`VOICE_DESIGN.md`(语音)、`SAAS_DESIGN.md`(多租户治理)、`DEPTH_ROADMAP.md`(路线图)
---
## 0. 一句话结论
> **中心化 ≠ 所有东西都在服务端执行。中心化 = 单一的控制/治理/观测平面。**
>
> 正确形态是**双平面**:**执行平面**下沉到客户端(拿 Claude Code 的手感),**控制平面**留服务端(守住计费/观测/记忆/策略)。这不是把已否决的"编排引擎下沉客户端"翻案——那否的是 DAG 工作流引擎;这里新增的是**另一个产品面**:一个 Claude-Code 式的本地操作 Agent。两者并存。
---
## 1. 目标与范围
### 1.1 要做什么
让用户在桌面端说/输入「把这个目录里的 CSV 都转成 JSON」「读一下这个日志找出报错」「跑一下测试」,Agent 就能**在用户自己的机器上**读写文件、执行命令,边干边把过程流式展示,且全程仍受服务端的预算/审批/审计管控。
```
用户意图 → Agent 决策(LLM) → 本地工具执行(读/写/命令) → 观察 → 再决策 → … → 结果
↑ 经服务端 LLM 代理(记忆/计费/护栏) ↑ 在本机瞬时执行(无额外过网)
```
### 1.2 核心原则
1. **执行在本地,控制在中心**——工具在本机跑(手感),LLM 访问/记忆/计费/观测/策略全经服务端咽喉。
2. **复用已有接口**——`ToolCaller``ToolCallingModel`、NATS req/reply、HITL 审批、prompt 注册表、预算护栏、secrets 托管,尽量不重造。
3. **安全是成败手,不是可选项**——LLM 驱动在用户机器上跑命令是最危险的面,默认收紧:路径沙箱 + 命令白名单 + HITL 审批 + 仅桌面端可用。
4. **可降级**——本地 runner 不在线时,明确报"本地执行器不可用",而非卡死;只读能力尽量保留。
### 1.3 不做什么(本期)
- ❌ 客户端本地跑大模型(推理一律经服务端 LLM 代理出去,这是中心化的支点)。
- ❌ 让 Web 端 / 共享工作区操作"别人的机器"(`local.*` 工具仅对有在线本地 runner 的桌面会话开放)。
- ❌ 客户端离线自治(无网即无推理,换取治理不失控)。
- ❌ 把 DAG 工作流引擎搬到客户端(那是另一条产品线,维持服务端)。
---
## 2. 与 Claude Code 的对比
### 2.1 先破除一个误解
Claude Code 的"手感"**不来自本地跑大模型**——它的 LLM 也在远端(Anthropic API),每一轮决策同样要过网。手感来自三点:
1. **紧凑的本地工具循环**:读→改→跑→看输出→再改,一秒好几轮,**内环不过网**。
2. **全程流式**:思考、工具输出实时可见。
3. **工作目录内的自主感**:锁定一个 cwd,在里面自由操作。
**所以"本地 vs 中心化"是假二选一。** 真正的问题是两个平面各放哪。而 Claude Code 自己就是混合的:**执行本地 + LLM 远端**。
### 2.2 逐项对比
| 维度 | Claude Code | sundynix-agentix(现状) | 本设计(双平面) |
|---|---|---|---|
| Agent 循环位置 | 本地 CLI 进程 | 服务端 dispatcher/Eino | **档 B:客户端 Go host** / 档 A:仍服务端 |
| LLM 访问 | 直连 Anthropic(用户持 key | dispatcher 直连 LLM 池 | **经 gateway LLM 代理**key 服务端托管) |
| 工具执行 | 本地、瞬时 | NATS→mcp-go(服务端) | **本地 runner,瞬时** |
| 每工具是否过网 | 否(内环本地) | 是(server↔mcp | **档 B 否 / 档 A 一跳(server↔client** |
| 多租户/隔离 | ❌ 无 | ✅ 已建 | ✅ 保留 |
| 计费/计量 | ❌ 无 | ✅ 积分/成本/rollup | ✅ 经 LLM 代理 + 事件上抛 |
| 集中可观测 | ❌ 本地日志 | ✅ OTel 全链路 | ✅ 客户端事件上抛服务端 |
| Prompt/模型治理 | 本地配置 | ✅ 注册表 + 热切换 | ✅ 保留 |
| 权限/审批 | 本地 permission 模式 | ✅ HITL 审批节点 | ✅ 策略下推 + 本地快审批 |
| 工作目录沙箱 | ✅ cwd | 无(服务端无本地盘概念) | ✅ 新增本地 cwd 沙箱 |
| 团队/共享知识 | ❌ | ✅ Space/KB 共享 | ✅ 保留 |
| 离线可用 | 部分(无 LLM 则不可) | ❌ | ❌(刻意,换治理) |
### 2.3 结论:我们要成为的东西
> **"Claude Code + 治理层"**——把 Claude Code 的本地执行手感,装进一个多租户、可计费、可观测、可审批的受管平台里。这正是 Claude Code **刻意不做**的部分,也是我们已投入建设的护城河所在。
---
## 3. 现有架构支持度(为什么不用推倒重来)
`sundynix-dispatcher/internal/eino/react_agent.go` 可知,agent 循环有两个关键事实,决定了本方案**是"换实现 + 加代理",不是"重写"**
1. **循环本体是 Eino 标准件 `react.NewAgent`**,可移植。它只吃两个注入:`ToolCallingModel`LLM+ 一组 `tool.BaseTool`(工具)。
2. **这两样都已在干净接口后面**
- LLM`o.agentPool(b).ToolCallingModel()` —— 池抽象,可换成"gateway LLM 代理客户端"。
- 工具:`mcpTool.InvokableRun``m.caller.CallTool(ctx, subject, ToolCall)``caller`**`ToolCaller` 接口**`subject` 是**可替换路由函数**;工具还是 `list_tools` **动态发现**,加工具零改循环。
**最难的两块(LLM 访问、工具调用)已被提前解耦。** 这是本方案成本可控的根本原因。
---
## 3.5 客户端可行性:Go + Wails 是最佳形态
> 直接结论:**支持,而且 Go+Wails 比 Electron/Tauri 都强。** 因为客户端与服务端 agent 是**同一种语言(Go)**——档 B 不是"用另一门语言重写循环",而是**直接共享同一份 Eino 循环代码**。
### 3.5.1 同语言 = 消掉最贵的成本
| 客户端技术 | 档 B 要付的代价 |
|---|---|
| ElectronJS/TS | 把 `react.NewAgent`/`mcpTool`/`ToolCaller` 全用 TS 重写 |
| TauriRust | 用 Rust 重写 |
| **Go + Wails(我们)** | **`import` 即用**dispatcher 与 desktop 共用同一个 `cloudwego/eino`、同一套 `contract` 类型、同一份循环逻辑 |
这把 §7 四个解耦点里最贵的"重写"直接消掉,只剩"搬家"。
### 3.5.2 界线:agent 跑在 Go host,不在 WebView
**Wails app = Go host(原生进程,无沙箱)+ WKWebView(只渲染 UI,经绑定调 Go)。** agent 循环跑在 Go host——所以 **WebView 的沙箱限制与执行完全无关**。逐块看,每一块都在 Go host 里正常跑:
| 部件 | Wails Go host | 说明 |
|---|---|---|
| Eino `react.NewAgent` 循环 | ✅ | 纯 Go 库;host 是正常 Go 进程,`goroutine`/`context`/`StreamReader` 全正常 |
| 本地工具 read/write/bash/grep | ✅ | `os.ReadFile`/`os.WriteFile`/`exec.CommandContext` 以用户 OS 权限跑——正是 Wails 甩纯 Web 应用之处 |
| LLM 代理客户端 | ✅ | `net/http` 打 gateway;实现成 Eino `ToolCallingChatModel` 接口,对循环透明 |
| 流式输出到 UI | ✅ | Go host 用 Wails 运行时事件(`EventsEmit`)推前端渲染;同时自持连接上抛服务端 |
| 长连接/后台 goroutine | ✅ | host 生命周期独立于 webview,启动时起 goroutine 持 gateway 连接、注册 runner |
### 3.5.3 唯一真实的坑:模块依赖接线(非能力问题)
1. **desktop 是独立 module、不在 go.work 里**(见 `wails3-migration``go-workspace-genproto` 坑)。
2. **Go `internal/` 包不能跨模块 import** → dispatcher 里那份循环**必须挪到非 internal 的共享位置**`sundynix-shared/agent` 或新模块)才能被 desktop 引。即 §7 解耦点 #3
3. `sundynix-desktop/go.mod` 要 require `sundynix-shared`,构建走 `GOWORK=off`,靠 **go.mod 依赖(replace 指向本地 / Gitea module proxy**解析,不能靠 go.work。**盯紧已踩过的 genproto/indirect-require 版本冲突。**
4. 次要:Eino 传递依赖不少,加进 desktop 会让**二进制变大、构建变慢**——可接受,但心里有数。
> 一句话:**能力上零障碍,Go+Wails 天然支持;成本上因同语言,档 B 从"重写"降级成"把共享单元搬进 sundynix-shared + 接好 go.mod 依赖"。** 你趟过的 go.work/genproto 坑,恰好就是这次搬家要绕的雷。
---
## 4. 双平面架构
```
┌────────────────────── 控制平面(服务端,中心化治理)──────────────────────┐
│ gateway │
│ ├─ LLM 代理端点 ← 咽喉:注入记忆/prompt、记用量/计费、护栏、OTel、key托管 │
│ ├─ 策略下推 → ToolPolicy / 白名单 / 预算令牌桶(复用 config 下推范式)│
│ ├─ HITL 策略定义 → 哪些操作必须审批(人工/组织级流程仍服务端 pause) │
│ └─ 事件汇聚 ← 客户端上抛的 token/trace/usage,进 NATS/JetStream │
│ dispatcher/EinoDAG 工作流编排(另一产品面,维持不变) │
└─────────────────────────────────────────────────────────────────────────────┘
▲ LLM 调用(每轮决策) ▲ 事件上抛(异步)
│ │
┌────────────────────── 执行平面(客户端 Wails Go host)──────────────────────┐
│ 本地 Agent 运行时(档 B:react 循环搬到这里) │
│ ├─ ToolCallingModel = gateway LLM 代理客户端(实现 Eino ChatModel 接口) │
│ ├─ 本地工具族 local.*read/write/edit/bash/grep/globos / os/exec
│ ├─ cwd 沙箱:所有操作锁在用户选的工作目录根下 │
│ ├─ 本地 HITL:破坏性操作快速弹窗审批(策略由服务端下推) │
│ └─ 策略缓存 + 预算令牌桶:内环本地判定,不阻塞网络 │
└─────────────────────────────────────────────────────────────────────────────┘
```
**关键点:只有"LLM 调用"离开本机(Claude Code 也一样),工具执行全在本地。** 手感回来,治理不漏。
---
## 5. 两档演进路线
> 建议**先做档 A**,用真实用例量延迟;**真的卡手了再上档 B**。你的场景(助手操作几个文件/跑个命令)工具调用频率低,档 A 那点往返大概率无感;Claude-Code 内环之痛只在"读几十文件、反复跑测试"才暴露。
### 档 A:只下放工具执行,循环留服务端 —— **成本低(几天级)**
因为 `ToolCaller` 已是接口 + 可替换 subject,只需:
1. 新增 `local.*` 工具族,`CallTool` 的 subject 路由到 **NATS→gateway→客户端 runner**(而非 →mcp-go)。
2. 客户端 Go host 跑一个"迷你 mcp"执行器,登录后注册到现有连接,收调用就 `os.ReadFile`/`exec.CommandContext`,结果原路回。
3. gateway 做 subject 桥接:把发给某用户 runner 的 NATS 请求,转到该 runner 的连接,等 reply。
**循环、图、LLM、记忆、计费、追踪一行不动。** 就是给工具层多插一个执行目标。
- ✅ 立刻拿到"操作本地"能力,治理全在。
- ❌ 每工具过一次网(server↔client),无 Claude Code 紧凑手感。
### 档 B:把 agent 循环搬到客户端 —— **成本中等(解耦+搬家,非重构)**
拦路的不是架构,是循环今天**和服务端世界缠在一起**。需拆的**四个耦合点**(见 §7)。搬完后:
- ✅ 内环本地、瞬时,真正的 Claude Code 手感。
- ✅ 控制平面(LLM 代理 + 策略下推 + 事件上抛)守住中心化。
---
## 6. 控制平面:三个支点
### 6.1 LLM 代理(咽喉,档 B 的地基)
- gateway 出一个 chat/completions 风格端点;客户端每次推理经它出去。
- 挂载:记忆注入(复用现有 recall)、prompt 版本化(注册表)、用量计量(计费/积分)、护栏/忠实度、OTel span、**provider key 永不下发客户端**。
- 客户端把该端点实现成一个 Eino `ToolCallingChatModel`,喂给 `react.NewAgent`——对循环透明。
- 净新增,但可复用 `sundynix-dispatcher/internal/llm` 的 client 逻辑。
### 6.2 策略下推(治理集中,但别让内环等网络)
复用现有 `PublishConfigUpdated` / `RequestConfig` 下推范式:
- **ToolPolicy / 白名单**:会话开始下推客户端,**内环本地判定**,异步回报;服务端可 push 撤销/调整。
- **预算令牌桶**:服务端发额度给客户端,本地花、异步续、花光**本地硬停**,不必每调用回问。
- **HITL**:常见破坏性操作走**本地快速审批弹窗**(不阻塞、不回服务端 pause);"哪些必须审批"由服务端策略下推,每个决策**记回服务端审计**;只有"组织级人工批"流程才走现有服务端 pause→waiting。
### 6.3 事件上抛(观测/计费不断)
- 客户端把 trace/token/usage 事件经 NATS(或现有 WS)上抛,进 JetStream 持久(计费类一律持久 + 幂等,见 `nats-durability` 约定)。
- 服务端观测面板照常看到"agent 读了哪个文件、跑了什么命令、花了多少 token"。
---
## 7. 档 B 的四个解耦点(真实工作量所在)
| # | 耦合点 | 现状 | 要做的解耦 | 性质 |
|---|---|---|---|---|
| 1 | 循环黏服务端专属件 | `o.sink.PublishToken`NATS 出 token)、`execTracer`span)、`harness.BudgetFrom`(预算)、`board`/`recordAgentOutput`(喂图下游) | 把循环抽成**吃接口的独立单元**token sink / tracer / budget 改注入,客户端本地实现 + 事件上抛。redactor 已是纯函数可直接搬 | 机械解耦 |
| 2 | LLM 代理净新增 | 仅 dispatcher 直连 LLM 池、持 key | gateway 出 chat 端点,客户端实现成 Eino ChatModel | 新增(可复用 llm client |
| 3 | 打包/模块边界 | 桌面端是**独立 module、不在 go.work**(见 `go-workspace-genproto``wails3-migration` 坑) | 把可复用 agent 单元挪进 `sundynix-shared`(或新模块),desktop 与 dispatcher 共享 import | 搬家(注意 go.work 依赖坑) |
| 4 | 事件回传 | 服务端内部 NATS | 客户端 → 服务端事件通道(复用 WS 或 NATS)+ 幂等落库 | 新增(复用现有总线) |
**没有一条是"架构不支持"或"推倒重来"——全是解耦、搬家、加一个代理端点。**
---
## 8. 安全模型(最重要)
LLM 驱动在用户机器上跑命令 = 最高危面。默认全部收紧,宁可先严后松:
1. **路径沙箱**:用户显式选一个工作目录根;所有 `local.*` 只能在根下操作,越界直接拒(软链接/`..` 逃逸也拦)。
2. **命令白名单/黑名单**`rm -rf``curl|sh`、写系统路径、`sudo` 等默认拦;exec 默认需审批。
3. **HITL 审批**:写文件/删除/执行命令默认走审批;可配"这次允许 / 本会话允许 / 加白名单"。
4. **仅桌面端可用**`local.*` 必须 gate 在"有在线本地 runner 的桌面会话";Web/共享工作区不给(否则语义即"让 A 操作 B 的机器",灾难)。写进 ToolPolicy。
5. **归属校验**:本地 runner 只服务自己登录的用户,注册带 `user_id` + 设备指纹,服务端严格校验目标连接归属,绝不串。
6. **审计**:每次本地文件写/命令执行,无论是否需审批,都落审计事件回服务端。
7. **超时 + 资源限制**:命令有超时、输出大小上限,防失控进程/刷屏。
---
## 9. 可靠性 / 寻址
- **连接注册表**:服务端维护 `user_id/device → runner 连接`;任务提交时绑定"发起它的那台 runner"。
- **多设备歧义**:一个用户开多台桌面端 → 任务必须绑定发起端,不能随便选。
- **断线兜底**:客户端中途断线,工具调用超时 + 明确报"本地执行器不在线",任务优雅失败而非卡死。
- **降级**:runner 不在线时,纯服务端能力(问答/检索/报告)不受影响。
---
## 10. 落地阶段(从现有往上加)
| 阶段 | 内容 | 依赖 | 产出 |
|---|---|---|---|
| **P0** | gateway **LLM 代理端点**:客户端推理经它,挂计量/追踪/记忆注入/护栏 | 复用 `internal/llm` | 中心化咽喉立住 |
| **P1(档 A** | `local.*` **只读**工具(list/read+ 客户端 runner 注册 + subject 桥接 + cwd 沙箱 | `ToolCaller` 接口 | **✅ 2026-07-24 落地并 live 验通**(实现见 JARVIS_BRAIN_DESIGN P4gateway `local_runner.go` + desktop `localrunner.go`;沙箱逃逸单测全拦;离线优雅降级) |
| **P2(档 A** | write/edit/bash + **本地 HITL** + ToolPolicy 下推 + 审计 | HITL 节点、config 下推 | 可操作本地,治理收紧 |
| **P3(档 B** | 按 §7 抽循环 → 搬 `sundynix-shared` → 客户端跑 react 循环 + 预算令牌桶 + 事件上抛 | 四个解耦点 | Claude Code 手感 |
| **P4** | 桥接服务端图编排(Task 式子 agent)、会话 resume、CLAUDE.md 式本地项目记忆 | P3 | 两产品面打通 |
**决策门**:P1 跑通后,用真实用例量 server↔client 单次往返延迟。若无感 → 停在档 A 收益已足;若明显卡手(高频内环)→ 才付 P3 的钱。
---
## 11. 风险与取舍
- **产品定位决定 ROI**:档 B 只有当产品是"完整编码/操作 agent 在我仓库里连续折腾"才值;若是"助手偶尔干几件本地事",档 A 足矣。**先确认定位再决定要不要 P3。**
- **模块边界坑**desktop 不在 go.work,搬共享 agent 单元时小心 `genproto`/indirect require 冲突(见 `go-workspace-genproto`)。
- **安全面扩大**:一旦能跑命令,任何提示注入/越权都可能落到用户机器。§8 的门禁是硬要求,不是可选。
- **老引擎兼容**:客户端 Go 运行时无浏览器引擎版本问题;但若本地 agent 的 UI 复用现有前端,注意 WKWebView 版本坑(另见前端白屏排查)。
---
## 12. 待定问题(需产品确认)
1. 产品定位:受管的**团队/企业操作平台** 还是 **更强的个人本地助手**?(决定要不要档 B
2. 本地 runner 的连接:直连 NATS 还是经 gateway WS?(出网策略 + 部署拓扑约束)
3. exec 的默认收紧程度:默认全审批,还是白名单内免审批?
4. 是否需要"CLAUDE.md 式"本地项目记忆文件,与服务端记忆如何合并?