Compare commits
13 Commits
b87968632a
...
bf09de6700
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf09de6700 | ||
|
|
dbb18a094c | ||
|
|
5db25b513e | ||
|
|
d2c9902966 | ||
|
|
f7e2ff256e | ||
|
|
c7d3d45c68 | ||
|
|
0fc6fe0c8e | ||
|
|
ef88487f4a | ||
|
|
e294fbb9b1 | ||
|
|
7d9036b2d4 | ||
|
|
54049a1e1e | ||
|
|
3844f2c34c | ||
|
|
5ed81e5f0c |
@@ -3,6 +3,8 @@
|
||||
## Project Structure & Module Organization
|
||||
`docs/` is the main source of product, architecture, integration, and team-process documentation. Keep active engineering documents in `docs/*.md`; presentation exports belong under `docs/archive/领导演示资料/`. `frontend/archive/sgClaw验证-已归档/` contains the historical Vue 2 verification page (`index.html`, `index.vue`) plus helper scripts (`serve.sh`, `download-libs.sh`, `testRunner.js`). `frontend/README.md` and `docs/README.md` describe what is active versus archived.
|
||||
|
||||
This repository only manages the sgClaw runtime, compatibility layers, skills, and architecture docs. The frontend overlay and Chromium-side code that embed sgClaw into SuperRPA live in the `superRPA` checkout; see `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/` (overlay HTML/JS/CSS) together with the surrounding Chromium resources and host bootstrap code for the actual browser-integrated UI.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
There is no formal build system in the repository today. Use the local verification page directly:
|
||||
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2428,6 +2428,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -11,6 +11,7 @@ futures-util = "0.3"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
regex = "1.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
|
||||
## 1. 产品定义
|
||||
|
||||
sgClaw 是一个嵌入企业浏览器运行环境中的浏览器智能体执行内核。它的职责不是替代整个平台,也不是承诺“全自动数字员工”,而是把自然语言任务转换成受控的浏览器操作,并通过既有浏览器宿主完成页面执行。
|
||||
sgClaw 不是“浏览器智能体执行内核”意义上的 browser-only 产品。更准确地说,它是一个嵌入企业浏览器运行环境中的、安全加固后的 zeroclaw runtime:zeroclaw 负责智能体能力本体,sgClaw 负责把这些能力包裹进受控的执行边界中。
|
||||
|
||||
ZeroClaw 重构之后,sgClaw 的产品形态可以概括为三件事:
|
||||
它的职责不是替代整个平台,也不是承诺“全自动数字员工”,而是:
|
||||
|
||||
1. 把用户任务接入统一的 Agent 执行入口。
|
||||
2. 通过固定的 `browser_action` 工具把意图翻译为浏览器命令。
|
||||
3. 在协议、域名和动作白名单的约束下完成可审计的页面操作。
|
||||
1. 以 zeroclaw 为核心提供统一的 Agent 执行入口、prompt/skills/memory/tool routing 能力。
|
||||
2. 通过 sgClaw 的 pipe、HMAC、MAC Policy 和宿主二次校验,把高风险执行面收敛成受保护的工具表面。
|
||||
3. 在浏览器场景下,把页面操作能力作为特权工具面暴露给 runtime,而不是让浏览器反过来定义整个 runtime。
|
||||
|
||||
当前仓库中的 sgClaw 不是一个完整前端产品,也不是浏览器发行版本身,而是“浏览器 Agent Runtime + Pipe 协议 + ZeroClaw 兼容层”的产品核心。
|
||||
当前仓库中的 sgClaw 不是一个完整前端产品,也不是浏览器发行版本身,而是“vendored zeroclaw core + sgClaw 安全封装层 + browser host integration”的产品核心。
|
||||
|
||||
---
|
||||
|
||||
@@ -24,17 +24,20 @@ ZeroClaw 重构之后,sgClaw 的产品形态可以概括为三件事:
|
||||
|
||||
### 2.1 当前已经落地的能力
|
||||
|
||||
- 已 vendored `zeroclaw`,并通过 Rust 运行时接入 provider、skills、memory 等核心能力入口。
|
||||
- 浏览器侧通过 STDIO JSON Line 协议与 Rust 进程通信。
|
||||
- 启动时执行 `init -> init_ack` 握手,并建立会话级 HMAC 密钥。
|
||||
- 任务输入统一走 `submit_task` 消息。
|
||||
- Rust 侧支持两条执行路径:
|
||||
- 未配置大模型时,使用仓库内置 planner/fallback 逻辑。
|
||||
- 配置 `DEEPSEEK_*` 环境变量时,切换到 ZeroClaw compatibility runtime。
|
||||
- 当前有效工具面收敛为一个工具:`browser_action`。
|
||||
- 当前真正稳定落地的特权执行面收敛为一个工具:`browser_action`。
|
||||
- 当前真正开放给模型的动作仅 4 个:`click`、`type`、`navigate`、`getText`。
|
||||
- 所有浏览器动作都受 `resources/rules.json` 中的域名和动作白名单约束。
|
||||
- 执行过程中会向宿主发送结构化日志和最终任务结果。
|
||||
|
||||
注意:上述“browser-first + compat-only”是当前实现状态,不是目标产品定义。主线目标仍然是“安全加固后的 zeroclaw runtime”,而不是“只会操作浏览器的 agent”。
|
||||
|
||||
### 2.2 当前明确不宣称的能力
|
||||
|
||||
以下内容在旧文档中存在较多规划性描述,但并非当前仓库中的已实现事实:
|
||||
@@ -45,6 +48,7 @@ ZeroClaw 重构之后,sgClaw 的产品形态可以概括为三件事:
|
||||
- 完整的浏览器 Side Panel 产品界面。
|
||||
- 40+ 页面动作在 Agent 侧全部开放。
|
||||
- 真实生产级多租户、审计后台、任务编排中心。
|
||||
- “浏览器是 sgClaw 的全部 runtime” 这种定义。
|
||||
|
||||
这些能力可以保留为后续扩展方向,但不应继续写入 L0-L4 作为现状描述。
|
||||
|
||||
@@ -52,7 +56,7 @@ ZeroClaw 重构之后,sgClaw 的产品形态可以概括为三件事:
|
||||
|
||||
## 3. 产品价值主张
|
||||
|
||||
ZeroClaw 重构后的 sgClaw,核心价值不在“功能堆叠”,而在于把原本分散的浏览器自动化能力收敛成一个可控、可替换、可验证的智能体执行底座。
|
||||
ZeroClaw 重构后的 sgClaw,核心价值不在“功能堆叠”,也不在“单纯浏览器自动化”,而在于把 zeroclaw 的智能体能力放进一个可控、可替换、可验证的安全执行底座里。
|
||||
|
||||
### 3.1 对业务侧
|
||||
|
||||
@@ -62,15 +66,15 @@ ZeroClaw 重构后的 sgClaw,核心价值不在“功能堆叠”,而在于
|
||||
|
||||
### 3.2 对集成侧
|
||||
|
||||
- 浏览器宿主只需实现固定协议,不必理解模型内部细节。
|
||||
- Agent Runtime 可以在保留宿主协议的前提下切换实现策略。
|
||||
- ZeroClaw 兼容层把未来模型、记忆、工具调度的升级入口预留在 Rust 侧。
|
||||
- 浏览器宿主只需实现固定协议和宿主侧安全复检,不必理解模型内部细节。
|
||||
- sgClaw Runtime 可以在保留宿主协议的前提下演进 zeroclaw 配置、skills 和工具策略。
|
||||
- 浏览器只是一个受保护执行面;未来其它客户端也应复用同一 runtime,而不是另起一套架构。
|
||||
|
||||
### 3.3 对安全侧
|
||||
|
||||
- 不是“模型可任意操作浏览器”,而是“模型只能调用被允许的动作”。
|
||||
- 安全边界前置到协议和 MAC Policy,而不是把约束留给提示词。
|
||||
- 域名、动作、HMAC 三类控制共同组成最小可信执行面。
|
||||
- 不是“模型可任意操作浏览器”,而是“runtime 只能通过被授权的特权工具面触发高风险动作”。
|
||||
- 安全边界前置到协议、MAC Policy、宿主二次校验和运行时工具策略,而不是把约束留给提示词。
|
||||
- 域名、动作、HMAC 与工具暴露策略共同组成最小可信执行面。
|
||||
|
||||
---
|
||||
|
||||
@@ -80,15 +84,16 @@ ZeroClaw 重构后的 sgClaw,核心价值不在“功能堆叠”,而在于
|
||||
|---|---|---|
|
||||
| 任务接入 | 已实现 | 接收浏览器宿主发来的 `submit_task` 指令 |
|
||||
| 协议握手 | 已实现 | 统一版本、会话标识、HMAC 种子交换 |
|
||||
| Agent 执行 | 已实现 | planner fallback 与 ZeroClaw compat 共存 |
|
||||
| 浏览器工具 | 已实现 | 单一 `browser_action` 工具 |
|
||||
| zeroclaw Core | 已接入但未充分释放 | prompt、skills、memory、provider routing 的能力本体已经在仓库中 |
|
||||
| Agent 执行 | 已实现但仍处过渡态 | planner fallback 与 browser-first ZeroClaw compat 共存 |
|
||||
| 浏览器特权工具面 | 已实现 | 通过 `browser_action` 暴露受保护的浏览器执行能力 |
|
||||
| 核心动作 | 已实现 | `click/type/navigate/getText` |
|
||||
| 域名白名单 | 已实现 | 仅允许规则文件中的域名 |
|
||||
| 动作白名单 | 已实现 | 仅允许规则文件中的动作 |
|
||||
| 结构化日志 | 已实现 | `log_entry` 与 `task_complete` 回传 |
|
||||
| 扩展动作枚举 | 已预留 | 协议枚举已定义,但默认未开放 |
|
||||
| Skill 引擎 | 未独立实现 | 当前仅保留“可被工具和提示词扩展”的语义入口 |
|
||||
| MCP 生态 | 未在主链路启用 | ZeroClaw 兼容层为后续保留位置 |
|
||||
| Skill 体系 | 已 vendored 但运行时只部分使用 | 应复用 zeroclaw 原生机制,而不是另起一套浏览器专用技能系统 |
|
||||
| MCP 生态 | 未在主链路启用 | 未来可接入,但仍应服从 sgClaw 安全封装 |
|
||||
|
||||
---
|
||||
|
||||
@@ -109,13 +114,13 @@ ZeroClaw 重构后的 sgClaw,核心价值不在“功能堆叠”,而在于
|
||||
当页面元素定位规则明确时,系统可用 `click` 和 `type` 组合完成表单录入、按钮点击、简单提交等动作。
|
||||
是否能覆盖完整业务流程,取决于浏览器宿主是否提供对应页面、选择器和回包信息,而不是文档层面预设“所有流程都能端到端执行”。
|
||||
|
||||
### 5.3 作为更大产品中的 Agent 执行核
|
||||
### 5.3 作为更大产品中的安全执行核
|
||||
|
||||
sgClaw 更适合被理解为产品底座中的一个执行核:
|
||||
|
||||
- 上层可以接入任务输入框、审批入口或业务编排器。
|
||||
- 下层通过既有浏览器控制面执行。
|
||||
- 中间由 sgClaw 把自然语言与浏览器动作连接起来。
|
||||
- 下层可以通过既有浏览器控制面执行,也可以在未来接入其它受保护工具面。
|
||||
- 中间由 sgClaw 把 zeroclaw runtime 与外部受控执行面连接起来。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
# L1 — 系统架构与安全模型层
|
||||
|
||||
**文档版本**: 2.0
|
||||
**适用项目**: sgClaw(ZeroClaw 重构版)
|
||||
**编制日期**: 2026-03-26
|
||||
**文档版本**: 2.1<br>
|
||||
**适用项目**: sgClaw(ZeroClaw 重构版)<br>
|
||||
**编制日期**: 2026-03-29
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构总览
|
||||
|
||||
重构后的 sgClaw 架构要点很简单:浏览器宿主负责页面执行,Rust 进程负责任务解释与协议编排,ZeroClaw 作为兼容运行时被接入到 Rust 侧,而不是直接替代整个系统。
|
||||
重构后的 sgClaw 架构要点应当这样理解:`host`(SuperRPA)是受保护的宿主安全边界,`sgClaw` 是运行时能力编排层,`zeroclaw` 是 planner / model / skill 的能力本体,`frontend bundle` 只是展示面。当前代码尚未完全落到这个目标结构,但主线架构口径必须先统一。
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Browser Host / Chromium Side │
|
||||
│ - 读取 launch config │
|
||||
│ - 启动 sgClaw 子进程 │
|
||||
│ - 发送 init / submit_task │
|
||||
│ - 执行 command 并回 response │
|
||||
│ - 复检 HMAC / domain / params │
|
||||
│ - 执行 browser command 并回包 │
|
||||
│ - 选择 frontend bundle │
|
||||
└──────────────┬───────────────┘
|
||||
│ STDIO + JSON Line
|
||||
┌──────────────▼───────────────┐
|
||||
│ sgClaw Rust Runtime │
|
||||
│ sgClaw Security Envelope │
|
||||
│ - 握手与消息循环 │
|
||||
│ - MAC Policy │
|
||||
│ - BrowserPipeTool │
|
||||
│ - Planner fallback │
|
||||
│ - ZeroClaw compat runtime │
|
||||
│ - Runtime / Tool Policy │
|
||||
│ - Config Adaptation │
|
||||
└──────────────┬───────────────┘
|
||||
│ Provider API / Local Config
|
||||
│ zeroclaw APIs / Local Config
|
||||
┌──────────────▼───────────────┐
|
||||
│ ZeroClaw Core Runtime │
|
||||
│ - Prompt Builder │
|
||||
│ - Skills / Memory │
|
||||
│ - Tool Loop / Routing │
|
||||
│ - Provider Dispatch │
|
||||
└──────────────┬───────────────┘
|
||||
│ Provider API / Optional Tools
|
||||
┌──────────────▼───────────────┐
|
||||
│ Model Provider │
|
||||
│ - DeepSeek/OpenAI-compatible │
|
||||
@@ -34,23 +45,25 @@
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
架构上最重要的变化是:当前系统不是“完整 ZeroClaw 产品”,而是“保留现有浏览器协议的前提下,把 ZeroClaw 作为兼容执行内核引入”。
|
||||
架构上最重要的变化是:sgClaw 不应被定义为“浏览器专用 agent”,而应被定义为“保留现有浏览器协议前提下,对 zeroclaw 做安全化封装的运行时分发”。同时 `host` 不再持有高频变化的业务策略,`launch config`、`runtime config` 和 `frontend bundle` 应成为运行时可替换对象。
|
||||
|
||||
---
|
||||
|
||||
## 2. 运行时分层
|
||||
|
||||
### 2.1 浏览器宿主层
|
||||
### 2.1 host(浏览器宿主)层
|
||||
|
||||
宿主负责三类职责:
|
||||
`host` 负责受保护边界内的四类职责:
|
||||
|
||||
- 读取并校验 `launch config`,决定进程如何被拉起。
|
||||
- 启动和托管 sgClaw Rust 子进程。
|
||||
- 按协议发送 `init`、`submit_task`、`response`。
|
||||
- 执行 Rust 发来的浏览器命令并回包。
|
||||
- 选择外部 `frontend bundle` 或内置资源作为展示面。
|
||||
|
||||
sgClaw 仓库本身不包含 Chromium/C++ 实现代码,因此 L1 只定义宿主责任边界,不再把外部仓库中的假定文件结构写成“当前仓库现状”。
|
||||
`host` 不拥有 planner、模型路由、skills 编排或业务策略定义。sgClaw 仓库本身不包含 Chromium/C++ 实现代码,因此 L1 只定义宿主责任边界,不再把外部仓库中的假定文件结构写成“当前仓库现状”。
|
||||
|
||||
### 2.2 Rust 控制层
|
||||
### 2.2 sgClaw 安全/控制层
|
||||
|
||||
Rust 侧是当前仓库的事实主体,职责包括:
|
||||
|
||||
@@ -58,10 +71,40 @@ Rust 侧是当前仓库的事实主体,职责包括:
|
||||
- 完成握手、加载 `rules.json`、创建 `BrowserPipeTool`。
|
||||
- 在消息循环中接收浏览器消息并分发到执行层。
|
||||
- 把执行日志和任务结果回传给宿主。
|
||||
- 读取 `runtime config`,决定 planner、provider、skills prompt mode、backend 选择等运行时行为。
|
||||
- 决定哪些 zeroclaw 能力能够暴露给当前运行环境。
|
||||
|
||||
### 2.3 执行层
|
||||
### 2.3 zeroclaw 核心层
|
||||
|
||||
执行层当前有两条路径:
|
||||
主线目标中,zeroclaw 应承担:
|
||||
|
||||
- planner / executor 决策
|
||||
- prompt/system sections 组装
|
||||
- skills / memory / routing
|
||||
- tool loop 与 provider 协调
|
||||
- 通用 agent 能力而非仅浏览器能力
|
||||
|
||||
也就是说,`planner-first` 是 sgClaw / zeroclaw 运行时行为,不是 `frontend bundle` 或 `host` 页面逻辑。
|
||||
|
||||
### 2.4 展示层(frontend bundle)
|
||||
|
||||
前端展示层只拥有显示权,不拥有执行权。其职责应被限制为:
|
||||
|
||||
- 展示会话状态、日志、消息、验收结果。
|
||||
- 将用户输入转成宿主可接受的事件。
|
||||
- 呈现 `planner-first` 的可视化状态,例如“先展示计划,再执行”。
|
||||
|
||||
前端不应决定:
|
||||
|
||||
- 是否调用 planner
|
||||
- 是否切换 provider / model
|
||||
- 是否绕过 sgClaw / zeroclaw 执行
|
||||
|
||||
这些决策必须留在 sgClaw / zeroclaw 运行时。
|
||||
|
||||
### 2.5 当前实现的过渡态
|
||||
|
||||
当前执行层仍有两条路径:
|
||||
|
||||
1. `planner fallback`
|
||||
说明:当未配置 `DEEPSEEK_API_KEY` 等环境变量时,使用仓库内置的轻量 planner 执行。
|
||||
@@ -69,23 +112,38 @@ Rust 侧是当前仓库的事实主体,职责包括:
|
||||
2. `ZeroClaw compat runtime`
|
||||
说明:当提供模型配置后,通过 [`src/compat/runtime.rs`](/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs) 构造 provider、memory 和 `browser_action` 工具,把任务交给 vendored ZeroClaw Agent。
|
||||
|
||||
这两条路径共存,是当前重构期的核心现实。文档必须保留这一点,否则会误导实现和联调。
|
||||
这两条路径是当前代码现实,但都不应被写成长期产品定义。长期目标是“zeroclaw-first runtime + sgClaw security layer”,而不是 browser-only compat。
|
||||
|
||||
### 2.6 运行时配置边界
|
||||
|
||||
本轮架构冻结后,必须使用以下统一术语:
|
||||
|
||||
- `launch config`
|
||||
由 `host` 读取,描述 `binary`、`args`、`env`、`working_dir`、`runtime_config_path`、`frontend_bundle_dir` 等启动时边界。
|
||||
- `runtime config`
|
||||
由 sgClaw 读取,描述 provider、model、planner mode、backend 选择、skills 策略等运行时行为。
|
||||
- `frontend bundle`
|
||||
由 `host` 装载的展示资源目录,允许外部 bundle 优先、内置资源兜底。
|
||||
|
||||
这三类对象都应支持运行期调整;只有安全边界和能力暴露由 `host` 固化。
|
||||
|
||||
---
|
||||
|
||||
## 3. ZeroClaw 重构的架构意义
|
||||
|
||||
ZeroClaw 在本项目中的角色不是“大而全框架接管一切”,而是解决三个具体问题:
|
||||
ZeroClaw 在本项目中的角色不是“大而全框架接管一切”,也不是“被 sgClaw 套壳后只剩一个 browser_action 工具”,而是系统能力本体。sgClaw 应该在它上面解决三个具体问题:
|
||||
|
||||
- 统一模型 Provider 抽象。
|
||||
- 为后续记忆、工具调度、可观测性留出标准扩展位。
|
||||
- 在不改浏览器协议的前提下,替换任务执行内核。
|
||||
- 统一模型 Provider 抽象、skills、memory 和 tool loop。
|
||||
- 在不改浏览器协议的前提下,把高风险执行约束到受保护的工具面。
|
||||
- 让浏览器成为特权执行面,而不是反过来让浏览器定义整个 runtime。
|
||||
|
||||
当前兼容层的限制也必须明确:
|
||||
|
||||
- 只注册一个工具:`browser_action`。
|
||||
- 只开放 4 个动作:`click/type/navigate/getText`。
|
||||
- 不以 ZeroClaw 的全量工具生态作为对外能力宣称。
|
||||
- 不以 ZeroClaw 的全量工具生态作为当前对外能力宣称。
|
||||
|
||||
这些限制是当前实现状态,不是目标架构原则。
|
||||
|
||||
---
|
||||
|
||||
@@ -142,21 +200,38 @@ ZeroClaw 在本项目中的角色不是“大而全框架接管一切”,而
|
||||
原因:
|
||||
|
||||
- 浏览器宿主联调成本最低。
|
||||
- Rust 侧可以独立迭代 planner 和 ZeroClaw 路径。
|
||||
- Rust 侧可以独立演进 zeroclaw runtime 与安全策略,而不破坏宿主联调。
|
||||
- 产品文档、测试和协议标准可以围绕同一条 contract 收敛。
|
||||
|
||||
### 5.3 先做最小工具面,再扩动作
|
||||
### 5.3 先做最小特权工具面,再扩动作
|
||||
|
||||
原因:
|
||||
|
||||
- 当前最稳定的是 `click/type/navigate/getText`。
|
||||
- 动作越多,宿主和模型之间的契约越难稳定。
|
||||
- 动作越多,宿主和 runtime 之间的契约越难稳定。
|
||||
- 在规则文件仍只开放 4 个动作的前提下,文档不应提前放大能力范围。
|
||||
|
||||
### 5.4 启动策略配置化,而不是编译期写死
|
||||
|
||||
原因:
|
||||
|
||||
- 更换 sgClaw binary 不应要求重编 Chromium。
|
||||
- 切换 `runtime config` 不应要求重编 Chromium。
|
||||
- 更换 `frontend bundle` 不应要求重编 Chromium。
|
||||
- 安全边界仍由 `host` 控制,配置化不等于放弃校验。
|
||||
|
||||
### 5.5 planner-first 只属于运行时,不属于展示层
|
||||
|
||||
原因:
|
||||
|
||||
- “先展示计划,再执行”是执行引擎行为,不是前端动画效果。
|
||||
- 前端只能显示 planner 状态,不能私自触发或跳过 planner。
|
||||
- 验收必须同时覆盖视觉呈现和功能执行两个维度。
|
||||
|
||||
---
|
||||
|
||||
## 6. 架构结论
|
||||
|
||||
L1 层面可以把 sgClaw 定义为:一个通过固定浏览器协议接入宿主、以 Rust 为控制层、以 ZeroClaw 为兼容执行核、以 MAC Policy 为最小安全边界的浏览器智能体运行时。
|
||||
L1 层面可以把 sgClaw 定义为:一个通过固定浏览器协议接入 `host`、以 Rust 为安全与控制层、以 zeroclaw 为能力核心、以 `runtime config` 驱动执行行为、以 `frontend bundle` 作为仅展示界面、以 MAC Policy 与宿主复检为受保护执行边界的安全加固运行时分发。
|
||||
|
||||
这一定义与当前仓库实现保持一致,也为后续继续扩展动作、工具和记忆系统保留了清晰边界。
|
||||
这一定义既承认当前仓库仍存在 browser-first compat 的过渡实现,也为后续把 runtime 真正收口到 zeroclaw-first、planner-first 主线保留了清晰边界。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# L2 — 核心模块与接口契约层
|
||||
|
||||
**文档版本**: 2.0
|
||||
**适用项目**: sgClaw(ZeroClaw 重构版)
|
||||
**编制日期**: 2026-03-26
|
||||
**文档版本**: 2.1<br>
|
||||
**适用项目**: sgClaw(ZeroClaw 重构版)<br>
|
||||
**编制日期**: 2026-03-29
|
||||
|
||||
**读者**: 架构工程师、实现工程师、联调工程师
|
||||
|
||||
@@ -24,14 +24,28 @@ src/
|
||||
└── security/
|
||||
```
|
||||
|
||||
模块边界按职责划分为四层:
|
||||
模块边界按职责划分为五层:
|
||||
|
||||
| 层级 | 模块 | 责任 |
|
||||
|---|---|---|
|
||||
| 传输层 | `pipe` | 定义消息、握手、序列号、收发与命令等待 |
|
||||
| 控制层 | `lib.rs`、`agent` | 接收任务、选择执行路径、回传日志与结果 |
|
||||
| 兼容层 | `compat` | 对接 vendored ZeroClaw,暴露单一 `browser_action` |
|
||||
| 安全层 | `security`、`resources/rules.json` | 域名与动作白名单控制 |
|
||||
| 安全层 | `security`、`resources/rules.json` | 域名、动作与 pipe 命令边界控制 |
|
||||
| 运行时入口层 | `lib.rs`、`agent` | 接收任务、选择执行路径、回传日志与结果 |
|
||||
| 适配层 | `compat`、`config` | 把 sgClaw 宿主环境映射到 zeroclaw 运行时;当前仍带有过渡性限制 |
|
||||
| 核心能力层 | `third_party/zeroclaw` | prompt、skills、memory、tool loop、provider 抽象 |
|
||||
|
||||
本轮冻结后还需要一组跨仓统一术语:
|
||||
|
||||
- `host`
|
||||
指 SuperRPA 浏览器宿主,只拥有安全边界、进程托管和展示装配权。
|
||||
- `launch config`
|
||||
指由 `host` 读取的启动描述文件。
|
||||
- `runtime config`
|
||||
指由 sgClaw 读取的运行时策略文件。
|
||||
- `frontend bundle`
|
||||
指由 `host` 装载的前端展示资源。
|
||||
- `planner-first`
|
||||
指 sgClaw / zeroclaw 先产生计划、前端先展示计划、随后再执行的运行时行为。
|
||||
|
||||
---
|
||||
|
||||
@@ -51,26 +65,27 @@ src/
|
||||
|
||||
### 2.2 `src/agent/mod.rs`
|
||||
|
||||
[`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs) 决定执行路径:
|
||||
[`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs) 当前决定执行路径:
|
||||
|
||||
- 收到 `BrowserMessage::SubmitTask` 时优先尝试读取 `DeepSeekSettings`。
|
||||
- 环境配置存在,则走 `compat::runtime::execute_task`。
|
||||
- 环境配置不存在,则走内置 planner fallback。
|
||||
- 收到 `BrowserMessage::SubmitTask` 时优先尝试读取 `SgClawSettings`。
|
||||
- 环境配置存在,则走 `compat::runtime::execute_task_with_sgclaw_settings`。
|
||||
- 环境配置不存在,则直接返回“未配置大语言模型”,而不是再把生产 submit 流量导回旧 planner。
|
||||
|
||||
这就是当前系统的“路由器”。
|
||||
这就是当前系统的“路由器”。但文档上应把它理解为过渡性 runtime selector,而不是最终产品架构中心。长期看,`planner-first`、provider 选择与 backend 选择都应由 `runtime config` 显式驱动。
|
||||
|
||||
### 2.3 `src/agent/runtime.rs`
|
||||
|
||||
该文件保留了仓库内的轻量 LLM/tool 调用逻辑,核心特点:
|
||||
该文件现在应被视为 `legacy/dev-only` 模块,只保留仓库内的轻量 LLM/tool 调用逻辑用于局部验证,核心特点:
|
||||
|
||||
- 工具名固定为 `browser_action`。
|
||||
- schema 只允许 `click/type/navigate/getText`。
|
||||
- 每次工具调用前后发送 `log_entry`。
|
||||
- 结果失败时直接返回 `PipeError::Protocol`。
|
||||
- 不参与当前生产浏览器 submit 路由。
|
||||
|
||||
### 2.4 `src/compat/runtime.rs`
|
||||
|
||||
[`src/compat/runtime.rs`](/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs) 是 ZeroClaw 重构的关键模块:
|
||||
[`src/compat/runtime.rs`](/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs) 是当前 ZeroClaw 接入的关键模块:
|
||||
|
||||
- 负责构造 ZeroClaw config。
|
||||
- 负责创建 provider。
|
||||
@@ -81,11 +96,64 @@ src/
|
||||
|
||||
- 当前 compat 层只向 ZeroClaw 注册一个工具。
|
||||
- `allowed_tools` 被收敛到 `browser_action`。
|
||||
- 这意味着 ZeroClaw 在本项目中是“兼容执行器”,不是“多工具平台”。
|
||||
- 这意味着当前代码还没有把 sgClaw 做成 zeroclaw-first runtime。
|
||||
- 这是一种实现限制,不应被文档提升为产品原则。
|
||||
|
||||
### 2.7 host / launch config / runtime config 契约
|
||||
|
||||
跨仓接口收口后,`host` 与 sgClaw 的最小契约应为:
|
||||
|
||||
| 对象 | 读取方 | 责任 | 失败时兜底 |
|
||||
|---|---|---|---|
|
||||
| `launch config` | `host` | 解析 `binary`、`args`、`env`、`working_dir`、`runtime_config_path`、`frontend_bundle_dir` | 回退到浏览器内置默认启动策略 |
|
||||
| `runtime config` | sgClaw | 解析 provider、model、planner mode、backend、skills policy | 回退到 sgClaw 默认运行时配置 |
|
||||
| `frontend bundle` | `host` | 提供浮窗/面板展示资源 | 回退到浏览器内置 WebUI 资源 |
|
||||
|
||||
这里的关键点是:`host` 只负责校验和装配,不负责决定 planner、model routing 或 business behavior。
|
||||
|
||||
### 2.8 launch config 文件路径与回退规则
|
||||
|
||||
设计冻结口径如下:
|
||||
|
||||
- profile-local `launch config` 路径:`<profile>/superrpa/sgclaw_launch_config.json`
|
||||
- profile-local `runtime config` 路径:`<profile>/superrpa/sgclaw_config.json`
|
||||
- profile-local hooks / rules 仍沿用:
|
||||
- `<profile>/superrpa/hooks.json`
|
||||
- `<profile>/superrpa/rules.json`
|
||||
|
||||
`launch config` 的回退规则必须保持稳定:
|
||||
|
||||
1. 优先读取 `<profile>/superrpa/sgclaw_launch_config.json`
|
||||
2. 缺失或解析失败时,回退到当前浏览器内置默认启动参数
|
||||
3. `runtime_config_path` 缺失时,回退到 `<profile>/superrpa/sgclaw_config.json`
|
||||
4. `frontend_bundle_dir` 缺失、无效或校验失败时,回退到 bundled frontend resources
|
||||
|
||||
### 2.9 frontend bundle 与 planner-first 契约
|
||||
|
||||
`frontend bundle` 只能消费这些由 `host` 转发的运行时状态:
|
||||
|
||||
- 当前状态与日志
|
||||
- planner 输出
|
||||
- 会话消息
|
||||
- 最终执行结果
|
||||
|
||||
`frontend bundle` 不能直接拥有:
|
||||
|
||||
- provider 切换决策权
|
||||
- planner 开关控制权
|
||||
- executor 旁路能力
|
||||
|
||||
因此 `planner-first` 的契约应是:
|
||||
|
||||
1. sgClaw / zeroclaw 产生计划
|
||||
2. `frontend bundle` 先展示计划
|
||||
3. 经运行时确认后再执行
|
||||
|
||||
这是一条 runtime contract,不是一条前端内部约定。
|
||||
|
||||
### 2.5 `src/pipe/browser_tool.rs`
|
||||
|
||||
该模块承担真实浏览器命令发送职责:
|
||||
该模块承担真实浏览器命令发送职责,也是当前系统中最重要的特权工具面桥接层:
|
||||
|
||||
- 为每个命令分配 `seq`。
|
||||
- 计算 HMAC。
|
||||
@@ -93,11 +161,11 @@ src/
|
||||
- 阻塞等待对应 `BrowserMessage::Response`。
|
||||
- 在超时、响应错配、校验失败时返回错误。
|
||||
|
||||
它是 Rust 侧最重要的协议执行点。
|
||||
它是 Rust 侧最重要的协议执行点。架构上应把它理解为“privileged browser surface adapter”,而不是“整个 runtime 本体”。
|
||||
|
||||
### 2.6 `src/security/mac_policy.rs`
|
||||
|
||||
安全策略只认规则文件,不认模型意图。
|
||||
安全策略只认规则文件与 pipe contract,不认模型意图。
|
||||
规则来源为 [`resources/rules.json`](/home/zyl/projects/sgClaw/claw/resources/rules.json),当前默认约束是:
|
||||
|
||||
- 允许域名:`oa.example.com`、`erp.example.com`、`hr.example.com` 及 demo 域名。
|
||||
@@ -235,6 +303,8 @@ L2 是产品内核视角的契约说明。两者关系如下:
|
||||
2. `src/agent/runtime.rs` 的 tool definition
|
||||
3. `src/compat/browser_tool_adapter.rs` 的 `parameters_schema` 与 `parse_action`
|
||||
|
||||
这三者表达的是“当前特权浏览器工具面”的开放范围,而不是 sgClaw 整体 runtime 的长期能力上限。
|
||||
|
||||
---
|
||||
|
||||
## 5. `browser_action` 工具契约
|
||||
|
||||
@@ -10,32 +10,32 @@
|
||||
|
||||
## 1. 端到端数据流
|
||||
|
||||
当前主链路的数据流如下:
|
||||
主线目标中的数据流应当如下:
|
||||
|
||||
```
|
||||
Browser Host
|
||||
└─ submit_task
|
||||
Client Surface
|
||||
└─ submit_task (+ optional browser context)
|
||||
↓
|
||||
sgClaw Transport / Handshake
|
||||
└─ handle_browser_message
|
||||
└─ sgClaw security envelope
|
||||
↓
|
||||
Execution Path Select
|
||||
├─ planner fallback
|
||||
└─ zeroclaw compat runtime
|
||||
ZeroClaw-first runtime
|
||||
└─ runtime profile / tool policy
|
||||
↓
|
||||
browser_action
|
||||
↓
|
||||
AgentMessage::Command
|
||||
↓
|
||||
Browser executes action
|
||||
↓
|
||||
BrowserMessage::Response
|
||||
Tool execution
|
||||
├─ browser_action -> pipe -> browser host -> response
|
||||
└─ non-browser-safe future surfaces only when policy allows
|
||||
↓
|
||||
log_entry / task_complete
|
||||
```
|
||||
|
||||
这条链路里没有独立 Skill 执行器,也没有独立任务编排数据库。
|
||||
因此 L3 的重点不再是“描述一个理想化智能体平台”,而是说明当前仓库里真实存在的数据流状态机。
|
||||
当前代码与上述目标之间仍有过渡态偏差:
|
||||
|
||||
- 浏览器是当前唯一成熟的特权工具面。
|
||||
- `planner/runtime` 旧链路仍保留在仓库中,但已收敛为 `legacy/dev-only` 辅助模块。
|
||||
- `zeroclaw` 已 vendored,但运行时还没有完全按 zeroclaw-first 方式释放能力。
|
||||
|
||||
因此 L3 既要说明目标数据流,也要明确指出当前代码仍处于过渡收口阶段。
|
||||
|
||||
---
|
||||
|
||||
@@ -52,26 +52,27 @@ log_entry / task_complete
|
||||
|
||||
### 2.2 任务接收阶段
|
||||
|
||||
宿主发送:
|
||||
浏览器宿主当前发送:
|
||||
|
||||
```json
|
||||
{ "type": "submit_task", "instruction": "..." }
|
||||
```
|
||||
|
||||
Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs) 中接收后,不直接执行页面命令,而是先决定走哪条执行路径。
|
||||
Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs) 中接收后,不应被理解为“直接开始网页自动化”,而是先决定当前任务使用什么 runtime/profile,并判断浏览器上下文是否真的必要。
|
||||
|
||||
### 2.3 执行路径选择
|
||||
### 2.3 当前执行路径选择(过渡态)
|
||||
|
||||
#### 路径 A:planner fallback
|
||||
#### 路径 A:legacy planner/runtime(非生产 submit 主链)
|
||||
|
||||
条件:没有可用的 `DEEPSEEK_*` 环境配置。
|
||||
行为:使用仓库内置 planner 直接产生若干步骤,并逐个调用 `BrowserPipeTool`。
|
||||
条件:仅用于 dev/test 验证或保留回归覆盖。
|
||||
行为:使用仓库内置 planner 或轻量 runtime 直接产生若干步骤,并逐个调用 `BrowserPipeTool`。
|
||||
|
||||
特点:
|
||||
|
||||
- 依赖更少。
|
||||
- 逻辑可预测。
|
||||
- 适合协议联调和最小功能验证。
|
||||
- 不再承接生产浏览器 submit 流量。
|
||||
|
||||
#### 路径 B:ZeroClaw compat runtime
|
||||
|
||||
@@ -108,7 +109,7 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs
|
||||
6. 形成观察结果
|
||||
说明:根据 `success`、`data`、`aom_snapshot` 和 `timing` 形成下一步输入或最终结果。
|
||||
|
||||
这意味着“智能体行为”和“浏览器动作执行”之间的接口已经被压缩到非常薄的一层,这是 ZeroClaw 重构最有价值的结构变化。
|
||||
这意味着“runtime 决策”和“浏览器动作执行”之间的接口已经被压缩到非常薄的一层,这是 sgClaw 作为 zeroclaw 安全封装层最有价值的结构变化。
|
||||
|
||||
---
|
||||
|
||||
@@ -126,6 +127,7 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs
|
||||
典型内容:
|
||||
|
||||
- 当前准备执行的动作。
|
||||
- `planner_mode=zeroclaw_plan_first` 时由 sgClaw 先发出的计划预览。
|
||||
- compat runtime 中转译出的事件摘要。
|
||||
- 执行中的信息性提示。
|
||||
|
||||
@@ -144,26 +146,28 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs
|
||||
|
||||
“L3 是灵魂”的前提,不是把 Skill 写得越来越玄,而是把 Skill 在当前阶段的真实语义说清楚。
|
||||
|
||||
### 5.1 当前不存在独立 Skill 引擎
|
||||
### 5.1 当前不应再设计独立于 zeroclaw 的 Skill 引擎
|
||||
|
||||
当前仓库中没有独立的:
|
||||
当前仓库中不应再把 Skill 理解为浏览器专用外挂子系统。sgClaw 已经 vendored zeroclaw,自带的 skill 体系才是主线。需要注意的是,当前运行时对它的使用仍不充分。
|
||||
|
||||
当前代码仍缺少或未完全释放的部分包括:
|
||||
|
||||
- Skill 脚本目录加载流程
|
||||
- Skill 注册表
|
||||
- Skill 沙箱执行器
|
||||
- Skill 版本与签名校验主链路
|
||||
|
||||
因此不能再把 Skill 描述为已落地子系统。
|
||||
因此文档上不能再把 Skill 描述为“浏览器侧另起一套引擎”,而应描述为“应复用 zeroclaw-native 机制的能力层,当前实现仍在收口”。
|
||||
|
||||
### 5.2 当前可以保留的 Skill 语义
|
||||
|
||||
在 ZeroClaw 重构版里,Skill 更准确的含义是:
|
||||
在 sgClaw 的主线架构里,Skill 更准确的含义是:
|
||||
|
||||
- 面向未来的“可复用任务模式”抽象。
|
||||
- 可能由提示词、模板、预设工具组合或 planner 规则来承载。
|
||||
- 最终仍要落到统一的 `browser_action` 契约。
|
||||
- 由 zeroclaw 管理的可复用任务模式、提示规范和可调用工具组合。
|
||||
- 在 compact/full 模式下进入 system prompt 或按需通过 `read_skill` 读取。
|
||||
- 当任务需要浏览器时,最终可落到统一的 `browser_action` 契约;当任务不需要浏览器时,不应强行绕浏览器一圈。
|
||||
|
||||
换句话说,当前 Skill 不是一个运行时目录,而是一种产品与执行层之间的抽象语言。
|
||||
换句话说,Skill 不是“浏览器脚本目录”的别名,而是 zeroclaw runtime 的一部分。
|
||||
|
||||
### 5.3 Skill 演进约束
|
||||
|
||||
@@ -179,13 +183,23 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs
|
||||
|
||||
### 6.1 配置
|
||||
|
||||
当前真正参与执行的关键配置来自 [`src/config/settings.rs`](/home/zyl/projects/sgClaw/claw/src/config/settings.rs):
|
||||
当前真正参与执行的关键配置来自 [`src/config/settings.rs`](/home/zyl/projects/sgClaw/claw/src/config/settings.rs)。它已经不再只是单一 `DEEPSEEK_*` shim,而是开始承载 sgClaw 自己的运行时策略:
|
||||
|
||||
- `DEEPSEEK_API_KEY`
|
||||
- `DEEPSEEK_BASE_URL`
|
||||
- `DEEPSEEK_MODEL`
|
||||
- `providers` / `active_provider`
|
||||
- `planner_mode`
|
||||
- `browser_backend`
|
||||
- `office_backend`
|
||||
- `skills_prompt_mode`
|
||||
- `runtime_profile`
|
||||
|
||||
这些配置决定是否启用 compat runtime,以及模型请求如何路由。
|
||||
当前默认语义是:
|
||||
|
||||
- `providers` 为空时,仍兼容旧的 `apiKey/baseUrl/model` DeepSeek 单模型配置。
|
||||
- `planner_mode=zeroclaw_plan_first` 时,由 sgClaw 在真实执行前先向宿主发送可展示的计划预览,前端只负责渲染。
|
||||
- `browser_backend=superrpa` 时,浏览器高权限动作仍以宿主 pipe 为边界;sgClaw 只决定运行时策略,不把特权上移到前端。
|
||||
- `office_backend=openxml` 时,导出类任务仍由 sgClaw 运行时选择实际导出工具。
|
||||
|
||||
这部分配置的目标很明确:让模型切换、planner 策略和运行时 backend 选择回到 sgClaw 自己,而不是继续散落在 SuperRPA 编译期常量或前端逻辑里。
|
||||
|
||||
### 6.2 记忆
|
||||
|
||||
@@ -199,10 +213,10 @@ ZeroClaw compat 路径中已经接入 memory adapter,但在产品能力层面
|
||||
|
||||
## 7. L3 结论
|
||||
|
||||
L3 的核心不是“把所有未来能力都放进一个宏大数据流图”,而是说明当前系统如何把自然语言任务压缩成可验证、可回包、可受控的浏览器动作。
|
||||
L3 的核心不是“把所有未来能力都放进一个宏大数据流图”,也不是“把所有任务都解释成浏览器动作”,而是说明 sgClaw 如何把任务先交给 zeroclaw runtime,再把其中需要高风险外部执行的部分压缩成可验证、可回包、可受控的浏览器动作。
|
||||
|
||||
重构后的灵魂有三点:
|
||||
|
||||
- 任务入口统一。
|
||||
- 动作契约统一。
|
||||
- 执行路径可替换,但协议和安全边界不变。
|
||||
- runtime 核心统一到 zeroclaw。
|
||||
- 特权工具面可替换,但协议和安全边界不变。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# L4 — 工程实现与部署拓扑层
|
||||
|
||||
**文档版本**: 2.0
|
||||
**适用项目**: sgClaw(ZeroClaw 重构版)
|
||||
**编制日期**: 2026-03-26
|
||||
**文档版本**: 2.1<br>
|
||||
**适用项目**: sgClaw(ZeroClaw 重构版)<br>
|
||||
**编制日期**: 2026-03-29
|
||||
|
||||
**读者**: 开发者、测试工程师、联调工程师
|
||||
|
||||
@@ -44,7 +44,7 @@ claw/
|
||||
└── archive/
|
||||
```
|
||||
|
||||
工程上应把 `third_party/zeroclaw` 理解为“已 vendored 的兼容依赖”,而不是单独维护的兄弟项目。
|
||||
工程上应把 `third_party/zeroclaw` 理解为“已 vendored 的能力核心”,而不是单独维护的兄弟项目,也不是只用于兼容的附属依赖。
|
||||
|
||||
---
|
||||
|
||||
@@ -83,10 +83,15 @@ claw/
|
||||
|
||||
职责:
|
||||
|
||||
- 决定 fallback 或 compat 执行。
|
||||
- 把统一工具契约映射到浏览器协议。
|
||||
- 当前决定 fallback 或 compat 执行。
|
||||
- 把受保护的浏览器工具契约映射到浏览器协议。
|
||||
- 在 ZeroClaw turn 事件与宿主日志之间做桥接。
|
||||
|
||||
说明:
|
||||
|
||||
- `src/agent/runtime.rs` 与 `src/agent/planner.rs` 属于过渡性轻量路径,不应再被写成长期产品主线。
|
||||
- 主线目标应是“sgClaw security layer + zeroclaw core runtime”,而不是长期保留 browser-only compat 分叉。
|
||||
|
||||
### 2.4 安全与配置
|
||||
|
||||
- [`src/security/mac_policy.rs`](/home/zyl/projects/sgClaw/claw/src/security/mac_policy.rs)
|
||||
@@ -96,7 +101,7 @@ claw/
|
||||
职责:
|
||||
|
||||
- 维护运行时安全边界。
|
||||
- 从环境变量读取 provider 配置。
|
||||
- 读取 provider / skills 等运行时配置,并逐步向 zeroclaw-first 配置模型收敛。
|
||||
|
||||
---
|
||||
|
||||
@@ -130,7 +135,7 @@ sgClaw 不是独立交互式 CLI 产品,正常运行前提是:
|
||||
|
||||
### 3.3 模型配置
|
||||
|
||||
启用 ZeroClaw compat runtime 的关键环境变量:
|
||||
当前启用 ZeroClaw compat runtime 的关键环境变量:
|
||||
|
||||
```bash
|
||||
DEEPSEEK_API_KEY=...
|
||||
@@ -138,7 +143,70 @@ DEEPSEEK_BASE_URL=...
|
||||
DEEPSEEK_MODEL=...
|
||||
```
|
||||
|
||||
若这些变量不存在或不完整,系统会退回 planner fallback。
|
||||
若这些变量不存在或不完整,系统会退回 planner fallback。这个行为是当前实现状态,不是长期架构推荐。
|
||||
|
||||
### 3.4 runtime config 文件
|
||||
|
||||
当 `host` 以 `--config-path=<workspace_root>/sgclaw_config.json` 拉起 `sgclaw` 时,`sgclaw` 会自己读取该 JSON 文件,而不是要求宿主额外复制 skills。
|
||||
|
||||
当前支持的关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "skill_lib"
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `skillsDir` 可省略。
|
||||
- 若省略,则默认使用 `<workspace_root>/.sgclaw-zeroclaw-workspace/skills`。
|
||||
- 若为相对路径,则相对于 `sgclaw_config.json` 所在目录解析。
|
||||
- 若指向某个 skill repo 根目录,且其下存在 `skills/` 子目录,运行时会自动落到该 `skills/` 目录。
|
||||
- 因此 `host` 只需要负责传递 `runtime config` 路径,skill 查找策略由 `sgclaw` 自己控制。
|
||||
- 长期看,这个文件应表达 zeroclaw-first、`planner-first` 的 runtime/profile/tool policy 配置,而不仅是 provider shim。
|
||||
|
||||
### 3.5 launch config 文件与 fallback
|
||||
|
||||
`launch config` 由 `host` 读取,不由 sgClaw 自己解析。设计冻结后的推荐路径为:
|
||||
|
||||
```text
|
||||
<profile>/superrpa/sgclaw_launch_config.json
|
||||
```
|
||||
|
||||
该文件承载的字段应包括:
|
||||
|
||||
- `binary`
|
||||
- `args`
|
||||
- `env`
|
||||
- `working_dir`
|
||||
- `runtime_config_path`
|
||||
- `frontend_bundle_dir`
|
||||
|
||||
加载规则必须保持稳定:
|
||||
|
||||
1. `host` 优先读取 profile-local `launch config`
|
||||
2. 若 `binary` 缺失或无效,则回退到浏览器已知可启动的默认 sgClaw 路径
|
||||
3. 若 `runtime_config_path` 缺失,则回退到 `<profile>/superrpa/sgclaw_config.json`
|
||||
4. 若 `frontend_bundle_dir` 缺失或无效,则回退到 bundled frontend resources
|
||||
|
||||
这样做的目的不是削弱宿主管控,而是把高频变化项从编译期常量改成运行期可替换对象。
|
||||
|
||||
### 3.6 frontend bundle 装载拓扑
|
||||
|
||||
`frontend bundle` 的部署方式应当是“外部 bundle 优先,内置资源兜底”:
|
||||
|
||||
```text
|
||||
host
|
||||
├─ validate frontend_bundle_dir
|
||||
├─ if valid: load external frontend bundle
|
||||
└─ else: load bundled frontend resources
|
||||
```
|
||||
|
||||
这意味着后续改浮窗 UI、验收页面或 planner 展示逻辑,不应再默认要求重编 Chromium。
|
||||
|
||||
---
|
||||
|
||||
@@ -165,7 +233,7 @@ DEEPSEEK_MODEL=...
|
||||
cargo test
|
||||
```
|
||||
|
||||
这组测试表达了一个重要工程事实:当前系统的稳定核心是协议、runtime 选择和 compat 适配,而不是旧版前端验证页。
|
||||
这组测试表达了一个重要工程事实:当前系统的稳定核心是协议、安全边界、runtime 选择和 zeroclaw 接入,而不是旧版前端验证页。
|
||||
|
||||
---
|
||||
|
||||
@@ -179,9 +247,11 @@ cargo test
|
||||
|
||||
### 5.2 外部宿主负责什么
|
||||
|
||||
- 读取并校验 `launch config`。
|
||||
- 拉起并托管 sgClaw 进程。
|
||||
- 提供页面执行能力。
|
||||
- 实现命令落地、响应回传和宿主侧校验。
|
||||
- 装载 `frontend bundle`,并在无效时回退到内置资源。
|
||||
|
||||
### 5.3 不在本仓库内交付的内容
|
||||
|
||||
@@ -199,13 +269,17 @@ L4 的工程边界必须按仓库现实写清楚,否则会把“外部依赖
|
||||
|
||||
```
|
||||
Browser Host Process
|
||||
├─ reads launch config
|
||||
├─ launches sgclaw binary
|
||||
├─ writes init / submit_task to stdin
|
||||
├─ reads command / log / task_complete from stdout
|
||||
└─ executes page actions in host environment
|
||||
├─ executes page actions in host environment
|
||||
└─ loads external frontend bundle or bundled resources
|
||||
|
||||
sgclaw binary
|
||||
├─ loads runtime config
|
||||
├─ loads resources/rules.json
|
||||
├─ runs planner-first execution
|
||||
├─ verifies action/domain
|
||||
├─ optionally calls provider API
|
||||
└─ waits for browser response
|
||||
@@ -220,4 +294,5 @@ sgclaw binary
|
||||
L4 层面的核心结论只有两点:
|
||||
|
||||
1. 本仓库已经从“带演示页的杂糅目录”收敛为“Rust Runtime + 协议文档 + 测试”的内核仓库。
|
||||
2. ZeroClaw 重构后的工程重点,是保证 compat runtime、fallback runtime、浏览器协议三者在同一 contract 上工作。
|
||||
2. ZeroClaw 重构后的工程重点,是把工程形态从“browser-first compat”收口为“zeroclaw-first runtime + sgClaw security envelope”,同时保持浏览器协议稳定。
|
||||
3. `host`、`launch config`、`runtime config`、`frontend bundle`、`planner-first` 必须在文档、代码和验收中使用同一套术语,避免再次把前端逻辑上移到 sgClaw 之外。
|
||||
|
||||
@@ -5,24 +5,25 @@
|
||||
- 目标:给出可执行的工程改造路径与落地记录
|
||||
|
||||
## 1. 结论(先说结论)
|
||||
项目存在至少两条主要提示词构造链路:
|
||||
项目当前存在至少两条主要提示词构造链路,但长期主线只能保留一条 authoritative chain:
|
||||
|
||||
1) **轻量运行时链路**(`src/agent/runtime.rs`)
|
||||
1) **轻量运行时链路**(`src/agent/runtime.rs`,过渡态)
|
||||
- 仅有非常基础的固定 system 提示。
|
||||
- 适用于非完整流程的本地/最小执行场景。
|
||||
- 不应继续被扩展成主线产品提示词体系。
|
||||
|
||||
2) **ZeroClaw 主链路**(`third_party/zeroclaw/*`)
|
||||
- 这条链路是“系统提示”主体,分为:
|
||||
- `Agent` 内部结构化构建器(`SystemPromptBuilder`)
|
||||
- `channels` 侧统一字符串拼装
|
||||
- `skills / personality / identity / bootstrap 文件 / 工具说明` 等多个注入源
|
||||
- 这也是你要关注的主要安全面。
|
||||
- 这也是你要关注的主要安全面,也是未来应保留的唯一主线。
|
||||
|
||||
---
|
||||
|
||||
## 2. 提示词分布结构(按文件/模块)
|
||||
|
||||
### 2.1 固定系统提示(轻量链路)
|
||||
### 2.1 固定系统提示(轻量链路,待收口)
|
||||
- `src/agent/runtime.rs`
|
||||
- `execute_task_with_provider` 的 `ChatMessage { role: "system" ... }`
|
||||
- 当前内容:`You are sgClaw. Use browser_action to complete the browser task.`
|
||||
@@ -108,6 +109,10 @@
|
||||
## 4. 安全改造建议(按优先级)
|
||||
|
||||
### P0(建议立即做)
|
||||
0) 收口双主链路
|
||||
- `sgclaw` 不应长期同时维护一条轻量自定义 prompt 链和一条 zeroclaw 主链。
|
||||
- 目标是:保留 zeroclaw 主链,sgClaw 仅增加安全摘要、浏览器上下文和受控工具面说明。
|
||||
|
||||
1) 接入 `PromptGuard`
|
||||
- 目前已有 `third_party/zeroclaw/src/security/prompt_guard.rs`
|
||||
- 在以下入口加扫描并截断/告警:
|
||||
@@ -148,7 +153,8 @@
|
||||
## 5. 本次已确认的“关键风险”
|
||||
- `PromptGuard` 尚未在主入口统一挂载(存在检测能力,但未形成强制拦截链)。
|
||||
- workspace/skills 内容可直接进入 prompt,注入面较宽。
|
||||
- 两套系统提示构建链路(agent builder 与 channel builder)存在口径差异,需要统一。
|
||||
- 两套系统提示构建链路(轻量链路与 zeroclaw 主链)同时存在,容易造成安全策略漂移。
|
||||
- `sgclaw` 如果继续把浏览器专用提示补丁放在主链外侧,会重新制造第三条 prompt source。
|
||||
|
||||
---
|
||||
|
||||
@@ -161,4 +167,3 @@
|
||||
- 复现提示词覆盖攻击
|
||||
- 系统提示重复/续接场景(seed/reseed)
|
||||
- compact/full 两种技能注入对比
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
- `L5-提示词分布与安全改造方案.md`:提示词治理与风控增强策略。
|
||||
- `浏览器对接标准.md`:Rust 与 Chromium 对接的协议基线。
|
||||
|
||||
当前运行时补充约定:
|
||||
|
||||
- `sgclaw` 在主线口径上应理解为“安全加固后的 zeroclaw runtime”,而不是浏览器侧 UI 产品,也不是 browser-only agent。
|
||||
- 浏览器宿主与 pipe 协议提供的是受保护执行面;浏览器是重要工具与前端载体,但不是整个 runtime 的定义。
|
||||
- 文档中提到 `planner fallback`、`compat runtime`、`browser_action only` 时,应优先理解为“当前实现状态/过渡态”,而不是长期产品边界。
|
||||
- `sgclaw` 的浏览器侧配置文件为 `sgclaw_config.json`。
|
||||
- `sgclaw` 负责解析模型配置与可选的 `skillsDir`。
|
||||
- `skillsDir` 未设置时,默认回退到 `<workspace_root>/.sgclaw-zeroclaw-workspace/skills`。
|
||||
- `skillsDir` 已设置时,支持直接指向 skills 目录,也支持指向包含 `skills/` 子目录的 skill repo 根目录。
|
||||
|
||||
## 归档文档
|
||||
|
||||
### 项目管理与排期(已归档)
|
||||
|
||||
81
docs/acceptance/2026-03-29-zhihu-hotlist-excel.md
Normal file
81
docs/acceptance/2026-03-29-zhihu-hotlist-excel.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Zhihu Hotlist Excel Acceptance
|
||||
|
||||
- Date: 2026-03-30 00:03:42 +0800
|
||||
- Mode: real provider + live Zhihu hotlist API + simulated browser pipe
|
||||
- Workspace: `/tmp/sgclaw-live-acceptance-_655xotg`
|
||||
- Final success: `True`
|
||||
- Total score: `100/100`
|
||||
|
||||
## Rubric
|
||||
|
||||
- skill selection: `30/30`
|
||||
- tool discipline: `25/25`
|
||||
- hotlist data correctness: `20/20`
|
||||
- xlsx export success: `20/20`
|
||||
- final response quality: `5/5`
|
||||
|
||||
## Final Output
|
||||
|
||||
- exported_path: `/tmp/sgclaw-live-acceptance-_655xotg/.sgclaw-zeroclaw-workspace/out/zhihu-hotlist-1774800210904715681.xlsx`
|
||||
- final_summary: `成功!我已经完成了知乎热榜数据的读取和Excel导出任务。以下是任务总结:
|
||||
|
||||
## 任务完成情况
|
||||
|
||||
1. **导航到知乎热榜页面**:成功从知乎首页导航到热榜页面(https://www.zhihu.com/hot)
|
||||
|
||||
2. **获取热榜数据**:使用SuperRPA浏览器接口获取了当前热榜页面的完整内容,包含10个热榜条目
|
||||
|
||||
3. **解析数据**:从页面文本中提取了排名、标题和热度信息,格式如下:
|
||||
- 排名:数字序号
|
||||
- 标题:完整的问题标题
|
||||
- 热度:以"万"为单位的阅读量
|
||||
|
||||
4. **导出Excel文件**:成功将10个热榜条目导出到Excel文件,包含"排名"、"标题"、"热度"三列
|
||||
|
||||
## 生成的文件
|
||||
|
||||
**Excel文件路径**:`/tmp/sgclaw-live-acceptance-_655xotg/.sgclaw-zeroclaw-workspace/out/zhihu-hotlist-1774800210904715681.xlsx`
|
||||
|
||||
## 热榜数据概览
|
||||
|
||||
当前知乎热榜前10名涵盖了多个热门话题:
|
||||
- 体育赛事(WSBK摩托车赛)
|
||||
- 娱乐事件(李荣浩与单依纯争议)
|
||||
- 国际政治经济(日本外汇政策、伊朗冲突)
|
||||
- 科技商业(字节跳动发展、内存价格)
|
||||
- 社会现象(学霸家长行为、Excel使用)
|
||||
|
||||
最高热度话题达到1607万阅读量,显示了知乎用户对这些话题的高度关注。`
|
||||
|
||||
## Skill Logs
|
||||
|
||||
- `DeepSeek config loaded from /tmp/sgclaw-live-acceptance-_655xotg/sgclaw_config.json model=deepseek-chat base_url=https://api.deepseek.com`
|
||||
- `skills dir resolved to /home/zyl/projects/sgClaw/skill_lib/skills`
|
||||
- `runtime profile=BrowserAttached skills_prompt_mode=Compact`
|
||||
- `zeroclaw_process_message_primary`
|
||||
- `先规划再执行知乎热榜 Excel 导出
|
||||
navigate https://www.zhihu.com/hot
|
||||
getText main
|
||||
call openxml_office
|
||||
return generated local .xlsx path`
|
||||
- `loaded skills: office-export-xlsx, zhihu-hotlist, zhihu-hotlist-screen, zhihu-navigate, zhihu-write`
|
||||
- `navigate https://www.zhihu.com/hot`
|
||||
- `getText main`
|
||||
- `call openxml_office`
|
||||
|
||||
## Live Hotlist Sample
|
||||
|
||||
- 1. 如何看待张雪机车在 2026 年 WSBK 葡萄牙站夺冠?这对国内的摩托赛事发展有什么影响? | 1607万
|
||||
- 2. 李荣浩摆证据 4 连质问单依纯,为什么没有授权的歌曲也能放进演唱会?演唱会筹备中可能出了什么问题? | 1064万
|
||||
- 3. 日本拟动用外储做空国际原油,以挽救日元汇率,对此你怎么看,其会重演 96 年「住友铜事件」么? | 573万
|
||||
- 4. 官方通报女子被羁押后无罪释放,申请国赔 13 天被叫停,当地成立联合调查组,最该查清什么?带来哪些深思? | 281万
|
||||
- 5. 字节跳动是怎么短短数年就能单挑所有互联网巨头的? | 185万
|
||||
- 6. 伊朗科技大学遭袭后,伊朗将美以大学列为「合法袭击目标」,如果战争扩大到教育机构,冲突还有回头路吗? | 175万
|
||||
- 7. 黄金大买家土耳其央行在伊朗战争期间抛售 80 亿美元黄金,这意味着什么? | 166万
|
||||
- 8. 为什么越厉害的学霸,她们家长越低调?从来不在朋友圈晒孩子成绩? | 141万
|
||||
- 9. DDR5 内存价格 3 月出现明显下降,请问这是短期现象,还是内存供需紧张真的缓和了? | 135万
|
||||
- 10. 为什么大公司不用 pandas 取代 Excel? | 81万
|
||||
|
||||
## Stderr
|
||||
|
||||
- `sgclaw ready: agent_id=db27f86f-4334-41a7-bc24-11e8fbd90486`
|
||||
598
docs/plans/2026-03-28-sgclaw-zeroclaw-core-realignment-plan.md
Normal file
598
docs/plans/2026-03-28-sgclaw-zeroclaw-core-realignment-plan.md
Normal file
@@ -0,0 +1,598 @@
|
||||
# SGClaw ZeroClaw Core Realignment Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Rebuild `sgclaw` as a security-hardened zeroclaw runtime where the browser is a privileged tool surface and client host, not the product's defining execution model.
|
||||
|
||||
**Architecture:** `zeroclaw` remains the real agent core for prompt building, skills, memory, tool routing, autonomy, and execution loops. `sgclaw` adds a security envelope around zeroclaw: hardened configuration defaults, MAC-validated pipe communication, browser-host revalidation, and constrained exposure of privileged tools. The browser stays important, but only as one protected execution surface.
|
||||
|
||||
**Tech Stack:** Rust, vendored `zeroclaw`, SuperRPA browser pipe protocol, Chromium WebUI/overlay, JSON config, HMAC/MAC policy rules.
|
||||
|
||||
## Scope Guard
|
||||
|
||||
- In scope:
|
||||
- zeroclaw-first runtime restructuring
|
||||
- security and tool policy layering
|
||||
- browser pipe re-framing as privileged tool surface
|
||||
- documentation correction before code changes
|
||||
- browser UI wording and observability alignment
|
||||
- Out of scope:
|
||||
- broad UI redesign before the runtime contract is fixed
|
||||
- indiscriminately enabling every zeroclaw built-in tool
|
||||
- keeping parallel browser-only and zeroclaw-first runtime stacks long-term
|
||||
|
||||
## Task 1: Rewrite The Product Docs Before Touching Runtime Code
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/README.md`
|
||||
- Modify: `docs/L0-产品白皮书与能力全景层.md`
|
||||
- Modify: `docs/L1-系统架构与安全模型层.md`
|
||||
- Modify: `docs/L2-核心模块与接口契约层.md`
|
||||
- Modify: `docs/L3-数据流与Skill体系层.md`
|
||||
- Modify: `docs/L4-工程实现与部署拓扑层.md`
|
||||
- Modify: `docs/L5-提示词分布与安全改造方案.md`
|
||||
- Modify: `docs/浏览器对接标准.md`
|
||||
|
||||
**Intent:**
|
||||
- Make the docs the single source of truth before implementation starts.
|
||||
- Remove the misleading framing that `sgclaw` is fundamentally a browser execution core.
|
||||
- Replace it with the correct framing: `sgclaw` is a security-hardened zeroclaw distribution with a privileged browser execution surface.
|
||||
|
||||
**Step 1: Add a docs checklist in the plan branch**
|
||||
|
||||
Checklist to apply consistently across the docs:
|
||||
|
||||
```text
|
||||
1. zeroclaw is the runtime core
|
||||
2. sgclaw adds security policy and protected execution surfaces
|
||||
3. browser pipe is one privileged tool surface, not the whole runtime
|
||||
4. docs must distinguish current implementation gaps from target architecture
|
||||
5. no doc may imply that browser-only compat is the desired end state
|
||||
```
|
||||
|
||||
**Step 2: Update the architecture docs**
|
||||
|
||||
Required wording changes:
|
||||
|
||||
- Replace phrases equivalent to “浏览器智能体执行内核” with wording equivalent to “安全加固后的 zeroclaw runtime”.
|
||||
- Reframe `compat` as a temporary adaptation layer, not the final product identity.
|
||||
- Clarify that `browser_action` is a protected tool contract, not the only capability sgClaw should ever have.
|
||||
- Clarify that prompt治理、安全摘要、skills、memory、routing should stay aligned with zeroclaw-native mechanisms.
|
||||
|
||||
**Step 3: Run doc consistency checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "浏览器智能体执行内核|单一 `browser_action` 工具|兼容执行器" docs
|
||||
rg -n "zeroclaw.*核心|特权工具面|安全加固" docs
|
||||
```
|
||||
|
||||
Expected:
|
||||
- The first command should only return historical or explicitly marked current-state references.
|
||||
- The second command should show the new target framing in the mainline docs.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/README.md docs/L0-产品白皮书与能力全景层.md docs/L1-系统架构与安全模型层.md docs/L2-核心模块与接口契约层.md docs/L3-数据流与Skill体系层.md docs/L4-工程实现与部署拓扑层.md docs/L5-提示词分布与安全改造方案.md docs/浏览器对接标准.md
|
||||
git commit -m "docs: redefine sgclaw as hardened zeroclaw runtime"
|
||||
```
|
||||
|
||||
## Task 2: Introduce A Zeroclaw-First Runtime Module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/runtime/mod.rs`
|
||||
- Create: `src/runtime/profile.rs`
|
||||
- Create: `src/runtime/tool_policy.rs`
|
||||
- Create: `src/runtime/engine.rs`
|
||||
- Modify: `src/lib.rs`
|
||||
- Test: `tests/runtime_profile_test.rs`
|
||||
|
||||
**Intent:**
|
||||
- Create a real runtime namespace that represents sgClaw’s zeroclaw-first architecture.
|
||||
- Stop letting `compat/runtime.rs` be the place where product architecture is defined.
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/runtime_profile_test.rs` with at least:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn browser_attached_profile_exposes_browser_surface_without_becoming_browser_only() {
|
||||
let profile = RuntimeProfile::BrowserAttached;
|
||||
let policy = ToolPolicy::for_profile(profile);
|
||||
|
||||
assert!(policy.allowed_tools.contains("browser_action"));
|
||||
assert!(policy.may_use_non_browser_tools);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_assistant_profile_does_not_require_browser_surface() {
|
||||
let profile = RuntimeProfile::GeneralAssistant;
|
||||
let policy = ToolPolicy::for_profile(profile);
|
||||
|
||||
assert!(!policy.requires_browser_surface);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run the tests to confirm failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_profile_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Fail with unresolved imports or missing types for `RuntimeProfile` and `ToolPolicy`.
|
||||
|
||||
**Step 3: Add the minimal runtime module**
|
||||
|
||||
Implement the first-pass skeleton:
|
||||
|
||||
```rust
|
||||
pub enum RuntimeProfile {
|
||||
BrowserAttached,
|
||||
BrowserHeavy,
|
||||
GeneralAssistant,
|
||||
}
|
||||
|
||||
pub struct ToolPolicy {
|
||||
pub requires_browser_surface: bool,
|
||||
pub may_use_non_browser_tools: bool,
|
||||
pub allowed_tools: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Re-run the focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_profile_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Both new tests pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/runtime/mod.rs src/runtime/profile.rs src/runtime/tool_policy.rs src/runtime/engine.rs src/lib.rs tests/runtime_profile_test.rs
|
||||
git commit -m "feat: add zeroclaw-first runtime module skeleton"
|
||||
```
|
||||
|
||||
## Task 3: Replace DeepSeek-Only Settings With Zeroclaw-First SGClaw Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/settings.rs`
|
||||
- Modify: `src/config/mod.rs`
|
||||
- Modify: `src/compat/config_adapter.rs`
|
||||
- Test: `tests/compat_config_test.rs`
|
||||
- Test: `tests/compat_memory_test.rs`
|
||||
- Test: `tests/compat_cron_test.rs`
|
||||
|
||||
**Intent:**
|
||||
- Stop treating browser config as only a DeepSeek shim.
|
||||
- Introduce sgClaw settings that can express zeroclaw-first runtime behavior while staying backward-compatible with the existing `sgclaw_config.json`.
|
||||
|
||||
**Step 1: Add the failing config tests**
|
||||
|
||||
Add tests beside the existing ones in `tests/compat_config_test.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
|
||||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"sk-test".into(),
|
||||
"https://api.deepseek.com".into(),
|
||||
"deepseek-chat".into(),
|
||||
None,
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(settings.runtime_profile, RuntimeProfile::BrowserAttached);
|
||||
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
||||
// write config with runtimeProfile / skillsPromptMode / allowedToolProfiles
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run the focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Fail because `SgClawSettings`, `RuntimeProfile`, or new parsing fields do not exist yet.
|
||||
|
||||
**Step 3: Implement backward-compatible settings expansion**
|
||||
|
||||
Minimal target shape:
|
||||
|
||||
```rust
|
||||
pub struct SgClawSettings {
|
||||
pub provider_api_key: String,
|
||||
pub provider_base_url: String,
|
||||
pub provider_model: String,
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
pub skills_prompt_mode: SkillsPromptMode,
|
||||
pub runtime_profile: RuntimeProfile,
|
||||
}
|
||||
```
|
||||
|
||||
Compatibility rules:
|
||||
- Existing `apiKey/baseUrl/model/skillsDir` continue to load.
|
||||
- New fields such as `skillsPromptMode` and `runtimeProfile` are optional.
|
||||
- Defaults should be hardened, not legacy-full-prompt by accident.
|
||||
|
||||
**Step 4: Run the config and adapter tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
cargo test --test compat_memory_test -- --nocapture
|
||||
cargo test --test compat_cron_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- All pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/config/settings.rs src/config/mod.rs src/compat/config_adapter.rs tests/compat_config_test.rs tests/compat_memory_test.rs tests/compat_cron_test.rs
|
||||
git commit -m "feat: add zeroclaw-first sgclaw settings model"
|
||||
```
|
||||
|
||||
## Task 4: Rebuild The Execution Path Around The New Runtime Engine
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Modify: `src/compat/event_bridge.rs`
|
||||
- Test: `tests/compat_runtime_test.rs`
|
||||
|
||||
**Intent:**
|
||||
- Make `compat/runtime.rs` a thin bridge into the new runtime engine instead of the place where the product’s core execution policy lives.
|
||||
- Keep browser-originated tasks attached to a browser surface, but do not collapse the runtime into a browser-only tool list.
|
||||
|
||||
**Step 1: Add the failing runtime tests**
|
||||
|
||||
Extend `tests/compat_runtime_test.rs` with at least:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn compat_runtime_can_complete_a_text_only_turn_without_browser_tool_calls() {
|
||||
// provider returns direct assistant content
|
||||
// no BrowserMessage::Response is queued
|
||||
// summary should still succeed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_runtime_allows_read_skill_under_compact_mode_policy() {
|
||||
// configure compact skills mode
|
||||
// verify the runtime tool policy includes read_skill
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run the focused runtime test file**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Fail because the current runtime still hardcodes browser-only exposure or cannot express the new policy.
|
||||
|
||||
**Step 3: Refactor the runtime path**
|
||||
|
||||
Implementation target:
|
||||
|
||||
- `src/agent/mod.rs` decides the runtime profile and passes browser context into the runtime engine.
|
||||
- `src/compat/runtime.rs` becomes a compatibility bridge, not the architecture center.
|
||||
- `src/runtime/engine.rs` owns:
|
||||
- zeroclaw agent creation
|
||||
- tool registration
|
||||
- skill loading
|
||||
- runtime profile application
|
||||
- browser contract prompt injection only when browser surface is actually present
|
||||
|
||||
**Step 4: Re-run the focused runtime tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Existing compat runtime tests still pass.
|
||||
- New text-only / compact-skill tests pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/runtime.rs src/agent/mod.rs src/compat/event_bridge.rs tests/compat_runtime_test.rs
|
||||
git commit -m "refactor: route browser requests through zeroclaw-first runtime engine"
|
||||
```
|
||||
|
||||
## Task 5: Treat The Browser Pipe As A Privileged Tool Surface
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/browser_tool_adapter.rs`
|
||||
- Modify: `src/pipe/protocol.rs`
|
||||
- Modify: `src/pipe/browser_tool.rs`
|
||||
- Modify: `src/security/mac_policy.rs`
|
||||
- Test: `tests/compat_browser_tool_test.rs`
|
||||
- Test: `tests/browser_tool_test.rs`
|
||||
- Test: `tests/pipe_protocol_test.rs`
|
||||
- Test: `tests/pipe_handshake_test.rs`
|
||||
|
||||
**Intent:**
|
||||
- Keep the browser powerful, but explicitly as a protected external execution surface.
|
||||
- Preserve the current HMAC/MAC/rules boundary while making it obvious in code that browser execution is not synonymous with runtime execution.
|
||||
|
||||
**Step 1: Add the failing tests**
|
||||
|
||||
Examples:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn browser_tool_contract_is_marked_as_privileged_surface_in_policy_metadata() {
|
||||
// assert runtime metadata treats browser tool separately from generic tools
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_submit_task_can_carry_browser_context_without_forcing_browser_only_execution() {
|
||||
// protocol/adapter level test
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run the safety-related tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_browser_tool_test -- --nocapture
|
||||
cargo test --test browser_tool_test -- --nocapture
|
||||
cargo test --test pipe_protocol_test -- --nocapture
|
||||
cargo test --test pipe_handshake_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Fail on new privilege/metadata expectations before implementation.
|
||||
|
||||
**Step 3: Implement the policy split**
|
||||
|
||||
Implementation target:
|
||||
- keep `browser_action` schema constrained
|
||||
- keep `MacPolicy` as the final guard on outbound browser commands
|
||||
- annotate browser surface metadata in the runtime/tool policy layer
|
||||
- avoid leaking browser-only assumptions into generic runtime config
|
||||
|
||||
**Step 4: Re-run the focused browser safety tests**
|
||||
|
||||
Run the same four commands from Step 2.
|
||||
|
||||
Expected:
|
||||
- All pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/browser_tool_adapter.rs src/pipe/protocol.rs src/pipe/browser_tool.rs src/security/mac_policy.rs tests/compat_browser_tool_test.rs tests/browser_tool_test.rs tests/pipe_protocol_test.rs tests/pipe_handshake_test.rs
|
||||
git commit -m "feat: model browser pipe as privileged runtime surface"
|
||||
```
|
||||
|
||||
## Task 6: Restore Zeroclaw-Native Skills Instead Of Browser-Specific Skill Prompt Hacks
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/runtime/engine.rs`
|
||||
- Modify: `src/compat/event_bridge.rs`
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Test: `tests/compat_runtime_test.rs`
|
||||
- Test: `tests/skill_lib_validation_test.py`
|
||||
|
||||
**Intent:**
|
||||
- Make skill loading and skill usage follow zeroclaw-native behavior first.
|
||||
- Prefer compact mode plus `read_skill` when safe.
|
||||
- Make skill usage observable in runtime logs so the browser UI can explain what happened.
|
||||
|
||||
**Step 1: Add the failing skill-observability tests**
|
||||
|
||||
Add tests for:
|
||||
- compact mode includes `read_skill`
|
||||
- runtime logs show when `read_skill` or skill-defined tools are invoked
|
||||
- configured `skillsDir` still resolves both repo-root and nested `skills/` layouts
|
||||
|
||||
**Step 2: Run the skill-related tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
python3 tests/skill_lib_validation_test.py
|
||||
```
|
||||
|
||||
Expected:
|
||||
- New observability assertions fail before implementation.
|
||||
|
||||
**Step 3: Implement the minimal skill-first runtime behavior**
|
||||
|
||||
Implementation target:
|
||||
- compact skills mode becomes the sgClaw default unless explicitly overridden
|
||||
- `read_skill` is allowed when the active tool policy permits it
|
||||
- runtime logs include loaded skill names and actual skill/tool usage
|
||||
|
||||
**Step 4: Re-run the skill tests**
|
||||
|
||||
Run the same two commands from Step 2.
|
||||
|
||||
Expected:
|
||||
- Both pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/runtime/engine.rs src/compat/event_bridge.rs src/agent/mod.rs tests/compat_runtime_test.rs tests/skill_lib_validation_test.py
|
||||
git commit -m "feat: restore zeroclaw-native skill flow and observability"
|
||||
```
|
||||
|
||||
## Task 7: Remove Or Quarantine The Legacy Parallel Runtime Paths
|
||||
|
||||
**Files:**
|
||||
- Modify or delete: `src/agent/runtime.rs`
|
||||
- Modify or delete: `src/agent/planner.rs`
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- Modify: `tests/planner_test.rs`
|
||||
- Modify: `docs/L2-核心模块与接口契约层.md`
|
||||
- Modify: `docs/L3-数据流与Skill体系层.md`
|
||||
|
||||
**Intent:**
|
||||
- Stop shipping multiple conceptual runtimes.
|
||||
- If the light runtime/planner path still has value, mark it as legacy/dev-only.
|
||||
- If it has no production value, remove it after the zeroclaw-first runtime is stable.
|
||||
|
||||
**Step 1: Decide the disposition**
|
||||
|
||||
Choose one:
|
||||
|
||||
```text
|
||||
A. delete planner/runtime legacy path
|
||||
B. keep as explicit legacy/dev-only module with zero production routing
|
||||
```
|
||||
|
||||
Recommendation: `B` first, then `A` after one clean release cycle.
|
||||
|
||||
**Step 2: Add the failing cleanup tests**
|
||||
|
||||
Examples:
|
||||
- production browser path never routes into planner fallback
|
||||
- docs no longer describe planner fallback as the primary model-enabled path
|
||||
|
||||
**Step 3: Run the legacy-path tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
cargo test --test planner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Fail once the cleanup assertions are added.
|
||||
|
||||
**Step 4: Apply the cleanup**
|
||||
|
||||
Implementation target:
|
||||
- remove production routing dependency
|
||||
- rename or mark legacy modules clearly
|
||||
- update docs to match the new reality
|
||||
|
||||
**Step 5: Re-run the legacy-path tests and commit**
|
||||
|
||||
Run the same two commands, then:
|
||||
|
||||
```bash
|
||||
git add src/agent/runtime.rs src/agent/planner.rs tests/agent_runtime_test.rs tests/planner_test.rs docs/L2-核心模块与接口契约层.md docs/L3-数据流与Skill体系层.md
|
||||
git commit -m "refactor: quarantine legacy browser-only runtime paths"
|
||||
```
|
||||
|
||||
## Task 8: Align The Browser UI With The New Runtime Truth
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/AGENTS.md`
|
||||
|
||||
**Intent:**
|
||||
- Make the browser UI an honest client of the runtime.
|
||||
- Stop labeling all tasks as “网页执行” when the runtime may have solved them through non-browser zeroclaw capabilities.
|
||||
|
||||
**Step 1: Add the UI contract checklist**
|
||||
|
||||
Checklist:
|
||||
|
||||
```text
|
||||
1. UI shows runtime profile or capability mode
|
||||
2. UI can show whether browser tools were actually used
|
||||
3. UI can show when skills were read or invoked
|
||||
4. wording does not imply browser is the whole runtime
|
||||
```
|
||||
|
||||
**Step 2: Implement the label and observability changes**
|
||||
|
||||
Examples:
|
||||
- replace fixed “网页执行” copy with runtime-derived capability wording
|
||||
- add a compact “本轮调用”/“能力来源” section
|
||||
- avoid implying every successful task came from page automation
|
||||
|
||||
**Step 3: Verify browser resources**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --check /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js
|
||||
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome
|
||||
```
|
||||
|
||||
Expected:
|
||||
- All pass.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/zyl/projects/superRpa/src add chrome/browser/resources/superrpa/sgclaw_overlay.js chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.html.ts chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.css.ts AGENTS.md
|
||||
git -C /home/zyl/projects/superRpa/src commit -m "feat: align browser ui with zeroclaw-first runtime"
|
||||
```
|
||||
|
||||
## Final Verification Matrix
|
||||
|
||||
Run, in order:
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_profile_test -- --nocapture
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
cargo test --test compat_browser_tool_test -- --nocapture
|
||||
cargo test --test browser_tool_test -- --nocapture
|
||||
cargo test --test pipe_protocol_test -- --nocapture
|
||||
cargo test --test pipe_handshake_test -- --nocapture
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
cargo test --test planner_test -- --nocapture
|
||||
python3 tests/skill_lib_validation_test.py
|
||||
```
|
||||
|
||||
If local Cargo registry state is unstable, use the Chromium hermetic wrapper instead:
|
||||
|
||||
```bash
|
||||
python3 /home/zyl/projects/superRpa/src/tools/crates/run_cargo.py test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml -- --nocapture
|
||||
```
|
||||
|
||||
Then verify browser integration:
|
||||
|
||||
```bash
|
||||
node --check /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/sgclaw_overlay.js
|
||||
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- The docs say the right thing before the code change begins.
|
||||
- `sgclaw` behaves like a security-hardened zeroclaw runtime, not a browser-only compat shell.
|
||||
- The browser pipe remains central for protected execution, but it is no longer mistaken for the whole product architecture.
|
||||
- Skills, prompt building, memory, routing, and tool policy all flow through zeroclaw-native mechanisms first.
|
||||
- The UI becomes a thin, honest client of the shared runtime.
|
||||
482
docs/plans/2026-03-29-sgclaw-superrpa-decoupled-runtime-plan.md
Normal file
482
docs/plans/2026-03-29-sgclaw-superrpa-decoupled-runtime-plan.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# sgClaw SuperRPA Decoupled Runtime Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Keep the SuperRPA parent-child security model, while moving high-frequency sgClaw startup, model, skill, and frontend presentation changes out of SuperRPA compile-time code and into runtime-managed configuration.
|
||||
|
||||
**Architecture:** SuperRPA remains the trusted host that owns process spawning, pipe security, browser/office capability gates, and frontend host contracts. sgClaw becomes the runtime-configured execution engine that reads launch/runtime policy from files, with SuperRPA preferring external launch descriptors and external frontend bundles before falling back to bundled defaults. This preserves the security boundary while removing the need to rebuild the browser for routine sgClaw iteration.
|
||||
|
||||
**Tech Stack:** Chromium C++ WebUI, TypeScript/Lit frontend, Rust sgClaw runtime, JSON config files, local filesystem-based runtime assets, existing pipe protocol and Zeroclaw planner-first execution path.
|
||||
|
||||
### Task 1: Freeze the design in docs before further code changes
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L1-系统架构与安全模型层.md`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L2-核心模块与接口契约层.md`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L4-工程实现与部署拓扑层.md`
|
||||
- Create: `/home/zyl/projects/sgClaw/claw/docs/plans/2026-03-29-sgclaw-superrpa-runtime-config-design.md`
|
||||
|
||||
**Step 1: Write the design delta doc**
|
||||
|
||||
Document these decisions explicitly:
|
||||
- SuperRPA owns host security and capability exposure only.
|
||||
- sgClaw owns planner, model routing, skill orchestration, and business behavior.
|
||||
- Launch behavior is described by runtime files, not hardcoded browser-side constants.
|
||||
- Frontend only has display rights; planner/executor decisions stay in sgClaw/Zeroclaw.
|
||||
|
||||
**Step 2: Add the failing doc checklist**
|
||||
|
||||
Create a checklist inside the design doc with these questions and mark them initially unresolved:
|
||||
- Can browser startup switch sgClaw binary without rebuilding Chromium?
|
||||
- Can model/provider selection change without rebuilding Chromium?
|
||||
- Can floating UI be replaced without rebuilding Chromium?
|
||||
- Can acceptance flows prove planner-first behavior visually and functionally?
|
||||
|
||||
**Step 3: Update the core architecture docs**
|
||||
|
||||
Add short sections showing:
|
||||
- Launch config file path and fallback rules.
|
||||
- Runtime config ownership split between SuperRPA and sgClaw.
|
||||
- External frontend bundle loading path and fallback to bundled assets.
|
||||
|
||||
**Step 4: Review docs for consistency**
|
||||
|
||||
Check that `L1`, `L2`, `L4`, and the new design doc all use the same terms:
|
||||
- `host`
|
||||
- `launch config`
|
||||
- `runtime config`
|
||||
- `frontend bundle`
|
||||
- `planner-first`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/zyl/projects/sgClaw/claw add \
|
||||
docs/L1-系统架构与安全模型层.md \
|
||||
docs/L2-核心模块与接口契约层.md \
|
||||
docs/L4-工程实现与部署拓扑层.md \
|
||||
docs/plans/2026-03-29-sgclaw-superrpa-runtime-config-design.md
|
||||
git -C /home/zyl/projects/sgClaw/claw commit -m "docs: define superrpa sgclaw runtime boundary"
|
||||
```
|
||||
|
||||
### Task 2: Finish and lock down the current stale-backend fix
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.h`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc`
|
||||
- Create: `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh`
|
||||
|
||||
**Step 1: Write the failing regression test**
|
||||
|
||||
Add internal tests for binary resolution priority:
|
||||
1. `SUPERRPA_SGCLAW_BINARY` override wins.
|
||||
2. `skillsDir`-inferred source checkout wrapper wins over bundled binary.
|
||||
3. Bundled `out/.../sgclaw` is only a fallback.
|
||||
|
||||
**Step 2: Run the failing test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
|
||||
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="SgClawSessionServiceInternalTest.*"
|
||||
```
|
||||
|
||||
Expected: the new test target fails before the final test helper wiring is complete.
|
||||
|
||||
**Step 3: Write the minimal implementation**
|
||||
|
||||
Expose a testable internal resolver function that accepts:
|
||||
- config path
|
||||
- bundled binary path
|
||||
- optional env override string
|
||||
- output detail string
|
||||
|
||||
Keep production `Start()` calling the same shared resolver to avoid divergence.
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
|
||||
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="SgClawSessionServiceInternalTest.*:FunctionsUiMainlineTest.StartPublishesDetailedRulesDiagnosticsToUiLogs"
|
||||
```
|
||||
|
||||
Expected: all targeted tests pass.
|
||||
|
||||
**Step 5: Run browser compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome
|
||||
```
|
||||
|
||||
Expected: `LINK ./chrome` with exit code `0`.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/zyl/projects/superRpa/src add \
|
||||
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc \
|
||||
chrome/browser/ui/webui/superrpa/sgclaw_session_service.h \
|
||||
chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc
|
||||
git -C /home/zyl/projects/sgClaw/claw add \
|
||||
tools/browser_runtime/sgclaw_browser_entry.sh
|
||||
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: resolve sgclaw binary from runtime config"
|
||||
```
|
||||
|
||||
### Task 3: Add a real launch descriptor so SuperRPA no longer hardcodes sgClaw startup policy
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_webui_config.h`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_webui_config.cc`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui.cc`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_state.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_mainline_unittest.ts`
|
||||
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_launch_config.h`
|
||||
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_launch_config.cc`
|
||||
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc`
|
||||
|
||||
**Step 1: Write the failing config tests**
|
||||
|
||||
Cover:
|
||||
- missing launch config falls back safely
|
||||
- explicit `binary`, `args`, `env`, `working_dir`, `runtime_config_path` parse correctly
|
||||
- unsafe or nonexistent paths are rejected with clear UI-visible errors
|
||||
|
||||
**Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
|
||||
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="*SgClaw*Config*"
|
||||
```
|
||||
|
||||
Expected: launch-config cases fail before parser/consumer code is added.
|
||||
|
||||
**Step 3: Implement minimal launch config support**
|
||||
|
||||
Define a host-side launch descriptor with fields:
|
||||
- `binary`
|
||||
- `args`
|
||||
- `env`
|
||||
- `working_dir`
|
||||
- `runtime_config_path`
|
||||
- `frontend_bundle_dir`
|
||||
|
||||
Load it from a predictable profile-local path, with safe defaults and fallback to existing behavior.
|
||||
|
||||
**Step 4: Wire startup to the descriptor**
|
||||
|
||||
Have `SgClawSessionService::Start()` resolve:
|
||||
- executable path
|
||||
- process args
|
||||
- working dir
|
||||
- env
|
||||
- runtime config path
|
||||
|
||||
without requiring browser recompilation for routine changes.
|
||||
|
||||
**Step 5: Wire config UI to persist supported fields**
|
||||
|
||||
Make `sgclaw-config` save and load the new fields so local users can adjust launch behavior from the UI or by editing the JSON file directly.
|
||||
|
||||
**Step 6: Run tests and browser compile**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests chrome
|
||||
```
|
||||
|
||||
Expected: config tests pass and browser still links.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/zyl/projects/superRpa/src add \
|
||||
chrome/browser/ui/webui/superrpa/sgclaw_launch_config.h \
|
||||
chrome/browser/ui/webui/superrpa/sgclaw_launch_config.cc \
|
||||
chrome/browser/ui/webui/superrpa/sgclaw_webui_config.h \
|
||||
chrome/browser/ui/webui/superrpa/sgclaw_webui_config.cc \
|
||||
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc \
|
||||
chrome/browser/ui/webui/superrpa/functions_ui.cc \
|
||||
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config.ts \
|
||||
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_state.ts \
|
||||
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_mainline_unittest.ts
|
||||
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: add runtime launch config for sgclaw"
|
||||
```
|
||||
|
||||
### Task 4: Expand sgClaw runtime config so model/provider/skill policy live in sgClaw, not SuperRPA
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/config/settings.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/config/mod.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/config_adapter.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/runtime.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/planner.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_config_test.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/runtime_profile_test.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/planner_test.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L3-数据流与Skill体系层.md`
|
||||
|
||||
**Step 1: Write failing Rust tests**
|
||||
|
||||
Cover config-driven behavior for:
|
||||
- planner-first mode
|
||||
- provider list / active provider
|
||||
- browser backend selection
|
||||
- office backend selection
|
||||
- skills prompt mode
|
||||
- runtime profile
|
||||
|
||||
**Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test compat_config_test runtime_profile_test planner_test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml
|
||||
```
|
||||
|
||||
Expected: new config fields are missing or ignored.
|
||||
|
||||
**Step 3: Implement minimal config schema changes**
|
||||
|
||||
Add fields that let sgClaw choose behavior without host rebuild:
|
||||
- `planner_mode`
|
||||
- `providers`
|
||||
- `active_provider`
|
||||
- `browser_backend`
|
||||
- `office_backend`
|
||||
- `skills_prompt_mode`
|
||||
- `runtime_profile`
|
||||
|
||||
**Step 4: Keep Zeroclaw-first execution**
|
||||
|
||||
Ensure the planner reads config before execution and produces a visible plan event for the frontend, but the frontend still only renders what sgClaw emits.
|
||||
|
||||
**Step 5: Re-run Rust tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml compat_config_test runtime_profile_test planner_test runtime_task_flow_test
|
||||
```
|
||||
|
||||
Expected: planner/config tests pass.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/zyl/projects/sgClaw/claw add \
|
||||
src/config/settings.rs \
|
||||
src/config/mod.rs \
|
||||
src/compat/config_adapter.rs \
|
||||
src/agent/runtime.rs \
|
||||
src/agent/planner.rs \
|
||||
tests/compat_config_test.rs \
|
||||
tests/runtime_profile_test.rs \
|
||||
tests/planner_test.rs \
|
||||
docs/L3-数据流与Skill体系层.md
|
||||
git -C /home/zyl/projects/sgClaw/claw commit -m "sgclaw: move runtime policy into config"
|
||||
```
|
||||
|
||||
### Task 5: Decouple the floating UI so visual iteration stops depending on Chromium rebuilds
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui.cc`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||
- Create: `/home/zyl/projects/sgClaw/claw/frontend/runtime-host/README.md`
|
||||
- Create: `/home/zyl/projects/sgClaw/claw/frontend/runtime-host/manifest.example.json`
|
||||
|
||||
**Step 1: Write failing UI host tests**
|
||||
|
||||
Cover:
|
||||
- external frontend bundle dir is preferred when declared in launch config
|
||||
- bundled frontend assets still load when external assets are absent
|
||||
- planner events are rendered as plan cards/log lines before execution
|
||||
|
||||
**Step 2: Run the failing frontend/browser tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome/test/data/webui_test_resources
|
||||
```
|
||||
|
||||
Then run the relevant TypeScript tests already wired for the sgClaw chat surface.
|
||||
|
||||
**Step 3: Implement the minimal external bundle loader**
|
||||
|
||||
SuperRPA should:
|
||||
- keep the host shell and JS bridge fixed
|
||||
- optionally load external `sgclaw-chat` assets from runtime-configured directory
|
||||
- fall back to bundled assets when missing
|
||||
|
||||
**Step 4: Surface planner output early**
|
||||
|
||||
Use existing runtime event flow so the frontend shows:
|
||||
- plan summary
|
||||
- current step
|
||||
- execution logs
|
||||
|
||||
without moving control logic into the frontend.
|
||||
|
||||
**Step 5: Re-run tests**
|
||||
|
||||
Run the existing sgClaw chat WebUI tests and a browser smoke.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/zyl/projects/superRpa/src add \
|
||||
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
|
||||
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts \
|
||||
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts \
|
||||
chrome/browser/ui/webui/superrpa/functions_ui.cc \
|
||||
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc
|
||||
git -C /home/zyl/projects/sgClaw/claw add \
|
||||
frontend/runtime-host/README.md \
|
||||
frontend/runtime-host/manifest.example.json
|
||||
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: support external sgclaw frontend bundle"
|
||||
```
|
||||
|
||||
### Task 6: Close the current remaining behavioral gaps before new feature work
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/live_acceptance_score_test.py`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts`
|
||||
|
||||
**Step 1: Write failing acceptance assertions**
|
||||
|
||||
Add explicit checks for:
|
||||
- no repeated assistant paragraphs
|
||||
- no fake fallback data when browser path exists
|
||||
- planner-first output appears before tool execution
|
||||
- Zhihu hotlist extraction returns structured rows
|
||||
- office export returns a real output path
|
||||
|
||||
**Step 2: Run the failing acceptance flow**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 /home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
|
||||
```
|
||||
|
||||
Expected: current score exposes the remaining regressions if they still exist.
|
||||
|
||||
**Step 3: Fix the smallest issue set first**
|
||||
|
||||
Order:
|
||||
1. repeated message rendering / repeated summary emission
|
||||
2. planner event visibility
|
||||
3. structured hotlist extraction handoff
|
||||
4. office export path propagation
|
||||
|
||||
**Step 4: Re-run acceptance**
|
||||
|
||||
Run the same command until:
|
||||
- `hotlist_data_correctness > 0`
|
||||
- `xlsx_export_success > 0`
|
||||
- repeated text is absent
|
||||
|
||||
**Step 5: Record fresh evidence**
|
||||
|
||||
Update the acceptance markdown with:
|
||||
- timestamp
|
||||
- score
|
||||
- exact exported path
|
||||
- screenshot/log snippets
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/zyl/projects/sgClaw/claw add \
|
||||
tests/live_acceptance_score_test.py \
|
||||
tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py \
|
||||
docs/acceptance/2026-03-29-zhihu-hotlist-excel.md
|
||||
git -C /home/zyl/projects/superRpa/src add \
|
||||
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
|
||||
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts
|
||||
git -C /home/zyl/projects/sgClaw/claw commit -m "acceptance: stabilize zhihu hotlist excel flow"
|
||||
```
|
||||
|
||||
### Task 7: Final integrated verification
|
||||
|
||||
**Files:**
|
||||
- Verify only: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||
- Verify only: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||
- Verify only: `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh`
|
||||
|
||||
**Step 1: Build all affected binaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome functions_ui_mainline_unittests
|
||||
cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml
|
||||
```
|
||||
|
||||
Expected: both complete successfully.
|
||||
|
||||
**Step 2: Do the live browser smoke**
|
||||
|
||||
Run browser with the local profile and verify the logs include one of:
|
||||
- `using SUPERRPA_SGCLAW_BINARY override: ...`
|
||||
- `using source checkout sgclaw inferred from skillsDir: ...`
|
||||
- `using bundled sgclaw from browser output dir: ...`
|
||||
|
||||
The expected dev mode result is the source checkout path, not the stale bundled fallback.
|
||||
|
||||
**Step 3: Run the final business acceptance**
|
||||
|
||||
Ask sgClaw to:
|
||||
1. read Zhihu hotlist
|
||||
2. export Excel
|
||||
3. open the screen presentation in a new tab
|
||||
|
||||
Verify:
|
||||
- planner appears first
|
||||
- skills are actually used
|
||||
- exported file path is returned
|
||||
- new-tab presentation opens
|
||||
|
||||
**Step 4: Record the result**
|
||||
|
||||
Append the final evidence to:
|
||||
- `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/zyl/projects/sgClaw/claw commit -m "chore: record final sgclaw superrpa runtime verification"
|
||||
```
|
||||
|
||||
## Remaining Items Explicitly Carried Into This Plan
|
||||
|
||||
- The current stale-backend risk is not considered closed until the resolver has automated regression coverage.
|
||||
- The current local edit in `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc` must be either completed under Task 2 or replaced with the final tested version.
|
||||
- The current wrapper script `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh` is still untracked and must be committed as part of Task 2.
|
||||
- The Zhihu hotlist to Excel acceptance still has unresolved correctness and export-path gaps and remains part of the critical path.
|
||||
- The repeated assistant text regression remains part of the critical path because it corrupts operator trust during demos.
|
||||
|
||||
Plan complete and saved to `docs/plans/2026-03-29-sgclaw-superrpa-decoupled-runtime-plan.md`. Two execution options:
|
||||
|
||||
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
|
||||
|
||||
**Which approach?**
|
||||
137
docs/plans/2026-03-29-sgclaw-superrpa-runtime-config-design.md
Normal file
137
docs/plans/2026-03-29-sgclaw-superrpa-runtime-config-design.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# sgClaw SuperRPA Runtime Config Design
|
||||
|
||||
**Status**: Draft frozen before implementation
|
||||
**Date**: 2026-03-29
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Freeze the runtime-boundary design before further implementation so Task 2+ do not drift back into browser-compiled behavior.
|
||||
|
||||
The design line is fixed:
|
||||
|
||||
- `host` keeps the security boundary.
|
||||
- sgClaw keeps runtime behavior.
|
||||
- `frontend bundle` keeps display rights only.
|
||||
- High-frequency changes move to runtime-managed files whenever possible.
|
||||
|
||||
## 2. Ownership Split
|
||||
|
||||
### 2.1 host
|
||||
|
||||
SuperRPA as `host` owns only the trusted boundary:
|
||||
|
||||
- process spawning
|
||||
- pipe lifecycle and session security
|
||||
- browser / office capability exposure
|
||||
- path validation for runtime-managed files
|
||||
- fallback to bundled defaults when external files are missing or unsafe
|
||||
|
||||
`host` does not own planner policy, model routing, provider selection, skill orchestration, or business behavior.
|
||||
|
||||
### 2.2 sgClaw runtime
|
||||
|
||||
sgClaw owns runtime behavior:
|
||||
|
||||
- planner / executor orchestration
|
||||
- provider list and active provider selection
|
||||
- skill loading and prompt mode
|
||||
- browser / office backend selection
|
||||
- runtime profile behavior
|
||||
- planner-first execution sequencing
|
||||
|
||||
### 2.3 frontend bundle
|
||||
|
||||
`frontend bundle` owns presentation only:
|
||||
|
||||
- render runtime state, logs, and conversation
|
||||
- render planner output before execution
|
||||
- collect user input and forward it through host events
|
||||
|
||||
`frontend bundle` must not:
|
||||
|
||||
- decide whether planner runs
|
||||
- directly select provider/backend outside runtime contract
|
||||
- bypass sgClaw / zeroclaw execution
|
||||
|
||||
## 3. Runtime-Managed Files
|
||||
|
||||
### 3.1 launch config
|
||||
|
||||
Owned by `host`, preferred path:
|
||||
|
||||
```text
|
||||
<profile>/superrpa/sgclaw_launch_config.json
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `binary`
|
||||
- `args`
|
||||
- `env`
|
||||
- `working_dir`
|
||||
- `runtime_config_path`
|
||||
- `frontend_bundle_dir`
|
||||
|
||||
Fallback rules:
|
||||
|
||||
1. Prefer external `launch config`
|
||||
2. Fall back to bundled browser defaults when file is missing or invalid
|
||||
3. Fall back to profile-local `runtime config` if `runtime_config_path` is absent
|
||||
4. Fall back to bundled frontend resources if `frontend_bundle_dir` is absent or invalid
|
||||
|
||||
### 3.2 runtime config
|
||||
|
||||
Owned by sgClaw, current default path:
|
||||
|
||||
```text
|
||||
<profile>/superrpa/sgclaw_config.json
|
||||
```
|
||||
|
||||
This file should carry runtime behavior instead of browser compile-time constants, including:
|
||||
|
||||
- planner mode
|
||||
- providers and active provider
|
||||
- browser backend
|
||||
- office backend
|
||||
- skills prompt mode
|
||||
- runtime profile
|
||||
|
||||
### 3.3 frontend bundle
|
||||
|
||||
Owned by `host` for loading, but externally replaceable at runtime:
|
||||
|
||||
- prefer `frontend_bundle_dir`
|
||||
- validate path and allowed loading rules
|
||||
- fall back to bundled resources if invalid
|
||||
|
||||
## 4. Planner-First Rule
|
||||
|
||||
`planner-first` is a runtime contract, not a frontend trick.
|
||||
|
||||
The sequence must be:
|
||||
|
||||
1. sgClaw / zeroclaw produces a plan
|
||||
2. `frontend bundle` displays the plan
|
||||
3. runtime continues into execution
|
||||
4. acceptance verifies both visible plan rendering and actual execution ordering
|
||||
|
||||
## 5. Failing Checklist
|
||||
|
||||
The following questions remain intentionally unresolved at design-freeze time and must be closed by implementation plus verification:
|
||||
|
||||
- [ ] Can browser startup switch sgClaw binary without rebuilding Chromium?
|
||||
- [ ] Can model/provider selection change without rebuilding Chromium?
|
||||
- [ ] Can floating UI be replaced without rebuilding Chromium?
|
||||
- [ ] Can acceptance flows prove planner-first behavior visually and functionally?
|
||||
|
||||
## 6. Terminology Guardrail
|
||||
|
||||
All related docs and code reviews must use the same terms:
|
||||
|
||||
- `host`
|
||||
- `launch config`
|
||||
- `runtime config`
|
||||
- `frontend bundle`
|
||||
- `planner-first`
|
||||
|
||||
Any proposal that moves planner or executor logic back into browser-side presentation code is out of bounds for this design.
|
||||
@@ -0,0 +1,451 @@
|
||||
# SGClaw ZeroClaw Planner-First Realignment Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Realign the browser submit path so `sgclaw` uses `zeroclaw` as the primary planner/executor, with `sgclaw` acting only as the secure SuperRPA host plus custom tool bridge.
|
||||
|
||||
**Architecture:** Stop treating `zeroclaw` as a thin LLM wrapper. The browser message path should enter a `zeroclaw`-native orchestration entry point first, let `zeroclaw` perform planning/tool-loop control, and expose SuperRPA-specific browser/office/screen capabilities as regular tools inside that runtime. Any deterministic fast paths for Zhihu/Office must be implemented as `zeroclaw`-aligned execution components, not as frontend-owned control flow. The frontend may display the generated plan and current stage for UX, but it must not own planning or execution decisions.
|
||||
|
||||
**Tech Stack:** Rust, `sgclaw` compat bridge, `third_party/zeroclaw` agent loop, SuperRPA browser pipe, local skill library, OpenXML office export, HTML screen export, cargo tests, Python live acceptance.
|
||||
|
||||
### Task 1: Freeze The Current Architecture Gap With Characterization Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
- Reference only: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
|
||||
- Reference only: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/agent/loop_.rs`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add a test that submits `读取知乎热榜前10,并导出 excel 文件` through `handle_browser_message_with_context(...)` and asserts the browser submit path does **not** terminate inside the current thin `Agent::turn_streamed(...)` compat bridge.
|
||||
|
||||
The test should check for one of these observable signals:
|
||||
- a new orchestration mode log such as `zeroclaw_process_message_primary`
|
||||
- absence of the old `compat_llm_primary` mode log
|
||||
- absence of selector-thrashing logs like repeated `getText .HotList-item`, `[data-hot-item]`, `ol li`
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the current implementation still enters `src/compat/runtime.rs` and drives `agent.turn_streamed(...)` directly.
|
||||
|
||||
**Step 3: Write the smallest additional characterization test**
|
||||
|
||||
Add a second failing test that proves SuperRPA-specific tools remain available after the orchestration switch:
|
||||
- browser host tool
|
||||
- `openxml_office`
|
||||
- `screen_html_export`
|
||||
|
||||
This test should not require real network calls.
|
||||
|
||||
**Step 4: Run both failing tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: at least the new characterization tests fail for the expected reason.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/compat_runtime_test.rs
|
||||
git commit -m "test: characterize browser path bypass of zeroclaw orchestrator"
|
||||
```
|
||||
|
||||
### Task 2: Introduce A ZeroClaw-Native Browser Orchestration Entry Point
|
||||
|
||||
**Files:**
|
||||
- Create: `/home/zyl/projects/sgClaw/claw/src/compat/orchestration.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/mod.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/agent/loop_.rs:4752`
|
||||
|
||||
**Step 1: Write the failing unit test for the new entry point**
|
||||
|
||||
Add a test for a new helper in `src/compat/orchestration.rs` that:
|
||||
- receives browser task context
|
||||
- builds a `zeroclaw` config
|
||||
- returns a browser-safe orchestration handle or result
|
||||
|
||||
The test should prove the new helper is chosen by `handle_browser_message_with_context(...)`.
|
||||
|
||||
**Step 2: Run the new test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the helper does not exist yet.
|
||||
|
||||
**Step 3: Implement the minimal entry point**
|
||||
|
||||
Create `src/compat/orchestration.rs` with one responsibility:
|
||||
- bridge browser submit tasks into a `zeroclaw`-native orchestration path
|
||||
|
||||
Do not implement Zhihu-specific logic here. This layer must only:
|
||||
- map config
|
||||
- map task context/history
|
||||
- inject SuperRPA tools
|
||||
- call the chosen `zeroclaw` orchestration function
|
||||
|
||||
**Step 4: Switch `handle_browser_message_with_context(...)` to the new entry point**
|
||||
|
||||
Modify:
|
||||
- `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
|
||||
|
||||
Replace the direct `compat::runtime::execute_task_with_sgclaw_settings(...)` primary path with the new orchestration bridge.
|
||||
|
||||
**Step 5: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/orchestration.rs src/compat/mod.rs src/agent/mod.rs src/compat/runtime.rs tests/compat_runtime_test.rs
|
||||
git commit -m "refactor: route browser submit flow through zeroclaw orchestration bridge"
|
||||
```
|
||||
|
||||
### Task 3: Register SuperRPA Browser/Office/Screen Capabilities As Native ZeroClaw Tools
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/browser_tool_adapter.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/openxml_office_tool.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/screen_html_export_tool.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/orchestration.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_openxml_office_tool_test.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_screen_html_export_tool_test.rs`
|
||||
|
||||
**Step 1: Write the failing tool-registration test**
|
||||
|
||||
Add a test that asserts the `zeroclaw` orchestration path exposes:
|
||||
- the preferred SuperRPA browser tool
|
||||
- `openxml_office` when Excel export is requested
|
||||
- `screen_html_export` when screen export is requested
|
||||
|
||||
The test must verify this through the new orchestration path, not the old compat path.
|
||||
|
||||
**Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test browser_orchestration_registers_superrpa_tools_natively -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL until tool wiring is complete.
|
||||
|
||||
**Step 3: Implement minimal native tool registration**
|
||||
|
||||
Ensure the new orchestration bridge injects `sgclaw` tools into the `zeroclaw` runtime without changing frontend code. Keep tool naming stable:
|
||||
- `superrpa_browser`
|
||||
- `openxml_office`
|
||||
- `screen_html_export`
|
||||
|
||||
**Step 4: Verify tool-level tests still pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_openxml_office_tool_test -- --nocapture
|
||||
cargo test --test compat_screen_html_export_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Run the new orchestration registration test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test browser_orchestration_registers_superrpa_tools_natively -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/browser_tool_adapter.rs src/compat/openxml_office_tool.rs src/compat/screen_html_export_tool.rs src/runtime/engine.rs src/compat/orchestration.rs tests/compat_runtime_test.rs tests/compat_openxml_office_tool_test.rs tests/compat_screen_html_export_tool_test.rs
|
||||
git commit -m "feat: expose superrpa browser and export tools through zeroclaw orchestration"
|
||||
```
|
||||
|
||||
### Task 4: Remove Frontend-Owned Or Custom Compat Mainline Control Flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/skill_runner.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
- Reference only: `/home/zyl/projects/sgClaw/claw/docs/plans/2026-03-29-sgclaw-zeroclaw-planner-first-execution-plan.md`
|
||||
|
||||
**Step 1: Write the failing regression test**
|
||||
|
||||
Add a test that proves Zhihu hotlist export no longer depends on a frontend-owned mainline such as:
|
||||
- `compat_skill_runner_primary`
|
||||
- direct `sgclaw`-local branching before `zeroclaw`
|
||||
|
||||
The expected primary mode should be a `zeroclaw`-owned orchestration mode.
|
||||
|
||||
**Step 2: Run the regression test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test zhihu_export_does_not_use_frontend_owned_mainline -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL while `src/compat/skill_runner.rs` still owns primary control flow.
|
||||
|
||||
**Step 3: Remove or demote the custom mainline**
|
||||
|
||||
Change the code so:
|
||||
- `src/compat/skill_runner.rs` becomes either a helper invoked inside the `zeroclaw` tool/runtime ecosystem, or is removed if redundant
|
||||
- `src/agent/mod.rs` no longer branches to a custom primary executor before `zeroclaw`
|
||||
|
||||
Do not leave two competing primary modes.
|
||||
|
||||
**Step 4: Run the regression test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test zhihu_export_does_not_use_frontend_owned_mainline -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Run the broader compat suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/runtime.rs src/agent/mod.rs src/compat/skill_runner.rs tests/compat_runtime_test.rs
|
||||
git commit -m "refactor: remove frontend-owned primary control flow from browser submit path"
|
||||
```
|
||||
|
||||
### Task 5: Align Skills With ZeroClaw Execution Semantics Instead Of Prompt-Only Semantics
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/tools/read_skill.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/read_skill_tool_test.rs`
|
||||
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
|
||||
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/SKILL.md`
|
||||
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist-screen/SKILL.md`
|
||||
|
||||
**Step 1: Write the failing skill-execution regression test**
|
||||
|
||||
Add a test that proves skill usage in the browser submit path is not just:
|
||||
- prompt injection
|
||||
- `read_skill` text stuffing
|
||||
- model-led selector wandering
|
||||
|
||||
Instead, the test should verify the task produces:
|
||||
- a plan-driven collection/execution flow
|
||||
- a real `.xlsx` or `.html` artifact path
|
||||
- no selector-thrashing loop
|
||||
|
||||
**Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test browser_skill_usage_is_execution_not_prompt_only -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL until skill semantics are aligned with execution.
|
||||
|
||||
**Step 3: Implement the minimal alignment**
|
||||
|
||||
Change the orchestration so `read_skill` is a fallback for missing context, not the primary means of making high-frequency browser workflows executable.
|
||||
|
||||
Keep:
|
||||
- skill discovery
|
||||
- skill references
|
||||
- artifact contract wording
|
||||
|
||||
Reduce:
|
||||
- over-reliance on prompt stuffing
|
||||
- over-reliance on model-led selector discovery for known workflows
|
||||
|
||||
**Step 4: Re-run the skill regression tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test browser_skill_usage_is_execution_not_prompt_only -- --nocapture
|
||||
cargo test --test read_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/runtime/engine.rs src/compat/runtime.rs third_party/zeroclaw/src/tools/read_skill.rs tests/compat_runtime_test.rs tests/read_skill_tool_test.rs
|
||||
git commit -m "refactor: align browser skill execution with zeroclaw-native workflow semantics"
|
||||
```
|
||||
|
||||
### Task 6: Verify The Planner-First Path End-To-End
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/runtime_profile_test.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_config_test.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/live_acceptance_score_test.py`
|
||||
- Reference only: `/home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw`
|
||||
|
||||
**Step 1: Run the Rust regression suites**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test runtime_profile_test -- --nocapture
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
cargo test --test read_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 2: Run the Python scoring test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 -m unittest tests/live_acceptance_score_test.py
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 3: Run the live Zhihu hotlist Excel acceptance**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
|
||||
```
|
||||
|
||||
Expected:
|
||||
- total score returns to `100`
|
||||
- logs show planner-first `zeroclaw` orchestration instead of selector-thrashing
|
||||
- no `shell`, `web_fetch`, `web_search_tool`
|
||||
- final summary includes a real `.xlsx` path
|
||||
|
||||
**Step 4: Update the acceptance note**
|
||||
|
||||
Record:
|
||||
- new orchestration mode
|
||||
- tool sequence
|
||||
- timing notes
|
||||
- any remaining selector or latency risk
|
||||
|
||||
**Step 5: Rebuild and sync the runtime binary used by SuperRPA**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo build
|
||||
cp /home/zyl/projects/sgClaw/claw/target/debug/sgclaw /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
|
||||
sha256sum /home/zyl/projects/sgClaw/claw/target/debug/sgclaw /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
|
||||
```
|
||||
|
||||
Expected: the two hashes match exactly.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/acceptance/2026-03-29-zhihu-hotlist-excel.md tests/runtime_profile_test.rs tests/compat_config_test.rs tests/compat_runtime_test.rs tests/live_acceptance_score_test.py
|
||||
git commit -m "test: verify planner-first zeroclaw browser orchestration end to end"
|
||||
```
|
||||
|
||||
### Task 7: Surface The Generated Plan In The Chat UI Without Giving Frontend Control
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/event_bridge.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/pipe/protocol.rs`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/` (the active sgClaw chat UI files that render task progress)
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/pipe_protocol_test.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
|
||||
**Step 1: Write the failing protocol/UI test**
|
||||
|
||||
Add a test that proves the backend can emit a structured planning event before tool execution starts. The event must carry:
|
||||
- a short plan title
|
||||
- a flat ordered step list
|
||||
- current phase such as `planning`, `executing`, `completed`
|
||||
|
||||
The frontend test or fixture should verify the chat can render the plan summary without waiting for final completion.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test pipe_protocol_test -- --nocapture
|
||||
cargo test --test compat_runtime_test plan_events_are_emitted_before_browser_execution -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the protocol does not yet expose a dedicated plan-progress event.
|
||||
|
||||
**Step 3: Add the minimal backend event shape**
|
||||
|
||||
Extend the `sgclaw` pipe/event bridge so the orchestration layer can emit:
|
||||
- planner summary
|
||||
- execution stage transitions
|
||||
|
||||
Keep the event read-only from the frontend’s perspective. The UI may display it, but cannot edit or branch execution.
|
||||
|
||||
**Step 4: Render the plan in the active chat UI**
|
||||
|
||||
Update the SuperRPA sgClaw chat UI so it:
|
||||
- prints the generated plan immediately after planning completes
|
||||
- keeps the plan compact and collapsible
|
||||
- highlights the current phase while waiting
|
||||
|
||||
Do not add frontend-owned retry logic, decision logic, or browser action generation.
|
||||
|
||||
**Step 5: Run verification**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test pipe_protocol_test -- --nocapture
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 6: Manual browser validation**
|
||||
|
||||
Submit:
|
||||
```text
|
||||
读取知乎热榜前10,并导出 excel 文件
|
||||
```
|
||||
|
||||
Expected:
|
||||
- the chat first shows a short generated plan
|
||||
- the user sees stage transitions instead of a blank wait
|
||||
- execution still follows the backend-owned `zeroclaw` path
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/event_bridge.rs src/pipe/protocol.rs tests/pipe_protocol_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "feat: surface backend-generated execution plans in sgclaw chat ui"
|
||||
```
|
||||
444
docs/plans/2026-03-29-zhihu-hotlist-office-export-plan.md
Normal file
444
docs/plans/2026-03-29-zhihu-hotlist-office-export-plan.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# Zhihu Hotlist To Excel Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Make sgClaw reliably read Zhihu hotlist data through a Zhihu browser skill and export the collected structured result into a local `.xlsx` file through an independent Office skill.
|
||||
|
||||
**Architecture:** Keep zeroclaw as the core planner, but stop it from wandering across unrelated tools once a browser-attached skill is selected. The hotlist skill must produce a strict structured artifact, and the Office skill must consume that artifact through a dedicated `openxml_office` tool that wraps the sibling `openxml_cli` project. For the first delivery, reuse `openxml_cli template render` with a bundled `.xlsx` template instead of inventing a new workbook-construction API.
|
||||
|
||||
**Tech Stack:** Rust, vendored zeroclaw, sgClaw browser pipe, skill packages under `/home/zyl/projects/sgClaw/skill_lib`, sibling `openxml_cli`, JSON payload handoff, `.xlsx` template render, Python/Rust regression tests, real-provider smoke verification.
|
||||
|
||||
## Scope Guard
|
||||
|
||||
- In scope:
|
||||
- browser-attached skill execution discipline
|
||||
- `zhihu-hotlist` structured export artifact
|
||||
- new `office-export-xlsx` skill
|
||||
- new `openxml_office` runtime tool
|
||||
- end-to-end acceptance for "读取知乎热榜数据,并导出 excel 文件"
|
||||
- Out of scope:
|
||||
- generic Office authoring platform
|
||||
- arbitrary shell-based export flows
|
||||
- browser-side file generation as the main export path
|
||||
- broad multi-site data export before Zhihu hotlist is stable
|
||||
|
||||
## Current Findings To Preserve
|
||||
|
||||
- Real-provider validation already proved that `zhihu-hotlist`, `zhihu-navigate`, and `zhihu-write` can be selected through `read_skill`.
|
||||
- The current failure mode is not "skill missing" but "tool discipline collapse":
|
||||
- `file_read`, `glob_search`, and `shell` are attempted after `read_skill`
|
||||
- `zhihu-write` can fill title/body but still exceeds max tool iterations
|
||||
- `zhihu-navigate` succeeds for some intents but still detours through non-browser tools
|
||||
- The sibling Office project already exists at `/home/zyl/projects/sgClaw/openxml_cli`.
|
||||
- `openxml_cli` currently exposes `capabilities`, `template inspect`, `template validate`, and `template render`; it does not yet expose a direct "create workbook from scratch" command.
|
||||
|
||||
## Final Acceptance Contract
|
||||
|
||||
Input:
|
||||
|
||||
```text
|
||||
读取知乎热榜数据,并导出 excel 文件
|
||||
```
|
||||
|
||||
Required behavior:
|
||||
|
||||
1. sgClaw selects `zhihu-hotlist`.
|
||||
2. sgClaw gathers hotlist rows through the SuperRPA browser interface only.
|
||||
3. sgClaw converts the result into a structured JSON export payload.
|
||||
4. sgClaw selects `office-export-xlsx`.
|
||||
5. sgClaw calls `openxml_office`.
|
||||
6. A local `.xlsx` file is produced and its path is returned.
|
||||
|
||||
Required logs:
|
||||
|
||||
- `read_skill zhihu-hotlist`
|
||||
- browser actions only: `navigate`, `getText`, optionally `click`
|
||||
- `read_skill office-export-xlsx`
|
||||
- `call openxml_office`
|
||||
|
||||
Forbidden logs during the mainline path:
|
||||
|
||||
- `call shell`
|
||||
- `call glob_search`
|
||||
- `call file_read` on skill references or skill roots
|
||||
- `docker run`
|
||||
|
||||
Required Excel content:
|
||||
|
||||
- one sheet named `知乎热榜`
|
||||
- columns: `rank`, `title`, `heat`
|
||||
- at least 10 hotlist rows
|
||||
- exported values match the collected rows
|
||||
|
||||
## Task 1: Lock Browser-Attached Skill Runs To The Right Tools
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/tool_policy.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||
|
||||
**Intent:**
|
||||
- Once the task is clearly in a browser-attached Zhihu skill flow, the runtime must stop offering unrelated tools such as `shell`, `glob_search`, and arbitrary `file_read`.
|
||||
|
||||
**Step 1: Write the failing regression tests**
|
||||
|
||||
Add focused tests in `tests/compat_runtime_test.rs` for:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn zhihu_hotlist_skill_flow_does_not_expose_shell_or_glob_tools() {}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_export_flow_exposes_browser_and_office_tools_only() {}
|
||||
```
|
||||
|
||||
Assertions to include:
|
||||
|
||||
- request tool list contains `superrpa_browser`
|
||||
- request tool list contains `read_skill`
|
||||
- request tool list does not contain `shell`
|
||||
- request tool list does not contain `glob_search`
|
||||
- request tool list does not contain generic `file_read` during the constrained browser skill phase
|
||||
|
||||
**Step 2: Run the focused tests to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test zhihu_hotlist_skill_flow_does_not_expose_shell_or_glob_tools -- --nocapture
|
||||
cargo test --test compat_runtime_test browser_attached_export_flow_exposes_browser_and_office_tools_only -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- fail because current runtime still exposes too many tools in browser-attached mode
|
||||
|
||||
**Step 3: Implement minimal constrained-tool policy**
|
||||
|
||||
Implement a browser-skill execution mode that:
|
||||
|
||||
- keeps `superrpa_browser`
|
||||
- keeps compatibility alias `browser_action`
|
||||
- keeps `read_skill`
|
||||
- optionally keeps the new `openxml_office` tool only for export tasks
|
||||
- removes `shell`, `glob_search`, and free-form `file_read` from the allowed tool list for these phases
|
||||
|
||||
Do this in `src/runtime/engine.rs` by deriving a narrower `allowed_tools` set from:
|
||||
|
||||
- runtime profile
|
||||
- browser surface present flag
|
||||
- instruction intent
|
||||
- whether export mode is active
|
||||
|
||||
**Step 4: Re-run the focused tests**
|
||||
|
||||
Run the same commands.
|
||||
|
||||
Expected:
|
||||
- both pass
|
||||
|
||||
## Task 2: Convert Zhihu Hotlist Skill To Structured Output First
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
|
||||
**Intent:**
|
||||
- The hotlist skill should stop ending with prose-only summaries. Its primary output must be a stable export artifact the Office skill can consume.
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests that enforce:
|
||||
|
||||
- `zhihu-hotlist` prompt body contains an explicit `Export Artifact` section
|
||||
- the artifact schema includes `sheet_name`, `columns`, and `rows`
|
||||
- runtime regression checks can find those fields in the skill content when `read_skill` is used
|
||||
|
||||
**Step 2: Run tests to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.skill_lib_validation_test
|
||||
cargo test --test compat_runtime_test handle_browser_message_executes_real_zhihu_hotlist_skill_flow -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- validation fails because the artifact contract is not yet required
|
||||
|
||||
**Step 3: Update `zhihu-hotlist`**
|
||||
|
||||
Add an `Export Artifact` section that requires this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "标题", "344万"]]
|
||||
}
|
||||
```
|
||||
|
||||
Also add hard rules:
|
||||
|
||||
- no extra exploratory tools after the browser data is collected
|
||||
- prose summary is secondary, structured artifact is primary
|
||||
|
||||
**Step 4: Re-run tests**
|
||||
|
||||
Expected:
|
||||
- validation passes
|
||||
|
||||
## Task 3: Create The Office Export Skill Package
|
||||
|
||||
**Files:**
|
||||
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/SKILL.md`
|
||||
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/references/export-flow.md`
|
||||
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/assets/zhihu_hotlist_template.xlsx`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
|
||||
|
||||
**Intent:**
|
||||
- Add a fully separate Office skill that knows nothing about browser scraping and only turns structured table data into a local Excel file.
|
||||
|
||||
**Step 1: Write the failing validation test**
|
||||
|
||||
Extend `tests/skill_lib_validation_test.py` so discovery expects:
|
||||
|
||||
```python
|
||||
EXPECTED_SKILL_NAMES = [
|
||||
"office-export-xlsx",
|
||||
"zhihu-hotlist",
|
||||
"zhihu-navigate",
|
||||
"zhihu-write",
|
||||
]
|
||||
```
|
||||
|
||||
Also require the new skill to mention:
|
||||
|
||||
- `openxml_office`
|
||||
- `.xlsx`
|
||||
- `sheet_name`
|
||||
- `columns`
|
||||
- `rows`
|
||||
|
||||
**Step 2: Run the validation test to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.skill_lib_validation_test
|
||||
```
|
||||
|
||||
Expected:
|
||||
- fail because the new skill package does not exist yet
|
||||
|
||||
**Step 3: Create the skill package**
|
||||
|
||||
`SKILL.md` must define:
|
||||
|
||||
- when to use: local Office export from structured rows
|
||||
- required input schema
|
||||
- output: exported file path
|
||||
- tool rule: only call `openxml_office`, do not use browser tools
|
||||
|
||||
`export-flow.md` must define:
|
||||
|
||||
- validate payload shape
|
||||
- choose output path
|
||||
- invoke `openxml_office`
|
||||
- return file path and row count
|
||||
|
||||
The first workbook template should be a fixed `zhihu_hotlist_template.xlsx` with:
|
||||
|
||||
- sheet `知乎热榜`
|
||||
- row 1 headers already present
|
||||
- table fill anchored to a stable name or placeholder expected by `openxml_cli`
|
||||
|
||||
**Step 4: Re-run validation**
|
||||
|
||||
Expected:
|
||||
- new skill passes audit
|
||||
|
||||
## Task 4: Add The `openxml_office` Runtime Tool
|
||||
|
||||
**Files:**
|
||||
- Create: `/home/zyl/projects/sgClaw/claw/src/compat/openxml_office_tool.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/mod.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/tool_policy.rs`
|
||||
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_openxml_office_tool_test.rs`
|
||||
|
||||
**Intent:**
|
||||
- Wrap sibling `openxml_cli` as a first-class local tool instead of leaking Office export through shell prompting.
|
||||
|
||||
**Step 1: Write the failing tool test**
|
||||
|
||||
Create `tests/compat_openxml_office_tool_test.rs` with cases for:
|
||||
|
||||
- capability probe
|
||||
- render request assembly for xlsx export
|
||||
- rejection when rows/columns are missing
|
||||
- stable JSON output containing `output_path`
|
||||
|
||||
**Step 2: Run the test to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_openxml_office_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- fail because the tool does not exist
|
||||
|
||||
**Step 3: Implement minimal tool**
|
||||
|
||||
Tool contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "export_hotlist_xlsx",
|
||||
"template_path": ".../zhihu_hotlist_template.xlsx",
|
||||
"output_path": "/tmp/zhihu_hotlist.xlsx",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "标题", "344万"]]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- write the payload JSON to a temp file
|
||||
- invoke sibling `openxml_cli template render --request <file> --json`
|
||||
- return parsed JSON result and normalized `output_path`
|
||||
- no free-form shell composition from model text
|
||||
|
||||
**Step 4: Re-run the focused tests**
|
||||
|
||||
Expected:
|
||||
- pass
|
||||
|
||||
## Task 5: Wire Export Tasks To Use Two Skills In Sequence
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
|
||||
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||
|
||||
**Intent:**
|
||||
- The single user instruction must naturally flow from hotlist capture into Office export, not end after the first skill.
|
||||
|
||||
**Step 1: Write the failing runtime test**
|
||||
|
||||
Add a focused regression test for:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn zhihu_hotlist_export_task_reads_hotlist_skill_then_office_skill() {}
|
||||
```
|
||||
|
||||
Assertions:
|
||||
|
||||
- request stream includes `read_skill zhihu-hotlist`
|
||||
- later includes `read_skill office-export-xlsx`
|
||||
- office phase exposes `openxml_office`
|
||||
- no `shell` is exposed in the constrained task path
|
||||
|
||||
**Step 2: Run the test to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test zhihu_hotlist_export_task_reads_hotlist_skill_then_office_skill -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- fail because the task currently has no structured handoff to Office export
|
||||
|
||||
**Step 3: Implement minimal chaining support**
|
||||
|
||||
Do not add a hard-coded workflow engine.
|
||||
|
||||
Minimal implementation:
|
||||
|
||||
- strengthen prompt contract so export tasks require structured hotlist artifact
|
||||
- include `openxml_office` in allowed tools for export intent
|
||||
- keep browser-only tools for the collection phase and Office-only tool for the export phase
|
||||
|
||||
**Step 4: Re-run the test**
|
||||
|
||||
Expected:
|
||||
- pass
|
||||
|
||||
## Task 6: Add Real Acceptance Harness And Scoring
|
||||
|
||||
**Files:**
|
||||
- Create: `/home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py`
|
||||
- Create: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||
|
||||
**Intent:**
|
||||
- Make the final acceptance repeatable with the real user config and a transparent score.
|
||||
|
||||
**Step 1: Write the script**
|
||||
|
||||
The script must:
|
||||
|
||||
- use `/home/zyl/.config/superrpa/Default/superrpa/sgclaw_config.json`
|
||||
- boot local `target/debug/sgclaw`
|
||||
- send one browser `submit_task`
|
||||
- respond to browser commands with controlled fixture responses
|
||||
- capture:
|
||||
- loaded skills
|
||||
- selected skills
|
||||
- forbidden tool calls
|
||||
- final summary
|
||||
- exported file path
|
||||
|
||||
**Step 2: Define score rubric**
|
||||
|
||||
Rubric:
|
||||
|
||||
- `skill selection`: 30
|
||||
- `tool discipline`: 25
|
||||
- `hotlist data correctness`: 20
|
||||
- `xlsx export success`: 20
|
||||
- `final response quality`: 5
|
||||
|
||||
Automatic deductions:
|
||||
|
||||
- `shell` called: `-15`
|
||||
- `glob_search` called: `-10`
|
||||
- `file_read` on skill references: `-10`
|
||||
- wrong skill selected first: `-15`
|
||||
- export missing output path: `-20`
|
||||
|
||||
**Step 3: Run acceptance**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
|
||||
```
|
||||
|
||||
Expected:
|
||||
- prints total score and per-dimension breakdown
|
||||
- stores final evidence in `docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||
|
||||
## Delivery Sequence
|
||||
|
||||
Execute in this order:
|
||||
|
||||
1. Task 1: constrain tools
|
||||
2. Task 2: structure hotlist output
|
||||
3. Task 3: add office skill package
|
||||
4. Task 4: add `openxml_office`
|
||||
5. Task 5: chain the two skills
|
||||
6. Task 6: run acceptance and score
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
- browser-attached hotlist tasks no longer wander into `shell`, `glob_search`, or ad-hoc `file_read`
|
||||
- `office-export-xlsx` exists as an independent skill
|
||||
- `openxml_office` exists as an explicit tool
|
||||
- a single user task can collect hotlist data and export `.xlsx`
|
||||
- acceptance score is at least `85/100`
|
||||
175
docs/plans/2026-03-30-browser-script-skill-capability-plan.md
Normal file
175
docs/plans/2026-03-30-browser-script-skill-capability-plan.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Browser Script Skill Capability Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Let zeroclaw-backed sgclaw skills call deterministic browser-context scripts through existing SuperRPA page-execution capabilities.
|
||||
|
||||
**Architecture:** Reuse SuperRPA's existing page script execution path instead of inventing a parallel workflow executor. Extend sgclaw's browser pipe and zeroclaw skill-tool mapping so a skill can declare a browser script tool, execute a packaged script file with parameters, and receive structured JSON back in the current page context.
|
||||
|
||||
**Tech Stack:** Rust (`sgclaw`, vendored `zeroclaw`), Chromium/SuperRPA C++, existing sgclaw browser pipe protocol, skill package `SKILL.toml` / `scripts/`.
|
||||
|
||||
### Task 1: Define the browser-script contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `third_party/zeroclaw/src/skills/mod.rs`
|
||||
- Modify: `third_party/zeroclaw/src/tools/mod.rs`
|
||||
- Create: `third_party/zeroclaw/src/tools/browser_script_skill.rs`
|
||||
- Test: `tests/compat_browser_tool_test.rs`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add a sgclaw-side test proving the browser tool schema accepts a script execution action with required browser-script fields and rejects missing script payload.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_browser_tool_test`
|
||||
Expected: FAIL because the browser tool still exposes only `click/type/navigate/getText`.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Extend the browser tool adapter and protocol-facing action parsing to support a deterministic script execution action carrying:
|
||||
- `action`
|
||||
- `expected_domain`
|
||||
- `script`
|
||||
- optional `args`
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_browser_tool_test`
|
||||
Expected: PASS for the new browser script action coverage.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/compat_browser_tool_test.rs src/compat/browser_tool_adapter.rs src/pipe/protocol.rs third_party/zeroclaw/src/skills/mod.rs third_party/zeroclaw/src/tools/mod.rs third_party/zeroclaw/src/tools/browser_script_skill.rs
|
||||
git commit -m "feat: add browser script skill tool support"
|
||||
```
|
||||
|
||||
### Task 2: Wire browser script execution through the sgclaw host bridge
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pipe/protocol.rs`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol.cc`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.cc`
|
||||
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol_mainline_unittest.cc`
|
||||
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_process_host_mainline_unittest.cc`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add host-side tests proving:
|
||||
- sgclaw protocol accepts the new browser script action
|
||||
- handshake advertises the action
|
||||
- the security gate allows it only through the sgclaw host allowlist
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run the relevant SuperRPA unit tests.
|
||||
Expected: FAIL because the host protocol and allowlist do not yet include browser script execution.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Reuse existing SuperRPA router support by bridging the new sgclaw action to the host's page script execution capability instead of inventing a second execution path.
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run the same SuperRPA unit tests.
|
||||
Expected: PASS with the new action accepted end-to-end.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pipe/protocol.rs /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol.cc /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.cc /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_pipe_protocol_mainline_unittest.cc /home/zyl/projects/superRpa/src/chrome/browser/superrpa/sgclaw/sgclaw_process_host_mainline_unittest.cc
|
||||
git commit -m "feat: expose browser script execution to sgclaw"
|
||||
```
|
||||
|
||||
### Task 3: Convert Zhihu hotlist collection into a script-backed skill path
|
||||
|
||||
**Files:**
|
||||
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.toml`
|
||||
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/scripts/extract_hotlist.js`
|
||||
- Modify: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add runtime coverage proving the Zhihu hotlist export flow uses the skill-backed browser script tool instead of repeated `getText` probing.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_runtime_test`
|
||||
Expected: FAIL because the current flow still uses `read_skill` plus generic `getText`.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Package the hotlist extractor as a deterministic browser script tool returning structured rows and update the skill description to require that tool before export.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml --test compat_runtime_test`
|
||||
Expected: PASS with the script-backed tool visible in logs and the generic repeated `getText` fallback removed from the primary path.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.toml /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/scripts/extract_hotlist.js /home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md tests/compat_runtime_test.rs
|
||||
git commit -m "feat: make zhihu hotlist extraction script-backed"
|
||||
```
|
||||
|
||||
### Task 4: Fix plan visibility in the sgclaw frontend
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts`
|
||||
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
|
||||
- Test: frontend or host tests covering `[sgclaw/plan]`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage proving `[sgclaw/plan]` logs are treated as planner logs and preserve multiline task-step rendering.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run the relevant frontend or browser-side tests.
|
||||
Expected: FAIL because the current frontend only recognizes `[browser/plan]` or `[plan]`.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Accept `[sgclaw/plan]` as a planner log source and keep the plan text rendered as multiple visible lines.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run the same tests.
|
||||
Expected: PASS with sgclaw planner logs rendered correctly.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts
|
||||
git commit -m "fix: render sgclaw planner logs in chat ui"
|
||||
```
|
||||
|
||||
### Task 5: Full verification
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
**Step 1: Run sgclaw Rust tests**
|
||||
|
||||
Run: `cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 2: Run relevant SuperRPA unit tests**
|
||||
|
||||
Run the updated sgclaw host and frontend tests.
|
||||
Expected: PASS.
|
||||
|
||||
**Step 3: Run a manual browser acceptance flow**
|
||||
|
||||
Verify that:
|
||||
- startup logs include runtime and skill versions
|
||||
- `[sgclaw/plan]` is shown as a real multi-line plan
|
||||
- Zhihu hotlist export invokes the script-backed skill tool
|
||||
- the flow returns a real `.xlsx` path without repeated self-referential output
|
||||
|
||||
**Step 4: Commit verification artifacts if needed**
|
||||
|
||||
Keep verification commits separate from behavior changes.
|
||||
@@ -3,12 +3,16 @@
|
||||
> 适用范围:P1a(Rust)与 P2(Chromium C++)联调开发。
|
||||
> 目标:双方只要严格按本文档实现,即可稳定联调。
|
||||
|
||||
附加口径:
|
||||
- 浏览器宿主是 sgClaw/zeroclaw runtime 的一个特权执行面,不是整个 runtime 的定义。
|
||||
- 只有真正需要浏览器执行的动作才应该跨过这条 pipe;不要把所有任务都假定为浏览器任务。
|
||||
|
||||
## 1. 协议边界与责任
|
||||
|
||||
- 单一事实来源:`docs/L2-核心模块与接口契约层.md` 第 5 章(5.1~5.4)。
|
||||
- 协议版本冻结:`1.0`;字段、action、错误码变更均视为协议变更。
|
||||
- P1a 负责:`seq` 生成、command 组包、HMAC 计算、response 关联。
|
||||
- P2 负责:message 解析、Schema 校验、MAC 检查、CommandRouter 执行、结构化回包。
|
||||
- P1a 负责:zeroclaw-first runtime、任务解释、tool policy、`seq` 生成、command 组包、HMAC 计算、response 关联。
|
||||
- P2 负责:process host、message 解析、Schema 校验、MAC 检查、CommandRouter 执行、结构化回包。
|
||||
|
||||
## 2. Wire Contract(双方 MUST)
|
||||
|
||||
@@ -36,6 +40,10 @@
|
||||
- 失败 response 必填:`error.code`、`error.message`(禁止纯文本错误)。
|
||||
- `action` 与 `params` 必须通过 L2 的枚举和 Schema 校验。
|
||||
|
||||
说明:
|
||||
- 这份标准约束的是“浏览器特权工具面”的 wire contract。
|
||||
- 它不定义 sgClaw/zeroclaw 整体任务语义,也不意味着所有任务都必须变成 browser command。
|
||||
|
||||
**标准 command 示例**:
|
||||
|
||||
```json
|
||||
|
||||
43
frontend/runtime-host/README.md
Normal file
43
frontend/runtime-host/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# sgClaw Runtime Host Bundle
|
||||
|
||||
`frontendBundleDir` lets SuperRPA prefer an external `sgclaw-chat` bundle at
|
||||
runtime while keeping the browser host, security boundary, and `chrome.send`
|
||||
bridge inside Chromium.
|
||||
|
||||
## Contract
|
||||
|
||||
- SuperRPA remains the host and security boundary.
|
||||
- sgClaw remains the runtime and planner/execution owner.
|
||||
- The frontend bundle only renders state that comes from the existing host
|
||||
bridge.
|
||||
- Missing external files fall back to Chromium-bundled resources.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
The external bundle root must mirror the Chromium resource paths under
|
||||
`functions/sgclaw-chat/`.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
frontend-bundle/
|
||||
sgclaw-chat/
|
||||
sgclaw-chat.js
|
||||
sgclaw-chat.css.js
|
||||
sgclaw-chat.html.js
|
||||
components/
|
||||
sgclaw-chat-shell.js
|
||||
sgclaw-message-list.js
|
||||
```
|
||||
|
||||
When `frontendBundleDir` points at `frontend-bundle/`, the browser will try to
|
||||
serve `sgclaw-chat/*` from that directory first. Any file that is absent will
|
||||
continue loading from the built-in Chromium resource pack.
|
||||
|
||||
## Packaging Notes
|
||||
|
||||
- Keep relative import paths inside the bundle identical to the bundled
|
||||
`sgclaw-chat` tree.
|
||||
- Do not move planner or execution logic into the bundle.
|
||||
- Treat [manifest.example.json](/home/zyl/projects/sgClaw/claw/frontend/runtime-host/manifest.example.json)
|
||||
as a packaging reference for humans and tooling.
|
||||
17
frontend/runtime-host/manifest.example.json
Normal file
17
frontend/runtime-host/manifest.example.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"bundleId": "sgclaw-chat",
|
||||
"version": "0.1.0",
|
||||
"description": "External sgclaw chat presentation bundle for SuperRPA runtime hosting.",
|
||||
"resourceRoot": ".",
|
||||
"resourcePrefix": "sgclaw-chat/",
|
||||
"entries": [
|
||||
"sgclaw-chat/sgclaw-chat.js",
|
||||
"sgclaw-chat/sgclaw-chat.css.js",
|
||||
"sgclaw-chat/sgclaw-chat.html.js"
|
||||
],
|
||||
"notes": [
|
||||
"Paths should mirror Chromium bundled sgclaw-chat resources.",
|
||||
"Missing files fall back to bundled Chromium resources.",
|
||||
"The bundle must stay presentation-only and use the existing host bridge."
|
||||
]
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
]
|
||||
},
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText"],
|
||||
"blocked": ["eval", "executeJsInPage"]
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": ["executeJsInPage"]
|
||||
}
|
||||
}
|
||||
|
||||
532
scripts/validate_skill_lib.py
Normal file
532
scripts/validate_skill_lib.py
Normal file
@@ -0,0 +1,532 @@
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
MAX_TEXT_FILE_BYTES = 512 * 1024
|
||||
SCRIPT_SUFFIXES = (
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".ksh",
|
||||
".fish",
|
||||
".ps1",
|
||||
".bat",
|
||||
".cmd",
|
||||
)
|
||||
HIGH_RISK_PATTERNS = (
|
||||
(re.compile(r"(?im)\bcurl\b[^\n|]{0,200}\|\s*(?:sh|bash|zsh)\b"), "curl-pipe-shell"),
|
||||
(re.compile(r"(?im)\bwget\b[^\n|]{0,200}\|\s*(?:sh|bash|zsh)\b"), "wget-pipe-shell"),
|
||||
(re.compile(r"(?im)\b(?:invoke-expression|iex)\b"), "powershell-iex"),
|
||||
(re.compile(r"(?im)\brm\s+-rf\s+/"), "destructive-rm-rf-root"),
|
||||
(re.compile(r"(?im)\bnc(?:at)?\b[^\n]{0,120}\s-e\b"), "netcat-remote-exec"),
|
||||
(re.compile(r"(?im)\bdd\s+if="), "disk-overwrite-dd"),
|
||||
(re.compile(r"(?im)\bmkfs(?:\.[a-z0-9]+)?\b"), "filesystem-format"),
|
||||
(re.compile(r"(?im):\(\)\s*\{\s*:\|\:&\s*\};:"), "fork-bomb"),
|
||||
)
|
||||
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILL_LIB_ROOT = REPO_ROOT.parent / "skill_lib"
|
||||
SKILLS_DIR = SKILL_LIB_ROOT / "skills"
|
||||
|
||||
|
||||
class SkillRecord(NamedTuple):
|
||||
name: str
|
||||
description: str
|
||||
version: str
|
||||
author: str | None
|
||||
tags: list[str]
|
||||
prompt_body: str
|
||||
location: Path
|
||||
|
||||
|
||||
class AuditReport(NamedTuple):
|
||||
files_scanned: int
|
||||
findings: list[str]
|
||||
|
||||
|
||||
class ValidationResult(NamedTuple):
|
||||
record: SkillRecord
|
||||
report: AuditReport
|
||||
ok: bool
|
||||
|
||||
|
||||
def discover_skill_dirs(skills_dir: Path | None = None) -> list[Path]:
|
||||
root = skills_dir or SKILLS_DIR
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(path for path in root.iterdir() if path.is_dir())
|
||||
|
||||
|
||||
def load_skill(skill_dir: Path) -> SkillRecord:
|
||||
manifest_path = skill_dir / "SKILL.toml"
|
||||
markdown_path = skill_dir / "SKILL.md"
|
||||
|
||||
if manifest_path.is_file():
|
||||
manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
skill_meta = manifest.get("skill", {})
|
||||
prompts = manifest.get("prompts", [])
|
||||
body = ""
|
||||
if markdown_path.is_file():
|
||||
_, body = parse_skill_markdown(markdown_path.read_text(encoding="utf-8"))
|
||||
elif prompts:
|
||||
body = "\n\n".join(str(prompt) for prompt in prompts)
|
||||
|
||||
description = skill_meta.get("description")
|
||||
if not description or not str(description).strip():
|
||||
description = extract_description(body)
|
||||
|
||||
return SkillRecord(
|
||||
name=skill_meta.get("name") or skill_dir.name,
|
||||
description=str(description),
|
||||
version=str(skill_meta.get("version") or "0.1.0"),
|
||||
author=skill_meta.get("author") or None,
|
||||
tags=list(skill_meta.get("tags", [])),
|
||||
prompt_body=body,
|
||||
location=manifest_path,
|
||||
)
|
||||
|
||||
skill_path = markdown_path
|
||||
content = skill_path.read_text(encoding="utf-8")
|
||||
meta, body = parse_skill_markdown(content)
|
||||
|
||||
name = meta["name"] or skill_dir.name
|
||||
description = meta["description"]
|
||||
if not description or not description.strip():
|
||||
description = extract_description(body)
|
||||
|
||||
version = meta["version"] or "0.1.0"
|
||||
author = meta["author"] or None
|
||||
tags = list(meta["tags"])
|
||||
|
||||
return SkillRecord(
|
||||
name=name,
|
||||
description=description,
|
||||
version=version,
|
||||
author=author,
|
||||
tags=tags,
|
||||
prompt_body=body,
|
||||
location=skill_path,
|
||||
)
|
||||
|
||||
|
||||
def validate_all_skills(allow_scripts: bool = False) -> list[ValidationResult]:
|
||||
results = []
|
||||
for skill_dir in discover_skill_dirs():
|
||||
record = load_skill(skill_dir)
|
||||
report = audit_skill_directory(skill_dir, allow_scripts=allow_scripts)
|
||||
results.append(ValidationResult(record=record, report=report, ok=not report.findings))
|
||||
return results
|
||||
|
||||
|
||||
def parse_skill_markdown(content: str) -> tuple[dict[str, object], str]:
|
||||
frontmatter = split_skill_frontmatter(content)
|
||||
if frontmatter is None:
|
||||
return empty_meta(), content
|
||||
raw_frontmatter, body = frontmatter
|
||||
return parse_simple_frontmatter(raw_frontmatter), body
|
||||
|
||||
|
||||
def split_skill_frontmatter(content: str) -> tuple[str, str] | None:
|
||||
normalized = content.replace("\r\n", "\n")
|
||||
if not normalized.startswith("---\n"):
|
||||
return None
|
||||
rest = normalized[len("---\n") :]
|
||||
marker = "\n---\n"
|
||||
idx = rest.find(marker)
|
||||
if idx != -1:
|
||||
return rest[:idx], rest[idx + len(marker) :]
|
||||
if rest.endswith("\n---"):
|
||||
return rest[:-4], ""
|
||||
return None
|
||||
|
||||
|
||||
def parse_simple_frontmatter(frontmatter: str) -> dict[str, object]:
|
||||
meta = empty_meta()
|
||||
collecting_tags = False
|
||||
|
||||
for raw_line in frontmatter.splitlines():
|
||||
if collecting_tags:
|
||||
trimmed = raw_line.strip()
|
||||
if trimmed.startswith("- "):
|
||||
tag = trimmed[2:].strip().strip('"').strip("'")
|
||||
if tag:
|
||||
meta["tags"].append(tag)
|
||||
continue
|
||||
collecting_tags = False
|
||||
|
||||
if ":" not in raw_line:
|
||||
continue
|
||||
key, value = raw_line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
|
||||
if key == "name":
|
||||
meta["name"] = value
|
||||
elif key == "description":
|
||||
meta["description"] = value
|
||||
elif key == "version":
|
||||
meta["version"] = value
|
||||
elif key == "author":
|
||||
meta["author"] = value
|
||||
elif key == "tags":
|
||||
if not value:
|
||||
collecting_tags = True
|
||||
else:
|
||||
cleaned = value.lstrip("[").rstrip("]")
|
||||
meta["tags"] = [
|
||||
item.strip().strip('"').strip("'")
|
||||
for item in cleaned.split(",")
|
||||
if item.strip().strip('"').strip("'")
|
||||
]
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
def empty_meta() -> dict[str, object]:
|
||||
return {
|
||||
"name": None,
|
||||
"description": None,
|
||||
"version": None,
|
||||
"author": None,
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
|
||||
def extract_description(body: str) -> str:
|
||||
for line in body.splitlines():
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
if not line.strip():
|
||||
continue
|
||||
return line.strip()
|
||||
return "No description"
|
||||
|
||||
|
||||
def audit_skill_directory(skill_dir: Path, allow_scripts: bool = False) -> AuditReport:
|
||||
if not skill_dir.exists():
|
||||
raise FileNotFoundError(f"Skill source does not exist: {skill_dir}")
|
||||
if not skill_dir.is_dir():
|
||||
raise NotADirectoryError(f"Skill source must be a directory: {skill_dir}")
|
||||
|
||||
canonical_root = skill_dir.resolve()
|
||||
findings: list[str] = []
|
||||
files_scanned = 0
|
||||
|
||||
has_manifest = (canonical_root / "SKILL.md").is_file() or (canonical_root / "SKILL.toml").is_file()
|
||||
if not has_manifest:
|
||||
findings.append(
|
||||
"Skill root must include SKILL.md or SKILL.toml for deterministic auditing."
|
||||
)
|
||||
|
||||
for path in collect_paths_depth_first(canonical_root):
|
||||
files_scanned += 1
|
||||
findings.extend(audit_path(canonical_root, path, allow_scripts=allow_scripts))
|
||||
|
||||
return AuditReport(files_scanned=files_scanned, findings=findings)
|
||||
|
||||
|
||||
def collect_paths_depth_first(root: Path) -> list[Path]:
|
||||
stack = [root]
|
||||
discovered: list[Path] = []
|
||||
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
discovered.append(current)
|
||||
if not current.is_dir():
|
||||
continue
|
||||
children = sorted(current.iterdir())
|
||||
for child in reversed(children):
|
||||
stack.append(child)
|
||||
|
||||
return discovered
|
||||
|
||||
|
||||
def audit_path(root: Path, path: Path, allow_scripts: bool) -> list[str]:
|
||||
findings: list[str] = []
|
||||
metadata = path.lstat()
|
||||
rel = relative_display(root, path)
|
||||
|
||||
if path.is_symlink():
|
||||
findings.append(f"{rel}: symlinks are not allowed in installed skills.")
|
||||
return findings
|
||||
|
||||
if path.is_dir():
|
||||
return findings
|
||||
|
||||
if not allow_scripts and is_unsupported_script_file(path):
|
||||
findings.append(f"{rel}: script-like files are blocked by skill security policy.")
|
||||
|
||||
if metadata.st_size > MAX_TEXT_FILE_BYTES and (is_markdown_file(path) or is_toml_file(path)):
|
||||
findings.append(f"{rel}: file is too large for static audit (>{MAX_TEXT_FILE_BYTES} bytes).")
|
||||
return findings
|
||||
|
||||
if is_markdown_file(path):
|
||||
findings.extend(audit_markdown_file(root, path))
|
||||
elif is_toml_file(path):
|
||||
findings.extend(audit_manifest_file(root, path))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def audit_markdown_file(root: Path, path: Path) -> list[str]:
|
||||
findings: list[str] = []
|
||||
content = path.read_text(encoding="utf-8")
|
||||
rel = relative_display(root, path)
|
||||
|
||||
pattern = detect_high_risk_snippet(content)
|
||||
if pattern:
|
||||
findings.append(f"{rel}: detected high-risk command pattern ({pattern}).")
|
||||
|
||||
for target in extract_markdown_links(content):
|
||||
findings.extend(audit_markdown_link_target(root, path, target))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def audit_manifest_file(root: Path, path: Path) -> list[str]:
|
||||
findings: list[str] = []
|
||||
content = path.read_text(encoding="utf-8")
|
||||
rel = relative_display(root, path)
|
||||
|
||||
pattern = detect_high_risk_snippet(content)
|
||||
if pattern:
|
||||
findings.append(f"{rel}: detected high-risk command pattern ({pattern}).")
|
||||
|
||||
if any(operator in content for operator in ("&&", "||", ";", "`", "$(")):
|
||||
findings.append(f"{rel}: manifest content uses shell chaining operators, which are blocked.")
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def extract_markdown_links(content: str) -> list[str]:
|
||||
return [match.group(1).strip() for match in MARKDOWN_LINK_RE.finditer(content)]
|
||||
|
||||
|
||||
def audit_markdown_link_target(root: Path, source: Path, raw_target: str) -> list[str]:
|
||||
findings: list[str] = []
|
||||
normalized = normalize_markdown_target(raw_target)
|
||||
if not normalized or normalized.startswith("#"):
|
||||
return findings
|
||||
|
||||
rel = relative_display(root, source)
|
||||
scheme = url_scheme(normalized)
|
||||
if scheme:
|
||||
if scheme in {"http", "https", "mailto"}:
|
||||
if has_markdown_suffix(normalized):
|
||||
findings.append(
|
||||
f"{rel}: remote markdown links are blocked by skill security audit ({normalized})."
|
||||
)
|
||||
return findings
|
||||
findings.append(f"{rel}: unsupported URL scheme in markdown link ({normalized}).")
|
||||
return findings
|
||||
|
||||
stripped = strip_query_and_fragment(normalized)
|
||||
if not stripped:
|
||||
return findings
|
||||
|
||||
if looks_like_absolute_path(stripped):
|
||||
findings.append(f"{rel}: absolute markdown link paths are not allowed ({normalized}).")
|
||||
return findings
|
||||
|
||||
if has_script_suffix(stripped):
|
||||
findings.append(f"{rel}: markdown links to script files are blocked ({normalized}).")
|
||||
|
||||
if not has_markdown_suffix(stripped):
|
||||
return findings
|
||||
|
||||
base_dir = source.parent
|
||||
linked_path = base_dir / stripped
|
||||
|
||||
try:
|
||||
canonical_target = linked_path.resolve(strict=True)
|
||||
except FileNotFoundError:
|
||||
if is_cross_skill_reference(stripped):
|
||||
return findings
|
||||
findings.append(f"{rel}: markdown link points to a missing file ({normalized}).")
|
||||
return findings
|
||||
|
||||
if not is_subpath(canonical_target, root):
|
||||
skills_root = skills_root_for(root)
|
||||
if skills_root and is_subpath(canonical_target, skills_root):
|
||||
if not canonical_target.is_file():
|
||||
findings.append(f"{rel}: markdown link must point to a file ({normalized}).")
|
||||
return findings
|
||||
findings.append(f"{rel}: markdown link escapes skill root ({normalized}).")
|
||||
return findings
|
||||
|
||||
if not canonical_target.is_file():
|
||||
findings.append(f"{rel}: markdown link must point to a file ({normalized}).")
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def detect_high_risk_snippet(content: str) -> str | None:
|
||||
for pattern, label in HIGH_RISK_PATTERNS:
|
||||
if pattern.search(content):
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def normalize_markdown_target(raw_target: str) -> str:
|
||||
trimmed = raw_target.strip()
|
||||
if trimmed.startswith("<"):
|
||||
trimmed = trimmed[1:]
|
||||
if trimmed.endswith(">"):
|
||||
trimmed = trimmed[:-1]
|
||||
parts = trimmed.split()
|
||||
return parts[0] if parts else ""
|
||||
|
||||
|
||||
def strip_query_and_fragment(target: str) -> str:
|
||||
end = len(target)
|
||||
hash_idx = target.find("#")
|
||||
if hash_idx != -1:
|
||||
end = min(end, hash_idx)
|
||||
query_idx = target.find("?")
|
||||
if query_idx != -1:
|
||||
end = min(end, query_idx)
|
||||
return target[:end]
|
||||
|
||||
|
||||
def url_scheme(target: str) -> str | None:
|
||||
if ":" not in target:
|
||||
return None
|
||||
scheme, rest = target.split(":", 1)
|
||||
if not scheme or not rest:
|
||||
return None
|
||||
if not all(ch.isalnum() or ch in "+-." for ch in scheme):
|
||||
return None
|
||||
return scheme
|
||||
|
||||
|
||||
def looks_like_absolute_path(target: str) -> bool:
|
||||
if Path(target).is_absolute():
|
||||
return True
|
||||
if len(target) >= 3 and target[0].isalpha() and target[1] == ":" and target[2] in "\\/":
|
||||
return True
|
||||
return target.startswith("~/")
|
||||
|
||||
|
||||
def is_cross_skill_reference(target: str) -> bool:
|
||||
normalized = target[2:] if target.startswith("./") else target
|
||||
path = Path(target)
|
||||
|
||||
if ".." in path.parts:
|
||||
return True
|
||||
|
||||
return "/" not in normalized and "\\" not in normalized and has_markdown_suffix(normalized)
|
||||
|
||||
|
||||
def skills_root_for(root: Path) -> Path | None:
|
||||
current = root
|
||||
while True:
|
||||
if current.name == "skills":
|
||||
return current
|
||||
if current.parent == current:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def relative_display(root: Path, path: Path) -> str:
|
||||
try:
|
||||
rel = path.relative_to(root)
|
||||
except ValueError:
|
||||
return str(path)
|
||||
return "." if str(rel) == "." else str(rel)
|
||||
|
||||
|
||||
def is_markdown_file(path: Path) -> bool:
|
||||
return path.suffix.lower() in {".md", ".markdown"}
|
||||
|
||||
|
||||
def is_toml_file(path: Path) -> bool:
|
||||
return path.suffix.lower() == ".toml"
|
||||
|
||||
|
||||
def is_unsupported_script_file(path: Path) -> bool:
|
||||
return has_script_suffix(str(path).lower()) or has_shell_shebang(path)
|
||||
|
||||
|
||||
def has_script_suffix(raw: str) -> bool:
|
||||
lowered = raw.lower()
|
||||
return any(lowered.endswith(suffix) for suffix in SCRIPT_SUFFIXES)
|
||||
|
||||
|
||||
def has_shell_shebang(path: Path) -> bool:
|
||||
try:
|
||||
prefix = path.read_bytes()[:128]
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
first_line = prefix.decode("utf-8", errors="ignore").splitlines()[0].strip().lower() if prefix else ""
|
||||
interpreter = shebang_interpreter(first_line)
|
||||
return interpreter in {"sh", "bash", "zsh", "ksh", "fish", "pwsh", "powershell"}
|
||||
|
||||
|
||||
def shebang_interpreter(line: str) -> str | None:
|
||||
if not line.startswith("#!"):
|
||||
return None
|
||||
|
||||
shebang = line[2:].strip()
|
||||
if not shebang:
|
||||
return None
|
||||
|
||||
parts = shebang.split()
|
||||
first = Path(parts[0]).name
|
||||
|
||||
if first == "env":
|
||||
for part in parts[1:]:
|
||||
if part.startswith("-"):
|
||||
continue
|
||||
return Path(part).name
|
||||
return None
|
||||
|
||||
return first
|
||||
|
||||
|
||||
def has_markdown_suffix(target: str) -> bool:
|
||||
lowered = target.lower()
|
||||
return lowered.endswith(".md") or lowered.endswith(".markdown")
|
||||
|
||||
|
||||
def is_subpath(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate the sibling skill_lib against ZeroClaw-like rules.")
|
||||
parser.add_argument(
|
||||
"--allow-scripts",
|
||||
action="store_true",
|
||||
help="Allow shell-script files during auditing.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
results = validate_all_skills(allow_scripts=args.allow_scripts)
|
||||
if not results:
|
||||
print(f"FAIL no skills discovered under {SKILLS_DIR}")
|
||||
return 1
|
||||
|
||||
all_ok = True
|
||||
for result in results:
|
||||
status = "PASS" if result.ok else "FAIL"
|
||||
print(f"{status} {result.record.name}")
|
||||
for finding in result.report.findings:
|
||||
print(f" - {finding}")
|
||||
all_ok = all_ok and result.ok
|
||||
|
||||
print(f"Checked {len(results)} skills in {SKILL_LIB_ROOT}")
|
||||
return 0 if all_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
138
src/agent/mod.rs
138
src/agent/mod.rs
@@ -4,10 +4,11 @@ pub mod runtime;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::DeepSeekSettings;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{
|
||||
AgentMessage, BrowserMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
|
||||
AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -58,12 +59,12 @@ impl AgentRuntimeContext {
|
||||
Ok(Self::new(config_path, workspace_root))
|
||||
}
|
||||
|
||||
fn load_deepseek_settings(&self) -> Result<Option<DeepSeekSettings>, PipeError> {
|
||||
DeepSeekSettings::load(self.config_path.as_deref())
|
||||
fn load_sgclaw_settings(&self) -> Result<Option<SgClawSettings>, PipeError> {
|
||||
SgClawSettings::load(self.config_path.as_deref())
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||
}
|
||||
|
||||
fn deepseek_source_label(&self) -> String {
|
||||
fn settings_source_label(&self) -> String {
|
||||
match &self.config_path {
|
||||
Some(path) if path.exists() => path.display().to_string(),
|
||||
_ => "environment".to_string(),
|
||||
@@ -88,39 +89,17 @@ fn send_mode_log<T: Transport>(transport: &T, mode: &str) -> Result<(), PipeErro
|
||||
})
|
||||
}
|
||||
|
||||
fn explicit_non_task_response(history: &[ConversationMessage], instruction: &str) -> Option<String> {
|
||||
if !history.is_empty() {
|
||||
return None;
|
||||
}
|
||||
fn missing_llm_configuration_summary() -> String {
|
||||
"未配置大语言模型。请先在 sgclaw_config.json 或环境变量中配置 apiKey、baseUrl 与 model。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
let trimmed = instruction.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Some("sgClaw 目前只处理浏览器任务,请直接描述要打开、搜索、点击或提取的网页操作。".to_string());
|
||||
}
|
||||
|
||||
const TASK_HINTS: &[&str] = &[
|
||||
"打开", "搜索", "点击", "输入", "导航", "跳转", "访问", "提取", "获取", "网页", "页面",
|
||||
"标签页", "百度", "知乎", "google", "open", "search", "click", "type", "navigate",
|
||||
];
|
||||
if TASK_HINTS.iter().any(|hint| trimmed.contains(hint)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
const CHITCHAT_INPUTS: &[&str] = &[
|
||||
"hi", "hello", "hey", "你好", "您好", "嗨", "在吗", "你是谁", "介绍一下你自己",
|
||||
];
|
||||
if CHITCHAT_INPUTS
|
||||
.iter()
|
||||
.any(|candidate| trimmed.eq_ignore_ascii_case(candidate) || trimmed == *candidate)
|
||||
{
|
||||
return Some("sgClaw 现在是浏览器任务入口,不做通用闲聊。请直接说你想在网页上执行什么操作,例如“打开百度搜索天气”。".to_string());
|
||||
}
|
||||
|
||||
if trimmed.chars().count() <= 8 {
|
||||
return Some("sgClaw 现在只处理浏览器任务。请直接描述网页操作目标,例如“打开知乎搜索天气”或“提取当前页面标题”。".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
fn runtime_version_log_message() -> String {
|
||||
format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
crate::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_plan<T: Transport>(
|
||||
@@ -187,10 +166,11 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
page_url,
|
||||
page_title,
|
||||
} => {
|
||||
if let Some(summary) = explicit_non_task_response(&messages, &instruction) {
|
||||
let instruction = instruction.trim().to_string();
|
||||
if instruction.is_empty() {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,6 +181,10 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
page_url: (!page_url.trim().is_empty()).then_some(page_url),
|
||||
page_title: (!page_title.trim().is_empty()).then_some(page_title),
|
||||
};
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: runtime_version_log_message(),
|
||||
});
|
||||
if !task_context.messages.is_empty() {
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
@@ -210,19 +194,64 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
),
|
||||
});
|
||||
}
|
||||
let completion = match context.load_deepseek_settings() {
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dir =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"DeepSeek config loaded from {} model={} base_url={}",
|
||||
context.deepseek_source_label(),
|
||||
settings.model,
|
||||
settings.base_url
|
||||
context.settings_source_label(),
|
||||
settings.provider_model,
|
||||
settings.provider_base_url
|
||||
),
|
||||
});
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"skills dir resolved to {}",
|
||||
resolved_skills_dir.display()
|
||||
),
|
||||
});
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||
settings.runtime_profile,
|
||||
settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if crate::compat::orchestration::should_use_primary_orchestration(
|
||||
&instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
let _ = send_mode_log(transport, "zeroclaw_process_message_primary");
|
||||
match crate::compat::orchestration::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_mode_log(transport, "compat_llm_primary");
|
||||
match crate::compat::runtime::execute_task(
|
||||
match crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
@@ -240,24 +269,9 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => match planner::plan_instruction(&instruction) {
|
||||
Ok(plan) => {
|
||||
let _ = send_mode_log(transport, "deterministic_planner");
|
||||
match execute_plan(transport, browser_tool, &plan) {
|
||||
Ok(summary) => AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: PipeError::Protocol(err.to_string()).to_string(),
|
||||
},
|
||||
Ok(None) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: missing_llm_configuration_summary(),
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
|
||||
@@ -2,13 +2,19 @@ use reqwest::Url;
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::config::PlannerMode;
|
||||
use crate::pipe::Action;
|
||||
|
||||
/// Legacy deterministic planner kept for dev-only verification and fixture coverage.
|
||||
/// Production browser submit flow no longer routes into this planner.
|
||||
pub const LEGACY_DEV_ONLY: bool = true;
|
||||
|
||||
const BAIDU_URL: &str = "https://www.baidu.com";
|
||||
const BAIDU_DOMAIN: &str = "www.baidu.com";
|
||||
const BAIDU_INPUT_SELECTOR: &str = "#kw";
|
||||
const BAIDU_SEARCH_BUTTON_SELECTOR: &str = "#su";
|
||||
const ZHIHU_URL: &str = "https://www.zhihu.com/search";
|
||||
const ZHIHU_HOME_URL: &str = "https://www.zhihu.com";
|
||||
const ZHIHU_SEARCH_URL: &str = "https://www.zhihu.com/search";
|
||||
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -25,6 +31,12 @@ pub struct TaskPlan {
|
||||
pub steps: Vec<PlannedStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExecutionPreview {
|
||||
pub summary: String,
|
||||
pub steps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum PlannerError {
|
||||
#[error("unsupported instruction: {0}")]
|
||||
@@ -35,10 +47,22 @@ pub enum PlannerError {
|
||||
|
||||
pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
||||
let trimmed = instruction.trim();
|
||||
if matches_exact(trimmed, &["打开百度"]) {
|
||||
return Ok(plan_homepage("已打开百度首页", BAIDU_URL, BAIDU_DOMAIN));
|
||||
}
|
||||
|
||||
if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? {
|
||||
return Ok(plan_baidu_search(query));
|
||||
}
|
||||
|
||||
if matches_exact(trimmed, &["打开知乎"]) {
|
||||
return Ok(plan_homepage(
|
||||
"已打开知乎首页",
|
||||
ZHIHU_HOME_URL,
|
||||
ZHIHU_DOMAIN,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(query) = extract_query(trimmed, &["打开知乎搜索", "打开知乎并搜索"])? {
|
||||
return Ok(plan_zhihu_search(query));
|
||||
}
|
||||
@@ -46,6 +70,42 @@ pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
||||
Err(PlannerError::UnsupportedInstruction(trimmed.to_string()))
|
||||
}
|
||||
|
||||
pub fn build_execution_preview(
|
||||
mode: PlannerMode,
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> Option<ExecutionPreview> {
|
||||
if matches!(mode, PlannerMode::LegacyDeterministic) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trimmed = instruction.trim();
|
||||
if crate::runtime::is_zhihu_hotlist_task(trimmed, page_url, page_title) {
|
||||
return Some(build_zhihu_hotlist_preview(trimmed));
|
||||
}
|
||||
|
||||
if let Ok(plan) = plan_instruction(trimmed) {
|
||||
return Some(ExecutionPreview {
|
||||
summary: format!("先规划再执行:{}", plan.summary),
|
||||
steps: plan
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|step| step.log_message)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
Some(ExecutionPreview {
|
||||
summary: "先规划再执行当前任务".to_string(),
|
||||
steps: vec![
|
||||
"inspect current browser context".to_string(),
|
||||
"choose the required sgclaw runtime tools".to_string(),
|
||||
"execute and return the concrete result".to_string(),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_query<'a>(
|
||||
instruction: &'a str,
|
||||
prefixes: &[&str],
|
||||
@@ -65,6 +125,22 @@ fn extract_query<'a>(
|
||||
Ok(Some(query))
|
||||
}
|
||||
|
||||
fn matches_exact(instruction: &str, candidates: &[&str]) -> bool {
|
||||
candidates.iter().any(|candidate| instruction == *candidate)
|
||||
}
|
||||
|
||||
fn plan_homepage(summary: &str, url: &str, domain: &str) -> TaskPlan {
|
||||
TaskPlan {
|
||||
summary: summary.to_string(),
|
||||
steps: vec![PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": url }),
|
||||
expected_domain: domain.to_string(),
|
||||
log_message: format!("navigate {url}"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_baidu_search(query: &str) -> TaskPlan {
|
||||
TaskPlan {
|
||||
summary: format!("已在百度搜索{query}"),
|
||||
@@ -96,7 +172,7 @@ fn plan_baidu_search(query: &str) -> TaskPlan {
|
||||
}
|
||||
|
||||
fn plan_zhihu_search(query: &str) -> TaskPlan {
|
||||
let url = Url::parse_with_params(ZHIHU_URL, &[("type", "content"), ("q", query)])
|
||||
let url = Url::parse_with_params(ZHIHU_SEARCH_URL, &[("type", "content"), ("q", query)])
|
||||
.expect("valid Zhihu search URL");
|
||||
let url: String = url.into();
|
||||
|
||||
@@ -110,3 +186,28 @@ fn plan_zhihu_search(query: &str) -> TaskPlan {
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_zhihu_hotlist_preview(instruction: &str) -> ExecutionPreview {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") {
|
||||
return ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜大屏生成".to_string(),
|
||||
steps: vec![
|
||||
"navigate https://www.zhihu.com/hot".to_string(),
|
||||
"getText main".to_string(),
|
||||
"call screen_html_export".to_string(),
|
||||
"return generated local .html path".to_string(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜 Excel 导出".to_string(),
|
||||
steps: vec![
|
||||
"navigate https://www.zhihu.com/hot".to_string(),
|
||||
"getText main".to_string(),
|
||||
"call openxml_office".to_string(),
|
||||
"return generated local .xlsx path".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
254
src/compat/browser_script_skill_tool.rs
Normal file
254
src/compat/browser_script_skill_tool.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Value};
|
||||
use zeroclaw::skills::{Skill, SkillTool};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::pipe::{Action, BrowserPipeTool, Transport};
|
||||
|
||||
pub struct BrowserScriptSkillTool<T: Transport> {
|
||||
tool_name: String,
|
||||
tool_description: String,
|
||||
script_path: PathBuf,
|
||||
args: HashMap<String, String>,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
}
|
||||
|
||||
impl<T: Transport> BrowserScriptSkillTool<T> {
|
||||
pub fn new(
|
||||
skill_name: &str,
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let script_path = skill_root.join(&tool.command);
|
||||
let canonical_skill_root = skill_root.canonicalize().unwrap_or_else(|_| skill_root.to_path_buf());
|
||||
let canonical_script_path = script_path
|
||||
.canonicalize()
|
||||
.map_err(|err| anyhow::anyhow!("failed to resolve browser script {}: {err}", script_path.display()))?;
|
||||
if !canonical_script_path.starts_with(&canonical_skill_root) {
|
||||
anyhow::bail!(
|
||||
"browser script path escapes skill root: {}",
|
||||
canonical_script_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
tool_name: format!("{}.{}", skill_name, tool.name),
|
||||
tool_description: tool.description.clone(),
|
||||
script_path: canonical_script_path,
|
||||
args: tool.args.clone(),
|
||||
browser_tool,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_parameters_schema(&self) -> Value {
|
||||
let mut properties = serde_json::Map::new();
|
||||
let mut required = vec![Value::String("expected_domain".to_string())];
|
||||
|
||||
properties.insert(
|
||||
"expected_domain".to_string(),
|
||||
json!({
|
||||
"type": "string",
|
||||
"description": "Bare hostname for the current page, for example www.zhihu.com."
|
||||
}),
|
||||
);
|
||||
|
||||
for (name, description) in &self.args {
|
||||
properties.insert(
|
||||
name.clone(),
|
||||
json!({
|
||||
"type": "string",
|
||||
"description": description
|
||||
}),
|
||||
);
|
||||
required.push(Value::String(name.clone()));
|
||||
}
|
||||
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.tool_description
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
self.build_parameters_schema()
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let mut args = match args {
|
||||
Value::Object(args) => args,
|
||||
other => {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected object arguments, got {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let raw_expected_domain = match args.remove("expected_domain") {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => value,
|
||||
Some(other) => {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected_domain must be a non-empty string, got {other}"
|
||||
)))
|
||||
}
|
||||
None => return Ok(failed_tool_result("missing required field expected_domain".to_string())),
|
||||
};
|
||||
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
for required_arg in self.args.keys() {
|
||||
if !args.contains_key(required_arg) {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"missing required field {required_arg}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let script_body = match fs::read_to_string(&self.script_path) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"failed to read browser script {}: {err}",
|
||||
self.script_path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone()));
|
||||
let result = match self.browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": wrapped_script }),
|
||||
&expected_domain,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(err) => return Ok(failed_tool_result(err.to_string())),
|
||||
};
|
||||
|
||||
if !result.success {
|
||||
return Ok(failed_tool_result(format_browser_script_error(&result.data)));
|
||||
}
|
||||
|
||||
let payload = result
|
||||
.data
|
||||
.get("text")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| result.data.clone());
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: stringify_tool_payload(&payload)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_browser_script_skill_tools<T: Transport + 'static>(
|
||||
skills: &[Skill],
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
) -> Result<Vec<Box<dyn Tool>>, anyhow::Error> {
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
|
||||
for skill in skills {
|
||||
let Some(location) = skill.location.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(skill_root) = location.parent() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for tool in &skill.tools {
|
||||
if tool.kind != "browser_script" {
|
||||
continue;
|
||||
}
|
||||
tools.push(Box::new(BrowserScriptSkillTool::new(
|
||||
&skill.name,
|
||||
tool,
|
||||
skill_root,
|
||||
browser_tool.clone(),
|
||||
)?));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
fn wrap_browser_script(script_body: &str, args: &Value) -> String {
|
||||
format!(
|
||||
"(function() {{\nconst args = {};\n{}\n}})()",
|
||||
serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()),
|
||||
script_body
|
||||
)
|
||||
}
|
||||
|
||||
fn stringify_tool_payload(payload: &Value) -> anyhow::Result<String> {
|
||||
Ok(match payload {
|
||||
Value::String(value) => value.clone(),
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Bool(_) | Value::Number(_) | Value::Array(_) | Value::Object(_) => {
|
||||
serde_json::to_string(payload)?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn failed_tool_result(error: String) -> ToolResult {
|
||||
ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_browser_script_error(data: &Value) -> String {
|
||||
data.get("error")
|
||||
.and_then(|value| value.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("browser script failed: {data}"))
|
||||
}
|
||||
|
||||
fn normalize_domain_like(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(url) = Url::parse(trimmed) {
|
||||
return url.host_str().map(|host| host.to_ascii_lowercase());
|
||||
}
|
||||
|
||||
let host = trimmed
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
(!host.is_empty()).then_some(host)
|
||||
}
|
||||
@@ -1,29 +1,70 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Map, Value};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::pipe::{Action, BrowserPipeTool, Transport};
|
||||
use crate::pipe::{Action, BrowserPipeTool, ExecutionSurfaceMetadata, Transport};
|
||||
|
||||
pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
pub const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||
const BROWSER_ACTION_TOOL_DESCRIPTION: &str =
|
||||
"Execute browser actions in SuperRPA through the existing sgClaw pipe protocol.";
|
||||
const SUPERRPA_BROWSER_TOOL_DESCRIPTION: &str =
|
||||
"Use SuperRPA's dedicated privileged browser interface for page navigation, DOM reading, clicking, and typing inside the protected browser host.";
|
||||
const MAX_DATA_STRING_CHARS: usize = 2048;
|
||||
const MAX_AOM_STRING_CHARS: usize = 128;
|
||||
const MAX_DATA_ARRAY_ITEMS: usize = 12;
|
||||
const MAX_DATA_OBJECT_FIELDS: usize = 24;
|
||||
const MAX_DATA_RECURSION_DEPTH: usize = 4;
|
||||
|
||||
pub struct ZeroClawBrowserTool<T: Transport> {
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
Self { browser_tool }
|
||||
Self::named(
|
||||
browser_tool,
|
||||
BROWSER_ACTION_TOOL_NAME,
|
||||
BROWSER_ACTION_TOOL_DESCRIPTION,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_superrpa(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
Self::named(
|
||||
browser_tool,
|
||||
SUPERRPA_BROWSER_TOOL_NAME,
|
||||
SUPERRPA_BROWSER_TOOL_DESCRIPTION,
|
||||
)
|
||||
}
|
||||
|
||||
fn named(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
) -> Self {
|
||||
Self {
|
||||
browser_tool,
|
||||
tool_name,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.browser_tool.surface_metadata()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
fn name(&self) -> &str {
|
||||
BROWSER_ACTION_TOOL_NAME
|
||||
self.tool_name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute browser actions in SuperRPA through the existing sgClaw pipe protocol."
|
||||
self.description
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -72,8 +113,9 @@ impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
let output = serde_json::to_string(&json!({
|
||||
"seq": result.seq,
|
||||
"success": result.success,
|
||||
"data": result.data,
|
||||
"aom_snapshot": result.aom_snapshot,
|
||||
"data": compact_json_value(&result.data, 0),
|
||||
"aom_snapshot": compact_aom_snapshot(&result.aom_snapshot),
|
||||
"aom_snapshot_count": result.aom_snapshot.len(),
|
||||
"timing": result.timing
|
||||
}))?;
|
||||
|
||||
@@ -103,9 +145,10 @@ fn parse_browser_action_request(args: Value) -> Result<BrowserActionRequest, Bro
|
||||
};
|
||||
|
||||
let action_name = take_required_string(&mut args, "action")?;
|
||||
let expected_domain = take_required_string(&mut args, "expected_domain")?;
|
||||
let raw_expected_domain = take_required_string(&mut args, "expected_domain")?;
|
||||
let action = parse_action(&action_name)?;
|
||||
validate_action_params(&action_name, &args)?;
|
||||
let expected_domain = normalize_expected_domain(&action, &raw_expected_domain, &args)?;
|
||||
|
||||
Ok(BrowserActionRequest {
|
||||
action,
|
||||
@@ -178,6 +221,59 @@ fn require_non_empty_string(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_expected_domain(
|
||||
action: &Action,
|
||||
raw_expected_domain: &str,
|
||||
args: &Map<String, Value>,
|
||||
) -> Result<String, BrowserActionAdapterError> {
|
||||
if matches!(action, Action::Navigate) {
|
||||
if let Some(url) = args.get("url").and_then(Value::as_str) {
|
||||
if let Some(host) = host_from_url(url) {
|
||||
return Ok(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalize_domain_like(raw_expected_domain).ok_or_else(|| {
|
||||
BrowserActionAdapterError::InvalidArguments(format!(
|
||||
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn host_from_url(raw: &str) -> Option<String> {
|
||||
Url::parse(raw)
|
||||
.ok()?
|
||||
.host_str()
|
||||
.map(|host| host.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn normalize_domain_like(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(host) = host_from_url(trimmed) {
|
||||
return Some(host);
|
||||
}
|
||||
|
||||
let without_scheme = trimmed
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
let host = without_scheme
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
(!host.is_empty()).then_some(host)
|
||||
}
|
||||
|
||||
fn format_browser_action_error(data: &Value) -> String {
|
||||
if let Some(error) = data.get("error") {
|
||||
if let Some(message) = error.get("message").and_then(Value::as_str) {
|
||||
@@ -193,6 +289,111 @@ fn format_browser_action_error(data: &Value) -> String {
|
||||
format!("browser action failed: {data}")
|
||||
}
|
||||
|
||||
fn compact_json_value(value: &Value, depth: usize) -> Value {
|
||||
compact_json_value_with_string_limit(value, depth, MAX_DATA_STRING_CHARS)
|
||||
}
|
||||
|
||||
fn compact_aom_snapshot(snapshot: &[Value]) -> Value {
|
||||
Value::Array(
|
||||
snapshot
|
||||
.iter()
|
||||
.take(MAX_DATA_ARRAY_ITEMS)
|
||||
.map(|item| compact_aom_value(item, 0))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn compact_aom_value(value: &Value, depth: usize) -> Value {
|
||||
if depth >= MAX_DATA_RECURSION_DEPTH {
|
||||
return Value::String("[truncated nested value]".to_string());
|
||||
}
|
||||
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut compacted = Map::new();
|
||||
for (key, item) in map.iter().take(MAX_DATA_OBJECT_FIELDS) {
|
||||
if matches!(key.as_str(), "text" | "value" | "html") {
|
||||
let summary = item
|
||||
.as_str()
|
||||
.map(|text| format!("[{} chars omitted]", text.chars().count()))
|
||||
.unwrap_or_else(|| "[omitted]".to_string());
|
||||
compacted.insert(key.clone(), Value::String(summary));
|
||||
continue;
|
||||
}
|
||||
|
||||
compacted.insert(key.clone(), compact_aom_value(item, depth + 1));
|
||||
}
|
||||
Value::Object(compacted)
|
||||
}
|
||||
Value::Array(items) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.take(MAX_DATA_ARRAY_ITEMS)
|
||||
.map(|item| compact_aom_value(item, depth + 1))
|
||||
.collect(),
|
||||
),
|
||||
_ => compact_json_value_with_string_limit(value, depth, MAX_AOM_STRING_CHARS),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_json_value_with_string_limit(
|
||||
value: &Value,
|
||||
depth: usize,
|
||||
max_string_chars: usize,
|
||||
) -> Value {
|
||||
if depth >= MAX_DATA_RECURSION_DEPTH {
|
||||
return Value::String("[truncated nested value]".to_string());
|
||||
}
|
||||
|
||||
match value {
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
|
||||
Value::String(text) => Value::String(truncate_string(text, max_string_chars)),
|
||||
Value::Array(items) => {
|
||||
let mut compacted: Vec<Value> = items
|
||||
.iter()
|
||||
.take(MAX_DATA_ARRAY_ITEMS)
|
||||
.map(|item| compact_json_value_with_string_limit(item, depth + 1, max_string_chars))
|
||||
.collect();
|
||||
if items.len() > MAX_DATA_ARRAY_ITEMS {
|
||||
compacted.push(Value::String(format!(
|
||||
"[{} more items omitted]",
|
||||
items.len() - MAX_DATA_ARRAY_ITEMS
|
||||
)));
|
||||
}
|
||||
Value::Array(compacted)
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let mut compacted = Map::new();
|
||||
for (key, item) in map.iter().take(MAX_DATA_OBJECT_FIELDS) {
|
||||
compacted.insert(
|
||||
key.clone(),
|
||||
compact_json_value_with_string_limit(item, depth + 1, max_string_chars),
|
||||
);
|
||||
}
|
||||
if map.len() > MAX_DATA_OBJECT_FIELDS {
|
||||
compacted.insert(
|
||||
"_truncated_fields".to_string(),
|
||||
Value::String(format!(
|
||||
"{} additional fields omitted",
|
||||
map.len() - MAX_DATA_OBJECT_FIELDS
|
||||
)),
|
||||
);
|
||||
}
|
||||
Value::Object(compacted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_string(text: &str, max_chars: usize) -> String {
|
||||
let total_chars = text.chars().count();
|
||||
if total_chars <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let prefix: String = text.chars().take(max_chars).collect();
|
||||
format!("{prefix}...[truncated {} chars]", total_chars - max_chars)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum BrowserActionAdapterError {
|
||||
#[error("unsupported action: {0}")]
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use zeroclaw::Config as ZeroClawConfig;
|
||||
use zeroclaw::config::schema::ModelProviderConfig;
|
||||
|
||||
use crate::compat::cron_adapter::configure_embedded_cron;
|
||||
use crate::compat::memory_adapter::configure_embedded_memory;
|
||||
use crate::config::DeepSeekSettings;
|
||||
use crate::config::{BrowserBackend, DeepSeekSettings, SgClawSettings};
|
||||
use crate::runtime::RuntimeProfile;
|
||||
|
||||
const SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
|
||||
pub fn build_zeroclaw_config(workspace_root: &Path) -> Result<ZeroClawConfig, crate::config::ConfigError> {
|
||||
let settings = DeepSeekSettings::from_env()?;
|
||||
Ok(build_zeroclaw_config_from_settings(
|
||||
let settings = SgClawSettings::from_env()?;
|
||||
Ok(build_zeroclaw_config_from_sgclaw_settings(
|
||||
workspace_root,
|
||||
&settings,
|
||||
))
|
||||
@@ -19,15 +24,54 @@ pub fn build_zeroclaw_config(workspace_root: &Path) -> Result<ZeroClawConfig, cr
|
||||
pub fn build_zeroclaw_config_from_settings(
|
||||
workspace_root: &Path,
|
||||
settings: &DeepSeekSettings,
|
||||
) -> ZeroClawConfig {
|
||||
build_zeroclaw_config_from_sgclaw_settings(workspace_root, &SgClawSettings::from(settings))
|
||||
}
|
||||
|
||||
pub fn build_zeroclaw_config_from_sgclaw_settings(
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> ZeroClawConfig {
|
||||
let workspace_dir = zeroclaw_workspace_dir(workspace_root);
|
||||
let active_provider = settings.active_provider_settings();
|
||||
let mut config = ZeroClawConfig::default();
|
||||
config.workspace_dir = workspace_dir.clone();
|
||||
config.config_path = workspace_dir.join("config.toml");
|
||||
config.default_provider = Some("deepseek".to_string());
|
||||
config.default_model = Some(settings.model.clone());
|
||||
config.api_key = Some(settings.api_key.clone());
|
||||
config.api_url = Some(settings.base_url.clone());
|
||||
config.default_provider = Some(active_provider.provider.clone());
|
||||
config.default_model = Some(active_provider.model.clone());
|
||||
config.api_key = Some(active_provider.api_key.clone());
|
||||
config.api_url = active_provider.base_url.clone();
|
||||
config.api_path = active_provider.api_path.clone();
|
||||
config.default_temperature = match settings.runtime_profile {
|
||||
RuntimeProfile::BrowserAttached | RuntimeProfile::BrowserHeavy => 0.0,
|
||||
RuntimeProfile::GeneralAssistant => config.default_temperature,
|
||||
};
|
||||
config.skills.prompt_injection_mode = settings.skills_prompt_mode;
|
||||
config.model_providers = settings
|
||||
.providers
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
(
|
||||
provider.id.clone(),
|
||||
ModelProviderConfig {
|
||||
name: (!provider.provider.starts_with("custom:"))
|
||||
.then(|| provider.provider.clone()),
|
||||
base_url: provider.base_url.clone(),
|
||||
api_path: provider.api_path.clone(),
|
||||
wire_api: provider.wire_api.clone(),
|
||||
requires_openai_auth: provider.requires_openai_auth,
|
||||
azure_openai_resource: None,
|
||||
azure_openai_deployment: None,
|
||||
azure_openai_api_version: None,
|
||||
max_tokens: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
config.browser.enabled = !matches!(settings.browser_backend, BrowserBackend::SuperRpa);
|
||||
if let Some(backend) = settings.browser_backend.zeroclaw_backend() {
|
||||
config.browser.backend = backend.to_string();
|
||||
}
|
||||
configure_embedded_memory(&mut config);
|
||||
configure_embedded_cron(&mut config);
|
||||
config
|
||||
@@ -36,3 +80,37 @@ pub fn build_zeroclaw_config_from_settings(
|
||||
pub fn zeroclaw_workspace_dir(workspace_root: &Path) -> PathBuf {
|
||||
workspace_root.join(SGCLAW_ZEROCLAW_WORKSPACE_DIR)
|
||||
}
|
||||
|
||||
pub fn zeroclaw_default_skills_dir(workspace_root: &Path) -> PathBuf {
|
||||
zeroclaw_workspace_dir(workspace_root).join(SKILLS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> PathBuf {
|
||||
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
|
||||
}
|
||||
|
||||
pub fn resolve_skills_dir_from_sgclaw_settings(
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> PathBuf {
|
||||
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
|
||||
}
|
||||
|
||||
fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
|
||||
if configured_dir.file_name() == Some(OsStr::new(SKILLS_DIR_NAME)) {
|
||||
return configured_dir.to_path_buf();
|
||||
}
|
||||
|
||||
let nested_skills_dir = configured_dir.join(SKILLS_DIR_NAME);
|
||||
if nested_skills_dir.is_dir() {
|
||||
nested_skills_dir
|
||||
} else {
|
||||
configured_dir.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_skills_dir_path(workspace_root: &Path, configured_dir: Option<&Path>) -> PathBuf {
|
||||
configured_dir
|
||||
.map(normalize_configured_skills_dir)
|
||||
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
use zeroclaw::agent::TurnEvent;
|
||||
|
||||
use crate::pipe::AgentMessage;
|
||||
|
||||
pub fn log_entry_for_turn_event(event: &TurnEvent) -> Option<AgentMessage> {
|
||||
pub fn log_entry_for_turn_event(
|
||||
event: &TurnEvent,
|
||||
skill_versions: &HashMap<String, String>,
|
||||
) -> Option<AgentMessage> {
|
||||
match event {
|
||||
TurnEvent::ToolCall { name, args } => Some(AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format_tool_call(name, args),
|
||||
message: format_tool_call(name, args, skill_versions),
|
||||
}),
|
||||
TurnEvent::ToolResult { output, .. } if is_tool_error(output) => Some(AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
@@ -17,8 +22,23 @@ pub fn log_entry_for_turn_event(event: &TurnEvent) -> Option<AgentMessage> {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_tool_call(name: &str, args: &Value) -> String {
|
||||
if name != "browser_action" {
|
||||
fn format_tool_call(
|
||||
name: &str,
|
||||
args: &Value,
|
||||
skill_versions: &HashMap<String, String>,
|
||||
) -> String {
|
||||
if name == "read_skill" {
|
||||
let skill_name = args
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("<missing-skill>");
|
||||
if let Some(version) = skill_versions.get(skill_name) {
|
||||
return format!("read_skill {skill_name}@{version}");
|
||||
}
|
||||
return format!("read_skill {skill_name}");
|
||||
}
|
||||
|
||||
if !is_browser_tool_call(name) {
|
||||
return format!("call {name}");
|
||||
}
|
||||
|
||||
@@ -54,10 +74,14 @@ fn format_tool_call(name: &str, args: &Value) -> String {
|
||||
.unwrap_or("<missing-selector>");
|
||||
format!("getText {selector}")
|
||||
}
|
||||
other => format!("browser_action {other}"),
|
||||
other => format!("{name} {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_browser_tool_call(name: &str) -> bool {
|
||||
name == "browser_action" || name == "superrpa_browser"
|
||||
}
|
||||
|
||||
fn is_tool_error(output: &str) -> bool {
|
||||
output.starts_with("Error:")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
pub mod browser_script_skill_tool;
|
||||
pub mod browser_tool_adapter;
|
||||
pub mod config_adapter;
|
||||
pub mod cron_adapter;
|
||||
pub mod event_bridge;
|
||||
pub mod memory_adapter;
|
||||
pub mod openxml_office_tool;
|
||||
pub mod orchestration;
|
||||
pub mod runtime;
|
||||
pub mod screen_html_export_tool;
|
||||
pub mod workflow_executor;
|
||||
|
||||
459
src/compat/openxml_office_tool.rs
Normal file
459
src/compat/openxml_office_tool.rs
Normal file
@@ -0,0 +1,459 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
|
||||
const DEFAULT_SHEET_NAME: &str = "知乎热榜";
|
||||
const MAX_COLUMNS: [&str; 3] = ["rank", "title", "heat"];
|
||||
|
||||
pub struct OpenXmlOfficeTool {
|
||||
workspace_root: PathBuf,
|
||||
}
|
||||
|
||||
impl OpenXmlOfficeTool {
|
||||
pub fn new(workspace_root: PathBuf) -> Self {
|
||||
Self { workspace_root }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenXmlOfficeArgs {
|
||||
sheet_name: String,
|
||||
columns: Vec<String>,
|
||||
rows: Vec<Vec<Value>>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for OpenXmlOfficeTool {
|
||||
fn name(&self) -> &str {
|
||||
OPENXML_OFFICE_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Export structured Zhihu hotlist rows into a local .xlsx file through the OpenXML office pipeline."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["sheet_name", "columns", "rows"],
|
||||
"properties": {
|
||||
"sheet_name": { "type": "string" },
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
}
|
||||
},
|
||||
"output_path": { "type": "string" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let parsed = match serde_json::from_value::<OpenXmlOfficeArgs>(args) {
|
||||
Ok(value) => value,
|
||||
Err(err) => return Ok(failed_tool_result(format!("invalid tool arguments: {err}"))),
|
||||
};
|
||||
|
||||
if parsed.sheet_name.trim() != DEFAULT_SHEET_NAME {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"unsupported sheet_name: expected {DEFAULT_SHEET_NAME}"
|
||||
)));
|
||||
}
|
||||
|
||||
let expected_columns = MAX_COLUMNS
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let column_order = match resolve_column_order(&parsed.columns, &expected_columns) {
|
||||
Some(order) => order,
|
||||
None => {
|
||||
return Ok(failed_tool_result(
|
||||
"unsupported columns: expected [rank, title, heat]".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if parsed.rows.is_empty() {
|
||||
return Ok(failed_tool_result("rows must not be empty".to_string()));
|
||||
}
|
||||
|
||||
if parsed.rows.iter().any(|row| row.len() != parsed.columns.len()) {
|
||||
return Ok(failed_tool_result(
|
||||
"each row must match the declared columns length".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if parsed.rows.iter().any(|row| row.len() != 3) {
|
||||
return Ok(failed_tool_result(
|
||||
"each row must contain exactly 3 values".to_string(),
|
||||
));
|
||||
}
|
||||
let normalized_rows = parsed
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| reorder_row(row, &column_order))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let job_root = create_job_root(&self.workspace_root)?;
|
||||
let template_path = job_root.join("zhihu_hotlist_template.xlsx");
|
||||
let payload_path = job_root.join("payload.json");
|
||||
let request_path = job_root.join("request.json");
|
||||
let output_path = parsed
|
||||
.output_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| default_output_path(&self.workspace_root));
|
||||
|
||||
write_hotlist_template(&template_path, normalized_rows.len())?;
|
||||
write_payload_json(&payload_path, &normalized_rows)?;
|
||||
write_request_json(&request_path, &template_path, &payload_path, &output_path)?;
|
||||
|
||||
let rendered = run_openxml_cli(&request_path)?;
|
||||
let artifact_path = rendered["data"]["artifact"]["path"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| output_path.to_string_lossy().to_string());
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: json!({
|
||||
"sheet_name": DEFAULT_SHEET_NAME,
|
||||
"output_path": artifact_path,
|
||||
"row_count": normalized_rows.len(),
|
||||
"renderer": OPENXML_OFFICE_TOOL_NAME
|
||||
})
|
||||
.to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_tool_result(error: String) -> ToolResult {
|
||||
ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_job_root(workspace_root: &Path) -> anyhow::Result<PathBuf> {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)?
|
||||
.as_nanos();
|
||||
let path = workspace_root.join(".sgclaw-openxml").join(format!("{nanos}"));
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn default_output_path(workspace_root: &Path) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_nanos())
|
||||
.unwrap_or_default();
|
||||
workspace_root
|
||||
.join("out")
|
||||
.join(format!("zhihu-hotlist-{nanos}.xlsx"))
|
||||
}
|
||||
|
||||
fn resolve_column_order(
|
||||
provided_columns: &[String],
|
||||
expected_columns: &[String],
|
||||
) -> Option<Vec<usize>> {
|
||||
if provided_columns.len() != expected_columns.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let canonicalized_columns = provided_columns
|
||||
.iter()
|
||||
.map(|value| canonicalize_column_name(value))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
|
||||
let provided_set = canonicalized_columns
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected_set = expected_columns
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
if provided_set != expected_set {
|
||||
return None;
|
||||
}
|
||||
|
||||
expected_columns
|
||||
.iter()
|
||||
.map(|expected| {
|
||||
canonicalized_columns
|
||||
.iter()
|
||||
.position(|provided| *provided == expected)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
}
|
||||
|
||||
fn canonicalize_column_name(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"rank" | "排名" | "名次" | "序号" => Some("rank"),
|
||||
"title" | "标题" | "题目" | "问题" => Some("title"),
|
||||
"heat" | "热度" | "热值" => Some("heat"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reorder_row(row: &[Value], column_order: &[usize]) -> Vec<Value> {
|
||||
column_order
|
||||
.iter()
|
||||
.map(|index| row[*index].clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_payload_json(path: &Path, rows: &[Vec<Value>]) -> anyhow::Result<()> {
|
||||
let mut variables = BTreeMap::new();
|
||||
for (idx, row) in rows.iter().enumerate() {
|
||||
let row_index = idx + 1;
|
||||
variables.insert(format!("RANK_{row_index}"), value_to_string(&row[0]));
|
||||
variables.insert(format!("TITLE_{row_index}"), value_to_string(&row[1]));
|
||||
variables.insert(format!("HEAT_{row_index}"), value_to_string(&row[2]));
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
"variables": variables,
|
||||
"tables": {},
|
||||
"images": {}
|
||||
});
|
||||
fs::write(path, serde_json::to_vec_pretty(&payload)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_request_json(
|
||||
path: &Path,
|
||||
template_path: &Path,
|
||||
payload_path: &Path,
|
||||
output_path: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let request = json!({
|
||||
"api_version": "2026-03-26",
|
||||
"job": "zhihu_hotlist_export",
|
||||
"template": {
|
||||
"kind": "xlsx",
|
||||
"path": template_path
|
||||
},
|
||||
"output": {
|
||||
"path": output_path
|
||||
},
|
||||
"data": {
|
||||
"json_path": payload_path
|
||||
},
|
||||
"options": {
|
||||
"strict": true,
|
||||
"allow_unresolved": false,
|
||||
"dry_run": false
|
||||
}
|
||||
});
|
||||
fs::write(path, serde_json::to_vec_pretty(&request)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
|
||||
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.map(|path| path.join("openxml_cli").join("Cargo.toml"))
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli manifest path"))?;
|
||||
let binary_path = manifest_path
|
||||
.parent()
|
||||
.map(|path| path.join("target").join("debug").join("openxml-cli"))
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli binary path"))?;
|
||||
|
||||
let output = if binary_path.exists() {
|
||||
Command::new(&binary_path)
|
||||
.args([
|
||||
"template",
|
||||
"render",
|
||||
"--request",
|
||||
request_path.to_string_lossy().as_ref(),
|
||||
"--json",
|
||||
])
|
||||
.output()?
|
||||
} else {
|
||||
Command::new("cargo")
|
||||
.args([
|
||||
"run",
|
||||
"--quiet",
|
||||
"--manifest-path",
|
||||
manifest_path.to_string_lossy().as_ref(),
|
||||
"--",
|
||||
"template",
|
||||
"render",
|
||||
"--request",
|
||||
request_path.to_string_lossy().as_ref(),
|
||||
"--json",
|
||||
])
|
||||
.output()?
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(anyhow::anyhow!(if stderr.is_empty() {
|
||||
"openxml_cli render failed".to_string()
|
||||
} else {
|
||||
stderr
|
||||
}));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
Ok(serde_json::from_str(&stdout)?)
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Number(number) => number.to_string(),
|
||||
Value::Bool(flag) => flag.to_string(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_hotlist_template(path: &Path, row_count: usize) -> anyhow::Result<()> {
|
||||
let build_root = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("template path has no parent"))?
|
||||
.join("template-build");
|
||||
fs::create_dir_all(build_root.join("_rels"))?;
|
||||
fs::create_dir_all(build_root.join("docProps"))?;
|
||||
fs::create_dir_all(build_root.join("xl/_rels"))?;
|
||||
fs::create_dir_all(build_root.join("xl/worksheets"))?;
|
||||
|
||||
fs::write(build_root.join("[Content_Types].xml"), content_types_xml())?;
|
||||
fs::write(build_root.join("_rels/.rels"), root_rels_xml())?;
|
||||
fs::write(build_root.join("docProps/app.xml"), app_xml())?;
|
||||
fs::write(build_root.join("docProps/core.xml"), core_xml())?;
|
||||
fs::write(build_root.join("xl/workbook.xml"), workbook_xml())?;
|
||||
fs::write(
|
||||
build_root.join("xl/_rels/workbook.xml.rels"),
|
||||
workbook_rels_xml(),
|
||||
)?;
|
||||
fs::write(
|
||||
build_root.join("xl/worksheets/sheet1.xml"),
|
||||
worksheet_xml(row_count),
|
||||
)?;
|
||||
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
let zip = Command::new("zip")
|
||||
.current_dir(&build_root)
|
||||
.args(["-q", "-r", path.to_string_lossy().as_ref(), "."])
|
||||
.output()?;
|
||||
if !zip.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&zip.stderr);
|
||||
return Err(anyhow::anyhow!(format!(
|
||||
"failed to create xlsx template: {}",
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&build_root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worksheet_xml(row_count: usize) -> String {
|
||||
let mut rows = Vec::new();
|
||||
rows.push(
|
||||
"<row r=\"1\"><c r=\"A1\" t=\"inlineStr\"><is><t>rank</t></is></c><c r=\"B1\" t=\"inlineStr\"><is><t>title</t></is></c><c r=\"C1\" t=\"inlineStr\"><is><t>heat</t></is></c></row>"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
for idx in 1..=row_count {
|
||||
let excel_row = idx + 1;
|
||||
rows.push(format!(
|
||||
"<row r=\"{excel_row}\"><c r=\"A{excel_row}\" t=\"inlineStr\"><is><t>{{{{RANK_{idx}}}}}</t></is></c><c r=\"B{excel_row}\" t=\"inlineStr\"><is><t>{{{{TITLE_{idx}}}}}</t></is></c><c r=\"C{excel_row}\" t=\"inlineStr\"><is><t>{{{{HEAT_{idx}}}}}</t></is></c></row>"
|
||||
));
|
||||
}
|
||||
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
|
||||
<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
|
||||
<sheetData>{}</sheetData>\
|
||||
</worksheet>",
|
||||
rows.join("")
|
||||
)
|
||||
}
|
||||
|
||||
fn content_types_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
||||
</Types>"#
|
||||
}
|
||||
|
||||
fn root_rels_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
||||
</Relationships>"#
|
||||
}
|
||||
|
||||
fn app_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
|
||||
xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||
<Application>sgClaw</Application>
|
||||
</Properties>"#
|
||||
}
|
||||
|
||||
fn core_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:dcterms="http://purl.org/dc/terms/"
|
||||
xmlns:dcmitype="http://purl.org/dc/dcmitype/"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dc:title>Zhihu Hotlist Export</dc:title>
|
||||
</cp:coreProperties>"#
|
||||
}
|
||||
|
||||
fn workbook_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets>
|
||||
<sheet name="知乎热榜" sheetId="1" r:id="rId1"/>
|
||||
</sheets>
|
||||
</workbook>"#
|
||||
}
|
||||
|
||||
fn workbook_rels_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
</Relationships>"#
|
||||
}
|
||||
67
src/compat/orchestration.rs
Normal file
67
src/compat/orchestration.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
pub fn should_use_primary_orchestration(
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> bool {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
let needs_export = normalized.contains("excel")
|
||||
|| normalized.contains("xlsx")
|
||||
|| instruction.contains("导出")
|
||||
|| instruction.contains("大屏")
|
||||
|| instruction.contains("新标签页")
|
||||
|| normalized.contains("dashboard");
|
||||
|
||||
crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) && needs_export
|
||||
}
|
||||
|
||||
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let route = crate::compat::workflow_executor::detect_route(
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
);
|
||||
let primary_result = crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
instruction,
|
||||
task_context,
|
||||
workspace_root,
|
||||
settings,
|
||||
);
|
||||
|
||||
match (route, primary_result) {
|
||||
(Some(route), Ok(summary))
|
||||
if crate::compat::workflow_executor::should_fallback_after_summary(&summary, &route) =>
|
||||
{
|
||||
crate::compat::workflow_executor::execute_route(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
instruction,
|
||||
route,
|
||||
)
|
||||
}
|
||||
(_, Ok(summary)) => Ok(summary),
|
||||
(Some(route), Err(_)) => crate::compat::workflow_executor::execute_route(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
instruction,
|
||||
route,
|
||||
),
|
||||
(None, Err(err)) => Err(err),
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{stream, StreamExt};
|
||||
use zeroclaw::agent::dispatcher::NativeToolDispatcher;
|
||||
use zeroclaw::agent::{Agent, TurnEvent};
|
||||
use zeroclaw::agent::TurnEvent;
|
||||
use zeroclaw::config::Config as ZeroClawConfig;
|
||||
use zeroclaw::observability::{NoopObserver, Observer};
|
||||
use zeroclaw::providers::{
|
||||
self, ChatMessage, ChatRequest, ChatResponse, Provider,
|
||||
};
|
||||
@@ -14,12 +12,18 @@ use zeroclaw::providers::traits::{
|
||||
ProviderCapabilities, StreamEvent, StreamOptions, StreamResult,
|
||||
};
|
||||
|
||||
use crate::compat::browser_tool_adapter::{ZeroClawBrowserTool, BROWSER_ACTION_TOOL_NAME};
|
||||
use crate::compat::config_adapter::build_zeroclaw_config_from_settings;
|
||||
use crate::config::DeepSeekSettings;
|
||||
use crate::compat::browser_script_skill_tool::build_browser_script_skill_tools;
|
||||
use crate::compat::browser_tool_adapter::ZeroClawBrowserTool;
|
||||
use crate::compat::config_adapter::{
|
||||
build_zeroclaw_config_from_sgclaw_settings,
|
||||
resolve_skills_dir_from_sgclaw_settings,
|
||||
};
|
||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
use crate::config::{DeepSeekSettings, OfficeBackend, SgClawSettings};
|
||||
use crate::compat::event_bridge::log_entry_for_turn_event;
|
||||
use crate::compat::memory_adapter::build_memory;
|
||||
use crate::pipe::{BrowserPipeTool, ConversationMessage, PipeError, Transport};
|
||||
use crate::runtime::RuntimeEngine;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CompatTaskContext {
|
||||
@@ -37,7 +41,27 @@ pub fn execute_task<T: Transport + 'static>(
|
||||
workspace_root: &Path,
|
||||
settings: &DeepSeekSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let config = build_zeroclaw_config_from_settings(workspace_root, settings);
|
||||
let sgclaw_settings = SgClawSettings::from(settings);
|
||||
execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool,
|
||||
instruction,
|
||||
task_context,
|
||||
workspace_root,
|
||||
&sgclaw_settings,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, settings);
|
||||
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
|
||||
let provider = build_provider(&config)?;
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||
@@ -49,6 +73,8 @@ pub fn execute_task<T: Transport + 'static>(
|
||||
instruction,
|
||||
task_context,
|
||||
config,
|
||||
skills_dir,
|
||||
settings.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -59,8 +85,73 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
config: ZeroClawConfig,
|
||||
skills_dir: PathBuf,
|
||||
settings: SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let mut agent = build_agent(browser_tool, provider, &config)?;
|
||||
let engine = RuntimeEngine::new(settings.runtime_profile);
|
||||
let browser_surface_present = engine.browser_surface_enabled();
|
||||
if let Some(preview) = crate::agent::planner::build_execution_preview(
|
||||
settings.planner_mode,
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
let mut message = preview.summary;
|
||||
if !preview.steps.is_empty() {
|
||||
message.push('\n');
|
||||
message.push_str(&preview.steps.join("\n"));
|
||||
}
|
||||
transport.send(&crate::pipe::AgentMessage::LogEntry {
|
||||
level: "plan".to_string(),
|
||||
message,
|
||||
})?;
|
||||
}
|
||||
let loaded_skills = engine.loaded_skills(&config, &skills_dir);
|
||||
let loaded_skill_versions = loaded_skills
|
||||
.iter()
|
||||
.map(|skill| (skill.name.clone(), skill.version.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let loaded_skill_labels = loaded_skills
|
||||
.iter()
|
||||
.map(|skill| format!("{}@{}", skill.name, skill.version))
|
||||
.collect::<Vec<_>>();
|
||||
if !loaded_skill_labels.is_empty() {
|
||||
transport.send(&crate::pipe::AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("loaded skills: {}", loaded_skill_labels.join(", ")),
|
||||
})?;
|
||||
}
|
||||
let browser_tool_for_scripts = browser_tool.clone();
|
||||
let mut tools: Vec<Box<dyn zeroclaw::tools::Tool>> = if browser_surface_present {
|
||||
vec![
|
||||
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool.clone())),
|
||||
Box::new(ZeroClawBrowserTool::new(browser_tool)),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if browser_surface_present {
|
||||
tools.extend(
|
||||
build_browser_script_skill_tools(&loaded_skills, browser_tool_for_scripts)
|
||||
.map_err(map_anyhow_to_pipe_error)?,
|
||||
);
|
||||
}
|
||||
if matches!(settings.office_backend, OfficeBackend::OpenXml) &&
|
||||
engine.should_attach_openxml_office_tool(instruction)
|
||||
{
|
||||
tools.push(Box::new(OpenXmlOfficeTool::new(config.workspace_dir.clone())));
|
||||
}
|
||||
if engine.should_attach_screen_html_export_tool(instruction) {
|
||||
tools.push(Box::new(ScreenHtmlExportTool::new(config.workspace_dir.clone())));
|
||||
}
|
||||
let mut agent = engine.build_agent(
|
||||
provider,
|
||||
&config,
|
||||
&skills_dir,
|
||||
tools,
|
||||
browser_surface_present,
|
||||
instruction,
|
||||
)?;
|
||||
if let Some(conversation_id) = task_context
|
||||
.conversation_id
|
||||
.as_deref()
|
||||
@@ -70,18 +161,24 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
||||
agent.set_memory_session_id(Some(conversation_id.to_string()));
|
||||
}
|
||||
|
||||
let seed_messages = build_seed_history(task_context);
|
||||
let mut seed_messages = Vec::new();
|
||||
seed_messages.extend(build_seed_history(task_context));
|
||||
if !seed_messages.is_empty() {
|
||||
agent.seed_history(&seed_messages);
|
||||
}
|
||||
|
||||
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<TurnEvent>(32);
|
||||
let instruction = instruction.to_string();
|
||||
let instruction = engine.build_instruction(
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
browser_surface_present,
|
||||
);
|
||||
|
||||
let task = tokio::spawn(async move { agent.turn_streamed(&instruction, event_tx).await });
|
||||
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
if let Some(log_entry) = log_entry_for_turn_event(&event) {
|
||||
if let Some(log_entry) = log_entry_for_turn_event(&event, &loaded_skill_versions) {
|
||||
transport.send(&log_entry)?;
|
||||
}
|
||||
}
|
||||
@@ -91,36 +188,6 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||
}
|
||||
|
||||
fn build_agent<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
provider: Box<dyn Provider>,
|
||||
config: &ZeroClawConfig,
|
||||
) -> Result<Agent, PipeError> {
|
||||
let memory = build_memory(config).map_err(map_anyhow_to_pipe_error)?;
|
||||
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
|
||||
let tools: Vec<Box<dyn zeroclaw::tools::Tool>> =
|
||||
vec![Box::new(ZeroClawBrowserTool::new(browser_tool))];
|
||||
|
||||
Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(tools)
|
||||
.memory(Arc::from(memory))
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.config(config.agent.clone())
|
||||
.model_name(
|
||||
config
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "deepseek-chat".to_string()),
|
||||
)
|
||||
.temperature(config.default_temperature)
|
||||
.workspace_dir(config.workspace_dir.clone())
|
||||
.allowed_tools(Some(vec![BROWSER_ACTION_TOOL_NAME.to_string()]))
|
||||
.build()
|
||||
.map_err(map_anyhow_to_pipe_error)
|
||||
}
|
||||
|
||||
fn build_provider(config: &ZeroClawConfig) -> Result<Box<dyn Provider>, PipeError> {
|
||||
let provider_name = config.default_provider.as_deref().unwrap_or("deepseek");
|
||||
let model_name = config
|
||||
|
||||
382
src/compat/screen_html_export_tool.rs
Normal file
382
src/compat/screen_html_export_tool.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
|
||||
const DEFAULT_SCREEN_TITLE: &str = "知乎热榜主题分类分析大屏";
|
||||
const TEMPLATE: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../skill_lib/skills/zhihu-hotlist-screen/assets/zhihu-hotlist-echarts.html"
|
||||
));
|
||||
const PAYLOAD_START_MARKER: &str = " const defaultPayload = ";
|
||||
const PAYLOAD_END_MARKER: &str = "\n\n const themeMeta = {";
|
||||
|
||||
pub struct ScreenHtmlExportTool {
|
||||
workspace_root: PathBuf,
|
||||
}
|
||||
|
||||
impl ScreenHtmlExportTool {
|
||||
pub fn new(workspace_root: PathBuf) -> Self {
|
||||
Self { workspace_root }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScreenHtmlExportArgs {
|
||||
#[serde(default)]
|
||||
snapshot_id: Option<String>,
|
||||
#[serde(default)]
|
||||
generated_at_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
rows: Option<Vec<Vec<Value>>>,
|
||||
#[serde(default)]
|
||||
table: Option<Vec<ScreenTableRow>>,
|
||||
#[serde(default)]
|
||||
categories: Option<Vec<ScreenCategory>>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct ScreenCategory {
|
||||
category_code: String,
|
||||
category_label: String,
|
||||
item_count: u64,
|
||||
total_heat: u64,
|
||||
avg_heat: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct ScreenTableRow {
|
||||
rank: u64,
|
||||
title: String,
|
||||
url: String,
|
||||
category_code: String,
|
||||
category_label: String,
|
||||
heat_text: String,
|
||||
heat_value: u64,
|
||||
reply_count: u64,
|
||||
upvote_count: u64,
|
||||
favorite_count: u64,
|
||||
heart_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ScreenPayload {
|
||||
snapshot_id: String,
|
||||
generated_at_ms: u64,
|
||||
categories: Vec<ScreenCategory>,
|
||||
table: Vec<ScreenTableRow>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ScreenHtmlExportTool {
|
||||
fn name(&self) -> &str {
|
||||
SCREEN_HTML_EXPORT_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Render a local Zhihu hotlist ECharts dashboard HTML for leadership demos and new-tab presentation."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"snapshot_id": { "type": "string" },
|
||||
"generated_at_ms": { "type": "integer" },
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"type": "array",
|
||||
"items": { "type": "object" }
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": { "type": "object" }
|
||||
},
|
||||
"output_path": { "type": "string" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let parsed = match serde_json::from_value::<ScreenHtmlExportArgs>(args) {
|
||||
Ok(value) => value,
|
||||
Err(err) => return Ok(failed_tool_result(format!("invalid tool arguments: {err}"))),
|
||||
};
|
||||
|
||||
let table = match parsed.table {
|
||||
Some(table) if !table.is_empty() => table,
|
||||
Some(_) => return Ok(failed_tool_result("table must not be empty".to_string())),
|
||||
None => match parsed.rows {
|
||||
Some(rows) => build_table_from_rows(&rows)?,
|
||||
None => {
|
||||
return Ok(failed_tool_result(
|
||||
"rows or table is required for screen_html_export".to_string(),
|
||||
))
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if table.is_empty() {
|
||||
return Ok(failed_tool_result("table must not be empty".to_string()));
|
||||
}
|
||||
|
||||
let categories = parsed
|
||||
.categories
|
||||
.filter(|items| !items.is_empty())
|
||||
.unwrap_or_else(|| derive_categories(&table));
|
||||
let payload = ScreenPayload {
|
||||
snapshot_id: parsed
|
||||
.snapshot_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(default_snapshot_id),
|
||||
generated_at_ms: parsed.generated_at_ms.unwrap_or_else(now_ms),
|
||||
categories,
|
||||
table,
|
||||
};
|
||||
|
||||
let rendered = render_template(&payload)?;
|
||||
let output_path = parsed
|
||||
.output_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| default_output_path(&self.workspace_root));
|
||||
write_output_html(&output_path, &rendered)?;
|
||||
|
||||
let presentation_url = file_url_for_path(&output_path);
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: json!({
|
||||
"title": DEFAULT_SCREEN_TITLE,
|
||||
"output_path": output_path,
|
||||
"renderer": SCREEN_HTML_EXPORT_TOOL_NAME,
|
||||
"row_count": payload.table.len(),
|
||||
"snapshot_id": payload.snapshot_id,
|
||||
"presentation": {
|
||||
"mode": "new_tab",
|
||||
"title": DEFAULT_SCREEN_TITLE,
|
||||
"url": presentation_url,
|
||||
"open_in_new_tab": true
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_tool_result(error: String) -> ToolResult {
|
||||
ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_table_from_rows(rows: &[Vec<Value>]) -> anyhow::Result<Vec<ScreenTableRow>> {
|
||||
if rows.is_empty() {
|
||||
return Err(anyhow::anyhow!("rows must not be empty"));
|
||||
}
|
||||
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
if row.len() != 3 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"each row must contain exactly 3 values: rank, title, heat"
|
||||
));
|
||||
}
|
||||
|
||||
let rank = value_to_rank(&row[0]).unwrap_or((index + 1) as u64);
|
||||
let title = value_to_string(&row[1]);
|
||||
if title.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!("title must not be empty"));
|
||||
}
|
||||
let heat_text = value_to_string(&row[2]);
|
||||
let heat_value = parse_heat_value(&heat_text);
|
||||
let (category_code, category_label) = classify_title(&title);
|
||||
|
||||
Ok(ScreenTableRow {
|
||||
rank,
|
||||
title,
|
||||
url: format!("https://www.zhihu.com/question/hotlist-{rank}"),
|
||||
category_code: category_code.to_string(),
|
||||
category_label: category_label.to_string(),
|
||||
heat_text,
|
||||
heat_value,
|
||||
reply_count: 0,
|
||||
upvote_count: 0,
|
||||
favorite_count: 0,
|
||||
heart_count: 0,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn derive_categories(table: &[ScreenTableRow]) -> Vec<ScreenCategory> {
|
||||
let mut grouped: BTreeMap<(String, String), (u64, u64)> = BTreeMap::new();
|
||||
for row in table {
|
||||
let key = (row.category_code.clone(), row.category_label.clone());
|
||||
let entry = grouped.entry(key).or_insert((0, 0));
|
||||
entry.0 += 1;
|
||||
entry.1 += row.heat_value;
|
||||
}
|
||||
|
||||
grouped
|
||||
.into_iter()
|
||||
.map(|((category_code, category_label), (item_count, total_heat))| ScreenCategory {
|
||||
category_code,
|
||||
category_label,
|
||||
item_count,
|
||||
total_heat,
|
||||
avg_heat: if item_count == 0 {
|
||||
0
|
||||
} else {
|
||||
total_heat / item_count
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn classify_title(title: &str) -> (&'static str, &'static str) {
|
||||
let normalized = title.to_ascii_lowercase();
|
||||
if contains_any(&normalized, &["ai", "芯片", "科技", "算法", "机器人", "无人机"]) {
|
||||
return ("technology", "科技");
|
||||
}
|
||||
if contains_any(&normalized, &["电影", "综艺", "明星", "周杰伦", "短剧", "娱乐"]) {
|
||||
return ("entertainment", "娱乐");
|
||||
}
|
||||
if contains_any(&normalized, &["足球", "比赛", "联赛", "国足", "体育", "冠军"]) {
|
||||
return ("sports", "体育");
|
||||
}
|
||||
if contains_any(&normalized, &["航母", "作战", "军", "军事", "演训"]) {
|
||||
return ("military", "军事");
|
||||
}
|
||||
if contains_any(&normalized, &["出口", "经济", "市场", "财经", "消费", "股"]) {
|
||||
return ("finance", "财经");
|
||||
}
|
||||
("society", "社会")
|
||||
}
|
||||
|
||||
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
|
||||
needles.iter().any(|needle| haystack.contains(needle))
|
||||
}
|
||||
|
||||
fn parse_heat_value(heat_text: &str) -> u64 {
|
||||
let compact = heat_text.trim().replace(',', "");
|
||||
if compact.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let number_part = compact
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_digit() || *ch == '.')
|
||||
.collect::<String>();
|
||||
let base = number_part.parse::<f64>().unwrap_or(0.0);
|
||||
|
||||
let multiplier = if compact.contains('亿') {
|
||||
100_000_000.0
|
||||
} else if compact.contains('万') {
|
||||
10_000.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
(base * multiplier).round() as u64
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Number(number) => number.to_string(),
|
||||
Value::Bool(flag) => flag.to_string(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_rank(value: &Value) -> Option<u64> {
|
||||
match value {
|
||||
Value::Number(number) => number.as_u64(),
|
||||
Value::String(text) => text.trim().parse::<u64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_template(payload: &ScreenPayload) -> anyhow::Result<String> {
|
||||
let payload_json = serde_json::to_string_pretty(payload)?;
|
||||
let payload_start = TEMPLATE
|
||||
.find(PAYLOAD_START_MARKER)
|
||||
.ok_or_else(|| anyhow::anyhow!("default payload start marker missing"))?;
|
||||
let payload_end = TEMPLATE
|
||||
.find(PAYLOAD_END_MARKER)
|
||||
.ok_or_else(|| anyhow::anyhow!("default payload end marker missing"))?;
|
||||
let replacement = format!(
|
||||
"{PAYLOAD_START_MARKER}{}\n",
|
||||
indent_block(&payload_json, " ")
|
||||
);
|
||||
|
||||
Ok(format!(
|
||||
"{}{}{}",
|
||||
&TEMPLATE[..payload_start],
|
||||
replacement,
|
||||
&TEMPLATE[payload_end..],
|
||||
))
|
||||
}
|
||||
|
||||
fn indent_block(value: &str, indent: &str) -> String {
|
||||
value
|
||||
.lines()
|
||||
.map(|line| format!("{indent}{line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn write_output_html(path: &Path, rendered: &str) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, rendered)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_output_path(workspace_root: &Path) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_nanos())
|
||||
.unwrap_or_default();
|
||||
workspace_root
|
||||
.join("out")
|
||||
.join(format!("zhihu-hotlist-screen-{nanos}.html"))
|
||||
}
|
||||
|
||||
fn default_snapshot_id() -> String {
|
||||
format!("zhihu-hotlist-screen-{}", now_ms())
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn file_url_for_path(path: &Path) -> String {
|
||||
Url::from_file_path(path)
|
||||
.map(|url| url.to_string())
|
||||
.unwrap_or_else(|_| format!("file://{}", path.display()))
|
||||
}
|
||||
285
src/compat/workflow_executor.rs
Normal file
285
src/compat/workflow_executor.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{json, Value};
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||
const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot";
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowRoute {
|
||||
ZhihuHotlistExportXlsx,
|
||||
ZhihuHotlistScreen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct HotlistItem {
|
||||
rank: u64,
|
||||
title: String,
|
||||
heat: String,
|
||||
}
|
||||
|
||||
pub fn detect_route(
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> Option<WorkflowRoute> {
|
||||
if !crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") {
|
||||
return Some(WorkflowRoute::ZhihuHotlistScreen);
|
||||
}
|
||||
if normalized.contains("excel") || normalized.contains("xlsx") || instruction.contains("导出") {
|
||||
return Some(WorkflowRoute::ZhihuHotlistExportXlsx);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bool {
|
||||
let normalized = summary.to_ascii_lowercase();
|
||||
if normalized.contains(".xlsx") || normalized.contains(".html") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let looks_like_denial = summary.contains("拒绝") ||
|
||||
normalized.contains("denied") ||
|
||||
normalized.contains("failed") ||
|
||||
summary.contains("失败") ||
|
||||
summary.contains("无法");
|
||||
|
||||
looks_like_denial || matches!(route, WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen)
|
||||
}
|
||||
|
||||
pub fn execute_route<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
workspace_root: &Path,
|
||||
instruction: &str,
|
||||
route: WorkflowRoute,
|
||||
) -> Result<String, PipeError> {
|
||||
let top_n = extract_top_n(instruction);
|
||||
let items = collect_hotlist_items(transport, browser_tool, top_n)?;
|
||||
if items.is_empty() {
|
||||
return Err(PipeError::Protocol(
|
||||
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
match route {
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
|
||||
WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_hotlist_items<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
top_n: usize,
|
||||
) -> Result<Vec<HotlistItem>, PipeError> {
|
||||
navigate_hotlist_with_retry(transport, browser_tool)?;
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: "call zhihu-hotlist.extract_hotlist".to_string(),
|
||||
})?;
|
||||
let response = browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": load_hotlist_extractor_script(top_n)? }),
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if !response.success {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"知乎热榜采集失败:{}",
|
||||
response
|
||||
.data
|
||||
.get("error")
|
||||
.and_then(|value| value.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("browser script execution failed")
|
||||
)));
|
||||
}
|
||||
|
||||
parse_hotlist_items_payload(response.data.get("text").unwrap_or(&response.data))
|
||||
}
|
||||
|
||||
fn navigate_hotlist_with_retry<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
) -> Result<(), PipeError> {
|
||||
let mut last_error = None;
|
||||
for _ in 0..2 {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("navigate {ZHIHU_HOT_URL}"),
|
||||
})?;
|
||||
match browser_tool.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": ZHIHU_HOT_URL }),
|
||||
ZHIHU_DOMAIN,
|
||||
) {
|
||||
Ok(response) if response.success => return Ok(()),
|
||||
Ok(response) => {
|
||||
last_error = Some(PipeError::Protocol(format!(
|
||||
"navigate failed: {}",
|
||||
response.data
|
||||
)));
|
||||
}
|
||||
Err(err) => last_error = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
PipeError::Protocol("navigate failed without detailed error".to_string())
|
||||
}))
|
||||
}
|
||||
|
||||
fn export_xlsx<T: Transport>(
|
||||
transport: &T,
|
||||
workspace_root: &Path,
|
||||
items: &[HotlistItem],
|
||||
) -> Result<String, PipeError> {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: "call openxml_office".to_string(),
|
||||
})?;
|
||||
let tool = OpenXmlOfficeTool::new(workspace_root.to_path_buf());
|
||||
let rows = items
|
||||
.iter()
|
||||
.map(|item| json!([item.rank, item.title, item.heat]))
|
||||
.collect::<Vec<_>>();
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||
let result = runtime
|
||||
.block_on(tool.execute(json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": rows,
|
||||
})))
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(
|
||||
result.error.unwrap_or_else(|| "openxml_office failed".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output)
|
||||
.map_err(|err| PipeError::Protocol(format!("invalid openxml_office output: {err}")))?;
|
||||
let output_path = payload["output_path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PipeError::Protocol("openxml_office did not return output_path".to_string()))?;
|
||||
Ok(format!("已导出知乎热榜 Excel {output_path}"))
|
||||
}
|
||||
|
||||
fn export_screen<T: Transport>(
|
||||
transport: &T,
|
||||
workspace_root: &Path,
|
||||
items: &[HotlistItem],
|
||||
) -> Result<String, PipeError> {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: "call screen_html_export".to_string(),
|
||||
})?;
|
||||
let tool = ScreenHtmlExportTool::new(workspace_root.to_path_buf());
|
||||
let rows = items
|
||||
.iter()
|
||||
.map(|item| json!([item.rank, item.title, item.heat]))
|
||||
.collect::<Vec<_>>();
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||
let result = runtime
|
||||
.block_on(tool.execute(json!({ "rows": rows })))
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(
|
||||
result.error.unwrap_or_else(|| "screen_html_export failed".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output)
|
||||
.map_err(|err| PipeError::Protocol(format!("invalid screen_html_export output: {err}")))?;
|
||||
let output_path = payload["output_path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PipeError::Protocol("screen_html_export did not return output_path".to_string()))?;
|
||||
Ok(format!("已生成知乎热榜大屏 {output_path}"))
|
||||
}
|
||||
|
||||
fn load_hotlist_extractor_script(top_n: usize) -> Result<String, PipeError> {
|
||||
let script_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
|
||||
.join("skill_lib")
|
||||
.join("skills")
|
||||
.join("zhihu-hotlist")
|
||||
.join("scripts")
|
||||
.join("extract_hotlist.js");
|
||||
let script = fs::read_to_string(&script_path).map_err(|err| {
|
||||
PipeError::Protocol(format!(
|
||||
"failed to read zhihu hotlist extractor script {}: {err}",
|
||||
script_path.display()
|
||||
))
|
||||
})?;
|
||||
Ok(format!(
|
||||
"(function() {{\nconst args = {};\n{}\n}})()",
|
||||
json!({ "top_n": top_n.to_string() }),
|
||||
script
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_hotlist_items_payload(payload: &Value) -> Result<Vec<HotlistItem>, PipeError> {
|
||||
let normalized_payload = if let Some(text) = payload.as_str() {
|
||||
serde_json::from_str::<Value>(text).unwrap_or_else(|_| Value::String(text.to_string()))
|
||||
} else {
|
||||
payload.clone()
|
||||
};
|
||||
|
||||
let rows = normalized_payload
|
||||
.get("rows")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol("知乎热榜采集失败:浏览器脚本未返回 rows".to_string())
|
||||
})?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
let Some(cells) = row.as_array() else {
|
||||
continue;
|
||||
};
|
||||
if cells.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rank = cells[0]
|
||||
.as_u64()
|
||||
.or_else(|| cells[0].as_str().and_then(|value| value.parse::<u64>().ok()))
|
||||
.unwrap_or((items.len() + 1) as u64);
|
||||
let title = cells[1].as_str().unwrap_or_default().trim().to_string();
|
||||
let heat = cells[2].as_str().unwrap_or_default().trim().to_string();
|
||||
if title.is_empty() || heat.is_empty() {
|
||||
continue;
|
||||
}
|
||||
items.push(HotlistItem { rank, title, heat });
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(PipeError::Protocol(
|
||||
"知乎热榜采集失败:浏览器脚本未返回有效热榜条目".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
fn extract_top_n(instruction: &str) -> usize {
|
||||
let re = Regex::new(r"(?:前|top\s*)(\d{1,2})").expect("valid top-n regex");
|
||||
re.captures(&instruction.to_ascii_lowercase())
|
||||
.and_then(|capture| capture.get(1))
|
||||
.and_then(|value| value.as_str().parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(10)
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
mod settings;
|
||||
|
||||
pub use settings::{ConfigError, DeepSeekSettings};
|
||||
pub use settings::{
|
||||
BrowserBackend,
|
||||
ConfigError,
|
||||
DeepSeekSettings,
|
||||
OfficeBackend,
|
||||
PlannerMode,
|
||||
ProviderSettings,
|
||||
SgClawSettings,
|
||||
SkillsPromptMode,
|
||||
};
|
||||
|
||||
@@ -3,17 +3,137 @@ use std::path::{Path, PathBuf};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::runtime::RuntimeProfile;
|
||||
|
||||
pub use zeroclaw::config::SkillsPromptInjectionMode as SkillsPromptMode;
|
||||
|
||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
||||
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat";
|
||||
const DEFAULT_PROVIDER_ID: &str = "deepseek";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlannerMode {
|
||||
ZeroclawPlanFirst,
|
||||
LegacyDeterministic,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BrowserBackend {
|
||||
SuperRpa,
|
||||
AgentBrowser,
|
||||
RustNative,
|
||||
ComputerUse,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OfficeBackend {
|
||||
OpenXml,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderSettings {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub api_key: String,
|
||||
pub base_url: Option<String>,
|
||||
pub model: String,
|
||||
pub api_path: Option<String>,
|
||||
pub wire_api: Option<String>,
|
||||
pub requires_openai_auth: bool,
|
||||
}
|
||||
|
||||
impl ProviderSettings {
|
||||
fn from_legacy_deepseek(
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let api_key = normalize_required_value("DEEPSEEK_API_KEY", api_key)?;
|
||||
let base_url = normalize_base_url(base_url);
|
||||
let model = normalize_model(model);
|
||||
|
||||
Ok(Self {
|
||||
id: DEFAULT_PROVIDER_ID.to_string(),
|
||||
provider: DEFAULT_PROVIDER_ID.to_string(),
|
||||
api_key,
|
||||
base_url: Some(base_url),
|
||||
model,
|
||||
api_path: None,
|
||||
wire_api: None,
|
||||
requires_openai_auth: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_raw(raw: RawProviderSettings) -> Result<Self, ConfigError> {
|
||||
let id = raw.id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"providers[].id",
|
||||
"must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let api_key = normalize_required_value("providers[].apiKey", raw.api_key)?;
|
||||
let model = normalize_required_value("providers[].model", raw.model)?;
|
||||
let base_url = normalize_optional_value(raw.base_url);
|
||||
let provider = normalize_optional_value(raw.provider)
|
||||
.or_else(|| base_url.as_ref().map(|url| format!("custom:{url}")))
|
||||
.ok_or_else(|| {
|
||||
ConfigError::InvalidValue(
|
||||
"providers[].provider",
|
||||
format!("provider {} must define provider or baseUrl", id),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
provider,
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
api_path: normalize_optional_value(raw.api_path),
|
||||
wire_api: normalize_optional_value(raw.wire_api),
|
||||
requires_openai_auth: raw.requires_openai_auth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeepSeekSettings {
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl DeepSeekSettings {
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
Ok(Self::from(&SgClawSettings::from_env()?))
|
||||
}
|
||||
|
||||
pub fn load(config_path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
|
||||
SgClawSettings::load(config_path).map(|settings| settings.map(|settings| Self::from(&settings)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SgClawSettings {
|
||||
pub provider_api_key: String,
|
||||
pub provider_base_url: String,
|
||||
pub provider_model: String,
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
pub skills_prompt_mode: SkillsPromptMode,
|
||||
pub runtime_profile: RuntimeProfile,
|
||||
pub planner_mode: PlannerMode,
|
||||
pub providers: Vec<ProviderSettings>,
|
||||
pub active_provider: String,
|
||||
pub browser_backend: BrowserBackend,
|
||||
pub office_backend: OfficeBackend,
|
||||
}
|
||||
|
||||
impl SgClawSettings {
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY"))
|
||||
}
|
||||
@@ -28,6 +148,34 @@ impl DeepSeekSettings {
|
||||
Self::maybe_from_env()
|
||||
}
|
||||
|
||||
pub fn from_legacy_deepseek_fields(
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
skills_dir: Option<PathBuf>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
Self::new(
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
skills_dir,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn active_provider_settings(&self) -> &ProviderSettings {
|
||||
self.providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == self.active_provider)
|
||||
.expect("active_provider should always resolve to a configured provider")
|
||||
}
|
||||
|
||||
fn maybe_from_env() -> Result<Option<Self>, ConfigError> {
|
||||
let api_key = match std::env::var("DEEPSEEK_API_KEY") {
|
||||
Ok(value) => value,
|
||||
@@ -41,58 +189,320 @@ impl DeepSeekSettings {
|
||||
let model =
|
||||
std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_DEEPSEEK_MODEL.to_string());
|
||||
|
||||
Ok(Some(Self::new(api_key, base_url, model)?))
|
||||
Ok(Some(Self::new(
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?))
|
||||
}
|
||||
|
||||
fn from_config_path(path: &Path) -> Result<Self, ConfigError> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.map_err(|err| ConfigError::ConfigRead(path.to_path_buf(), err.to_string()))?;
|
||||
let config: RawDeepSeekSettings = serde_json::from_str(&raw)
|
||||
let config: RawSgClawSettings = serde_json::from_str(&raw)
|
||||
.map_err(|err| ConfigError::ConfigParse(path.to_path_buf(), err.to_string()))?;
|
||||
let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let runtime_profile = config
|
||||
.runtime_profile
|
||||
.as_deref()
|
||||
.map(parse_runtime_profile)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(path.to_path_buf(), format!("invalid runtimeProfile: {value}"))
|
||||
})?;
|
||||
let skills_prompt_mode = config
|
||||
.skills_prompt_mode
|
||||
.as_deref()
|
||||
.map(parse_skills_prompt_mode)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(
|
||||
path.to_path_buf(),
|
||||
format!("invalid skillsPromptMode: {value}"),
|
||||
)
|
||||
})?;
|
||||
let planner_mode = config
|
||||
.planner_mode
|
||||
.as_deref()
|
||||
.map(parse_planner_mode)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(path.to_path_buf(), format!("invalid plannerMode: {value}"))
|
||||
})?;
|
||||
let browser_backend = config
|
||||
.browser_backend
|
||||
.as_deref()
|
||||
.map(parse_browser_backend)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(path.to_path_buf(), format!("invalid browserBackend: {value}"))
|
||||
})?;
|
||||
let office_backend = config
|
||||
.office_backend
|
||||
.as_deref()
|
||||
.map(parse_office_backend)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(path.to_path_buf(), format!("invalid officeBackend: {value}"))
|
||||
})?;
|
||||
let providers = config
|
||||
.providers
|
||||
.into_iter()
|
||||
.map(ProviderSettings::from_raw)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|err| err.with_path(path))?;
|
||||
|
||||
Self::new(config.api_key, config.base_url, config.model)
|
||||
.map_err(|err| err.with_path(path))
|
||||
Self::new(
|
||||
config.api_key,
|
||||
config.base_url,
|
||||
config.model,
|
||||
resolve_configured_skills_dir(config.skills_dir, config_dir),
|
||||
skills_prompt_mode,
|
||||
runtime_profile,
|
||||
planner_mode,
|
||||
providers,
|
||||
config.active_provider,
|
||||
browser_backend,
|
||||
office_backend,
|
||||
)
|
||||
.map_err(|err| err.with_path(path))
|
||||
}
|
||||
|
||||
fn new(api_key: String, base_url: String, model: String) -> Result<Self, ConfigError> {
|
||||
let api_key = api_key.trim().to_string();
|
||||
let base_url = if base_url.trim().is_empty() {
|
||||
DEFAULT_DEEPSEEK_BASE_URL.to_string()
|
||||
fn new(
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
skills_dir: Option<PathBuf>,
|
||||
skills_prompt_mode: Option<SkillsPromptMode>,
|
||||
runtime_profile: Option<RuntimeProfile>,
|
||||
planner_mode: Option<PlannerMode>,
|
||||
providers: Vec<ProviderSettings>,
|
||||
active_provider: Option<String>,
|
||||
browser_backend: Option<BrowserBackend>,
|
||||
office_backend: Option<OfficeBackend>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let providers = if providers.is_empty() {
|
||||
vec![ProviderSettings::from_legacy_deepseek(api_key, base_url, model)?]
|
||||
} else {
|
||||
base_url.trim().to_string()
|
||||
providers
|
||||
};
|
||||
let model = if model.trim().is_empty() {
|
||||
DEFAULT_DEEPSEEK_MODEL.to_string()
|
||||
} else {
|
||||
model.trim().to_string()
|
||||
};
|
||||
|
||||
if api_key.is_empty() {
|
||||
return Err(ConfigError::EmptyValue("DEEPSEEK_API_KEY"));
|
||||
}
|
||||
if base_url.is_empty() {
|
||||
return Err(ConfigError::EmptyValue("DEEPSEEK_BASE_URL"));
|
||||
}
|
||||
if model.is_empty() {
|
||||
return Err(ConfigError::EmptyValue("DEEPSEEK_MODEL"));
|
||||
}
|
||||
let active_provider = normalize_optional_value(active_provider)
|
||||
.unwrap_or_else(|| providers[0].id.clone());
|
||||
let active_provider_settings = providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == active_provider)
|
||||
.ok_or_else(|| {
|
||||
ConfigError::InvalidValue(
|
||||
"activeProvider",
|
||||
format!("unknown provider id: {active_provider}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
provider_api_key: active_provider_settings.api_key.clone(),
|
||||
provider_base_url: active_provider_settings.base_url.clone().unwrap_or_default(),
|
||||
provider_model: active_provider_settings.model.clone(),
|
||||
skills_dir,
|
||||
skills_prompt_mode: skills_prompt_mode.unwrap_or(SkillsPromptMode::Compact),
|
||||
runtime_profile: runtime_profile.unwrap_or(RuntimeProfile::BrowserAttached),
|
||||
planner_mode: planner_mode.unwrap_or(PlannerMode::ZeroclawPlanFirst),
|
||||
providers,
|
||||
active_provider,
|
||||
browser_backend: browser_backend.unwrap_or(BrowserBackend::SuperRpa),
|
||||
office_backend: office_backend.unwrap_or(OfficeBackend::OpenXml),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SgClawSettings> for DeepSeekSettings {
|
||||
fn from(value: &SgClawSettings) -> Self {
|
||||
Self {
|
||||
api_key: value.provider_api_key.clone(),
|
||||
base_url: value.provider_base_url.clone(),
|
||||
model: value.provider_model.clone(),
|
||||
skills_dir: value.skills_dir.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DeepSeekSettings> for SgClawSettings {
|
||||
fn from(value: &DeepSeekSettings) -> Self {
|
||||
Self::from_legacy_deepseek_fields(
|
||||
value.api_key.clone(),
|
||||
value.base_url.clone(),
|
||||
value.model.clone(),
|
||||
value.skills_dir.clone(),
|
||||
)
|
||||
.expect("DeepSeekSettings should already be validated")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_runtime_profile(raw: &str) -> Result<RuntimeProfile, String> {
|
||||
let normalized = raw
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| *ch != '_' && *ch != '-')
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
match normalized.as_str() {
|
||||
"browserattached" => Ok(RuntimeProfile::BrowserAttached),
|
||||
"browserheavy" => Ok(RuntimeProfile::BrowserHeavy),
|
||||
"generalassistant" => Ok(RuntimeProfile::GeneralAssistant),
|
||||
_ => Err(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skills_prompt_mode(raw: &str) -> Result<SkillsPromptMode, String> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"compact" => Ok(SkillsPromptMode::Compact),
|
||||
"full" => Ok(SkillsPromptMode::Full),
|
||||
_ => Err(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_planner_mode(raw: &str) -> Result<PlannerMode, String> {
|
||||
let normalized = normalize_enum_token(raw);
|
||||
|
||||
match normalized.as_str() {
|
||||
"zeroclawplanfirst" | "planfirst" | "plannerfirst" => Ok(PlannerMode::ZeroclawPlanFirst),
|
||||
"legacy" | "legacydeterministic" | "deterministic" => Ok(PlannerMode::LegacyDeterministic),
|
||||
_ => Err(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_browser_backend(raw: &str) -> Result<BrowserBackend, String> {
|
||||
let normalized = normalize_enum_token(raw);
|
||||
|
||||
match normalized.as_str() {
|
||||
"superrpa" | "superrpapipe" | "host" | "hostpipe" => Ok(BrowserBackend::SuperRpa),
|
||||
"agentbrowser" => Ok(BrowserBackend::AgentBrowser),
|
||||
"rustnative" => Ok(BrowserBackend::RustNative),
|
||||
"computeruse" => Ok(BrowserBackend::ComputerUse),
|
||||
"auto" => Ok(BrowserBackend::Auto),
|
||||
_ => Err(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_office_backend(raw: &str) -> Result<OfficeBackend, String> {
|
||||
let normalized = normalize_enum_token(raw);
|
||||
|
||||
match normalized.as_str() {
|
||||
"openxml" => Ok(OfficeBackend::OpenXml),
|
||||
"disabled" | "none" => Ok(OfficeBackend::Disabled),
|
||||
_ => Err(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_configured_skills_dir(raw: Option<String>, config_dir: &Path) -> Option<PathBuf> {
|
||||
let trimmed = raw
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let path = PathBuf::from(trimmed);
|
||||
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
Some(config_dir.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_required_value(field: &'static str, raw: String) -> Result<String, ConfigError> {
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ConfigError::EmptyValue(field));
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn normalize_optional_value(raw: Option<String>) -> Option<String> {
|
||||
raw.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn normalize_base_url(raw: String) -> String {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
DEFAULT_DEEPSEEK_BASE_URL.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model(raw: String) -> String {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
DEFAULT_DEEPSEEK_MODEL.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_enum_token(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.chars()
|
||||
.filter(|ch| *ch != '_' && *ch != '-')
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawDeepSeekSettings {
|
||||
struct RawSgClawSettings {
|
||||
#[serde(rename = "apiKey", default)]
|
||||
api_key: String,
|
||||
#[serde(rename = "baseUrl", default)]
|
||||
base_url: String,
|
||||
#[serde(default)]
|
||||
model: String,
|
||||
#[serde(rename = "skillsDir", alias = "skills_dir", default)]
|
||||
skills_dir: Option<String>,
|
||||
#[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)]
|
||||
skills_prompt_mode: Option<String>,
|
||||
#[serde(rename = "runtimeProfile", alias = "runtime_profile", default)]
|
||||
runtime_profile: Option<String>,
|
||||
#[serde(rename = "plannerMode", alias = "planner_mode", default)]
|
||||
planner_mode: Option<String>,
|
||||
#[serde(rename = "activeProvider", alias = "active_provider", default)]
|
||||
active_provider: Option<String>,
|
||||
#[serde(rename = "browserBackend", alias = "browser_backend", default)]
|
||||
browser_backend: Option<String>,
|
||||
#[serde(rename = "officeBackend", alias = "office_backend", default)]
|
||||
office_backend: Option<String>,
|
||||
#[serde(default)]
|
||||
providers: Vec<RawProviderSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawProviderSettings {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
provider: Option<String>,
|
||||
#[serde(rename = "apiKey", default)]
|
||||
api_key: String,
|
||||
#[serde(rename = "baseUrl", default)]
|
||||
base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
model: String,
|
||||
#[serde(rename = "apiPath", alias = "api_path", default)]
|
||||
api_path: Option<String>,
|
||||
#[serde(rename = "wireApi", alias = "wire_api", default)]
|
||||
wire_api: Option<String>,
|
||||
#[serde(
|
||||
rename = "requiresOpenaiAuth",
|
||||
alias = "requires_openai_auth",
|
||||
default
|
||||
)]
|
||||
requires_openai_auth: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
@@ -109,13 +519,32 @@ pub enum ConfigError {
|
||||
ConfigParse(PathBuf, String),
|
||||
#[error("DeepSeek config value must not be empty: {0} ({1})")]
|
||||
ConfigValueEmpty(&'static str, PathBuf),
|
||||
#[error("invalid config value for {0}: {1}")]
|
||||
InvalidValue(&'static str, String),
|
||||
#[error("invalid DeepSeek config value for {0}: {2} ({1})")]
|
||||
ConfigInvalidValue(&'static str, PathBuf, String),
|
||||
}
|
||||
|
||||
impl ConfigError {
|
||||
fn with_path(self, path: &Path) -> Self {
|
||||
match self {
|
||||
Self::EmptyValue(field) => Self::ConfigValueEmpty(field, path.to_path_buf()),
|
||||
Self::InvalidValue(field, detail) => {
|
||||
Self::ConfigInvalidValue(field, path.to_path_buf(), detail)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend {
|
||||
pub fn zeroclaw_backend(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::SuperRpa => None,
|
||||
Self::AgentBrowser => Some("agent_browser"),
|
||||
Self::RustNative => Some("rust_native"),
|
||||
Self::ComputerUse => Some("computer_use"),
|
||||
Self::Auto => Some("auto"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::pipe::protocol::{Action, AgentMessage, BrowserMessage, SecurityFields, Timing};
|
||||
use crate::pipe::protocol::{
|
||||
Action, AgentMessage, BrowserMessage, ExecutionSurfaceMetadata, SecurityFields, Timing,
|
||||
};
|
||||
use crate::pipe::{PipeError, Transport};
|
||||
use crate::security::{sign_command, MacPolicy};
|
||||
|
||||
@@ -53,6 +55,10 @@ impl<T: Transport> BrowserPipeTool<T> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.mac_policy.privileged_surface_metadata()
|
||||
}
|
||||
|
||||
pub fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
|
||||
@@ -2,7 +2,9 @@ use std::time::Duration;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::pipe::protocol::{supported_actions, AgentMessage, BrowserMessage, PROTOCOL_VERSION};
|
||||
use crate::pipe::protocol::{
|
||||
supported_actions, AgentMessage, BrowserMessage, ExecutionSurfaceMetadata, PROTOCOL_VERSION,
|
||||
};
|
||||
use crate::pipe::{PipeError, Transport};
|
||||
use crate::security::derive_session_key;
|
||||
|
||||
@@ -13,6 +15,19 @@ pub struct HandshakeResult {
|
||||
pub capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
impl HandshakeResult {
|
||||
pub fn browser_surface_metadata(&self) -> Option<ExecutionSurfaceMetadata> {
|
||||
self.capabilities
|
||||
.iter()
|
||||
.any(|capability| capability == "browser_action")
|
||||
.then(|| {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe(
|
||||
"browser_host_and_mac_policy",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn perform_handshake<T: Transport>(
|
||||
transport: &T,
|
||||
timeout: Duration,
|
||||
|
||||
@@ -5,8 +5,8 @@ pub mod protocol;
|
||||
pub use browser_tool::{BrowserPipeTool, CommandOutput};
|
||||
pub use handshake::{perform_handshake, HandshakeResult};
|
||||
pub use protocol::{
|
||||
supported_actions, Action, AgentMessage, BrowserMessage, ConversationMessage,
|
||||
SecurityFields, Timing,
|
||||
supported_actions, Action, AgentMessage, BrowserContext, BrowserMessage,
|
||||
ConversationMessage, ExecutionSurfaceKind, ExecutionSurfaceMetadata, SecurityFields, Timing,
|
||||
};
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
|
||||
@@ -3,6 +3,49 @@ use serde_json::{json, Value};
|
||||
|
||||
pub const PROTOCOL_VERSION: &str = "1.0";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionSurfaceKind {
|
||||
PrivilegedBrowserPipe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct BrowserContext {
|
||||
pub page_url: String,
|
||||
pub page_title: String,
|
||||
}
|
||||
|
||||
impl BrowserContext {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.page_url.trim().is_empty() && self.page_title.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionSurfaceMetadata {
|
||||
pub kind: ExecutionSurfaceKind,
|
||||
pub privileged: bool,
|
||||
pub defines_runtime_identity: bool,
|
||||
pub guard: String,
|
||||
#[serde(default)]
|
||||
pub allowed_domains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_actions: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExecutionSurfaceMetadata {
|
||||
pub fn privileged_browser_pipe(guard: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: ExecutionSurfaceKind::PrivilegedBrowserPipe,
|
||||
privileged: true,
|
||||
defines_runtime_identity: false,
|
||||
guard: guard.into(),
|
||||
allowed_domains: Vec::new(),
|
||||
allowed_actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum BrowserMessage {
|
||||
@@ -34,6 +77,34 @@ pub enum BrowserMessage {
|
||||
},
|
||||
}
|
||||
|
||||
impl BrowserMessage {
|
||||
pub fn browser_context(&self) -> Option<BrowserContext> {
|
||||
match self {
|
||||
Self::SubmitTask {
|
||||
page_url,
|
||||
page_title,
|
||||
..
|
||||
} => {
|
||||
let context = BrowserContext {
|
||||
page_url: page_url.clone(),
|
||||
page_title: page_title.clone(),
|
||||
};
|
||||
(!context.is_empty()).then_some(context)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requested_surface_metadata(&self) -> Option<ExecutionSurfaceMetadata> {
|
||||
match self {
|
||||
Self::SubmitTask { .. } => Some(ExecutionSurfaceMetadata::privileged_browser_pipe(
|
||||
"browser_host_and_mac_policy",
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConversationMessage {
|
||||
pub role: String,
|
||||
@@ -71,6 +142,7 @@ pub enum Action {
|
||||
Type,
|
||||
Navigate,
|
||||
GetText,
|
||||
Eval,
|
||||
GetHtml,
|
||||
WaitForSelector,
|
||||
PageScreenshot,
|
||||
@@ -90,6 +162,7 @@ impl Action {
|
||||
Action::Type => "type",
|
||||
Action::Navigate => "navigate",
|
||||
Action::GetText => "getText",
|
||||
Action::Eval => "eval",
|
||||
Action::GetHtml => "getHtml",
|
||||
Action::WaitForSelector => "waitForSelector",
|
||||
Action::PageScreenshot => "pageScreenshot",
|
||||
@@ -122,6 +195,7 @@ pub fn supported_actions() -> Vec<Action> {
|
||||
Action::Type,
|
||||
Action::Navigate,
|
||||
Action::GetText,
|
||||
Action::Eval,
|
||||
Action::GetHtml,
|
||||
Action::WaitForSelector,
|
||||
Action::PageScreenshot,
|
||||
|
||||
355
src/runtime/engine.rs
Normal file
355
src/runtime/engine.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use zeroclaw::agent::dispatcher::NativeToolDispatcher;
|
||||
use zeroclaw::agent::Agent;
|
||||
use zeroclaw::config::{Config as ZeroClawConfig, SkillsPromptInjectionMode};
|
||||
use zeroclaw::memory::Memory;
|
||||
use zeroclaw::observability::{NoopObserver, Observer};
|
||||
use zeroclaw::providers::Provider;
|
||||
use zeroclaw::runtime::NativeRuntime;
|
||||
use zeroclaw::tools::{self, ReadSkillTool};
|
||||
use zeroclaw::SecurityPolicy;
|
||||
|
||||
use crate::compat::memory_adapter::build_memory;
|
||||
use crate::pipe::PipeError;
|
||||
use crate::runtime::{RuntimeProfile, ToolPolicy};
|
||||
|
||||
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||
const READ_SKILL_TOOL_NAME: &str = "read_skill";
|
||||
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
|
||||
const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
|
||||
const BROWSER_TOOL_CONTRACT_PROMPT: &str = "SuperRPA browser interface contract:\n- Use superrpa_browser as the preferred dedicated SuperRPA interface inside this browser host.\n- browser_action is a legacy alias with the same contract; prefer superrpa_browser when choosing between them.\n- Browser actions allowed by policy are already approved by the user inside this BrowserAttached host.\n- Do not claim a browser action was denied, blocked, or rejected unless an actual tool call returns an error.\n- expected_domain must be the bare hostname only, for example www.zhihu.com.\n- Never include scheme, path, query, fragment, or port in expected_domain.\n- selector values are executed with document.querySelector(...), so they must be valid CSS selectors only.\n- Never use XPath selectors or jQuery-style :contains().\n- Prefer direct navigation to canonical URLs when they are known, instead of clicking text links to reach common pages.\n- If you need broad page content, use getText with a valid CSS selector such as body or a stable container.\n- If a task matches an installed skill, load that skill first and then execute it through the SuperRPA interface.";
|
||||
const ZHIHU_HOTLIST_EXECUTION_PROMPT: &str = "Zhihu hotlist execution contract:\n- Treat Zhihu hotlist export/presentation requests as a real browser workflow, not as a text-only summarization task.\n- You must attempt the browser workflow before concluding failure; a prose-only answer is invalid for this workflow.\n- If the current page is not already `https://www.zhihu.com/hot`, navigate there first.\n- If the `zhihu-hotlist.extract_hotlist` skill tool is available, call it before any generic browser probing.\n- Use generic `getText` only as a last-resort fallback when the packaged extractor fails.\n- Extract ordered rows containing `rank`, `title`, and `heat` as structured data.\n- Do not use shell, web_fetch, web_search_tool, or fabricated sample data for this workflow.\n- Do not repeat the same sentence or section in your final answer.";
|
||||
const OFFICE_EXPORT_COMPLETION_PROMPT: &str = "Export completion contract:\n- This task requires a real Excel export.\n- After the Zhihu rows are available, you must call openxml_office before finishing.\n- Never fabricate, simulate, or invent substitute hotlist data when a live collection/export task fails.\n- If live collection fails, report the failure concisely instead of producing fake rows.\n- Do not stop after describing how you will parse or export the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the generated local .xlsx path.";
|
||||
const SCREEN_EXPORT_COMPLETION_PROMPT: &str = "Presentation completion contract:\n- This task requires a real dashboard artifact.\n- After the Zhihu rows are available, you must call screen_html_export before finishing.\n- Do not stop after describing how you will render or present the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the local .html path and the presentation object.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeEngine {
|
||||
profile: RuntimeProfile,
|
||||
tool_policy: ToolPolicy,
|
||||
}
|
||||
|
||||
impl RuntimeEngine {
|
||||
pub fn new(profile: RuntimeProfile) -> Self {
|
||||
Self {
|
||||
profile,
|
||||
tool_policy: ToolPolicy::for_profile(profile),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile(&self) -> RuntimeProfile {
|
||||
self.profile
|
||||
}
|
||||
|
||||
pub fn tool_policy(&self) -> &ToolPolicy {
|
||||
&self.tool_policy
|
||||
}
|
||||
|
||||
pub fn browser_surface_enabled(&self) -> bool {
|
||||
self.tool_policy
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| {
|
||||
tool == BROWSER_ACTION_TOOL_NAME || tool == SUPERRPA_BROWSER_TOOL_NAME
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_agent(
|
||||
&self,
|
||||
provider: Box<dyn Provider>,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dir: &Path,
|
||||
mut tools: Vec<Box<dyn zeroclaw::tools::Tool>>,
|
||||
browser_surface_present: bool,
|
||||
instruction: &str,
|
||||
) -> Result<Agent, PipeError> {
|
||||
let memory: Arc<dyn Memory> =
|
||||
Arc::from(build_memory(config).map_err(map_anyhow_to_pipe_error)?);
|
||||
let security = Arc::new(SecurityPolicy::from_config(
|
||||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
|
||||
let skills = load_runtime_skills(config, skills_dir);
|
||||
let (mut runtime_tools, _, _, _, _, _) = tools::all_tools_with_runtime(
|
||||
Arc::new(config.clone()),
|
||||
&security,
|
||||
Arc::new(NativeRuntime::new()),
|
||||
memory.clone(),
|
||||
None,
|
||||
None,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&config.web_fetch,
|
||||
&config.workspace_dir,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
config,
|
||||
None,
|
||||
);
|
||||
runtime_tools.append(&mut tools);
|
||||
|
||||
if matches!(
|
||||
config.skills.prompt_injection_mode,
|
||||
SkillsPromptInjectionMode::Compact
|
||||
) && skills_dir != config.workspace_dir.join("skills")
|
||||
{
|
||||
runtime_tools.retain(|tool| tool.name() != READ_SKILL_TOOL_NAME);
|
||||
runtime_tools.push(Box::new(ReadSkillTool::with_runtime_skills_dir(
|
||||
config.workspace_dir.clone(),
|
||||
Some(skills_dir.to_path_buf()),
|
||||
config.skills.allow_scripts,
|
||||
config.skills.open_skills_enabled,
|
||||
config.skills.open_skills_dir.clone(),
|
||||
)));
|
||||
}
|
||||
|
||||
Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(runtime_tools)
|
||||
.memory(memory)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.config(config.agent.clone())
|
||||
.model_name(
|
||||
config
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "deepseek-chat".to_string()),
|
||||
)
|
||||
.temperature(config.default_temperature)
|
||||
.workspace_dir(config.workspace_dir.clone())
|
||||
.skills(skills)
|
||||
.skills_prompt_mode(config.skills.prompt_injection_mode)
|
||||
.allowed_tools(self.allowed_tools_for_config(
|
||||
config,
|
||||
skills_dir,
|
||||
browser_surface_present,
|
||||
instruction,
|
||||
))
|
||||
.build()
|
||||
.map_err(map_anyhow_to_pipe_error)
|
||||
}
|
||||
|
||||
pub fn build_instruction(
|
||||
&self,
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
browser_surface_present: bool,
|
||||
) -> String {
|
||||
let trimmed_instruction = instruction.trim();
|
||||
if !browser_surface_present || !self.browser_surface_enabled() {
|
||||
return trimmed_instruction.to_string();
|
||||
}
|
||||
|
||||
let mut sections = vec![BROWSER_TOOL_CONTRACT_PROMPT.to_string()];
|
||||
if is_zhihu_hotlist_task(trimmed_instruction, page_url, page_title) {
|
||||
sections.push(ZHIHU_HOTLIST_EXECUTION_PROMPT.to_string());
|
||||
}
|
||||
if task_needs_office_export(trimmed_instruction) {
|
||||
sections.push(OFFICE_EXPORT_COMPLETION_PROMPT.to_string());
|
||||
}
|
||||
if task_needs_screen_export(trimmed_instruction) {
|
||||
sections.push(SCREEN_EXPORT_COMPLETION_PROMPT.to_string());
|
||||
}
|
||||
if let Some(page_context) = build_page_context_message(page_url, page_title) {
|
||||
sections.push(page_context);
|
||||
}
|
||||
sections.push(format!("User task: {trimmed_instruction}"));
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
pub fn loaded_skills(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dir: &Path,
|
||||
) -> Vec<zeroclaw::skills::Skill> {
|
||||
let mut skills = load_runtime_skills(config, skills_dir);
|
||||
skills.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then(left.version.cmp(&right.version))
|
||||
});
|
||||
skills.dedup_by(|left, right| {
|
||||
left.name == right.name && left.version == right.version
|
||||
});
|
||||
skills
|
||||
}
|
||||
|
||||
pub fn loaded_skill_names(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dir: &Path,
|
||||
) -> Vec<String> {
|
||||
let mut names = self
|
||||
.loaded_skills(config, skills_dir)
|
||||
.into_iter()
|
||||
.map(|skill| skill.name)
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn should_attach_openxml_office_tool(&self, instruction: &str) -> bool {
|
||||
task_needs_office_export(instruction)
|
||||
}
|
||||
|
||||
pub fn should_attach_screen_html_export_tool(&self, instruction: &str) -> bool {
|
||||
task_needs_screen_export(instruction)
|
||||
}
|
||||
|
||||
fn allowed_tools_for_config(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dir: &Path,
|
||||
browser_surface_present: bool,
|
||||
instruction: &str,
|
||||
) -> Option<Vec<String>> {
|
||||
let mut allowed_tools = self.tool_policy.allowed_tools.clone();
|
||||
if !browser_surface_present {
|
||||
allowed_tools.retain(|tool| {
|
||||
tool != BROWSER_ACTION_TOOL_NAME && tool != SUPERRPA_BROWSER_TOOL_NAME
|
||||
});
|
||||
}
|
||||
if matches!(
|
||||
config.skills.prompt_injection_mode,
|
||||
SkillsPromptInjectionMode::Compact
|
||||
) {
|
||||
allowed_tools.push(READ_SKILL_TOOL_NAME.to_string());
|
||||
}
|
||||
if task_needs_office_export(instruction) {
|
||||
allowed_tools.push(OPENXML_OFFICE_TOOL_NAME.to_string());
|
||||
}
|
||||
if task_needs_screen_export(instruction) {
|
||||
allowed_tools.push(SCREEN_HTML_EXPORT_TOOL_NAME.to_string());
|
||||
}
|
||||
if task_needs_local_file_read(instruction) {
|
||||
allowed_tools.push("file_read".to_string());
|
||||
}
|
||||
if browser_surface_present {
|
||||
allowed_tools.extend(browser_script_tool_names(&load_runtime_skills(
|
||||
config, skills_dir,
|
||||
)));
|
||||
}
|
||||
allowed_tools.dedup();
|
||||
|
||||
if matches!(self.profile, RuntimeProfile::GeneralAssistant) &&
|
||||
self.tool_policy.may_use_non_browser_tools
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(allowed_tools)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_script_tool_names(skills: &[zeroclaw::skills::Skill]) -> Vec<String> {
|
||||
skills
|
||||
.iter()
|
||||
.flat_map(|skill| {
|
||||
skill
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|tool| tool.kind == "browser_script")
|
||||
.map(|tool| format!("{}.{}", skill.name, tool.name))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn task_needs_local_file_read(instruction: &str) -> bool {
|
||||
let normalized = instruction.trim();
|
||||
normalized.contains("/home/") ||
|
||||
normalized.contains("./") ||
|
||||
normalized.contains("../")
|
||||
}
|
||||
|
||||
pub fn is_zhihu_hotlist_task(
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> bool {
|
||||
let normalized_instruction = instruction.to_ascii_lowercase();
|
||||
let normalized_url = page_url.unwrap_or_default().to_ascii_lowercase();
|
||||
let normalized_title = page_title.unwrap_or_default().to_ascii_lowercase();
|
||||
|
||||
let is_zhihu = normalized_instruction.contains("zhihu") ||
|
||||
instruction.contains("知乎") ||
|
||||
normalized_url.contains("zhihu.com") ||
|
||||
normalized_title.contains("zhihu") ||
|
||||
page_title.unwrap_or_default().contains("知乎");
|
||||
let is_hotlist = normalized_instruction.contains("hotlist") ||
|
||||
instruction.contains("热榜") ||
|
||||
normalized_url.contains("/hot") ||
|
||||
normalized_title.contains("hotlist") ||
|
||||
page_title.unwrap_or_default().contains("热榜");
|
||||
|
||||
is_zhihu && is_hotlist
|
||||
}
|
||||
|
||||
fn task_needs_office_export(instruction: &str) -> bool {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
normalized.contains("excel")
|
||||
|| normalized.contains(".xlsx")
|
||||
|| normalized.contains("导出")
|
||||
|| normalized.contains("xlsx")
|
||||
}
|
||||
|
||||
fn task_needs_screen_export(instruction: &str) -> bool {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
normalized.contains("大屏")
|
||||
|| normalized.contains("看板")
|
||||
|| normalized.contains("dashboard")
|
||||
|| normalized.contains("screen")
|
||||
|| normalized.contains("echarts")
|
||||
|| normalized.contains("演示")
|
||||
|| normalized.contains("汇报")
|
||||
}
|
||||
|
||||
fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec<zeroclaw::skills::Skill> {
|
||||
let default_skills_dir = config.workspace_dir.join("skills");
|
||||
if skills_dir == default_skills_dir {
|
||||
return zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
|
||||
}
|
||||
|
||||
let mut skills = zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
|
||||
skills.retain(|skill| {
|
||||
skill
|
||||
.location
|
||||
.as_ref()
|
||||
.map(|location| !location.starts_with(&default_skills_dir))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
skills.extend(zeroclaw::skills::load_skills_from_directory(
|
||||
skills_dir,
|
||||
config.skills.allow_scripts,
|
||||
));
|
||||
skills
|
||||
}
|
||||
|
||||
fn build_page_context_message(page_url: Option<&str>, page_title: Option<&str>) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(page_url) = page_url.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
parts.push(format!("Current page URL: {page_url}"));
|
||||
}
|
||||
if let Some(page_title) = page_title.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
parts.push(format!("Current page title: {page_title}"));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"Current browser context:\n{}",
|
||||
parts.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
fn map_anyhow_to_pipe_error(err: anyhow::Error) -> PipeError {
|
||||
PipeError::Protocol(err.to_string())
|
||||
}
|
||||
7
src/runtime/mod.rs
Normal file
7
src/runtime/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod engine;
|
||||
mod profile;
|
||||
mod tool_policy;
|
||||
|
||||
pub use engine::{is_zhihu_hotlist_task, RuntimeEngine};
|
||||
pub use profile::RuntimeProfile;
|
||||
pub use tool_policy::ToolPolicy;
|
||||
36
src/runtime/tool_policy.rs
Normal file
36
src/runtime/tool_policy.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::runtime::RuntimeProfile;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolPolicy {
|
||||
pub requires_browser_surface: bool,
|
||||
pub may_use_non_browser_tools: bool,
|
||||
pub allowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolPolicy {
|
||||
pub fn for_profile(profile: RuntimeProfile) -> Self {
|
||||
match profile {
|
||||
RuntimeProfile::BrowserAttached => Self {
|
||||
requires_browser_surface: false,
|
||||
may_use_non_browser_tools: true,
|
||||
allowed_tools: vec![
|
||||
"superrpa_browser".to_string(),
|
||||
"browser_action".to_string(),
|
||||
],
|
||||
},
|
||||
RuntimeProfile::BrowserHeavy => Self {
|
||||
requires_browser_surface: true,
|
||||
may_use_non_browser_tools: true,
|
||||
allowed_tools: vec![
|
||||
"superrpa_browser".to_string(),
|
||||
"browser_action".to_string(),
|
||||
],
|
||||
},
|
||||
RuntimeProfile::GeneralAssistant => Self {
|
||||
requires_browser_surface: false,
|
||||
may_use_non_browser_tools: true,
|
||||
allowed_tools: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::pipe::Action;
|
||||
use crate::pipe::{Action, ExecutionSurfaceMetadata};
|
||||
use crate::security::SecurityError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -77,6 +77,13 @@ impl MacPolicy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn privileged_surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
let mut metadata = ExecutionSurfaceMetadata::privileged_browser_pipe("mac_policy");
|
||||
metadata.allowed_domains = self.domains.allowed.clone();
|
||||
metadata.allowed_actions = self.pipe_actions.allowed.clone();
|
||||
metadata
|
||||
}
|
||||
|
||||
fn validate_rules(&self) -> Result<(), SecurityError> {
|
||||
if self.version.trim().is_empty() {
|
||||
return Err(SecurityError::InvalidRules(
|
||||
|
||||
53
tests/browser_runtime_entrypoint_test.sh
Normal file
53
tests/browser_runtime_entrypoint_test.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CRATE_DIR="$(cd -- "${TEST_DIR}/.." && pwd)"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||
|
||||
FAKE_CRATE_DIR="${TMP_DIR}/claw"
|
||||
mkdir -p \
|
||||
"${FAKE_CRATE_DIR}/tools/browser_runtime" \
|
||||
"${FAKE_CRATE_DIR}/target/debug/resources" \
|
||||
"${FAKE_CRATE_DIR}/resources" \
|
||||
"${FAKE_CRATE_DIR}/src" \
|
||||
"${FAKE_CRATE_DIR}/third_party/zeroclaw/src" \
|
||||
"${TMP_DIR}/skill_lib"
|
||||
|
||||
cp "${CRATE_DIR}/tools/browser_runtime/sgclaw_browser_entry.sh" \
|
||||
"${FAKE_CRATE_DIR}/tools/browser_runtime/sgclaw_browser_entry.sh"
|
||||
chmod +x "${FAKE_CRATE_DIR}/tools/browser_runtime/sgclaw_browser_entry.sh"
|
||||
|
||||
cat > "${FAKE_CRATE_DIR}/Cargo.toml" <<'EOF'
|
||||
[package]
|
||||
name = "sgclaw"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
EOF
|
||||
|
||||
cat > "${FAKE_CRATE_DIR}/Cargo.lock" <<'EOF'
|
||||
# fake lockfile for launcher test
|
||||
EOF
|
||||
|
||||
cat > "${FAKE_CRATE_DIR}/target/debug/resources/rules.json" <<'EOF'
|
||||
{"version":"1.0","domains":{"allowed":["www.zhihu.com"]},"pipe_actions":{"allowed":["click","type","navigate","getText"],"blocked":[]}}
|
||||
EOF
|
||||
|
||||
sleep 1
|
||||
|
||||
cat > "${FAKE_CRATE_DIR}/resources/rules.json" <<'EOF'
|
||||
{"version":"1.0","domains":{"allowed":["www.zhihu.com"]},"pipe_actions":{"allowed":["click","type","navigate","getText","eval"],"blocked":[]}}
|
||||
EOF
|
||||
|
||||
cat > "${FAKE_CRATE_DIR}/target/debug/sgclaw" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
grep -q '"eval"' "${SCRIPT_DIR}/resources/rules.json"
|
||||
EOF
|
||||
chmod +x "${FAKE_CRATE_DIR}/target/debug/sgclaw"
|
||||
|
||||
"${FAKE_CRATE_DIR}/tools/browser_runtime/sgclaw_browser_entry.sh"
|
||||
|
||||
grep -q '"eval"' "${FAKE_CRATE_DIR}/target/debug/resources/rules.json"
|
||||
127
tests/browser_script_skill_tool_test.rs
Normal file
127
tests/browser_script_skill_tool_test.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::fs;
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::json;
|
||||
use sgclaw::compat::browser_script_skill_tool::BrowserScriptSkillTool;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use zeroclaw::skills::SkillTool;
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_skill_tool_executes_packaged_script_via_eval() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-skill");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
scripts_dir.join("extract_hotlist.js"),
|
||||
r#"
|
||||
const topN = Number(args.top_n || 10);
|
||||
return {
|
||||
sheet_name: "知乎热榜",
|
||||
rows: [[1, "标题", `${topN}条`]]
|
||||
};
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 5,
|
||||
},
|
||||
}]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args,
|
||||
};
|
||||
let tool = BrowserScriptSkillTool::new(
|
||||
"zhihu-hotlist",
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
browser_tool,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"top_n": "10"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||
json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval
|
||||
&& security.expected_domain == "www.zhihu.com"
|
||||
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10\"};")
|
||||
&& params["script"].as_str().unwrap().contains("return {")
|
||||
));
|
||||
}
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::pipe::{
|
||||
Action, AgentMessage, BrowserMessage, BrowserPipeTool, ExecutionSurfaceKind, Timing,
|
||||
};
|
||||
use sgclaw::security::MacPolicy;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
@@ -84,6 +86,20 @@ fn browser_tool_rejects_action_when_mac_policy_blocks_it() {
|
||||
assert!(err.to_string().contains("action is not allowed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_tool_exposes_privileged_surface_metadata_backed_by_mac_policy() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let tool = BrowserPipeTool::new(transport, test_policy(), vec![1, 2, 3, 4]);
|
||||
let metadata = tool.surface_metadata();
|
||||
|
||||
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
|
||||
assert!(metadata.privileged);
|
||||
assert!(!metadata.defines_runtime_identity);
|
||||
assert_eq!(metadata.guard, "mac_policy");
|
||||
assert_eq!(metadata.allowed_domains, vec!["oa.example.com", "erp.example.com"]);
|
||||
assert_eq!(metadata.allowed_actions, vec!["click", "type", "navigate", "getText"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_rules_allow_zhihu_navigation() {
|
||||
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{json, Value};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use sgclaw::{
|
||||
compat::browser_tool_adapter::ZeroClawBrowserTool,
|
||||
pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing},
|
||||
pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, ExecutionSurfaceKind, Timing},
|
||||
};
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
@@ -51,6 +51,17 @@ fn zeroclaw_browser_tool_schema_exposes_only_supported_safe_actions() {
|
||||
assert_eq!(schema["required"], json!(["action", "expected_domain"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeroclaw_browser_tool_marks_browser_action_as_privileged_surface() {
|
||||
let (_, tool) = build_adapter(vec![]);
|
||||
let metadata = tool.surface_metadata();
|
||||
|
||||
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
|
||||
assert!(metadata.privileged);
|
||||
assert!(!metadata.defines_runtime_identity);
|
||||
assert_eq!(metadata.guard, "mac_policy");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zeroclaw_browser_tool_executes_supported_actions_and_returns_observation_payload() {
|
||||
let (transport, tool) = build_adapter(vec![
|
||||
@@ -202,6 +213,63 @@ async fn zeroclaw_browser_tool_keeps_domain_validation_in_mac_policy() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zeroclaw_browser_tool_normalizes_expected_domain_before_sending_command() {
|
||||
let (transport, tool) = build_adapter(vec![
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 11,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: json!({ "clicked": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 2,
|
||||
exec_ms: 12,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let navigate = tool
|
||||
.execute(json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "https://www.baidu.com/s?wd=天气",
|
||||
"url": "https://www.baidu.com/s?wd=天气"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let click = tool
|
||||
.execute(json!({
|
||||
"action": "click",
|
||||
"expected_domain": "https://www.baidu.com/s?wd=天气",
|
||||
"selector": "#su"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(navigate.success);
|
||||
assert!(click.success);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command { security, .. }
|
||||
if security.expected_domain == "www.baidu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::Command { security, .. }
|
||||
if security.expected_domain == "www.baidu.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zeroclaw_browser_tool_rejects_missing_required_action_parameters() {
|
||||
let (transport, tool) = build_adapter(vec![]);
|
||||
|
||||
@@ -4,10 +4,21 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use sgclaw::compat::config_adapter::{
|
||||
build_zeroclaw_config,
|
||||
build_zeroclaw_config_from_sgclaw_settings,
|
||||
build_zeroclaw_config_from_settings,
|
||||
resolve_skills_dir,
|
||||
zeroclaw_default_skills_dir,
|
||||
zeroclaw_workspace_dir,
|
||||
};
|
||||
use sgclaw::config::DeepSeekSettings;
|
||||
use sgclaw::config::{
|
||||
BrowserBackend,
|
||||
DeepSeekSettings,
|
||||
OfficeBackend,
|
||||
PlannerMode,
|
||||
SgClawSettings,
|
||||
SkillsPromptMode,
|
||||
};
|
||||
use sgclaw::runtime::RuntimeProfile;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
@@ -44,6 +55,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://proxy.example.com/v1".to_string(),
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
skills_dir: None,
|
||||
};
|
||||
|
||||
let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw"));
|
||||
@@ -54,6 +66,10 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
||||
assert_eq!(config.default_provider.as_deref(), Some("deepseek"));
|
||||
assert_eq!(config.default_model.as_deref(), Some("deepseek-reasoner"));
|
||||
assert_eq!(config.api_url.as_deref(), Some("https://proxy.example.com/v1"));
|
||||
assert_eq!(
|
||||
resolve_skills_dir(Path::new("/var/lib/sgclaw"), &settings),
|
||||
zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -78,13 +94,15 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
|
||||
assert_eq!(first.api_key, "sk-first");
|
||||
assert_eq!(first.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(first.model, "deepseek-chat");
|
||||
assert_eq!(first.skills_dir, None);
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-second",
|
||||
"baseUrl": "https://proxy.example.com/v1",
|
||||
"model": "deepseek-reasoner"
|
||||
"model": "deepseek-reasoner",
|
||||
"skillsDir": "skill_lib"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -95,4 +113,184 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
|
||||
assert_eq!(second.api_key, "sk-second");
|
||||
assert_eq!(second.base_url, "https://proxy.example.com/v1");
|
||||
assert_eq!(second.model, "deepseek-reasoner");
|
||||
assert_eq!(second.skills_dir, Some(root.join("skill_lib")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skills_dir_prefers_nested_skills_subdirectory_for_configured_repo_root() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(root.join("skill_lib/skills")).unwrap();
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Some(root.join("skill_lib")),
|
||||
};
|
||||
|
||||
let resolved = resolve_skills_dir(&root, &settings);
|
||||
|
||||
assert_eq!(resolved, root.join("skill_lib/skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skills_dir_preserves_absolute_configured_skills_directory() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", Uuid::new_v4()));
|
||||
let external_skills = root.join("external-skill-lib/skills");
|
||||
fs::create_dir_all(&external_skills).unwrap();
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Some(external_skills.clone()),
|
||||
};
|
||||
|
||||
let resolved = resolve_skills_dir(&root, &settings);
|
||||
|
||||
assert_eq!(resolved, external_skills);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
|
||||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.runtime_profile, RuntimeProfile::BrowserAttached);
|
||||
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-runtime-config-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "skill_lib",
|
||||
"runtimeProfile": "generalAssistant",
|
||||
"skillsPromptMode": "full"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(&root, &settings);
|
||||
|
||||
assert_eq!(settings.runtime_profile, RuntimeProfile::GeneralAssistant);
|
||||
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Full);
|
||||
assert_eq!(settings.skills_dir, Some(root.join("skill_lib")));
|
||||
assert_eq!(config.skills.prompt_injection_mode, SkillsPromptMode::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_config_uses_low_temperature_for_deterministic_execution() {
|
||||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(Path::new("/tmp/sgclaw"), &settings);
|
||||
|
||||
assert_eq!(config.default_temperature, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_provider_switching_and_backend_policy_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-provider-config-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-legacy",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"plannerMode": "zeroclawPlanFirst",
|
||||
"activeProvider": "glm-prod",
|
||||
"browserBackend": "superrpa",
|
||||
"officeBackend": "openxml",
|
||||
"providers": [
|
||||
{
|
||||
"id": "deepseek-default",
|
||||
"provider": "deepseek",
|
||||
"apiKey": "sk-deepseek",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat"
|
||||
},
|
||||
{
|
||||
"id": "glm-prod",
|
||||
"provider": "glm",
|
||||
"apiKey": "sk-glm",
|
||||
"baseUrl": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"model": "glm-4.5"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(&root, &settings);
|
||||
|
||||
assert_eq!(settings.planner_mode, PlannerMode::ZeroclawPlanFirst);
|
||||
assert_eq!(settings.active_provider, "glm-prod");
|
||||
assert_eq!(settings.providers.len(), 2);
|
||||
assert_eq!(settings.provider_base_url, "https://open.bigmodel.cn/api/paas/v4");
|
||||
assert_eq!(settings.provider_model, "glm-4.5");
|
||||
assert_eq!(settings.browser_backend, BrowserBackend::SuperRpa);
|
||||
assert_eq!(settings.office_backend, OfficeBackend::OpenXml);
|
||||
assert_eq!(config.default_provider.as_deref(), Some("glm"));
|
||||
assert_eq!(config.default_model.as_deref(), Some("glm-4.5"));
|
||||
assert_eq!(config.api_key.as_deref(), Some("sk-glm"));
|
||||
assert_eq!(
|
||||
config.api_url.as_deref(),
|
||||
Some("https://open.bigmodel.cn/api/paas/v4")
|
||||
);
|
||||
assert!(!config.browser.enabled);
|
||||
assert!(config.model_providers.contains_key("deepseek-default"));
|
||||
assert!(config.model_providers.contains_key("glm-prod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_enable_non_host_browser_backend_when_requested() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-browser-backend-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"browserBackend": "rustNative"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(&root, &settings);
|
||||
|
||||
assert_eq!(settings.browser_backend, BrowserBackend::RustNative);
|
||||
assert!(config.browser.enabled);
|
||||
assert_eq!(config.browser.backend, "rust_native");
|
||||
}
|
||||
|
||||
129
tests/compat_openxml_office_tool_test.rs
Normal file
129
tests/compat_openxml_office_tool_test.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
use serde_json::json;
|
||||
use sgclaw::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use uuid::Uuid;
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-openxml-office-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("out/zhihu-hotlist.xlsx");
|
||||
let tool = OpenXmlOfficeTool::new(workspace_root.clone());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"]
|
||||
],
|
||||
"output_path": output_path
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
assert!(result.output.contains(output_path.to_str().unwrap()));
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains("问题二"));
|
||||
assert!(!xml.contains("{{TITLE_1}}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openxml_office_tool_accepts_reordered_columns_when_rows_are_structured() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("out/zhihu-hotlist-reordered.xlsx");
|
||||
let tool = OpenXmlOfficeTool::new(workspace_root.clone());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["title", "heat", "rank"],
|
||||
"rows": [
|
||||
["问题一", "344万", 1],
|
||||
["问题二", "266万", 2]
|
||||
],
|
||||
"output_path": output_path
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains(">1<"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openxml_office_tool_accepts_localized_hotlist_column_aliases() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("out/zhihu-hotlist-localized.xlsx");
|
||||
let tool = OpenXmlOfficeTool::new(workspace_root.clone());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["排名", "标题", "热度"],
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"]
|
||||
],
|
||||
"output_path": output_path
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains(">1<"));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
50
tests/compat_screen_html_export_tool_test.rs
Normal file
50
tests/compat_screen_html_export_tool_test.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
use uuid::Uuid;
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-screen-html-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn screen_html_export_tool_renders_dashboard_html_with_presentation_contract() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("out/zhihu-hotlist-screen.html");
|
||||
let tool = ScreenHtmlExportTool::new(workspace_root.clone());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"snapshot_id": "snapshot-20260329",
|
||||
"generated_at_ms": 1774713600000u64,
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"]
|
||||
],
|
||||
"output_path": output_path
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output).unwrap();
|
||||
let html = std::fs::read_to_string(&output_path).unwrap();
|
||||
|
||||
assert_eq!(payload["output_path"], json!(output_path));
|
||||
assert_eq!(payload["presentation"]["mode"], json!("new_tab"));
|
||||
assert_eq!(payload["renderer"], json!("screen_html_export"));
|
||||
assert!(payload["presentation"]["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("file://"));
|
||||
assert!(html.contains("snapshot-20260329"));
|
||||
assert!(html.contains("问题一"));
|
||||
assert!(html.contains("344万"));
|
||||
assert!(html.contains("const defaultPayload ="));
|
||||
}
|
||||
102
tests/live_acceptance_score_test.py
Normal file
102
tests/live_acceptance_score_test.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import unittest
|
||||
|
||||
from tools.live_acceptance.run_zhihu_hotlist_excel_acceptance import HotItem, score_acceptance
|
||||
|
||||
|
||||
class LiveAcceptanceScoreTest(unittest.TestCase):
|
||||
def test_score_acceptance_handles_preloaded_office_skill_without_read_skill_log(self):
|
||||
result = {
|
||||
"logs": [
|
||||
{"message": "plan 读取知乎热榜并导出 Excel"},
|
||||
{"message": "navigate https://www.zhihu.com/hot"},
|
||||
{"message": "getText body"},
|
||||
{"message": "call openxml_office"},
|
||||
],
|
||||
"final_task": {
|
||||
"success": True,
|
||||
"summary": "已导出 Excel /tmp/sgclaw/out.xlsx",
|
||||
},
|
||||
"stderr": [],
|
||||
"exports": [],
|
||||
}
|
||||
items = [HotItem(rank=1, title="标题", heat="123万")]
|
||||
|
||||
score = score_acceptance(result, items)
|
||||
|
||||
self.assertEqual(score["skill_selection"], 30)
|
||||
self.assertEqual(score["final_response_quality"], 5)
|
||||
self.assertNotIn("planner output missing before tool execution", score["deductions"])
|
||||
|
||||
def test_score_acceptance_flags_missing_plan_repeated_summary_and_fake_export_path(self):
|
||||
repeated = "第一段总结。\n\n第一段总结。"
|
||||
result = {
|
||||
"logs": [
|
||||
{"message": "navigate https://www.zhihu.com/hot"},
|
||||
{"message": "getText main"},
|
||||
{"message": "call openxml_office"},
|
||||
],
|
||||
"final_task": {
|
||||
"success": True,
|
||||
"summary": f"{repeated}\n\n导出路径:/tmp/not-real.xlsx",
|
||||
},
|
||||
"stderr": [],
|
||||
"exports": [],
|
||||
}
|
||||
items = [HotItem(rank=1, title="标题", heat="123万")]
|
||||
|
||||
score = score_acceptance(result, items)
|
||||
|
||||
self.assertIn("planner output missing before tool execution", score["deductions"])
|
||||
self.assertIn("repeated assistant paragraphs detected", score["deductions"])
|
||||
self.assertIn("export missing output path", score["deductions"])
|
||||
self.assertEqual(score["final_response_quality"], 0)
|
||||
|
||||
def test_score_acceptance_flags_fake_rows_when_export_contains_no_live_hotlist_data(self):
|
||||
result = {
|
||||
"logs": [
|
||||
{"message": "plan 读取知乎热榜并导出 Excel"},
|
||||
{"message": "navigate https://www.zhihu.com/hot"},
|
||||
{"message": "getText main"},
|
||||
{"message": "call openxml_office"},
|
||||
],
|
||||
"final_task": {
|
||||
"success": True,
|
||||
"summary": "已导出 Excel /tmp/sgclaw/out.xlsx",
|
||||
},
|
||||
"stderr": [],
|
||||
"exports": [],
|
||||
}
|
||||
items = [HotItem(rank=1, title="真实标题", heat="123万")]
|
||||
|
||||
score = score_acceptance(result, items)
|
||||
|
||||
self.assertIn("hotlist rows were not exported as structured live data", score["deductions"])
|
||||
self.assertEqual(score["hotlist_data_correctness"], 0)
|
||||
self.assertEqual(score["xlsx_export_success"], 0)
|
||||
|
||||
def test_score_acceptance_flags_structured_handoff_retry_noise(self):
|
||||
result = {
|
||||
"logs": [
|
||||
{"message": "plan 读取知乎热榜并导出 Excel"},
|
||||
{"message": "navigate https://www.zhihu.com/hot"},
|
||||
{"message": "getText main"},
|
||||
{"message": "call openxml_office"},
|
||||
{"message": "unsupported columns: expected [rank, title, heat]"},
|
||||
{"message": "call openxml_office"},
|
||||
],
|
||||
"final_task": {
|
||||
"success": True,
|
||||
"summary": "已导出 Excel /tmp/sgclaw/out.xlsx",
|
||||
},
|
||||
"stderr": [],
|
||||
"exports": [],
|
||||
}
|
||||
items = [HotItem(rank=1, title="真实标题", heat="123万")]
|
||||
|
||||
score = score_acceptance(result, items)
|
||||
|
||||
self.assertIn("structured handoff required export retries", score["deductions"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,7 +3,7 @@ mod common;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::pipe::{perform_handshake, AgentMessage, BrowserMessage};
|
||||
use sgclaw::pipe::{perform_handshake, AgentMessage, BrowserMessage, ExecutionSurfaceKind};
|
||||
|
||||
#[test]
|
||||
fn handshake_reads_init_and_writes_init_ack() {
|
||||
@@ -24,7 +24,10 @@ fn handshake_reads_init_and_writes_init_ack() {
|
||||
version,
|
||||
agent_id,
|
||||
supported_actions
|
||||
} if version == "1.0" && !agent_id.is_empty() && supported_actions.len() >= 4
|
||||
} if version == "1.0" &&
|
||||
!agent_id.is_empty() &&
|
||||
supported_actions.iter().any(|action| action == &sgclaw::pipe::Action::Click) &&
|
||||
supported_actions.iter().any(|action| action.as_str() == "eval")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -39,3 +42,21 @@ fn handshake_rejects_version_mismatch() {
|
||||
let err = perform_handshake(&transport, Duration::from_secs(5)).unwrap_err();
|
||||
assert!(err.to_string().contains("unsupported protocol version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_capabilities_report_browser_surface_without_redefining_runtime() {
|
||||
let transport = MockTransport::new(vec![BrowserMessage::Init {
|
||||
version: "1.0".to_string(),
|
||||
hmac_seed: "0123456789abcdef".to_string(),
|
||||
capabilities: vec!["browser_action".to_string()],
|
||||
}]);
|
||||
|
||||
let result = perform_handshake(&transport, Duration::from_secs(5)).unwrap();
|
||||
let metadata = result
|
||||
.browser_surface_metadata()
|
||||
.expect("expected browser surface metadata");
|
||||
|
||||
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
|
||||
assert!(metadata.privileged);
|
||||
assert!(!metadata.defines_runtime_identity);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, SecurityFields, Timing};
|
||||
use sgclaw::pipe::{
|
||||
Action, AgentMessage, BrowserMessage, ExecutionSurfaceKind, SecurityFields, Timing,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn browser_init_round_trip_uses_frozen_wire_format() {
|
||||
@@ -57,3 +59,32 @@ fn response_deserializes_timing_and_payload() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_exposes_browser_context_without_implying_browser_only_runtime() {
|
||||
let message = BrowserMessage::SubmitTask {
|
||||
instruction: "统计一下知乎热榜".to_string(),
|
||||
conversation_id: "conversation-1".to_string(),
|
||||
messages: vec![],
|
||||
page_url: "https://www.zhihu.com/hot".to_string(),
|
||||
page_title: "知乎热榜".to_string(),
|
||||
};
|
||||
|
||||
let context = message.browser_context().expect("browser context");
|
||||
let surface = message
|
||||
.requested_surface_metadata()
|
||||
.expect("surface metadata");
|
||||
|
||||
assert_eq!(context.page_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(context.page_title, "知乎热榜");
|
||||
assert_eq!(surface.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
|
||||
assert!(surface.privileged);
|
||||
assert!(!surface.defines_runtime_identity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_actions_include_browser_script_execution() {
|
||||
let supported = sgclaw::pipe::supported_actions();
|
||||
|
||||
assert!(supported.iter().any(|action| action.as_str() == "eval"));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use serde_json::json;
|
||||
use sgclaw::agent::planner::{plan_instruction, PlannerError};
|
||||
use sgclaw::agent::planner::{build_execution_preview, plan_instruction, PlannerError};
|
||||
use sgclaw::config::PlannerMode;
|
||||
use sgclaw::pipe::Action;
|
||||
|
||||
#[test]
|
||||
fn planner_module_is_explicitly_legacy_dev_only() {
|
||||
assert!(sgclaw::agent::planner::LEGACY_DEV_ONLY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_converts_baidu_search_instruction_into_three_steps() {
|
||||
let plan = plan_instruction("打开百度搜索天气").unwrap();
|
||||
@@ -48,6 +54,36 @@ fn planner_supports_zhihu_search_instruction_with_direct_search_url() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_supports_open_zhihu_homepage_instruction() {
|
||||
let plan = plan_instruction("打开知乎").unwrap();
|
||||
|
||||
assert_eq!(plan.summary, "已打开知乎首页");
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.steps[0].action, Action::Navigate);
|
||||
assert_eq!(
|
||||
plan.steps[0].params,
|
||||
json!({ "url": "https://www.zhihu.com" })
|
||||
);
|
||||
assert_eq!(plan.steps[0].expected_domain, "www.zhihu.com");
|
||||
assert_eq!(plan.steps[0].log_message, "navigate https://www.zhihu.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_supports_open_baidu_homepage_instruction() {
|
||||
let plan = plan_instruction("打开百度").unwrap();
|
||||
|
||||
assert_eq!(plan.summary, "已打开百度首页");
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.steps[0].action, Action::Navigate);
|
||||
assert_eq!(
|
||||
plan.steps[0].params,
|
||||
json!({ "url": "https://www.baidu.com" })
|
||||
);
|
||||
assert_eq!(plan.steps[0].expected_domain, "www.baidu.com");
|
||||
assert_eq!(plan.steps[0].log_message, "navigate https://www.baidu.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_rejects_unrelated_instruction() {
|
||||
let err = plan_instruction("打开谷歌搜索天气").unwrap_err();
|
||||
@@ -57,3 +93,37 @@ fn planner_rejects_unrelated_instruction() {
|
||||
PlannerError::UnsupportedInstruction("打开谷歌搜索天气".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_first_mode_builds_visible_preview_for_zhihu_excel_flow() {
|
||||
let preview = build_execution_preview(
|
||||
PlannerMode::ZeroclawPlanFirst,
|
||||
"读取知乎热榜数据,并导出 excel 文件",
|
||||
Some("https://www.zhihu.com/hot"),
|
||||
Some("知乎热榜"),
|
||||
)
|
||||
.expect("expected plan preview");
|
||||
|
||||
assert_eq!(preview.summary, "先规划再执行知乎热榜 Excel 导出");
|
||||
assert!(preview
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.contains("navigate https://www.zhihu.com/hot")));
|
||||
assert!(preview.steps.iter().any(|step| step.contains("getText main")));
|
||||
assert!(preview
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.contains("call openxml_office")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_planner_mode_skips_runtime_preview() {
|
||||
let preview = build_execution_preview(
|
||||
PlannerMode::LegacyDeterministic,
|
||||
"打开百度搜索天气",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(preview.is_none());
|
||||
}
|
||||
|
||||
80
tests/read_skill_tool_test.rs
Normal file
80
tests/read_skill_tool_test.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
use zeroclaw::tools::{ReadSkillTool, Tool};
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_inlines_referenced_markdown_files() {
|
||||
let workspace_dir = temp_workspace_dir();
|
||||
let skill_dir = workspace_dir.join("skills/zhihu-hotlist");
|
||||
let refs_dir = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
concat!(
|
||||
"# Zhihu Hotlist\n\n",
|
||||
"Follow [collection-flow.md](references/collection-flow.md).\n",
|
||||
"Apply [data-quality.md](references/data-quality.md).\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
refs_dir.join("collection-flow.md"),
|
||||
"# Collection Flow\n\nCollect rows from the hotlist first.\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
refs_dir.join("data-quality.md"),
|
||||
"# Data Quality\n\nMark partial metrics explicitly.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tool = ReadSkillTool::new(workspace_dir, false, None);
|
||||
let result = tool.execute(json!({ "name": "zhihu-hotlist" })).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("# Zhihu Hotlist"));
|
||||
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
|
||||
assert!(result.output.contains("Collect rows from the hotlist first."));
|
||||
assert!(result.output.contains("## Referenced File: references/data-quality.md"));
|
||||
assert!(result.output.contains("Mark partial metrics explicitly."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_recursively_inlines_relative_asset_references() {
|
||||
let workspace_dir = temp_workspace_dir();
|
||||
let skill_dir = workspace_dir.join("skills/zhihu-hotlist");
|
||||
let refs_dir = skill_dir.join("references");
|
||||
let assets_dir = skill_dir.join("assets");
|
||||
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||
std::fs::create_dir_all(&assets_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"# Zhihu Hotlist\n\nFollow [collection-flow.md](references/collection-flow.md).\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
refs_dir.join("collection-flow.md"),
|
||||
"Use `assets/zhihu_hotlist_flow.source.json` for exact selectors.\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
assets_dir.join("zhihu_hotlist_flow.source.json"),
|
||||
"{\n \"selectors\": [\".HotList-list\", \".HotItem\"]\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tool = ReadSkillTool::new(workspace_dir, false, None);
|
||||
let result = tool.execute(json!({ "name": "zhihu-hotlist" })).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
|
||||
assert!(result.output.contains("## Referenced File: assets/zhihu_hotlist_flow.source.json"));
|
||||
assert!(result.output.contains("\"selectors\": [\".HotList-list\", \".HotItem\"]"));
|
||||
}
|
||||
|
||||
fn temp_workspace_dir() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sgclaw-read-skill-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
55
tests/runtime_profile_test.rs
Normal file
55
tests/runtime_profile_test.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use sgclaw::runtime::{RuntimeEngine, RuntimeProfile, ToolPolicy};
|
||||
use sgclaw::config::{BrowserBackend, OfficeBackend, PlannerMode, SgClawSettings};
|
||||
|
||||
#[test]
|
||||
fn browser_attached_profile_exposes_browser_surface_without_becoming_browser_only() {
|
||||
let profile = RuntimeProfile::BrowserAttached;
|
||||
let policy = ToolPolicy::for_profile(profile);
|
||||
|
||||
assert!(policy.allowed_tools.contains(&"browser_action".to_string()));
|
||||
assert!(policy
|
||||
.allowed_tools
|
||||
.contains(&"superrpa_browser".to_string()));
|
||||
assert!(policy.may_use_non_browser_tools);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_assistant_profile_does_not_require_browser_surface() {
|
||||
let profile = RuntimeProfile::GeneralAssistant;
|
||||
let policy = ToolPolicy::for_profile(profile);
|
||||
|
||||
assert!(!policy.requires_browser_surface);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_export_prompt_requires_openxml_completion() {
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached);
|
||||
|
||||
let instruction = engine.build_instruction(
|
||||
"读取知乎热榜数据,并导出 excel 文件",
|
||||
Some("https://www.zhihu.com/hot"),
|
||||
Some("知乎热榜"),
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(instruction.contains("must call openxml_office"));
|
||||
assert!(instruction.contains("Do not stop after describing how you will parse"));
|
||||
assert!(instruction.contains("Never fabricate, simulate, or invent substitute hotlist data"));
|
||||
assert!(instruction.contains("Do not repeat the same sentence or section"));
|
||||
assert!(instruction.contains("final answer must include the generated local .xlsx path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_default_to_plan_first_superrpa_and_openxml_backends() {
|
||||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.planner_mode, PlannerMode::ZeroclawPlanFirst);
|
||||
assert_eq!(settings.browser_backend, BrowserBackend::SuperRpa);
|
||||
assert_eq!(settings.office_backend, OfficeBackend::OpenXml);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::agent::handle_browser_message;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool};
|
||||
use sgclaw::security::MacPolicy;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
@@ -23,39 +23,8 @@ fn test_policy() -> MacPolicy {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_sends_three_commands_and_finishes_with_task_complete() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 20,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: serde_json::json!({ "typed": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 20,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 3,
|
||||
success: true,
|
||||
data: serde_json::json!({ "clicked": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 20,
|
||||
},
|
||||
},
|
||||
]));
|
||||
fn submit_task_without_llm_configuration_returns_clear_error() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
@@ -78,45 +47,15 @@ fn submit_task_sends_three_commands_and_finishes_with_task_complete() {
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(sent.len(), 8);
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "deterministic_planner"
|
||||
if level == "info" && message == "sgclaw runtime version=0.1.0 protocol=1.0"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.baidu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 1 && action == &Action::Navigate
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "type 天气 into #kw"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[4],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 2 && action == &Action::Type
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[5],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "click #su"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[6],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 3 && action == &Action::Click
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[7],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "已在百度搜索天气"
|
||||
if !success && summary.contains("未配置大语言模型")
|
||||
));
|
||||
}
|
||||
|
||||
126
tests/skill_lib_validation_test.py
Normal file
126
tests/skill_lib_validation_test.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILL_LIB_ROOT = REPO_ROOT.parent / "skill_lib"
|
||||
SKILLS_DIR = SKILL_LIB_ROOT / "skills"
|
||||
VALIDATOR_PATH = REPO_ROOT / "scripts" / "validate_skill_lib.py"
|
||||
EXPECTED_SKILL_NAMES = [
|
||||
"office-export-xlsx",
|
||||
"zhihu-hotlist",
|
||||
"zhihu-hotlist-screen",
|
||||
"zhihu-navigate",
|
||||
"zhihu-write",
|
||||
]
|
||||
|
||||
|
||||
def load_validator_module():
|
||||
spec = importlib.util.spec_from_file_location("validate_skill_lib", VALIDATOR_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SkillLibValidationTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.validator = load_validator_module()
|
||||
|
||||
def test_discovers_expected_skill_packages(self):
|
||||
skill_dirs = self.validator.discover_skill_dirs()
|
||||
self.assertEqual([path.name for path in skill_dirs], EXPECTED_SKILL_NAMES)
|
||||
|
||||
def test_load_skill_matches_current_metadata(self):
|
||||
loaded = {}
|
||||
for skill_dir in self.validator.discover_skill_dirs():
|
||||
record = self.validator.load_skill(skill_dir)
|
||||
loaded[record.name] = record
|
||||
|
||||
self.assertEqual(sorted(loaded), EXPECTED_SKILL_NAMES)
|
||||
|
||||
for name, record in loaded.items():
|
||||
self.assertEqual(record.name, name)
|
||||
self.assertEqual(record.version, "0.1.0")
|
||||
self.assertEqual(record.author, "sgclaw")
|
||||
self.assertTrue(record.description.startswith("Use when"))
|
||||
if name.startswith("zhihu-"):
|
||||
self.assertIn("zhihu", record.tags)
|
||||
self.assertIn("browser", record.tags)
|
||||
if name == "office-export-xlsx":
|
||||
self.assertIn("office", record.tags)
|
||||
self.assertIn("xlsx", record.tags)
|
||||
expected_location = (
|
||||
SKILLS_DIR / name / "SKILL.toml"
|
||||
if name == "zhihu-hotlist"
|
||||
else SKILLS_DIR / name / "SKILL.md"
|
||||
)
|
||||
self.assertEqual(record.location, expected_location)
|
||||
self.assertTrue(record.prompt_body.lstrip().startswith("# "))
|
||||
self.assertNotIn("\n---\n", record.prompt_body)
|
||||
|
||||
def test_each_skill_passes_audit_with_current_script_policy(self):
|
||||
for skill_dir in self.validator.discover_skill_dirs():
|
||||
report = self.validator.audit_skill_directory(skill_dir, allow_scripts=True)
|
||||
self.assertEqual(
|
||||
report.findings,
|
||||
[],
|
||||
f"{skill_dir.name} findings: {report.findings}",
|
||||
)
|
||||
|
||||
def test_current_packages_keep_required_structure(self):
|
||||
for name in EXPECTED_SKILL_NAMES:
|
||||
skill_dir = SKILLS_DIR / name
|
||||
self.assertTrue(
|
||||
(skill_dir / "SKILL.md").is_file() or (skill_dir / "SKILL.toml").is_file()
|
||||
)
|
||||
self.assertTrue((skill_dir / "references").is_dir())
|
||||
self.assertTrue((skill_dir / "assets").is_dir())
|
||||
self.assertTrue((SKILLS_DIR / "zhihu-hotlist" / "SKILL.toml").is_file())
|
||||
self.assertTrue(
|
||||
(SKILLS_DIR / "zhihu-hotlist" / "scripts" / "extract_hotlist.js").is_file()
|
||||
)
|
||||
|
||||
def test_each_skill_declares_superrpa_browser_contract(self):
|
||||
for name in [name for name in EXPECTED_SKILL_NAMES if name.startswith("zhihu-")]:
|
||||
content = (SKILLS_DIR / name / "SKILL.md").read_text(encoding="utf-8")
|
||||
self.assertIn("superrpa_browser", content)
|
||||
self.assertIn("expected_domain", content)
|
||||
self.assertIn("CSS", content)
|
||||
|
||||
def test_zhihu_hotlist_declares_export_artifact_contract(self):
|
||||
content = (SKILLS_DIR / "zhihu-hotlist" / "SKILL.md").read_text(encoding="utf-8")
|
||||
self.assertIn("Export Artifact", content)
|
||||
self.assertIn('"sheet_name": "知乎热榜"', content)
|
||||
self.assertIn('"columns": ["rank", "title", "heat"]', content)
|
||||
self.assertIn('"rows": [[1, "标题", "344万"]]', content)
|
||||
self.assertIn("structured artifact is primary", content)
|
||||
|
||||
def test_office_export_skill_declares_openxml_contract(self):
|
||||
content = (SKILLS_DIR / "office-export-xlsx" / "SKILL.md").read_text(encoding="utf-8")
|
||||
self.assertIn("openxml_office", content)
|
||||
self.assertIn(".xlsx", content)
|
||||
self.assertIn("sheet_name", content)
|
||||
self.assertIn("columns", content)
|
||||
self.assertIn("rows", content)
|
||||
|
||||
def test_hotlist_screen_skill_declares_echarts_html_contract(self):
|
||||
content = (SKILLS_DIR / "zhihu-hotlist-screen" / "SKILL.md").read_text(encoding="utf-8")
|
||||
self.assertIn("screen_html_export", content)
|
||||
self.assertIn(".html", content)
|
||||
self.assertIn("ECharts", content)
|
||||
self.assertIn("大屏", content)
|
||||
self.assertIn("新标签页", content)
|
||||
self.assertIn("presentation", content)
|
||||
|
||||
def test_validate_all_skills_reports_pass(self):
|
||||
results = self.validator.validate_all_skills(allow_scripts=True)
|
||||
self.assertEqual([result.record.name for result in results], EXPECTED_SKILL_NAMES)
|
||||
self.assertTrue(all(result.ok for result in results))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
127
tests/skill_script_hotlist_extractor_test.py
Normal file
127
tests/skill_script_hotlist_extractor_test.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXTRACTOR_PATH = (
|
||||
REPO_ROOT.parent / "skill_lib" / "skills" / "zhihu-hotlist" / "scripts" /
|
||||
"extract_hotlist.js"
|
||||
)
|
||||
|
||||
|
||||
def run_extractor(*, body_text: str, selectors: dict[str, list[dict]] | None = None) -> dict:
|
||||
selector_map = selectors or {}
|
||||
node_script = textwrap.dedent(
|
||||
f"""
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const extractorPath = {json.dumps(str(EXTRACTOR_PATH))};
|
||||
const selectorMap = {json.dumps(selector_map, ensure_ascii=False)};
|
||||
const bodyText = {json.dumps(body_text, ensure_ascii=False)};
|
||||
const source = fs.readFileSync(extractorPath, 'utf8');
|
||||
|
||||
function createNode(spec) {{
|
||||
const text = String(spec?.text ?? '');
|
||||
const children = spec?.selectors ?? {{}};
|
||||
return {{
|
||||
textContent: text,
|
||||
innerText: text,
|
||||
querySelector(selector) {{
|
||||
const value = children[selector];
|
||||
if (!value) {{
|
||||
return null;
|
||||
}}
|
||||
return createNode(value);
|
||||
}},
|
||||
}};
|
||||
}}
|
||||
|
||||
const bodyNode = createNode({{text: bodyText}});
|
||||
const context = {{
|
||||
args: {{top_n: '10'}},
|
||||
location: {{origin: 'https://www.zhihu.com', pathname: '/hot'}},
|
||||
document: {{
|
||||
body: bodyNode,
|
||||
querySelector(selector) {{
|
||||
if (selector === 'body' || selector === '#root' || selector === 'main') {{
|
||||
return bodyNode;
|
||||
}}
|
||||
return null;
|
||||
}},
|
||||
querySelectorAll(selector) {{
|
||||
return (selectorMap[selector] || []).map((item) => createNode(item));
|
||||
}},
|
||||
}},
|
||||
console,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
Object,
|
||||
RegExp,
|
||||
Set,
|
||||
String,
|
||||
Array,
|
||||
Error,
|
||||
}};
|
||||
|
||||
try {{
|
||||
const result = vm.runInNewContext(`(function(){{\\n${{source}}\\n}})()`, context);
|
||||
process.stdout.write(JSON.stringify({{ok: true, result}}));
|
||||
}} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
ok: false,
|
||||
error: String(error && error.message ? error.message : error),
|
||||
}}));
|
||||
process.exitCode = 1;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
completed = subprocess.run(
|
||||
["node", "--input-type=module", "-e", node_script],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
payload = json.loads(completed.stdout)
|
||||
if completed.returncode != 0:
|
||||
raise AssertionError(payload["error"])
|
||||
return payload["result"]
|
||||
|
||||
|
||||
class SkillScriptHotlistExtractorTest(unittest.TestCase):
|
||||
def test_extracts_hotlist_from_page_text_when_legacy_dom_classes_are_missing(self):
|
||||
result = run_extractor(
|
||||
body_text=textwrap.dedent(
|
||||
"""
|
||||
知乎热榜
|
||||
1
|
||||
如何看待张雪机车在 2026 年 WSBK 葡萄牙站夺冠?
|
||||
1707 万热度
|
||||
2
|
||||
李荣浩摆证据 4 连质问单依纯
|
||||
1150 万热度
|
||||
3
|
||||
日本拟动用外储做空国际原油
|
||||
601 万热度
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(result["sheet_name"], "知乎热榜")
|
||||
self.assertEqual(result["columns"], ["rank", "title", "heat"])
|
||||
self.assertEqual(
|
||||
result["rows"][:3],
|
||||
[
|
||||
[1, "如何看待张雪机车在 2026 年 WSBK 葡萄牙站夺冠?", "1707万"],
|
||||
[2, "李荣浩摆证据 4 连质问单依纯", "1150万"],
|
||||
[3, "日本拟动用外储做空国际原油", "601万"],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
273
third_party/zeroclaw/src/agent/agent.rs
vendored
273
third_party/zeroclaw/src/agent/agent.rs
vendored
@@ -774,7 +774,7 @@ impl Agent {
|
||||
});
|
||||
}
|
||||
|
||||
let response = match self
|
||||
let mut response = match self
|
||||
.provider
|
||||
.chat(
|
||||
ChatRequest {
|
||||
@@ -795,12 +795,15 @@ impl Agent {
|
||||
};
|
||||
|
||||
let (text, calls) = self.tool_dispatcher.parse_response(&response);
|
||||
let calls = canonicalize_parsed_tool_calls(&self.tools, calls);
|
||||
response.tool_calls = canonicalize_provider_tool_calls(&self.tools, response.tool_calls);
|
||||
if calls.is_empty() {
|
||||
let final_text = if text.is_empty() {
|
||||
response.text.unwrap_or_default()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let final_text = sanitize_final_text(&final_text);
|
||||
|
||||
// Store in response cache (text-only, no tool calls)
|
||||
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
|
||||
@@ -1029,7 +1032,7 @@ impl Agent {
|
||||
|
||||
// If streaming produced text, use it as the response and
|
||||
// check for tool calls via the dispatcher.
|
||||
let response = if got_stream {
|
||||
let mut response = if got_stream {
|
||||
// Build a synthetic ChatResponse from streamed text
|
||||
crate::providers::ChatResponse {
|
||||
text: Some(streamed_text),
|
||||
@@ -1061,12 +1064,15 @@ impl Agent {
|
||||
};
|
||||
|
||||
let (text, calls) = self.tool_dispatcher.parse_response(&response);
|
||||
let calls = canonicalize_parsed_tool_calls(&self.tools, calls);
|
||||
response.tool_calls = canonicalize_provider_tool_calls(&self.tools, response.tool_calls);
|
||||
if calls.is_empty() {
|
||||
let final_text = if text.is_empty() {
|
||||
response.text.unwrap_or_default()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let final_text = sanitize_final_text(&final_text);
|
||||
|
||||
// Store in response cache
|
||||
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
|
||||
@@ -1175,6 +1181,67 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_final_text(text: &str) -> String {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut last_normalized = String::new();
|
||||
|
||||
for block in trimmed.split("\n\n") {
|
||||
let candidate = block.trim();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized = candidate.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !last_normalized.is_empty() && normalized == last_normalized {
|
||||
continue;
|
||||
}
|
||||
result.push(candidate.to_string());
|
||||
last_normalized = normalized;
|
||||
}
|
||||
|
||||
result.join("\n\n")
|
||||
}
|
||||
|
||||
fn resolve_registered_tool_name(tools: &[Box<dyn Tool>], raw: &str) -> Option<String> {
|
||||
tools.iter()
|
||||
.find(|tool| {
|
||||
tool.name() == raw || crate::tools::provider_safe_tool_name(tool.name()) == raw
|
||||
})
|
||||
.map(|tool| tool.name().to_string())
|
||||
}
|
||||
|
||||
fn canonicalize_parsed_tool_calls(
|
||||
tools: &[Box<dyn Tool>],
|
||||
calls: Vec<ParsedToolCall>,
|
||||
) -> Vec<ParsedToolCall> {
|
||||
calls.into_iter()
|
||||
.map(|mut call| {
|
||||
if let Some(canonical_name) = resolve_registered_tool_name(tools, &call.name) {
|
||||
call.name = canonical_name;
|
||||
}
|
||||
call
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn canonicalize_provider_tool_calls(
|
||||
tools: &[Box<dyn Tool>],
|
||||
calls: Vec<crate::providers::ToolCall>,
|
||||
) -> Vec<crate::providers::ToolCall> {
|
||||
calls.into_iter()
|
||||
.map(|mut call| {
|
||||
if let Some(canonical_name) = resolve_registered_tool_name(tools, &call.name) {
|
||||
call.name = canonical_name;
|
||||
}
|
||||
call
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
config: Config,
|
||||
message: Option<String>,
|
||||
@@ -1333,6 +1400,92 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct MockDottedTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MockDottedTool {
|
||||
fn name(&self) -> &str {
|
||||
"zhihu-hotlist.extract_hotlist"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"extract zhihu hotlist"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object"})
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: serde_json::Value) -> Result<crate::tools::ToolResult> {
|
||||
Ok(crate::tools::ToolResult {
|
||||
success: true,
|
||||
output: "hotlist-out".into(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingDuplicateParagraphProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StreamingDuplicateParagraphProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<crate::providers::ChatResponse> {
|
||||
Ok(crate::providers::ChatResponse {
|
||||
text: Some("fallback".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn supports_streaming(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn stream_chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
_options: crate::providers::traits::StreamOptions,
|
||||
) -> futures_util::stream::BoxStream<
|
||||
'static,
|
||||
crate::providers::traits::StreamResult<crate::providers::traits::StreamEvent>,
|
||||
> {
|
||||
use crate::providers::traits::{StreamChunk, StreamEvent};
|
||||
use futures_util::{stream, StreamExt};
|
||||
|
||||
stream::iter(vec![
|
||||
Ok(StreamEvent::TextDelta(StreamChunk::delta(
|
||||
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。",
|
||||
))),
|
||||
Ok(StreamEvent::TextDelta(StreamChunk::delta("\n\n"))),
|
||||
Ok(StreamEvent::TextDelta(StreamChunk::delta(
|
||||
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。",
|
||||
))),
|
||||
Ok(StreamEvent::TextDelta(StreamChunk::delta("\n\n"))),
|
||||
Ok(StreamEvent::TextDelta(StreamChunk::delta("文件已生成。"))),
|
||||
Ok(StreamEvent::Final),
|
||||
])
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_without_tools_returns_text() {
|
||||
let provider = Box::new(MockProvider {
|
||||
@@ -1419,6 +1572,101 @@ mod tests {
|
||||
.any(|msg| matches!(msg, ConversationMessage::ToolResults(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_streamed_restores_original_tool_name_for_provider_safe_calls() {
|
||||
let provider = Box::new(MockProvider {
|
||||
responses: Mutex::new(vec![
|
||||
crate::providers::ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![crate::providers::ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "zhihu-hotlist_extract_hotlist".into(),
|
||||
arguments: "{}".into(),
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
crate::providers::ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
let memory_cfg = crate::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
|
||||
.expect("memory creation should succeed with valid config"),
|
||||
);
|
||||
|
||||
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(vec![Box::new(MockDottedTool)])
|
||||
.memory(mem)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||
.build()
|
||||
.expect("agent builder should succeed with valid config");
|
||||
|
||||
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(8);
|
||||
let response = agent.turn_streamed("导出知乎热榜", event_tx).await.unwrap();
|
||||
|
||||
assert_eq!(response, "done");
|
||||
let tool_event = event_rx.recv().await.expect("tool event should be emitted");
|
||||
assert!(matches!(
|
||||
tool_event,
|
||||
TurnEvent::ToolCall { ref name, .. } if name == "zhihu-hotlist.extract_hotlist"
|
||||
));
|
||||
assert!(agent.history().iter().any(|message| matches!(
|
||||
message,
|
||||
ConversationMessage::AssistantToolCalls { tool_calls, .. }
|
||||
if tool_calls.iter().any(|call| call.name == "zhihu-hotlist.extract_hotlist")
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_streamed_sanitizes_duplicate_final_paragraphs() {
|
||||
let provider = Box::new(StreamingDuplicateParagraphProvider);
|
||||
|
||||
let memory_cfg = crate::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
|
||||
.expect("memory creation should succeed with valid config"),
|
||||
);
|
||||
|
||||
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||
.build()
|
||||
.expect("agent builder should succeed with valid config");
|
||||
|
||||
let (event_tx, _event_rx) = tokio::sync::mpsc::channel(8);
|
||||
let response = agent.turn_streamed("读取知乎热榜前10,并导出 excel 文件", event_tx).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
concat!(
|
||||
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。\n\n",
|
||||
"文件已生成。"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_routes_with_hint_when_query_classification_matches() {
|
||||
let seen_models = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -1670,4 +1918,25 @@ mod tests {
|
||||
);
|
||||
assert_eq!(history.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_final_text_collapses_consecutive_duplicate_paragraphs() {
|
||||
let text = concat!(
|
||||
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。\n\n",
|
||||
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。\n\n",
|
||||
"## 结果\n\n",
|
||||
"文件已生成。"
|
||||
);
|
||||
|
||||
let sanitized = sanitize_final_text(text);
|
||||
|
||||
assert_eq!(
|
||||
sanitized,
|
||||
concat!(
|
||||
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。\n\n",
|
||||
"## 结果\n\n",
|
||||
"文件已生成。"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
78
third_party/zeroclaw/src/agent/loop_.rs
vendored
78
third_party/zeroclaw/src/agent/loop_.rs
vendored
@@ -4753,6 +4753,15 @@ pub async fn process_message(
|
||||
config: Config,
|
||||
message: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<String> {
|
||||
process_message_with_extra_tools(config, message, session_id, Vec::new()).await
|
||||
}
|
||||
|
||||
pub async fn process_message_with_extra_tools(
|
||||
config: Config,
|
||||
message: &str,
|
||||
session_id: Option<&str>,
|
||||
mut extra_tools: Vec<Box<dyn Tool>>,
|
||||
) -> Result<String> {
|
||||
let observer: Arc<dyn Observer> =
|
||||
Arc::from(observability::create_observer(&config.observability));
|
||||
@@ -4805,6 +4814,7 @@ pub async fn process_message(
|
||||
let peripheral_tools: Vec<Box<dyn Tool>> =
|
||||
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
|
||||
tools_registry.extend(peripheral_tools);
|
||||
tools_registry.append(&mut extra_tools);
|
||||
|
||||
// ── Wire MCP tools (non-fatal) — process_message path ────────
|
||||
// NOTE: Same ordering contract as the CLI path above — MCP tools must be
|
||||
@@ -4919,62 +4929,12 @@ pub async fn process_message(
|
||||
// Register skill-defined tools as callable tool specs (process_message path).
|
||||
tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
|
||||
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
("shell", "Execute terminal commands."),
|
||||
("file_read", "Read file contents."),
|
||||
("file_write", "Write file contents."),
|
||||
("memory_store", "Save to memory."),
|
||||
("memory_recall", "Search memory."),
|
||||
("memory_forget", "Delete a memory entry."),
|
||||
(
|
||||
"model_routing_config",
|
||||
"Configure default model, scenario routing, and delegate agents.",
|
||||
),
|
||||
("screenshot", "Capture a screenshot."),
|
||||
("image_info", "Read image metadata."),
|
||||
];
|
||||
if matches!(
|
||||
config.skills.prompt_injection_mode,
|
||||
crate::config::SkillsPromptInjectionMode::Compact
|
||||
) {
|
||||
tool_descs.push((
|
||||
"read_skill",
|
||||
"Load the full source for an available skill by name.",
|
||||
));
|
||||
}
|
||||
if config.browser.enabled {
|
||||
tool_descs.push(("browser_open", "Open approved URLs in browser."));
|
||||
}
|
||||
if config.composio.enabled {
|
||||
tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
|
||||
}
|
||||
if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
|
||||
tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
|
||||
tool_descs.push((
|
||||
"gpio_write",
|
||||
"Set GPIO pin high or low on connected hardware.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"arduino_upload",
|
||||
"Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_memory_map",
|
||||
"Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_board_info",
|
||||
"Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_memory_read",
|
||||
"Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_capabilities",
|
||||
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
|
||||
));
|
||||
}
|
||||
let mut tool_descs: Vec<(String, String)> = tools_registry
|
||||
.iter()
|
||||
.map(|tool| (tool.name().to_string(), tool.description().to_string()))
|
||||
.collect();
|
||||
tool_descs.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
tool_descs.dedup_by(|left, right| left.0 == right.0);
|
||||
|
||||
// Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
|
||||
// Skip when autonomy is `Full` — full-autonomy agents keep all tools.
|
||||
@@ -4984,6 +4944,10 @@ pub async fn process_message(
|
||||
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
|
||||
}
|
||||
}
|
||||
let tool_desc_refs: Vec<(&str, &str)> = tool_descs
|
||||
.iter()
|
||||
.map(|(name, description)| (name.as_str(), description.as_str()))
|
||||
.collect();
|
||||
|
||||
let bootstrap_max_chars = if config.agent.compact_context {
|
||||
Some(6000)
|
||||
@@ -4994,7 +4958,7 @@ pub async fn process_message(
|
||||
let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
|
||||
&config.workspace_dir,
|
||||
&model_name,
|
||||
&tool_descs,
|
||||
&tool_desc_refs,
|
||||
&skills,
|
||||
Some(&config.identity),
|
||||
bootstrap_max_chars,
|
||||
|
||||
2
third_party/zeroclaw/src/agent/mod.rs
vendored
2
third_party/zeroclaw/src/agent/mod.rs
vendored
@@ -19,4 +19,4 @@ mod tests;
|
||||
#[allow(unused_imports)]
|
||||
pub use agent::{Agent, AgentBuilder, TurnEvent};
|
||||
#[allow(unused_imports)]
|
||||
pub use loop_::{process_message, run};
|
||||
pub use loop_::{process_message, process_message_with_extra_tools, run};
|
||||
|
||||
54
third_party/zeroclaw/src/providers/compatible.rs
vendored
54
third_party/zeroclaw/src/providers/compatible.rs
vendored
@@ -229,6 +229,18 @@ impl OpenAiCompatibleProvider {
|
||||
self
|
||||
}
|
||||
|
||||
fn tool_choice_for_tools(&self, has_tools: bool) -> Option<String> {
|
||||
if !has_tools {
|
||||
return None;
|
||||
}
|
||||
|
||||
crate::agent::loop_::TOOL_CHOICE_OVERRIDE
|
||||
.try_with(Clone::clone)
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| Some("auto".to_string()))
|
||||
}
|
||||
|
||||
/// Collect all `system` role messages, concatenate their content,
|
||||
/// and prepend to the first `user` message. Drop all system messages.
|
||||
/// Used for providers (e.g. MiniMax) that reject `role: system`.
|
||||
@@ -393,10 +405,11 @@ impl OpenAiCompatibleProvider {
|
||||
tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
let provider_name = crate::tools::provider_safe_tool_name(&tool.name);
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"name": provider_name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters
|
||||
}
|
||||
@@ -1309,10 +1322,11 @@ impl OpenAiCompatibleProvider {
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
let provider_name = crate::tools::provider_safe_tool_name(&tool.name);
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"name": provider_name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
}
|
||||
@@ -1375,7 +1389,7 @@ impl OpenAiCompatibleProvider {
|
||||
id: Some(tc.id),
|
||||
kind: Some("function".to_string()),
|
||||
function: Some(Function {
|
||||
name: Some(tc.name),
|
||||
name: Some(crate::tools::provider_safe_tool_name(&tc.name)),
|
||||
arguments: Some(tc.arguments),
|
||||
}),
|
||||
name: None,
|
||||
@@ -1829,11 +1843,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
} else {
|
||||
Some(tools.to_vec())
|
||||
},
|
||||
tool_choice: if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some("auto".to_string())
|
||||
},
|
||||
tool_choice: self.tool_choice_for_tools(!tools.is_empty()),
|
||||
max_tokens: self.max_tokens,
|
||||
};
|
||||
|
||||
@@ -1933,7 +1943,9 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
reasoning_effort: self.reasoning_effort_for_model(model),
|
||||
tool_stream: self
|
||||
.tool_stream_for_tools(tools.as_ref().is_some_and(|tools| !tools.is_empty())),
|
||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||
tool_choice: self.tool_choice_for_tools(
|
||||
tools.as_ref().is_some_and(|tools| !tools.is_empty()),
|
||||
),
|
||||
tools,
|
||||
max_tokens: self.max_tokens,
|
||||
};
|
||||
@@ -2087,7 +2099,9 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
tool_stream: if options.enabled { Some(true) } else { None },
|
||||
stream: Some(options.enabled),
|
||||
tools: tools.clone(),
|
||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||
tool_choice: self.tool_choice_for_tools(
|
||||
tools.as_ref().is_some_and(|tools| !tools.is_empty()),
|
||||
),
|
||||
max_tokens: self.max_tokens,
|
||||
})
|
||||
} else {
|
||||
@@ -3221,6 +3235,26 @@ mod tests {
|
||||
assert_eq!(tools[0]["function"]["parameters"]["required"][0], "command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_specs_convert_invalid_function_names_to_provider_safe_names() {
|
||||
let specs = vec![crate::tools::ToolSpec {
|
||||
name: "zhihu-hotlist.extract_hotlist".to_string(),
|
||||
description: "Extract Zhihu hotlist rows".to_string(),
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"top_n": {"type": "string"}},
|
||||
"required": ["top_n"]
|
||||
}),
|
||||
}];
|
||||
|
||||
let tools = OpenAiCompatibleProvider::tool_specs_to_openai_format(&specs);
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(
|
||||
tools[0]["function"]["name"],
|
||||
"zhihu-hotlist_extract_hotlist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serializes_with_tools() {
|
||||
let tools = vec![serde_json::json!({
|
||||
|
||||
33
third_party/zeroclaw/src/skills/mod.rs
vendored
33
third_party/zeroclaw/src/skills/mod.rs
vendored
@@ -810,18 +810,18 @@ pub fn skills_to_prompt_with_mode(
|
||||
}
|
||||
|
||||
if !skill.tools.is_empty() {
|
||||
// Tools with known kinds (shell, script, http) are registered as
|
||||
// Tools with known kinds (shell, script, http, browser_script) are registered as
|
||||
// callable tool specs and can be invoked directly via function calling.
|
||||
// We note them here for context but mark them as callable.
|
||||
let registered: Vec<_> = skill
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|t| matches!(t.kind.as_str(), "shell" | "script" | "http"))
|
||||
.filter(|t| matches!(t.kind.as_str(), "shell" | "script" | "http" | "browser_script"))
|
||||
.collect();
|
||||
let unregistered: Vec<_> = skill
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.kind.as_str(), "shell" | "script" | "http"))
|
||||
.filter(|t| !matches!(t.kind.as_str(), "shell" | "script" | "http" | "browser_script"))
|
||||
.collect();
|
||||
|
||||
if !registered.is_empty() {
|
||||
@@ -887,6 +887,7 @@ pub fn skills_to_tools(
|
||||
tool,
|
||||
)));
|
||||
}
|
||||
"browser_script" => {}
|
||||
other => {
|
||||
tracing::warn!(
|
||||
"Unknown skill tool kind '{}' for {}.{}, skipping",
|
||||
@@ -1900,6 +1901,32 @@ description = "Bare minimum"
|
||||
assert!(prompt.contains("<description>Fetch forecast</description>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_to_prompt_marks_browser_script_tools_as_callable() {
|
||||
let skills = vec![Skill {
|
||||
name: "zhihu-hotlist".to_string(),
|
||||
description: "Collect hotlist rows".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: None,
|
||||
tags: vec![],
|
||||
tools: vec![SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows from the current page".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args: HashMap::new(),
|
||||
}],
|
||||
prompts: vec![],
|
||||
location: None,
|
||||
}];
|
||||
|
||||
let prompt = skills_to_prompt(&skills, Path::new("/tmp"));
|
||||
|
||||
assert!(prompt.contains("<callable_tools"));
|
||||
assert!(prompt.contains("<name>zhihu-hotlist.extract_hotlist</name>"));
|
||||
assert!(!prompt.contains("<kind>browser_script</kind>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_to_prompt_escapes_xml_content() {
|
||||
let skills = vec![Skill {
|
||||
|
||||
2
third_party/zeroclaw/src/tools/mod.rs
vendored
2
third_party/zeroclaw/src/tools/mod.rs
vendored
@@ -204,7 +204,7 @@ pub use text_browser::TextBrowserTool;
|
||||
pub use tool_search::ToolSearchTool;
|
||||
pub use traits::Tool;
|
||||
#[allow(unused_imports)]
|
||||
pub use traits::{ToolResult, ToolSpec};
|
||||
pub use traits::{provider_safe_tool_name, ToolResult, ToolSpec};
|
||||
pub use verifiable_intent::VerifiableIntentTool;
|
||||
pub use weather_tool::WeatherTool;
|
||||
pub use web_fetch::WebFetchTool;
|
||||
|
||||
245
third_party/zeroclaw/src/tools/read_skill.rs
vendored
245
third_party/zeroclaw/src/tools/read_skill.rs
vendored
@@ -1,11 +1,14 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Compact-mode helper for loading a skill's source file on demand.
|
||||
pub struct ReadSkillTool {
|
||||
workspace_dir: PathBuf,
|
||||
runtime_skills_dir: Option<PathBuf>,
|
||||
allow_scripts: bool,
|
||||
open_skills_enabled: bool,
|
||||
open_skills_dir: Option<String>,
|
||||
}
|
||||
@@ -18,6 +21,24 @@ impl ReadSkillTool {
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace_dir,
|
||||
runtime_skills_dir: None,
|
||||
allow_scripts: false,
|
||||
open_skills_enabled,
|
||||
open_skills_dir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_runtime_skills_dir(
|
||||
workspace_dir: PathBuf,
|
||||
runtime_skills_dir: Option<PathBuf>,
|
||||
allow_scripts: bool,
|
||||
open_skills_enabled: bool,
|
||||
open_skills_dir: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace_dir,
|
||||
runtime_skills_dir,
|
||||
allow_scripts,
|
||||
open_skills_enabled,
|
||||
open_skills_dir,
|
||||
}
|
||||
@@ -55,11 +76,27 @@ impl Tool for ReadSkillTool {
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
|
||||
|
||||
let skills = crate::skills::load_skills_with_open_skills_settings(
|
||||
let mut skills = crate::skills::load_skills_with_open_skills_settings(
|
||||
&self.workspace_dir,
|
||||
self.open_skills_enabled,
|
||||
self.open_skills_dir.as_deref(),
|
||||
);
|
||||
let default_skills_dir = self.workspace_dir.join("skills");
|
||||
if let Some(runtime_skills_dir) = &self.runtime_skills_dir {
|
||||
if runtime_skills_dir != &default_skills_dir {
|
||||
skills.retain(|skill| {
|
||||
skill
|
||||
.location
|
||||
.as_ref()
|
||||
.map(|location| !location.starts_with(&default_skills_dir))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
skills.extend(crate::skills::load_skills_from_directory(
|
||||
runtime_skills_dir,
|
||||
self.allow_scripts,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let Some(skill) = skills
|
||||
.iter()
|
||||
@@ -93,7 +130,7 @@ impl Tool for ReadSkillTool {
|
||||
});
|
||||
};
|
||||
|
||||
match tokio::fs::read_to_string(location).await {
|
||||
match read_skill_bundle(location).await {
|
||||
Ok(output) => Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
@@ -112,6 +149,169 @@ impl Tool for ReadSkillTool {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_skill_bundle(location: &Path) -> std::io::Result<String> {
|
||||
let primary = tokio::fs::read_to_string(location).await?;
|
||||
let Some(skill_root) = location.parent() else {
|
||||
return Ok(primary);
|
||||
};
|
||||
let skill_root = skill_root.canonicalize().unwrap_or_else(|_| skill_root.to_path_buf());
|
||||
let mut output = primary.clone();
|
||||
let mut appended = BTreeSet::new();
|
||||
let mut queued = BTreeSet::new();
|
||||
let mut pending = VecDeque::new();
|
||||
|
||||
enqueue_reference_paths(
|
||||
&primary,
|
||||
location.parent().unwrap_or(skill_root.as_path()),
|
||||
&skill_root,
|
||||
&mut queued,
|
||||
&mut pending,
|
||||
);
|
||||
|
||||
if location.file_name().and_then(|name| name.to_str()) == Some("SKILL.toml") {
|
||||
let sibling_markdown = skill_root.join("SKILL.md");
|
||||
if sibling_markdown.exists() {
|
||||
if let Ok(markdown) = tokio::fs::read_to_string(&sibling_markdown).await {
|
||||
output.push_str("\n\n## Referenced File: SKILL.md\n\n");
|
||||
output.push_str(&markdown);
|
||||
enqueue_reference_paths(
|
||||
&markdown,
|
||||
sibling_markdown.parent().unwrap_or(skill_root.as_path()),
|
||||
&skill_root,
|
||||
&mut queued,
|
||||
&mut pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(path) = pending.pop_front() {
|
||||
let canonical = path.canonicalize().unwrap_or(path.clone());
|
||||
if !canonical.starts_with(&skill_root) || !appended.insert(canonical.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(content) = tokio::fs::read_to_string(&canonical).await else {
|
||||
continue;
|
||||
};
|
||||
let relative = canonical
|
||||
.strip_prefix(&skill_root)
|
||||
.unwrap_or(canonical.as_path())
|
||||
.display()
|
||||
.to_string();
|
||||
output.push_str("\n\n## Referenced File: ");
|
||||
output.push_str(&relative);
|
||||
output.push_str("\n\n");
|
||||
output.push_str(&content);
|
||||
|
||||
enqueue_reference_paths(
|
||||
&content,
|
||||
canonical.parent().unwrap_or(skill_root.as_path()),
|
||||
&skill_root,
|
||||
&mut queued,
|
||||
&mut pending,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn enqueue_reference_paths(
|
||||
content: &str,
|
||||
base_dir: &Path,
|
||||
skill_root: &Path,
|
||||
queued: &mut BTreeSet<PathBuf>,
|
||||
pending: &mut VecDeque<PathBuf>,
|
||||
) {
|
||||
for candidate in extract_reference_paths(content) {
|
||||
for resolved in resolve_reference_candidates(&candidate, base_dir, skill_root) {
|
||||
let canonical = resolved.canonicalize().unwrap_or(resolved);
|
||||
if !canonical.starts_with(skill_root) || !is_supported_reference_file(&canonical) {
|
||||
continue;
|
||||
}
|
||||
if queued.insert(canonical.clone()) {
|
||||
pending.push_back(canonical);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_reference_paths(content: &str) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
let mut cursor = content;
|
||||
while let Some(start) = cursor.find("](") {
|
||||
cursor = &cursor[start + 2..];
|
||||
let Some(end) = cursor.find(')') else {
|
||||
break;
|
||||
};
|
||||
let raw = cursor[..end].trim();
|
||||
if looks_like_relative_reference_path(raw) {
|
||||
paths.push(raw.to_string());
|
||||
}
|
||||
cursor = &cursor[end + 1..];
|
||||
}
|
||||
|
||||
let mut in_backticks = false;
|
||||
let mut token = String::new();
|
||||
for ch in content.chars() {
|
||||
if ch == '`' {
|
||||
if in_backticks {
|
||||
let raw = token.trim();
|
||||
if looks_like_relative_reference_path(raw) {
|
||||
paths.push(raw.to_string());
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
in_backticks = !in_backticks;
|
||||
continue;
|
||||
}
|
||||
if in_backticks {
|
||||
token.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn looks_like_relative_reference_path(raw: &str) -> bool {
|
||||
if raw.is_empty() ||
|
||||
raw.starts_with('/') ||
|
||||
raw.starts_with("http://") ||
|
||||
raw.starts_with("https://") ||
|
||||
raw.starts_with('#')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let candidate = raw.split('#').next().unwrap_or(raw).split('?').next().unwrap_or(raw);
|
||||
let path = Path::new(candidate);
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
candidate.contains('/') && is_supported_reference_file(path)
|
||||
}
|
||||
|
||||
fn is_supported_reference_file(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.extension().and_then(|value| value.to_str()),
|
||||
Some("md" | "txt" | "json" | "html" | "toml" | "yaml" | "yml" | "csv")
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_reference_candidates(raw: &str, base_dir: &Path, skill_root: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![base_dir.join(raw)];
|
||||
let skill_root_candidate = skill_root.join(raw);
|
||||
if skill_root_candidate != candidates[0] {
|
||||
candidates.push(skill_root_candidate);
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -184,4 +384,43 @@ description = "Ship safely"
|
||||
Some("Unknown skill 'calendar'. Available skills: weather")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inlines_markdown_reference_files_for_skill_context() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path().join("workspace/skills/zhihu-hotlist");
|
||||
let refs_dir = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
concat!(
|
||||
"# Zhihu Hotlist\n\n",
|
||||
"Follow [collection-flow.md](references/collection-flow.md).\n",
|
||||
"Apply [data-quality.md](references/data-quality.md).\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
refs_dir.join("collection-flow.md"),
|
||||
"# Collection Flow\n\nCollect rows from the hotlist first.\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
refs_dir.join("data-quality.md"),
|
||||
"# Data Quality\n\nMark partial metrics explicitly.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = make_tool(&tmp)
|
||||
.execute(json!({ "name": "zhihu-hotlist" }))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("# Zhihu Hotlist"));
|
||||
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
|
||||
assert!(result.output.contains("Collect rows from the hotlist first."));
|
||||
assert!(result.output.contains("## Referenced File: references/data-quality.md"));
|
||||
assert!(result.output.contains("Mark partial metrics explicitly."));
|
||||
}
|
||||
}
|
||||
|
||||
21
third_party/zeroclaw/src/tools/traits.rs
vendored
21
third_party/zeroclaw/src/tools/traits.rs
vendored
@@ -17,6 +17,18 @@ pub struct ToolSpec {
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
pub fn provider_safe_tool_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Core tool trait — implement for any capability
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
@@ -118,4 +130,13 @@ mod tests {
|
||||
assert!(!parsed.success);
|
||||
assert_eq!(parsed.error.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_safe_tool_name_replaces_invalid_function_characters() {
|
||||
assert_eq!(
|
||||
provider_safe_tool_name("zhihu-hotlist.extract_hotlist"),
|
||||
"zhihu-hotlist_extract_hotlist"
|
||||
);
|
||||
assert_eq!(provider_safe_tool_name("shell"), "shell");
|
||||
}
|
||||
}
|
||||
|
||||
35
tools/browser_runtime/sgclaw_browser_entry.sh
Executable file
35
tools/browser_runtime/sgclaw_browser_entry.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CRATE_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
BIN_PATH="${CRATE_DIR}/target/debug/sgclaw"
|
||||
MANIFEST_PATH="${CRATE_DIR}/Cargo.toml"
|
||||
RULES_SRC_PATH="${CRATE_DIR}/resources/rules.json"
|
||||
RULES_DEST_PATH="${CRATE_DIR}/target/debug/resources/rules.json"
|
||||
|
||||
needs_build=0
|
||||
if [[ ! -x "${BIN_PATH}" ]]; then
|
||||
needs_build=1
|
||||
elif [[ "${MANIFEST_PATH}" -nt "${BIN_PATH}" ]]; then
|
||||
needs_build=1
|
||||
elif [[ "${CRATE_DIR}/Cargo.lock" -nt "${BIN_PATH}" ]]; then
|
||||
needs_build=1
|
||||
elif find \
|
||||
"${CRATE_DIR}/src" \
|
||||
"${CRATE_DIR}/third_party/zeroclaw/src" \
|
||||
"${CRATE_DIR}/../skill_lib" \
|
||||
-type f -newer "${BIN_PATH}" -print -quit | grep -q .; then
|
||||
needs_build=1
|
||||
fi
|
||||
|
||||
if [[ "${needs_build}" -eq 1 ]]; then
|
||||
cargo build --manifest-path "${MANIFEST_PATH}" --bin sgclaw
|
||||
fi
|
||||
|
||||
if [[ ! -f "${RULES_DEST_PATH}" || "${RULES_SRC_PATH}" -nt "${RULES_DEST_PATH}" ]]; then
|
||||
mkdir -p "$(dirname -- "${RULES_DEST_PATH}")"
|
||||
cp "${RULES_SRC_PATH}" "${RULES_DEST_PATH}"
|
||||
fi
|
||||
|
||||
exec "${BIN_PATH}" "$@"
|
||||
469
tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
Normal file
469
tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SGCLAW_BIN = REPO_ROOT / "target" / "debug" / "sgclaw"
|
||||
REAL_CONFIG_PATH = Path("/home/zyl/.config/superrpa/Default/superrpa/sgclaw_config.json")
|
||||
ACCEPTANCE_DOC = REPO_ROOT / "docs" / "acceptance" / "2026-03-29-zhihu-hotlist-excel.md"
|
||||
ZH_HOTLIST_API = "https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=10&desktop=true"
|
||||
HANDSHAKE_SEED = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HotItem:
|
||||
rank: int
|
||||
title: str
|
||||
heat: str
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_binary()
|
||||
hot_items = fetch_live_hotlist()
|
||||
result = run_live_acceptance(hot_items)
|
||||
score = score_acceptance(result, hot_items)
|
||||
write_acceptance_doc(result, hot_items, score)
|
||||
print(json.dumps(score, ensure_ascii=False, indent=2))
|
||||
print(f"evidence written to {ACCEPTANCE_DOC}")
|
||||
return 0 if score["total_score"] >= 85 else 1
|
||||
|
||||
|
||||
def ensure_binary() -> None:
|
||||
if SGCLAW_BIN.exists():
|
||||
return
|
||||
subprocess.run(["cargo", "build", "--bin", "sgclaw"], cwd=REPO_ROOT, check=True)
|
||||
|
||||
|
||||
def fetch_live_hotlist() -> list[HotItem]:
|
||||
response = requests.get(
|
||||
ZH_HOTLIST_API,
|
||||
headers={"User-Agent": "Mozilla/5.0", "Referer": "https://www.zhihu.com/hot"},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()["data"]
|
||||
items = []
|
||||
for index, entry in enumerate(data[:10], start=1):
|
||||
target = entry.get("target", {})
|
||||
title = target.get("title_area", {}).get("text", "").strip()
|
||||
raw_heat = target.get("metrics_area", {}).get("text", "").strip()
|
||||
heat = normalize_heat_text(raw_heat)
|
||||
if not title or not heat:
|
||||
raise RuntimeError(f"missing title/heat in live hotlist entry {index}")
|
||||
items.append(HotItem(rank=index, title=title, heat=heat))
|
||||
return items
|
||||
|
||||
|
||||
def normalize_heat_text(text: str) -> str:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
compact = compact.removesuffix("热度")
|
||||
return compact
|
||||
|
||||
|
||||
def build_hotlist_text(items: list[HotItem]) -> str:
|
||||
lines = []
|
||||
for item in items:
|
||||
lines.append(f"{item.rank}. {item.title}")
|
||||
lines.append(f"热度 {item.heat}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_temp_config(workspace_root: Path) -> Path:
|
||||
source = json.loads(REAL_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
config_path = workspace_root / "sgclaw_config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"apiKey": source["apiKey"],
|
||||
"baseUrl": source["baseUrl"],
|
||||
"model": source["model"],
|
||||
"skillsDir": source["skillsDir"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return config_path
|
||||
|
||||
|
||||
def run_live_acceptance(items: list[HotItem]) -> dict:
|
||||
workspace_root = Path(tempfile.mkdtemp(prefix="sgclaw-live-acceptance-"))
|
||||
config_path = write_temp_config(workspace_root)
|
||||
existing_exports = set(workspace_root.rglob("*.xlsx"))
|
||||
hotlist_text = build_hotlist_text(items)
|
||||
|
||||
child = subprocess.Popen(
|
||||
[str(SGCLAW_BIN), "--config-path", str(config_path)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=REPO_ROOT,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
stdout_queue: queue.Queue[str] = queue.Queue()
|
||||
stderr_lines: list[str] = []
|
||||
start_reader(child.stdout, stdout_queue)
|
||||
start_reader(child.stderr, None, stderr_lines)
|
||||
|
||||
send_line(
|
||||
child,
|
||||
{
|
||||
"type": "init",
|
||||
"version": "1.0",
|
||||
"hmac_seed": HANDSHAKE_SEED,
|
||||
"capabilities": ["browser_action"],
|
||||
},
|
||||
)
|
||||
init_ack = read_json_line(stdout_queue, timeout=10)
|
||||
if init_ack.get("type") != "init_ack":
|
||||
raise RuntimeError(f"unexpected init response: {init_ack}")
|
||||
|
||||
send_line(
|
||||
child,
|
||||
{
|
||||
"type": "submit_task",
|
||||
"instruction": "读取知乎热榜数据,并导出 excel 文件",
|
||||
"conversation_id": "",
|
||||
"messages": [],
|
||||
"page_url": "https://www.zhihu.com/",
|
||||
"page_title": "知乎",
|
||||
},
|
||||
)
|
||||
|
||||
logs: list[dict] = []
|
||||
final_task = None
|
||||
current_page = "https://www.zhihu.com/"
|
||||
deadline = time.time() + 180
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
message = read_json_line(stdout_queue, timeout=5)
|
||||
except queue.Empty:
|
||||
if child.poll() is not None:
|
||||
break
|
||||
continue
|
||||
msg_type = message.get("type")
|
||||
if msg_type == "log_entry":
|
||||
logs.append(message)
|
||||
continue
|
||||
if msg_type == "command":
|
||||
action = message["action"]
|
||||
params = message.get("params", {})
|
||||
seq = message["seq"]
|
||||
if action == "navigate":
|
||||
current_page = params.get("url", current_page)
|
||||
respond_browser(child, seq, {"navigated": True, "url": current_page})
|
||||
continue
|
||||
if action == "click":
|
||||
selector = params.get("selector", "")
|
||||
if "hot" in selector:
|
||||
current_page = "https://www.zhihu.com/hot"
|
||||
respond_browser(child, seq, {"clicked": True, "selector": selector})
|
||||
continue
|
||||
if action == "getText":
|
||||
text = hotlist_text if "zhihu.com" in current_page else ""
|
||||
respond_browser(child, seq, {"text": text})
|
||||
continue
|
||||
if action == "type":
|
||||
respond_browser(child, seq, {"typed": True})
|
||||
continue
|
||||
respond_browser(child, seq, {"unsupported_action": action}, success=False)
|
||||
continue
|
||||
if msg_type == "task_complete":
|
||||
final_task = message
|
||||
break
|
||||
|
||||
try:
|
||||
child.terminate()
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait(timeout=5)
|
||||
|
||||
exports = sorted(set(workspace_root.rglob("*.xlsx")) - existing_exports)
|
||||
return {
|
||||
"workspace_root": str(workspace_root),
|
||||
"init_ack": init_ack,
|
||||
"logs": logs,
|
||||
"final_task": final_task,
|
||||
"stderr": stderr_lines,
|
||||
"exports": [str(path) for path in exports],
|
||||
}
|
||||
|
||||
|
||||
def start_reader(stream, output_queue: queue.Queue[str] | None, collector: list[str] | None = None) -> None:
|
||||
def _reader() -> None:
|
||||
try:
|
||||
for line in stream:
|
||||
if collector is not None:
|
||||
collector.append(line.rstrip("\n"))
|
||||
if output_queue is not None:
|
||||
output_queue.put(line)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
thread = threading.Thread(target=_reader, daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def send_line(child: subprocess.Popen, payload: dict) -> None:
|
||||
assert child.stdin is not None
|
||||
child.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
child.stdin.flush()
|
||||
|
||||
|
||||
def respond_browser(child: subprocess.Popen, seq: int, data: dict, success: bool = True) -> None:
|
||||
send_line(
|
||||
child,
|
||||
{
|
||||
"type": "response",
|
||||
"seq": seq,
|
||||
"success": success,
|
||||
"data": data,
|
||||
"aom_snapshot": [],
|
||||
"timing": {"queue_ms": 1, "exec_ms": 10},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def read_json_line(output_queue: queue.Queue[str], timeout: int) -> dict:
|
||||
raw = output_queue.get(timeout=timeout)
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def score_acceptance(result: dict, items: list[HotItem]) -> dict:
|
||||
log_entries = result["logs"]
|
||||
logs = [entry.get("message", "") for entry in log_entries]
|
||||
final_task = result.get("final_task") or {}
|
||||
exports = [Path(path) for path in result["exports"]]
|
||||
exported_path = resolve_exported_path(exports, final_task.get("summary", ""))
|
||||
browser_path_exists = (
|
||||
"navigate https://www.zhihu.com/hot" in logs and
|
||||
any(message.startswith("getText ") for message in logs)
|
||||
)
|
||||
|
||||
skill_selection = 0
|
||||
executed_hotlist_collection = browser_path_exists
|
||||
read_hotlist_skill = "read_skill zhihu-hotlist" in logs
|
||||
read_office_skill = "read_skill office-export-xlsx" in logs
|
||||
completed_office_export = "call openxml_office" in logs
|
||||
|
||||
if read_hotlist_skill or executed_hotlist_collection:
|
||||
skill_selection += 15
|
||||
if read_office_skill or completed_office_export:
|
||||
skill_selection += 15
|
||||
if read_hotlist_skill and read_office_skill and \
|
||||
logs.index("read_skill zhihu-hotlist") > logs.index("read_skill office-export-xlsx"):
|
||||
skill_selection = max(0, skill_selection - 15)
|
||||
|
||||
tool_discipline = 25
|
||||
if any(message == "call shell" for message in logs):
|
||||
tool_discipline -= 15
|
||||
if any(message == "call glob_search" for message in logs):
|
||||
tool_discipline -= 10
|
||||
if any(message == "call file_read" for message in logs):
|
||||
tool_discipline -= 10
|
||||
tool_discipline = max(0, tool_discipline)
|
||||
|
||||
hotlist_data_correctness = 0
|
||||
xlsx_export_success = 0
|
||||
workbook_ok = False
|
||||
if exported_path and exported_path.exists():
|
||||
with zipfile.ZipFile(exported_path) as archive:
|
||||
sheet_xml = archive.read("xl/worksheets/sheet1.xml").decode("utf-8")
|
||||
workbook_xml = archive.read("xl/workbook.xml").decode("utf-8")
|
||||
title_matches = sum(1 for item in items if item.title in sheet_xml)
|
||||
heat_matches = sum(1 for item in items if item.heat in sheet_xml)
|
||||
if title_matches >= 10 and heat_matches >= 10:
|
||||
hotlist_data_correctness = 20
|
||||
elif title_matches >= 8 and heat_matches >= 8:
|
||||
hotlist_data_correctness = 15
|
||||
elif title_matches >= 5 and heat_matches >= 5:
|
||||
hotlist_data_correctness = 10
|
||||
workbook_ok = "知乎热榜" in workbook_xml and title_matches >= 10
|
||||
if workbook_ok:
|
||||
xlsx_export_success = 20
|
||||
|
||||
final_response_quality = 0
|
||||
summary = final_task.get("summary", "")
|
||||
repeated_paragraphs = find_repeated_paragraphs(summary)
|
||||
if final_task.get("success") and summary.strip() and not repeated_paragraphs:
|
||||
final_response_quality = 5
|
||||
|
||||
deductions = []
|
||||
planner_index = find_planner_log_index(log_entries)
|
||||
first_tool_index = find_first_tool_execution_index(logs)
|
||||
if planner_index is None or (first_tool_index is not None and planner_index > first_tool_index):
|
||||
deductions.append("planner output missing before tool execution")
|
||||
if repeated_paragraphs:
|
||||
deductions.append("repeated assistant paragraphs detected")
|
||||
if not exported_path:
|
||||
deductions.append("export missing output path")
|
||||
if browser_path_exists and (not exported_path or hotlist_data_correctness == 0):
|
||||
deductions.append("hotlist rows were not exported as structured live data")
|
||||
if logs.count("call openxml_office") > 1 or any(
|
||||
"unsupported columns:" in message for message in logs):
|
||||
deductions.append("structured handoff required export retries")
|
||||
|
||||
total_score = (
|
||||
skill_selection
|
||||
+ tool_discipline
|
||||
+ hotlist_data_correctness
|
||||
+ xlsx_export_success
|
||||
+ final_response_quality
|
||||
)
|
||||
total_score = max(0, total_score - acceptance_penalty(deductions))
|
||||
|
||||
return {
|
||||
"total_score": total_score,
|
||||
"skill_selection": skill_selection,
|
||||
"tool_discipline": tool_discipline,
|
||||
"hotlist_data_correctness": hotlist_data_correctness,
|
||||
"xlsx_export_success": xlsx_export_success,
|
||||
"final_response_quality": final_response_quality,
|
||||
"final_success": bool(final_task.get("success")),
|
||||
"final_summary": summary,
|
||||
"exported_path": str(exported_path) if exported_path else "",
|
||||
"deductions": deductions,
|
||||
"logs": logs,
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
|
||||
|
||||
def find_planner_log_index(log_entries: list[dict]) -> int | None:
|
||||
for index, entry in enumerate(log_entries):
|
||||
message = str(entry.get("message", "")).strip()
|
||||
if entry.get("level") == "plan":
|
||||
return index
|
||||
if not message:
|
||||
continue
|
||||
if message.startswith("plan ") or "先规划再执行" in message:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def find_first_tool_execution_index(logs: list[str]) -> int | None:
|
||||
tool_prefixes = (
|
||||
"navigate ",
|
||||
"click ",
|
||||
"type ",
|
||||
"getText ",
|
||||
"call openxml_office",
|
||||
"call screen_html_export",
|
||||
)
|
||||
for index, message in enumerate(logs):
|
||||
if message.startswith(tool_prefixes):
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def find_repeated_paragraphs(summary: str) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
repeated: list[str] = []
|
||||
for paragraph in re.split(r"\n\s*\n", summary):
|
||||
normalized = re.sub(r"\s+", " ", paragraph).strip()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized in seen and normalized not in repeated:
|
||||
repeated.append(normalized)
|
||||
continue
|
||||
seen.add(normalized)
|
||||
return repeated
|
||||
|
||||
|
||||
def acceptance_penalty(deductions: list[str]) -> int:
|
||||
penalty_map = {
|
||||
"planner output missing before tool execution": 10,
|
||||
"repeated assistant paragraphs detected": 10,
|
||||
"export missing output path": 10,
|
||||
"hotlist rows were not exported as structured live data": 15,
|
||||
"structured handoff required export retries": 10,
|
||||
}
|
||||
return sum(penalty_map.get(item, 0) for item in deductions)
|
||||
|
||||
|
||||
def resolve_exported_path(exports: list[Path], summary: str) -> Path | None:
|
||||
match = re.search(r"(/[^\s`]+\.xlsx)", summary)
|
||||
if match:
|
||||
candidate = Path(match.group(1))
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
filtered = [
|
||||
path
|
||||
for path in exports
|
||||
if path.name != "zhihu_hotlist_template.xlsx"
|
||||
]
|
||||
if filtered:
|
||||
return sorted(filtered)[-1]
|
||||
return None
|
||||
|
||||
|
||||
def write_acceptance_doc(result: dict, items: list[HotItem], score: dict) -> None:
|
||||
ACCEPTANCE_DOC.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [
|
||||
"# Zhihu Hotlist Excel Acceptance",
|
||||
"",
|
||||
f"- Date: {time.strftime('%Y-%m-%d %H:%M:%S %z')}",
|
||||
"- Mode: real provider + live Zhihu hotlist API + simulated browser pipe",
|
||||
f"- Workspace: `{result['workspace_root']}`",
|
||||
f"- Final success: `{score['final_success']}`",
|
||||
f"- Total score: `{score['total_score']}/100`",
|
||||
"",
|
||||
"## Rubric",
|
||||
"",
|
||||
f"- skill selection: `{score['skill_selection']}/30`",
|
||||
f"- tool discipline: `{score['tool_discipline']}/25`",
|
||||
f"- hotlist data correctness: `{score['hotlist_data_correctness']}/20`",
|
||||
f"- xlsx export success: `{score['xlsx_export_success']}/20`",
|
||||
f"- final response quality: `{score['final_response_quality']}/5`",
|
||||
"",
|
||||
"## Final Output",
|
||||
"",
|
||||
f"- exported_path: `{score['exported_path']}`",
|
||||
f"- final_summary: `{score['final_summary']}`",
|
||||
"",
|
||||
"## Skill Logs",
|
||||
"",
|
||||
]
|
||||
for message in score["logs"]:
|
||||
lines.append(f"- `{message}`")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Live Hotlist Sample",
|
||||
"",
|
||||
]
|
||||
)
|
||||
for item in items:
|
||||
lines.append(f"- {item.rank}. {item.title} | {item.heat}")
|
||||
if score["stderr"]:
|
||||
lines.extend(["", "## Stderr", ""])
|
||||
for line in score["stderr"]:
|
||||
lines.append(f"- `{line}`")
|
||||
ACCEPTANCE_DOC.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130)
|
||||
Reference in New Issue
Block a user