Compare commits
34 Commits
eaa99a20db
...
chore/sqlx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7a1d5400 | ||
|
|
d9b0b4f4f7 | ||
|
|
edd6dd5fc8 | ||
|
|
4329bae1ea | ||
|
|
924ad5a6ec | ||
|
|
e94235c4f9 | ||
|
|
72b3206a6b | ||
|
|
0fd78ac321 | ||
|
|
ab4d06c4d6 | ||
|
|
1595290db2 | ||
|
|
2c0602e0e6 | ||
|
|
f358f14f12 | ||
|
|
7cdcfaddb0 | ||
|
|
3c6581f915 | ||
|
|
cb727fdcc7 | ||
|
|
a9ea9d8691 | ||
|
|
f97e6fdbb6 | ||
|
|
7d03e6a90c | ||
|
|
415abf9e66 | ||
|
|
8d218e9ab9 | ||
|
|
e2d44ecf52 | ||
|
|
8ec6ca5990 | ||
|
|
7e8eb64c4a | ||
|
|
e88c51fd85 | ||
|
|
e10549a1b9 | ||
|
|
f3fb5340b5 | ||
|
|
35a11504d7 | ||
|
|
450569dc88 | ||
|
|
3a24455401 | ||
|
|
4e4eefdde1 | ||
|
|
0522f2bf95 | ||
|
|
04f70c797d | ||
|
|
a685e97b17 | ||
|
|
2037809196 |
31
CLAUDE.md
31
CLAUDE.md
@@ -227,21 +227,22 @@ Client → 负责网络通信和协议转换
|
|||||||
|
|
||||||
## 6. 自主能力系统 (Hands)
|
## 6. 自主能力系统 (Hands)
|
||||||
|
|
||||||
ZCLAW 提供 11 个自主能力包(9 启用 + 2 禁用):
|
ZCLAW 提供 12 个自主能力包(7 已注册 + 3 开发中 + 2 禁用):
|
||||||
|
|
||||||
| Hand | 功能 | 状态 |
|
| Hand | 功能 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||||
| Researcher | 深度研究 | ✅ 可用 |
|
| Researcher | 深度研究 | ✅ 可用 |
|
||||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
|
||||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
|
||||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||||
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
||||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
|
||||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
|
||||||
| Speech | 语音合成 | ✅ 可用(Browser TTS 前端集成完成) |
|
|
||||||
| Quiz | 测验生成 | ✅ 可用 |
|
| Quiz | 测验生成 | ✅ 可用 |
|
||||||
|
| _reminder | 系统内部提醒 | ✅ 可用(kernel 编程注册,无 HAND.toml) |
|
||||||
|
| Whiteboard | 白板演示 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||||
|
| Slideshow | 幻灯片生成 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||||
|
| Speech | 语音合成 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||||
|
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
|
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
|
|
||||||
**触发 Hand 时:**
|
**触发 Hand 时:**
|
||||||
1. 检查依赖是否满足
|
1. 检查依赖是否满足
|
||||||
@@ -541,10 +542,10 @@ refactor(store): 统一 Store 数据获取方式
|
|||||||
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
|
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
|
||||||
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
|
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
|
||||||
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
|
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
|
||||||
| 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 |
|
| 记忆管道 (Memory) | ✅ 稳定 | 04-17 E2E 验证: 存储+FTS5+TF-IDF+注入闭环,去重+跨会话注入已修复 |
|
||||||
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
||||||
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
|
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
|
||||||
| Hands 系统 | ✅ 稳定 | 9 启用 (Browser/Collector/Researcher/Twitter/Whiteboard/Slideshow/Speech/Quiz/Clip) |
|
| Hands 系统 | ✅ 稳定 | 7 注册 (6 HAND.toml + _reminder),Whiteboard/Slideshow/Speech 开发中 |
|
||||||
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
|
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
|
||||||
| 中间件链 | ✅ 稳定 | 14 层 (ButlerRouter@80, DataMasking@90, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) |
|
| 中间件链 | ✅ 稳定 | 14 层 (ButlerRouter@80, DataMasking@90, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) |
|
||||||
|
|
||||||
@@ -555,17 +556,17 @@ refactor(store): 统一 Store 数据获取方式
|
|||||||
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
|
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
|
||||||
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
|
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
|
||||||
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
|
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
|
||||||
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示
|
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示(E2E 04-17 验证通过,去重+跨会话注入已修复)
|
||||||
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
|
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
|
||||||
|
|
||||||
### 最近变更
|
### 最近变更
|
||||||
|
|
||||||
1. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
1. [04-17] 全系统 E2E 测试 129 链路: 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP,有效通过率 79.1%。7 项 Bug 修复 (Dashboard 404/记忆去重/记忆注入/invoice_id/Prompt版本/agent隔离/行业字段)
|
||||||
2. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
2. [04-16] 3 项 P0 修复 + 5 项 E2E Bug 修复 + Agent 面板刷新 + TRUTH.md 数字校准
|
||||||
2. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
3. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
||||||
3. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
4. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||||
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite
|
5. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||||
4. [04-06] 4 个发布前 bug 修复 (身份覆盖/模型配置/agent同步/自动身份)
|
6. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||||
|
|
||||||
<!-- ARCH-SNAPSHOT-END -->
|
<!-- ARCH-SNAPSHOT-END -->
|
||||||
|
|
||||||
|
|||||||
320
Cargo.lock
generated
320
Cargo.lock
generated
@@ -61,19 +61,6 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ahash"
|
|
||||||
version = "0.8.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
"once_cell",
|
|
||||||
"version_check",
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -179,7 +166,7 @@ version = "0.7.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-listener 5.4.1",
|
"event-listener",
|
||||||
"event-listener-strategy",
|
"event-listener-strategy",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -235,7 +222,7 @@ version = "3.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-listener 5.4.1",
|
"event-listener",
|
||||||
"event-listener-strategy",
|
"event-listener-strategy",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
@@ -253,7 +240,7 @@ dependencies = [
|
|||||||
"async-task",
|
"async-task",
|
||||||
"blocking",
|
"blocking",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"event-listener 5.4.1",
|
"event-listener",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
]
|
]
|
||||||
@@ -1606,9 +1593,9 @@ dependencies = [
|
|||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml_bw",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx 0.7.4",
|
"sqlx",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-mcp",
|
"tauri-plugin-mcp",
|
||||||
@@ -1974,12 +1961,6 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "event-listener"
|
|
||||||
version = "2.5.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -1997,7 +1978,7 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-listener 5.4.1",
|
"event-listener",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2699,10 +2680,6 @@ name = "hashbrown"
|
|||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
"allocator-api2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
@@ -2726,15 +2703,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashlink"
|
|
||||||
version = "0.8.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
|
|
||||||
dependencies = [
|
|
||||||
"hashbrown 0.14.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -2773,9 +2741,6 @@ name = "heck"
|
|||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
dependencies = [
|
|
||||||
"unicode-segmentation",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
@@ -3555,9 +3520,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.27.0"
|
version = "0.30.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716"
|
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
@@ -4358,12 +4323,6 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "paste"
|
|
||||||
version = "1.0.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -4426,7 +4385,7 @@ version = "0.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b"
|
checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"sqlx 0.8.6",
|
"sqlx",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5492,6 +5451,7 @@ version = "0.23.37"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -5912,19 +5872,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_yaml"
|
|
||||||
version = "0.9.34+deprecated"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
|
||||||
dependencies = [
|
|
||||||
"indexmap 2.13.0",
|
|
||||||
"itoa 1.0.18",
|
|
||||||
"ryu",
|
|
||||||
"serde",
|
|
||||||
"unsafe-libyaml",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_yaml_bw"
|
name = "serde_yaml_bw"
|
||||||
version = "2.5.3"
|
version = "2.5.3"
|
||||||
@@ -6187,78 +6134,17 @@ dependencies = [
|
|||||||
"der",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sqlformat"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790"
|
|
||||||
dependencies = [
|
|
||||||
"nom",
|
|
||||||
"unicode_categories",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sqlx"
|
|
||||||
version = "0.7.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa"
|
|
||||||
dependencies = [
|
|
||||||
"sqlx-core 0.7.4",
|
|
||||||
"sqlx-macros 0.7.4",
|
|
||||||
"sqlx-mysql",
|
|
||||||
"sqlx-postgres 0.7.4",
|
|
||||||
"sqlx-sqlite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx"
|
name = "sqlx"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
|
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"sqlx-core 0.8.6",
|
"sqlx-core",
|
||||||
"sqlx-macros 0.8.6",
|
"sqlx-macros",
|
||||||
"sqlx-postgres 0.8.6",
|
"sqlx-mysql",
|
||||||
]
|
"sqlx-postgres",
|
||||||
|
"sqlx-sqlite",
|
||||||
[[package]]
|
|
||||||
name = "sqlx-core"
|
|
||||||
version = "0.7.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6"
|
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
"atoi",
|
|
||||||
"byteorder",
|
|
||||||
"bytes",
|
|
||||||
"chrono",
|
|
||||||
"crc",
|
|
||||||
"crossbeam-queue",
|
|
||||||
"either",
|
|
||||||
"event-listener 2.5.3",
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-intrusive",
|
|
||||||
"futures-io",
|
|
||||||
"futures-util",
|
|
||||||
"hashlink 0.8.4",
|
|
||||||
"hex",
|
|
||||||
"indexmap 2.13.0",
|
|
||||||
"log",
|
|
||||||
"memchr",
|
|
||||||
"once_cell",
|
|
||||||
"paste",
|
|
||||||
"percent-encoding",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"smallvec",
|
|
||||||
"sqlformat",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
"tokio-stream",
|
|
||||||
"tracing",
|
|
||||||
"url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6269,16 +6155,17 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
"either",
|
"either",
|
||||||
"event-listener 5.4.1",
|
"event-listener",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-intrusive",
|
"futures-intrusive",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"hashlink 0.10.0",
|
"hashlink",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"log",
|
"log",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -6289,23 +6176,12 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sqlx-macros"
|
|
||||||
version = "0.7.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"sqlx-core 0.7.4",
|
|
||||||
"sqlx-macros-core 0.7.4",
|
|
||||||
"syn 1.0.109",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx-macros"
|
name = "sqlx-macros"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -6314,37 +6190,11 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"sqlx-core 0.8.6",
|
"sqlx-core",
|
||||||
"sqlx-macros-core 0.8.6",
|
"sqlx-macros-core",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sqlx-macros-core"
|
|
||||||
version = "0.7.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8"
|
|
||||||
dependencies = [
|
|
||||||
"dotenvy",
|
|
||||||
"either",
|
|
||||||
"heck 0.4.1",
|
|
||||||
"hex",
|
|
||||||
"once_cell",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"sqlx-core 0.7.4",
|
|
||||||
"sqlx-mysql",
|
|
||||||
"sqlx-postgres 0.7.4",
|
|
||||||
"sqlx-sqlite",
|
|
||||||
"syn 1.0.109",
|
|
||||||
"tempfile",
|
|
||||||
"tokio",
|
|
||||||
"url",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx-macros-core"
|
name = "sqlx-macros-core"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -6361,20 +6211,23 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx-core 0.8.6",
|
"sqlx-core",
|
||||||
"sqlx-postgres 0.8.6",
|
"sqlx-mysql",
|
||||||
|
"sqlx-postgres",
|
||||||
|
"sqlx-sqlite",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx-mysql"
|
name = "sqlx-mysql"
|
||||||
version = "0.7.4"
|
version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
|
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64 0.21.7",
|
"base64 0.22.1",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -6403,48 +6256,9 @@ dependencies = [
|
|||||||
"sha1 0.10.6",
|
"sha1 0.10.6",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core 0.7.4",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 1.0.69",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
|
||||||
"whoami",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sqlx-postgres"
|
|
||||||
version = "0.7.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
|
|
||||||
dependencies = [
|
|
||||||
"atoi",
|
|
||||||
"base64 0.21.7",
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"byteorder",
|
|
||||||
"chrono",
|
|
||||||
"crc",
|
|
||||||
"dotenvy",
|
|
||||||
"etcetera",
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-io",
|
|
||||||
"futures-util",
|
|
||||||
"hex",
|
|
||||||
"hkdf",
|
|
||||||
"hmac",
|
|
||||||
"home",
|
|
||||||
"itoa 1.0.18",
|
|
||||||
"log",
|
|
||||||
"md-5",
|
|
||||||
"memchr",
|
|
||||||
"once_cell",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"smallvec",
|
|
||||||
"sqlx-core 0.7.4",
|
|
||||||
"stringprep",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
@@ -6459,6 +6273,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"etcetera",
|
"etcetera",
|
||||||
@@ -6479,7 +6294,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core 0.8.6",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6488,9 +6303,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx-sqlite"
|
name = "sqlx-sqlite"
|
||||||
version = "0.7.4"
|
version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
|
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6504,10 +6319,11 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx-core 0.7.4",
|
"serde_urlencoded",
|
||||||
|
"sqlx-core",
|
||||||
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7824,12 +7640,6 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode_categories"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -7840,12 +7650,6 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unsafe-libyaml"
|
|
||||||
version = "0.2.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unsafe-libyaml-norway"
|
name = "unsafe-libyaml-norway"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -7858,6 +7662,35 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"ureq-proto",
|
||||||
|
"utf8-zero",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq-proto"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"http 1.4.0",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -7895,6 +7728,12 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-zero"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -9573,7 +9412,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"blocking",
|
"blocking",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"event-listener 5.4.1",
|
"event-listener",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -9630,7 +9469,7 @@ dependencies = [
|
|||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx 0.7.4",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
@@ -9696,7 +9535,7 @@ dependencies = [
|
|||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx 0.7.4",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -9723,7 +9562,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zclaw-hands",
|
"zclaw-hands",
|
||||||
"zclaw-kernel",
|
|
||||||
"zclaw-runtime",
|
"zclaw-runtime",
|
||||||
"zclaw-skills",
|
"zclaw-skills",
|
||||||
"zclaw-types",
|
"zclaw-types",
|
||||||
@@ -9809,7 +9647,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"sqlx 0.7.4",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -9840,6 +9678,8 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"ureq",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasmtime",
|
"wasmtime",
|
||||||
"wasmtime-wasi",
|
"wasmtime-wasi",
|
||||||
|
|||||||
@@ -57,12 +57,15 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
|
||||||
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||||
|
|
||||||
# HTTP client (for LLM drivers)
|
# HTTP client (for LLM drivers)
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
|
|
||||||
|
# Synchronous HTTP (for WASM host functions in blocking threads)
|
||||||
|
ureq = { version = "3", features = ["rustls"] }
|
||||||
|
|
||||||
# URL parsing
|
# URL parsing
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function Sidebar({
|
|||||||
const isActive =
|
const isActive =
|
||||||
item.path === '/'
|
item.path === '/'
|
||||||
? activePath === '/'
|
? activePath === '/'
|
||||||
: activePath.startsWith(item.path)
|
: activePath === item.path || activePath.startsWith(item.path + '/')
|
||||||
|
|
||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import type { DashboardStats } from '@/types'
|
|||||||
|
|
||||||
export const statsService = {
|
export const statsService = {
|
||||||
dashboard: (signal?: AbortSignal) =>
|
dashboard: (signal?: AbortSignal) =>
|
||||||
request.get<DashboardStats>('/stats/dashboard', withSignal({}, signal)).then((r) => r.data),
|
request.get<DashboardStats>('/admin/dashboard', withSignal({}, signal)).then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
305
crates/zclaw-growth/src/evolution_engine.rs
Normal file
305
crates/zclaw-growth/src/evolution_engine.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
//! 进化引擎中枢
|
||||||
|
//! 协调 L1/L2/L3 三层进化的触发和执行
|
||||||
|
//! L1 (记忆进化) 在 GrowthIntegration 中处理
|
||||||
|
//! L2 (技能进化) 通过 PatternAggregator + SkillGenerator + QualityGate 协调
|
||||||
|
//! L3 (工作流进化) 通过 WorkflowComposer 协调
|
||||||
|
//! 反馈闭环通过 FeedbackCollector 管理
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::experience_store::ExperienceStore;
|
||||||
|
use crate::feedback_collector::{
|
||||||
|
FeedbackCollector, FeedbackEntry, TrustUpdate,
|
||||||
|
};
|
||||||
|
use crate::pattern_aggregator::{AggregatedPattern, PatternAggregator};
|
||||||
|
use crate::quality_gate::{QualityGate, QualityReport};
|
||||||
|
use crate::skill_generator::{SkillCandidate, SkillGenerator};
|
||||||
|
use crate::workflow_composer::{ToolChainPattern, WorkflowComposer};
|
||||||
|
use crate::VikingAdapter;
|
||||||
|
use zclaw_types::Result;
|
||||||
|
|
||||||
|
/// 进化引擎配置
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EvolutionConfig {
|
||||||
|
/// 经验复用次数达到此阈值触发 L2
|
||||||
|
pub min_reuse_for_skill: u32,
|
||||||
|
/// 置信度阈值
|
||||||
|
pub quality_confidence_threshold: f32,
|
||||||
|
/// 是否启用进化引擎
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EvolutionConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
min_reuse_for_skill: 3,
|
||||||
|
quality_confidence_threshold: 0.7,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进化引擎中枢
|
||||||
|
pub struct EvolutionEngine {
|
||||||
|
viking: Arc<VikingAdapter>,
|
||||||
|
feedback: Arc<tokio::sync::Mutex<FeedbackCollector>>,
|
||||||
|
config: EvolutionConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvolutionEngine {
|
||||||
|
pub fn new(viking: Arc<VikingAdapter>) -> Self {
|
||||||
|
Self {
|
||||||
|
viking: viking.clone(),
|
||||||
|
feedback: Arc::new(tokio::sync::Mutex::new(
|
||||||
|
FeedbackCollector::with_viking(viking),
|
||||||
|
)),
|
||||||
|
config: EvolutionConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// Backward-compatible constructor
|
||||||
|
/// 从 ExperienceStore 中提取共享的 VikingAdapter 实例
|
||||||
|
pub fn from_experience_store(experience_store: Arc<ExperienceStore>) -> Self {
|
||||||
|
let viking = experience_store.viking().clone();
|
||||||
|
Self {
|
||||||
|
viking: viking.clone(),
|
||||||
|
feedback: Arc::new(tokio::sync::Mutex::new(
|
||||||
|
FeedbackCollector::with_viking(viking),
|
||||||
|
)),
|
||||||
|
config: EvolutionConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
pub fn with_config(mut self, config: EvolutionConfig) -> Self {
|
||||||
|
self.config = config;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
|
self.config.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// L2 检查:是否有可进化的模式
|
||||||
|
pub async fn check_evolvable_patterns(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
) -> Result<Vec<AggregatedPattern>> {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let store = ExperienceStore::new(self.viking.clone());
|
||||||
|
let aggregator = PatternAggregator::new(store);
|
||||||
|
aggregator
|
||||||
|
.find_evolvable_patterns(agent_id, self.config.min_reuse_for_skill)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// L2 执行:为给定模式构建技能生成 prompt
|
||||||
|
/// 返回 (prompt_string, pattern) 供上层通过 LLM 调用后 parse
|
||||||
|
pub fn build_skill_prompt(&self, pattern: &AggregatedPattern) -> String {
|
||||||
|
SkillGenerator::build_prompt(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// L2 执行:解析 LLM 返回的技能 JSON 并进行质量门控
|
||||||
|
pub fn validate_skill_candidate(
|
||||||
|
&self,
|
||||||
|
json_str: &str,
|
||||||
|
pattern: &AggregatedPattern,
|
||||||
|
existing_triggers: Vec<String>,
|
||||||
|
) -> Result<(SkillCandidate, QualityReport)> {
|
||||||
|
let candidate = SkillGenerator::parse_response(json_str, pattern)?;
|
||||||
|
let gate = QualityGate::new(self.config.quality_confidence_threshold, existing_triggers);
|
||||||
|
let report = gate.validate_skill(&candidate);
|
||||||
|
Ok((candidate, report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// 获取当前配置
|
||||||
|
pub fn config(&self) -> &EvolutionConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// L3: 工作流进化
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// L3: 从轨迹数据中提取重复的工具链模式
|
||||||
|
pub fn analyze_trajectory_patterns(
|
||||||
|
&self,
|
||||||
|
trajectories: &[(String, Vec<String>)], // (session_id, tools_used)
|
||||||
|
) -> Vec<(ToolChainPattern, Vec<String>)> {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
WorkflowComposer::extract_patterns(trajectories)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// L3: 为给定工具链模式构建工作流生成 prompt
|
||||||
|
pub fn build_workflow_prompt(
|
||||||
|
&self,
|
||||||
|
pattern: &ToolChainPattern,
|
||||||
|
frequency: usize,
|
||||||
|
industry: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
WorkflowComposer::build_prompt(pattern, frequency, industry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 反馈闭环
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 提交反馈并获取信任度更新,自动持久化
|
||||||
|
pub async fn submit_feedback(&self, entry: FeedbackEntry) -> TrustUpdate {
|
||||||
|
let mut feedback = self.feedback.lock().await;
|
||||||
|
let update = feedback.submit_feedback(entry);
|
||||||
|
// 非阻塞持久化:失败仅打日志,不影响返回值
|
||||||
|
if let Err(e) = feedback.save().await {
|
||||||
|
tracing::warn!("[EvolutionEngine] Failed to persist trust records: {}", e);
|
||||||
|
}
|
||||||
|
update
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// 获取需要优化的进化产物
|
||||||
|
pub async fn get_artifacts_needing_optimization(&self) -> Vec<String> {
|
||||||
|
self.feedback
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get_artifacts_needing_optimization()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.artifact_id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// 获取建议归档的进化产物
|
||||||
|
pub async fn get_artifacts_to_archive(&self) -> Vec<String> {
|
||||||
|
self.feedback
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get_artifacts_to_archive()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.artifact_id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||||
|
/// 获取推荐产物
|
||||||
|
pub async fn get_recommended_artifacts(&self) -> Vec<String> {
|
||||||
|
self.feedback
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get_recommended_artifacts()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.artifact_id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动时加载已持久化的信任度记录
|
||||||
|
pub async fn load_feedback(&self) -> Result<usize> {
|
||||||
|
self.feedback
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.load()
|
||||||
|
.await
|
||||||
|
.map_err(|e| zclaw_types::ZclawError::Internal(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::experience_store::Experience;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_disabled_returns_empty() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let mut engine = EvolutionEngine::new(viking);
|
||||||
|
engine.set_enabled(false);
|
||||||
|
|
||||||
|
let patterns = engine.check_evolvable_patterns("agent-1").await.unwrap();
|
||||||
|
assert!(patterns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_no_evolvable_patterns() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let engine = EvolutionEngine::new(viking);
|
||||||
|
|
||||||
|
let patterns = engine.check_evolvable_patterns("unknown-agent").await.unwrap();
|
||||||
|
assert!(patterns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_finds_evolvable_pattern() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let store_inner = ExperienceStore::new(viking.clone());
|
||||||
|
|
||||||
|
let mut exp = Experience::new(
|
||||||
|
"agent-1",
|
||||||
|
"report generation",
|
||||||
|
"researcher",
|
||||||
|
vec!["query db".into(), "format".into()],
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
exp.reuse_count = 5;
|
||||||
|
store_inner.store_experience(&exp).await.unwrap();
|
||||||
|
|
||||||
|
let engine = EvolutionEngine::new(viking);
|
||||||
|
|
||||||
|
let patterns = engine.check_evolvable_patterns("agent-1").await.unwrap();
|
||||||
|
assert_eq!(patterns.len(), 1);
|
||||||
|
assert_eq!(patterns[0].pain_pattern, "report generation");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_skill_prompt() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let engine = EvolutionEngine::new(viking);
|
||||||
|
|
||||||
|
let exp = Experience::new(
|
||||||
|
"a", "report", "researcher", vec!["step1".into()], "ok",
|
||||||
|
);
|
||||||
|
let pattern = AggregatedPattern {
|
||||||
|
pain_pattern: "report".to_string(),
|
||||||
|
experiences: vec![exp],
|
||||||
|
common_steps: vec!["step1".into()],
|
||||||
|
total_reuse: 5,
|
||||||
|
tools_used: vec!["researcher".into()],
|
||||||
|
industry_context: None,
|
||||||
|
};
|
||||||
|
let prompt = engine.build_skill_prompt(&pattern);
|
||||||
|
assert!(prompt.contains("report"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_skill_candidate() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let engine = EvolutionEngine::new(viking);
|
||||||
|
|
||||||
|
let exp = Experience::new(
|
||||||
|
"a", "report", "researcher", vec!["step1".into()], "ok",
|
||||||
|
);
|
||||||
|
let pattern = AggregatedPattern {
|
||||||
|
pain_pattern: "report".to_string(),
|
||||||
|
experiences: vec![exp],
|
||||||
|
common_steps: vec!["step1".into()],
|
||||||
|
total_reuse: 5,
|
||||||
|
tools_used: vec!["researcher".into()],
|
||||||
|
industry_context: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = r##"{"name":"报表技能","description":"生成报表","triggers":["报表","日报"],"tools":["researcher"],"body_markdown":"# 报表\n步骤","confidence":0.9}"##;
|
||||||
|
let (candidate, report) = engine
|
||||||
|
.validate_skill_candidate(json, &pattern, vec!["搜索".to_string()])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(candidate.name, "报表技能");
|
||||||
|
assert!(report.passed);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
crates/zclaw-growth/src/experience_extractor.rs
Normal file
119
crates/zclaw-growth/src/experience_extractor.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
//! 结构化经验提取器
|
||||||
|
//! 从对话中提取 ExperienceCandidate(pain_pattern → solution_steps → outcome)
|
||||||
|
//! 持久化到 ExperienceStore
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::experience_store::ExperienceStore;
|
||||||
|
use crate::types::{CombinedExtraction, Outcome};
|
||||||
|
|
||||||
|
/// 结构化经验提取器
|
||||||
|
/// LLM 调用已由上层 MemoryExtractor 完成,这里只做解析和持久化
|
||||||
|
pub struct ExperienceExtractor {
|
||||||
|
store: Option<Arc<ExperienceStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExperienceExtractor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { store: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_store(mut self, store: Arc<ExperienceStore>) -> Self {
|
||||||
|
self.store = Some(store);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 CombinedExtraction 中提取经验并持久化
|
||||||
|
/// LLM 调用已由上层完成,这里只做解析和存储
|
||||||
|
pub async fn persist_experiences(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
extraction: &CombinedExtraction,
|
||||||
|
) -> zclaw_types::Result<usize> {
|
||||||
|
let store = match &self.store {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
for candidate in &extraction.experiences {
|
||||||
|
if candidate.confidence < 0.6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let outcome_str = match candidate.outcome {
|
||||||
|
Outcome::Success => "success",
|
||||||
|
Outcome::Partial => "partial",
|
||||||
|
Outcome::Failed => "failed",
|
||||||
|
};
|
||||||
|
let mut exp = crate::experience_store::Experience::new(
|
||||||
|
agent_id,
|
||||||
|
&candidate.pain_pattern,
|
||||||
|
&candidate.context,
|
||||||
|
candidate.solution_steps.clone(),
|
||||||
|
outcome_str,
|
||||||
|
);
|
||||||
|
// 填充 tool_used:取 tools_used 中的第一个作为主要工具
|
||||||
|
exp.tool_used = candidate.tools_used.first().cloned();
|
||||||
|
exp.industry_context = candidate.industry_context.clone();
|
||||||
|
store.store_experience(&exp).await?;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ExperienceExtractor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::{ExperienceCandidate, Outcome};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extractor_new_without_store() {
|
||||||
|
let ext = ExperienceExtractor::new();
|
||||||
|
assert!(ext.store.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_persist_no_store_returns_zero() {
|
||||||
|
let ext = ExperienceExtractor::new();
|
||||||
|
let extraction = CombinedExtraction::default();
|
||||||
|
let count = ext.persist_experiences("agent1", &extraction).await.unwrap();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_persist_filters_low_confidence() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let store = Arc::new(ExperienceStore::new(viking));
|
||||||
|
let ext = ExperienceExtractor::new().with_store(store);
|
||||||
|
|
||||||
|
let mut extraction = CombinedExtraction::default();
|
||||||
|
extraction.experiences.push(ExperienceCandidate {
|
||||||
|
pain_pattern: "low confidence task".to_string(),
|
||||||
|
context: "should be filtered".to_string(),
|
||||||
|
solution_steps: vec!["step1".to_string()],
|
||||||
|
outcome: Outcome::Success,
|
||||||
|
confidence: 0.3, // 低于 0.6 阈值
|
||||||
|
tools_used: vec![],
|
||||||
|
industry_context: None,
|
||||||
|
});
|
||||||
|
extraction.experiences.push(ExperienceCandidate {
|
||||||
|
pain_pattern: "high confidence task".to_string(),
|
||||||
|
context: "should be stored".to_string(),
|
||||||
|
solution_steps: vec!["step1".to_string(), "step2".to_string()],
|
||||||
|
outcome: Outcome::Success,
|
||||||
|
confidence: 0.9,
|
||||||
|
tools_used: vec!["researcher".to_string()],
|
||||||
|
industry_context: Some("healthcare".to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = ext.persist_experiences("agent-1", &extraction).await.unwrap();
|
||||||
|
assert_eq!(count, 1); // 只有 1 个通过置信度过滤
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,9 @@ pub struct Experience {
|
|||||||
/// Which trigger signal produced this experience.
|
/// Which trigger signal produced this experience.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub source_trigger: Option<String>,
|
pub source_trigger: Option<String>,
|
||||||
|
/// Primary tool/skill used to resolve this pain point.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tool_used: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Experience {
|
impl Experience {
|
||||||
@@ -72,6 +75,7 @@ impl Experience {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
industry_context: None,
|
industry_context: None,
|
||||||
source_trigger: None,
|
source_trigger: None,
|
||||||
|
tool_used: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +113,11 @@ impl ExperienceStore {
|
|||||||
Self { viking }
|
Self { viking }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the underlying VikingAdapter.
|
||||||
|
pub fn viking(&self) -> &Arc<VikingAdapter> {
|
||||||
|
&self.viking
|
||||||
|
}
|
||||||
|
|
||||||
/// Store (or overwrite) an experience. The URI is derived from
|
/// Store (or overwrite) an experience. The URI is derived from
|
||||||
/// `agent_id + pain_pattern`, ensuring one experience per pattern.
|
/// `agent_id + pain_pattern`, ensuring one experience per pattern.
|
||||||
pub async fn store_experience(&self, exp: &Experience) -> zclaw_types::Result<()> {
|
pub async fn store_experience(&self, exp: &Experience) -> zclaw_types::Result<()> {
|
||||||
@@ -119,6 +128,9 @@ impl ExperienceStore {
|
|||||||
if let Some(ref industry) = exp.industry_context {
|
if let Some(ref industry) = exp.industry_context {
|
||||||
keywords.push(industry.clone());
|
keywords.push(industry.clone());
|
||||||
}
|
}
|
||||||
|
if let Some(ref tool) = exp.tool_used {
|
||||||
|
keywords.push(tool.clone());
|
||||||
|
}
|
||||||
|
|
||||||
let entry = MemoryEntry {
|
let entry = MemoryEntry {
|
||||||
uri,
|
uri,
|
||||||
|
|||||||
@@ -19,6 +19,34 @@ pub trait LlmDriverForExtraction: Send + Sync {
|
|||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
extraction_type: MemoryType,
|
extraction_type: MemoryType,
|
||||||
) -> Result<Vec<ExtractedMemory>>;
|
) -> Result<Vec<ExtractedMemory>>;
|
||||||
|
|
||||||
|
/// 单次 LLM 调用提取全部类型(记忆 + 经验 + 画像信号)
|
||||||
|
/// 默认实现:退化到 3 次独立调用(experiences 和 profile_signals 为空)
|
||||||
|
async fn extract_combined_all(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
) -> Result<crate::types::CombinedExtraction> {
|
||||||
|
let mut combined = crate::types::CombinedExtraction::default();
|
||||||
|
for mt in [MemoryType::Preference, MemoryType::Knowledge, MemoryType::Experience] {
|
||||||
|
if let Ok(mems) = self.extract_memories(messages, mt).await {
|
||||||
|
combined.memories.extend(mems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用自定义 prompt 进行单次 LLM 调用,返回原始文本响应
|
||||||
|
/// 用于统一提取场景,默认返回不支持错误
|
||||||
|
async fn extract_with_prompt(
|
||||||
|
&self,
|
||||||
|
_messages: &[Message],
|
||||||
|
_system_prompt: &str,
|
||||||
|
_user_prompt: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
Err(zclaw_types::ZclawError::Internal(
|
||||||
|
"extract_with_prompt not implemented".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Memory Extractor - extracts memories from conversations
|
/// Memory Extractor - extracts memories from conversations
|
||||||
@@ -85,13 +113,10 @@ impl MemoryExtractor {
|
|||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
) -> Result<Vec<ExtractedMemory>> {
|
) -> Result<Vec<ExtractedMemory>> {
|
||||||
// Check if LLM driver is available
|
// Check if LLM driver is available
|
||||||
let _llm_driver = match &self.llm_driver {
|
if self.llm_driver.is_none() {
|
||||||
Some(driver) => driver,
|
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
|
||||||
None => {
|
return Ok(Vec::new());
|
||||||
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
|
}
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
@@ -227,6 +252,299 @@ impl MemoryExtractor {
|
|||||||
tracing::info!("[MemoryExtractor] Stored {} memories to OpenViking", stored);
|
tracing::info!("[MemoryExtractor] Stored {} memories to OpenViking", stored);
|
||||||
Ok(stored)
|
Ok(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 统一提取:单次 LLM 调用同时产出 memories + experiences + profile_signals
|
||||||
|
///
|
||||||
|
/// 优先使用 `extract_with_prompt()` 进行单次调用;若 driver 不支持则
|
||||||
|
/// 退化为 `extract()` + 从记忆推断经验/画像。
|
||||||
|
pub async fn extract_combined(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<crate::types::CombinedExtraction> {
|
||||||
|
let llm_driver = match &self.llm_driver {
|
||||||
|
Some(driver) => driver,
|
||||||
|
None => {
|
||||||
|
tracing::debug!(
|
||||||
|
"[MemoryExtractor] No LLM driver configured, skipping combined extraction"
|
||||||
|
);
|
||||||
|
return Ok(crate::types::CombinedExtraction::default());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 尝试单次 LLM 调用路径
|
||||||
|
let system_prompt = "You are a memory extraction assistant. Analyze conversations and extract \
|
||||||
|
structured memories, experiences, and profile signals in valid JSON format. \
|
||||||
|
Always respond with valid JSON only, no additional text or markdown formatting.";
|
||||||
|
let user_prompt = format!(
|
||||||
|
"{}{}",
|
||||||
|
crate::extractor::prompts::COMBINED_EXTRACTION_PROMPT,
|
||||||
|
format_conversation_text(messages)
|
||||||
|
);
|
||||||
|
|
||||||
|
match llm_driver
|
||||||
|
.extract_with_prompt(messages, system_prompt, &user_prompt)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(raw_text) if !raw_text.trim().is_empty() => {
|
||||||
|
match parse_combined_response(&raw_text, session_id.clone()) {
|
||||||
|
Ok(combined) => {
|
||||||
|
tracing::info!(
|
||||||
|
"[MemoryExtractor] Combined extraction: {} memories, {} experiences, {} profile signals",
|
||||||
|
combined.memories.len(),
|
||||||
|
combined.experiences.len(),
|
||||||
|
combined.profile_signals.signal_count(),
|
||||||
|
);
|
||||||
|
return Ok(combined);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"[MemoryExtractor] Combined response parse failed, falling back: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::debug!("[MemoryExtractor] extract_with_prompt returned empty, falling back");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"[MemoryExtractor] extract_with_prompt not supported ({}), falling back",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退化路径:使用已有的 extract() 然后推断 experiences 和 profile_signals
|
||||||
|
let memories = self.extract(messages, session_id).await?;
|
||||||
|
let experiences = infer_experiences_from_memories(&memories);
|
||||||
|
let profile_signals = infer_profile_signals_from_memories(&memories);
|
||||||
|
|
||||||
|
Ok(crate::types::CombinedExtraction {
|
||||||
|
memories,
|
||||||
|
experiences,
|
||||||
|
profile_signals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化对话消息为文本
|
||||||
|
fn format_conversation_text(messages: &[Message]) -> String {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|msg| match msg {
|
||||||
|
Message::User { content } => Some(format!("[User]: {}", content)),
|
||||||
|
Message::Assistant { content, .. } => Some(format!("[Assistant]: {}", content)),
|
||||||
|
Message::System { content } => Some(format!("[System]: {}", content)),
|
||||||
|
Message::ToolUse { .. } | Message::ToolResult { .. } => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 LLM 原始响应解析 CombinedExtraction
|
||||||
|
pub fn parse_combined_response(
|
||||||
|
raw: &str,
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<crate::types::CombinedExtraction> {
|
||||||
|
use crate::types::CombinedExtraction;
|
||||||
|
|
||||||
|
let json_str = crate::json_utils::extract_json_block(raw);
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(json_str).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::Internal(format!("Failed to parse combined JSON: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 解析 memories
|
||||||
|
let memories = parsed
|
||||||
|
.get("memories")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|item| parse_memory_item(item, &session_id))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// 解析 experiences
|
||||||
|
let experiences = parsed
|
||||||
|
.get("experiences")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(parse_experience_item)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// 解析 profile_signals
|
||||||
|
let profile_signals = parse_profile_signals(&parsed);
|
||||||
|
|
||||||
|
Ok(CombinedExtraction {
|
||||||
|
memories,
|
||||||
|
experiences,
|
||||||
|
profile_signals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析单个 memory 项
|
||||||
|
fn parse_memory_item(
|
||||||
|
value: &serde_json::Value,
|
||||||
|
session_id: &SessionId,
|
||||||
|
) -> Option<ExtractedMemory> {
|
||||||
|
let content = value.get("content")?.as_str()?.to_string();
|
||||||
|
let category = value
|
||||||
|
.get("category")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let memory_type_str = value
|
||||||
|
.get("memory_type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("knowledge");
|
||||||
|
let memory_type = crate::types::MemoryType::parse(memory_type_str);
|
||||||
|
let confidence = value
|
||||||
|
.get("confidence")
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(0.7) as f32;
|
||||||
|
let keywords = crate::json_utils::extract_string_array(value, "keywords");
|
||||||
|
|
||||||
|
Some(
|
||||||
|
ExtractedMemory::new(memory_type, category, content, session_id.clone())
|
||||||
|
.with_confidence(confidence)
|
||||||
|
.with_keywords(keywords),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析单个 experience 项
|
||||||
|
fn parse_experience_item(value: &serde_json::Value) -> Option<crate::types::ExperienceCandidate> {
|
||||||
|
use crate::types::Outcome;
|
||||||
|
|
||||||
|
let pain_pattern = value.get("pain_pattern")?.as_str()?.to_string();
|
||||||
|
let context = value
|
||||||
|
.get("context")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let solution_steps = crate::json_utils::extract_string_array(value, "solution_steps");
|
||||||
|
let outcome_str = value
|
||||||
|
.get("outcome")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("partial");
|
||||||
|
let outcome = match outcome_str {
|
||||||
|
"success" => Outcome::Success,
|
||||||
|
"failed" => Outcome::Failed,
|
||||||
|
_ => Outcome::Partial,
|
||||||
|
};
|
||||||
|
let confidence = value
|
||||||
|
.get("confidence")
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(0.6) as f32;
|
||||||
|
let tools_used = crate::json_utils::extract_string_array(value, "tools_used");
|
||||||
|
let industry_context = value
|
||||||
|
.get("industry_context")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
Some(crate::types::ExperienceCandidate {
|
||||||
|
pain_pattern,
|
||||||
|
context,
|
||||||
|
solution_steps,
|
||||||
|
outcome,
|
||||||
|
confidence,
|
||||||
|
tools_used,
|
||||||
|
industry_context,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 profile_signals
|
||||||
|
fn parse_profile_signals(obj: &serde_json::Value) -> crate::types::ProfileSignals {
|
||||||
|
let signals = obj.get("profile_signals");
|
||||||
|
crate::types::ProfileSignals {
|
||||||
|
industry: signals
|
||||||
|
.and_then(|s| s.get("industry"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
recent_topic: signals
|
||||||
|
.and_then(|s| s.get("recent_topic"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
pain_point: signals
|
||||||
|
.and_then(|s| s.get("pain_point"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
preferred_tool: signals
|
||||||
|
.and_then(|s| s.get("preferred_tool"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
communication_style: signals
|
||||||
|
.and_then(|s| s.get("communication_style"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从已有记忆推断结构化经验(退化路径)
|
||||||
|
fn infer_experiences_from_memories(
|
||||||
|
memories: &[ExtractedMemory],
|
||||||
|
) -> Vec<crate::types::ExperienceCandidate> {
|
||||||
|
memories
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.memory_type == crate::types::MemoryType::Experience)
|
||||||
|
.filter_map(|m| {
|
||||||
|
// 经验类记忆 → ExperienceCandidate
|
||||||
|
let content = &m.content;
|
||||||
|
if content.len() < 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(crate::types::ExperienceCandidate {
|
||||||
|
pain_pattern: m.category.clone(),
|
||||||
|
context: content.clone(),
|
||||||
|
solution_steps: Vec::new(),
|
||||||
|
outcome: crate::types::Outcome::Partial,
|
||||||
|
confidence: m.confidence * 0.7, // 降低推断置信度
|
||||||
|
tools_used: m.keywords.clone(),
|
||||||
|
industry_context: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从已有记忆推断画像信号(退化路径)
|
||||||
|
fn infer_profile_signals_from_memories(
|
||||||
|
memories: &[ExtractedMemory],
|
||||||
|
) -> crate::types::ProfileSignals {
|
||||||
|
use crate::types::ProfileSignals;
|
||||||
|
|
||||||
|
let mut signals = ProfileSignals::default();
|
||||||
|
for m in memories {
|
||||||
|
match m.memory_type {
|
||||||
|
crate::types::MemoryType::Preference => {
|
||||||
|
if m.category.contains("style") || m.category.contains("风格") {
|
||||||
|
if signals.communication_style.is_none() {
|
||||||
|
signals.communication_style = Some(m.content.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::types::MemoryType::Knowledge => {
|
||||||
|
if signals.recent_topic.is_none() && !m.keywords.is_empty() {
|
||||||
|
signals.recent_topic = Some(m.keywords.first().cloned().unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::types::MemoryType::Experience => {
|
||||||
|
for kw in &m.keywords {
|
||||||
|
if signals.preferred_tool.is_none()
|
||||||
|
&& m.content.contains(kw.as_str())
|
||||||
|
{
|
||||||
|
signals.preferred_tool = Some(kw.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signals
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default extraction prompts for LLM
|
/// Default extraction prompts for LLM
|
||||||
@@ -243,6 +561,55 @@ pub mod prompts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 统一提取 prompt — 单次 LLM 调用同时提取记忆、结构化经验、画像信号
|
||||||
|
pub const COMBINED_EXTRACTION_PROMPT: &str = r#"
|
||||||
|
分析以下对话,一次性提取三类信息。严格按 JSON 格式返回。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"memories": [
|
||||||
|
{
|
||||||
|
"memory_type": "preference|knowledge|experience",
|
||||||
|
"category": "分类标签",
|
||||||
|
"content": "记忆内容",
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"keywords": ["关键词"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"experiences": [
|
||||||
|
{
|
||||||
|
"pain_pattern": "痛点模式简述",
|
||||||
|
"context": "问题发生的上下文",
|
||||||
|
"solution_steps": ["步骤1", "步骤2"],
|
||||||
|
"outcome": "success|partial|failed",
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"tools_used": ["使用的工具/技能"],
|
||||||
|
"industry_context": "行业标识(可选)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"profile_signals": {
|
||||||
|
"industry": "用户所在行业(可选)",
|
||||||
|
"recent_topic": "最近讨论的主要话题(可选)",
|
||||||
|
"pain_point": "用户当前痛点(可选)",
|
||||||
|
"preferred_tool": "用户偏好的工具/技能(可选)",
|
||||||
|
"communication_style": "沟通风格: concise|detailed|formal|casual(可选)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提取规则
|
||||||
|
|
||||||
|
1. **memories**: 提取用户偏好(沟通风格/格式/语言)、知识(事实/领域知识/经验教训)、使用经验(技能/工具使用模式和结果)
|
||||||
|
2. **experiences**: 仅提取明确的"问题→解决"模式,要求有清晰的痛点和步骤,confidence >= 0.6
|
||||||
|
3. **profile_signals**: 从对话中推断用户画像信息,只在有明确信号时填写,留空则不填
|
||||||
|
4. 每个字段都要有实际内容,不确定的宁可省略
|
||||||
|
5. 只返回 JSON,不要附加其他文本
|
||||||
|
|
||||||
|
对话内容:
|
||||||
|
"#;
|
||||||
|
|
||||||
const PREFERENCE_EXTRACTION_PROMPT: &str = r#"
|
const PREFERENCE_EXTRACTION_PROMPT: &str = r#"
|
||||||
分析以下对话,提取用户的偏好设置。关注:
|
分析以下对话,提取用户的偏好设置。关注:
|
||||||
- 沟通风格偏好(简洁/详细、正式/随意)
|
- 沟通风格偏好(简洁/详细、正式/随意)
|
||||||
@@ -362,11 +729,103 @@ mod tests {
|
|||||||
assert!(!result.is_empty());
|
assert!(!result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extract_combined_all_default_impl() {
|
||||||
|
let driver = MockLlmDriver;
|
||||||
|
let messages = vec![Message::user("Hello")];
|
||||||
|
let result = driver.extract_combined_all(&messages).await.unwrap();
|
||||||
|
assert_eq!(result.memories.len(), 3); // 3 types
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_prompts_available() {
|
fn test_prompts_available() {
|
||||||
assert!(!prompts::get_extraction_prompt(MemoryType::Preference).is_empty());
|
assert!(!prompts::get_extraction_prompt(MemoryType::Preference).is_empty());
|
||||||
assert!(!prompts::get_extraction_prompt(MemoryType::Knowledge).is_empty());
|
assert!(!prompts::get_extraction_prompt(MemoryType::Knowledge).is_empty());
|
||||||
assert!(!prompts::get_extraction_prompt(MemoryType::Experience).is_empty());
|
assert!(!prompts::get_extraction_prompt(MemoryType::Experience).is_empty());
|
||||||
assert!(!prompts::get_extraction_prompt(MemoryType::Session).is_empty());
|
assert!(!prompts::get_extraction_prompt(MemoryType::Session).is_empty());
|
||||||
|
assert!(!prompts::COMBINED_EXTRACTION_PROMPT.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_combined_response_full() {
|
||||||
|
let raw = r#"```json
|
||||||
|
{
|
||||||
|
"memories": [
|
||||||
|
{
|
||||||
|
"memory_type": "preference",
|
||||||
|
"category": "communication-style",
|
||||||
|
"content": "用户偏好简洁回复",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"keywords": ["简洁", "风格"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"memory_type": "knowledge",
|
||||||
|
"category": "user-facts",
|
||||||
|
"content": "用户是医院行政人员",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"keywords": ["医院", "行政"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"experiences": [
|
||||||
|
{
|
||||||
|
"pain_pattern": "报表生成耗时",
|
||||||
|
"context": "月度报表需要手动汇总多个Excel",
|
||||||
|
"solution_steps": ["使用researcher工具自动抓取", "格式化输出为Excel"],
|
||||||
|
"outcome": "success",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"tools_used": ["researcher"],
|
||||||
|
"industry_context": "healthcare"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"profile_signals": {
|
||||||
|
"industry": "healthcare",
|
||||||
|
"recent_topic": "报表自动化",
|
||||||
|
"pain_point": "手动汇总Excel太慢",
|
||||||
|
"preferred_tool": "researcher",
|
||||||
|
"communication_style": "concise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```"#;
|
||||||
|
|
||||||
|
let result = super::parse_combined_response(raw, SessionId::new()).unwrap();
|
||||||
|
assert_eq!(result.memories.len(), 2);
|
||||||
|
assert_eq!(result.experiences.len(), 1);
|
||||||
|
assert_eq!(result.experiences[0].pain_pattern, "报表生成耗时");
|
||||||
|
assert_eq!(result.experiences[0].outcome, crate::types::Outcome::Success);
|
||||||
|
assert_eq!(result.profile_signals.industry.as_deref(), Some("healthcare"));
|
||||||
|
assert_eq!(result.profile_signals.pain_point.as_deref(), Some("手动汇总Excel太慢"));
|
||||||
|
assert!(result.profile_signals.has_any_signal());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_combined_response_minimal() {
|
||||||
|
let raw = r#"{"memories": [], "experiences": [], "profile_signals": {}}"#;
|
||||||
|
let result = super::parse_combined_response(raw, SessionId::new()).unwrap();
|
||||||
|
assert!(result.memories.is_empty());
|
||||||
|
assert!(result.experiences.is_empty());
|
||||||
|
assert!(!result.profile_signals.has_any_signal());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_combined_response_invalid() {
|
||||||
|
let raw = "not json at all";
|
||||||
|
let result = super::parse_combined_response(raw, SessionId::new());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extract_combined_fallback() {
|
||||||
|
// MockLlmDriver doesn't implement extract_with_prompt, so it falls back
|
||||||
|
let driver = Arc::new(MockLlmDriver);
|
||||||
|
let extractor = MemoryExtractor::new(driver);
|
||||||
|
let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
|
||||||
|
|
||||||
|
let result = extractor
|
||||||
|
.extract_combined(&messages, SessionId::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Fallback: extract() produces 3 memories, infer produces experiences from them
|
||||||
|
assert!(!result.memories.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
448
crates/zclaw-growth/src/feedback_collector.rs
Normal file
448
crates/zclaw-growth/src/feedback_collector.rs
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
//! 反馈信号收集与信任度管理(Phase 5 反馈闭环)
|
||||||
|
//! 收集用户对进化产物(技能/Pipeline)的显式/隐式反馈
|
||||||
|
//! 管理信任度衰减和优化循环
|
||||||
|
//! 信任度记录通过 VikingAdapter 持久化
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::types::MemoryType;
|
||||||
|
use crate::viking_adapter::VikingAdapter;
|
||||||
|
|
||||||
|
/// 反馈信号类型
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum FeedbackSignal {
|
||||||
|
/// 用户直接表达的意见
|
||||||
|
Explicit,
|
||||||
|
/// 从使用行为推断
|
||||||
|
ImplicitUsage,
|
||||||
|
/// 使用频率
|
||||||
|
UsageCount,
|
||||||
|
/// 任务完成率
|
||||||
|
CompletionRate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 情感倾向
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum Sentiment {
|
||||||
|
Positive,
|
||||||
|
Negative,
|
||||||
|
Neutral,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进化产物类型
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum EvolutionArtifact {
|
||||||
|
Skill,
|
||||||
|
Pipeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单条反馈记录
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FeedbackEntry {
|
||||||
|
pub artifact_id: String,
|
||||||
|
pub artifact_type: EvolutionArtifact,
|
||||||
|
pub signal: FeedbackSignal,
|
||||||
|
pub sentiment: Sentiment,
|
||||||
|
pub details: Option<String>,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 信任度记录
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TrustRecord {
|
||||||
|
pub artifact_id: String,
|
||||||
|
pub artifact_type: EvolutionArtifact,
|
||||||
|
pub trust_score: f32,
|
||||||
|
pub total_feedback: u32,
|
||||||
|
pub positive_count: u32,
|
||||||
|
pub negative_count: u32,
|
||||||
|
pub last_updated: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 反馈收集器
|
||||||
|
/// 管理反馈记录和信任度评分
|
||||||
|
/// 通过 VikingAdapter 持久化信任度记录(可选)
|
||||||
|
pub struct FeedbackCollector {
|
||||||
|
trust_records: HashMap<String, TrustRecord>,
|
||||||
|
viking: Option<Arc<VikingAdapter>>,
|
||||||
|
/// 是否已从持久化存储加载信任度记录
|
||||||
|
loaded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedbackCollector {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
trust_records: HashMap::new(),
|
||||||
|
viking: None,
|
||||||
|
loaded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建带 VikingAdapter 的 FeedbackCollector
|
||||||
|
pub fn with_viking(viking: Arc<VikingAdapter>) -> Self {
|
||||||
|
Self {
|
||||||
|
trust_records: HashMap::new(),
|
||||||
|
viking: Some(viking),
|
||||||
|
loaded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 VikingAdapter 加载已持久化的信任度记录
|
||||||
|
pub async fn load(&mut self) -> Result<usize, String> {
|
||||||
|
let viking = match &self.viking {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return Ok(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
// MemoryEntry::new("feedback", Session, artifact_id) 生成
|
||||||
|
// URI: agent://feedback/sessions/{artifact_id}
|
||||||
|
let entries = viking
|
||||||
|
.find_by_prefix("agent://feedback/sessions/")
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to load trust records: {}", e))?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
for entry in entries {
|
||||||
|
match serde_json::from_str::<TrustRecord>(&entry.content) {
|
||||||
|
Ok(record) => {
|
||||||
|
// 只合并不覆盖:保留内存中的较新记录
|
||||||
|
self.trust_records
|
||||||
|
.entry(record.artifact_id.clone())
|
||||||
|
.or_insert(record);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"[FeedbackCollector] Failed to deserialize trust record at {}: {}",
|
||||||
|
entry.uri,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"[FeedbackCollector] Loaded {} trust records from storage",
|
||||||
|
count
|
||||||
|
);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将信任度记录持久化到 VikingAdapter
|
||||||
|
/// 首次调用时自动从存储加载已有记录,避免覆盖
|
||||||
|
pub async fn save(&mut self) -> Result<usize, String> {
|
||||||
|
// 首次保存前自动加载已有记录,防止丢失历史数据
|
||||||
|
if !self.loaded {
|
||||||
|
match self.load().await {
|
||||||
|
Ok(_) => {
|
||||||
|
self.loaded = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// 加载失败时保留 loaded=false,下次 save 会重试
|
||||||
|
tracing::warn!(
|
||||||
|
"[FeedbackCollector] Auto-load before save failed, will retry next save: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let viking = match &self.viking {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return Ok(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut saved = 0;
|
||||||
|
for record in self.trust_records.values() {
|
||||||
|
let content = match serde_json::to_string(record) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"[FeedbackCollector] Failed to serialize trust record {}: {}",
|
||||||
|
record.artifact_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let entry = crate::types::MemoryEntry::new(
|
||||||
|
"feedback",
|
||||||
|
MemoryType::Session,
|
||||||
|
&record.artifact_id,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
.with_importance((record.trust_score * 10.0) as u8);
|
||||||
|
|
||||||
|
match viking.store(&entry).await {
|
||||||
|
Ok(_) => saved += 1,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"[FeedbackCollector] Failed to save trust record {}: {}",
|
||||||
|
record.artifact_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"[FeedbackCollector] Saved {} trust records to storage",
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
Ok(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交一条反馈
|
||||||
|
pub fn submit_feedback(&mut self, entry: FeedbackEntry) -> TrustUpdate {
|
||||||
|
let record = self
|
||||||
|
.trust_records
|
||||||
|
.entry(entry.artifact_id.clone())
|
||||||
|
.or_insert_with(|| TrustRecord {
|
||||||
|
artifact_id: entry.artifact_id.clone(),
|
||||||
|
artifact_type: entry.artifact_type.clone(),
|
||||||
|
trust_score: 0.5,
|
||||||
|
total_feedback: 0,
|
||||||
|
positive_count: 0,
|
||||||
|
negative_count: 0,
|
||||||
|
last_updated: Utc::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新计数
|
||||||
|
record.total_feedback += 1;
|
||||||
|
match entry.sentiment {
|
||||||
|
Sentiment::Positive => record.positive_count += 1,
|
||||||
|
Sentiment::Negative => record.negative_count += 1,
|
||||||
|
Sentiment::Neutral => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新计算信任度
|
||||||
|
let old_score = record.trust_score;
|
||||||
|
record.trust_score = Self::calculate_trust_internal(
|
||||||
|
record.positive_count,
|
||||||
|
record.negative_count,
|
||||||
|
record.total_feedback,
|
||||||
|
record.last_updated,
|
||||||
|
);
|
||||||
|
record.last_updated = Utc::now();
|
||||||
|
|
||||||
|
let new_score = record.trust_score;
|
||||||
|
let total = record.total_feedback;
|
||||||
|
let action = Self::recommend_action_internal(new_score, total);
|
||||||
|
|
||||||
|
TrustUpdate {
|
||||||
|
artifact_id: entry.artifact_id.clone(),
|
||||||
|
old_score,
|
||||||
|
new_score,
|
||||||
|
action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取信任度记录
|
||||||
|
pub fn get_trust(&self, artifact_id: &str) -> Option<&TrustRecord> {
|
||||||
|
self.trust_records.get(artifact_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有需要优化的产物(信任度 < 0.4)
|
||||||
|
pub fn get_artifacts_needing_optimization(&self) -> Vec<&TrustRecord> {
|
||||||
|
self.trust_records
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.trust_score < 0.4 && r.total_feedback >= 2)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有应该归档的产物(信任度 < 0.2 且反馈 >= 5)
|
||||||
|
pub fn get_artifacts_to_archive(&self) -> Vec<&TrustRecord> {
|
||||||
|
self.trust_records
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.trust_score < 0.2 && r.total_feedback >= 5)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有高信任产物(信任度 >= 0.8)
|
||||||
|
pub fn get_recommended_artifacts(&self) -> Vec<&TrustRecord> {
|
||||||
|
self.trust_records
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.trust_score >= 0.8)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_trust_internal(
|
||||||
|
positive: u32,
|
||||||
|
negative: u32,
|
||||||
|
total: u32,
|
||||||
|
last_updated: DateTime<Utc>,
|
||||||
|
) -> f32 {
|
||||||
|
if total == 0 {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
let positive_ratio = positive as f32 / total as f32;
|
||||||
|
let negative_penalty = negative as f32 * 0.1;
|
||||||
|
let days_since = (Utc::now() - last_updated).num_days().max(0) as f32;
|
||||||
|
let time_decay = 1.0 - (days_since * 0.005).min(0.5);
|
||||||
|
(positive_ratio * time_decay - negative_penalty).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recommend_action_internal(trust_score: f32, total_feedback: u32) -> RecommendedAction {
|
||||||
|
if trust_score >= 0.8 {
|
||||||
|
RecommendedAction::Promote
|
||||||
|
} else if trust_score < 0.2 && total_feedback >= 5 {
|
||||||
|
RecommendedAction::Archive
|
||||||
|
} else if trust_score < 0.4 && total_feedback >= 2 {
|
||||||
|
RecommendedAction::Optimize
|
||||||
|
} else {
|
||||||
|
RecommendedAction::Monitor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FeedbackCollector {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 信任度更新结果
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TrustUpdate {
|
||||||
|
pub artifact_id: String,
|
||||||
|
pub old_score: f32,
|
||||||
|
pub new_score: f32,
|
||||||
|
pub action: RecommendedAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建议动作
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum RecommendedAction {
|
||||||
|
/// 继续观察
|
||||||
|
Monitor,
|
||||||
|
/// 需要优化
|
||||||
|
Optimize,
|
||||||
|
/// 建议归档(降级为记忆)
|
||||||
|
Archive,
|
||||||
|
/// 建议提升为推荐技能
|
||||||
|
Promote,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_feedback(artifact_id: &str, sentiment: Sentiment) -> FeedbackEntry {
|
||||||
|
FeedbackEntry {
|
||||||
|
artifact_id: artifact_id.to_string(),
|
||||||
|
artifact_type: EvolutionArtifact::Skill,
|
||||||
|
signal: FeedbackSignal::Explicit,
|
||||||
|
sentiment,
|
||||||
|
details: None,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_trust() {
|
||||||
|
let collector = FeedbackCollector::new();
|
||||||
|
assert!(collector.get_trust("skill-1").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_positive_feedback_increases_trust() {
|
||||||
|
let mut collector = FeedbackCollector::new();
|
||||||
|
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
|
||||||
|
let record = collector.get_trust("skill-1").unwrap();
|
||||||
|
assert!(record.trust_score > 0.5);
|
||||||
|
assert_eq!(record.positive_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_negative_feedback_decreases_trust() {
|
||||||
|
let mut collector = FeedbackCollector::new();
|
||||||
|
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
|
||||||
|
let record = collector.get_trust("skill-1").unwrap();
|
||||||
|
assert!(record.trust_score < 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mixed_feedback() {
|
||||||
|
let mut collector = FeedbackCollector::new();
|
||||||
|
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
|
||||||
|
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
|
||||||
|
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
|
||||||
|
let record = collector.get_trust("skill-1").unwrap();
|
||||||
|
assert_eq!(record.total_feedback, 3);
|
||||||
|
assert!(record.trust_score > 0.3); // 2/3 positive
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recommend_optimize() {
|
||||||
|
let mut collector = FeedbackCollector::new();
|
||||||
|
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
|
||||||
|
let update = collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
|
||||||
|
assert_eq!(update.action, RecommendedAction::Optimize);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_needs_optimization_filter() {
|
||||||
|
let mut collector = FeedbackCollector::new();
|
||||||
|
collector.submit_feedback(make_feedback("bad-skill", Sentiment::Negative));
|
||||||
|
collector.submit_feedback(make_feedback("bad-skill", Sentiment::Negative));
|
||||||
|
collector.submit_feedback(make_feedback("good-skill", Sentiment::Positive));
|
||||||
|
|
||||||
|
let needs = collector.get_artifacts_needing_optimization();
|
||||||
|
assert_eq!(needs.len(), 1);
|
||||||
|
assert_eq!(needs[0].artifact_id, "bad-skill");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_promote_recommendation() {
|
||||||
|
let mut collector = FeedbackCollector::new();
|
||||||
|
for _ in 0..5 {
|
||||||
|
collector.submit_feedback(make_feedback("great-skill", Sentiment::Positive));
|
||||||
|
}
|
||||||
|
let recommended = collector.get_recommended_artifacts();
|
||||||
|
assert_eq!(recommended.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_and_load_roundtrip() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
|
||||||
|
// 写入阶段
|
||||||
|
let mut collector = FeedbackCollector::with_viking(viking.clone());
|
||||||
|
collector.submit_feedback(make_feedback("skill-a", Sentiment::Positive));
|
||||||
|
collector.submit_feedback(make_feedback("skill-a", Sentiment::Positive));
|
||||||
|
collector.submit_feedback(make_feedback("skill-b", Sentiment::Negative));
|
||||||
|
|
||||||
|
let saved = collector.save().await.unwrap();
|
||||||
|
assert_eq!(saved, 2); // 2 个 artifact
|
||||||
|
|
||||||
|
// 读取阶段:新 collector 从存储加载
|
||||||
|
let mut collector2 = FeedbackCollector::with_viking(viking);
|
||||||
|
let loaded = collector2.load().await.unwrap();
|
||||||
|
assert_eq!(loaded, 2);
|
||||||
|
|
||||||
|
let record_a = collector2.get_trust("skill-a").unwrap();
|
||||||
|
assert_eq!(record_a.positive_count, 2);
|
||||||
|
assert_eq!(record_a.total_feedback, 2);
|
||||||
|
|
||||||
|
let record_b = collector2.get_trust("skill-b").unwrap();
|
||||||
|
assert_eq!(record_b.negative_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_without_viking_returns_zero() {
|
||||||
|
let mut collector = FeedbackCollector::new();
|
||||||
|
let loaded = collector.load().await.unwrap();
|
||||||
|
assert_eq!(loaded, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_without_viking_returns_zero() {
|
||||||
|
let mut collector = FeedbackCollector::new();
|
||||||
|
let saved = collector.save().await.unwrap();
|
||||||
|
assert_eq!(saved, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
crates/zclaw-growth/src/json_utils.rs
Normal file
148
crates/zclaw-growth/src/json_utils.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
//! 共享 JSON 工具函数
|
||||||
|
//! 从 LLM 返回的文本中提取 JSON 块
|
||||||
|
|
||||||
|
/// 从 LLM 返回文本中提取 JSON 块
|
||||||
|
/// 支持三种格式:```json...``` 围栏、```...``` 围栏、裸 {...}
|
||||||
|
/// 使用括号平衡算法找到第一个完整 JSON 块,避免误匹配
|
||||||
|
pub fn extract_json_block(text: &str) -> &str {
|
||||||
|
// 尝试匹配 ```json ... ```
|
||||||
|
if let Some(start) = text.find("```json") {
|
||||||
|
let json_start = start + 7;
|
||||||
|
if let Some(end) = text[json_start..].find("```") {
|
||||||
|
return text[json_start..json_start + end].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 尝试匹配 ``` ... ```
|
||||||
|
if let Some(start) = text.find("```") {
|
||||||
|
let json_start = start + 3;
|
||||||
|
if let Some(end) = text[json_start..].find("```") {
|
||||||
|
return text[json_start..json_start + end].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 用括号平衡算法找第一个完整 {...} 块
|
||||||
|
if let Some(slice) = find_balanced_json(text) {
|
||||||
|
return slice;
|
||||||
|
}
|
||||||
|
text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用括号平衡计数找到第一个完整的 {...} JSON 块
|
||||||
|
/// 正确处理字符串字面量中的花括号
|
||||||
|
fn find_balanced_json(text: &str) -> Option<&str> {
|
||||||
|
let start = text.find('{')?;
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut in_string = false;
|
||||||
|
let mut escape_next = false;
|
||||||
|
|
||||||
|
for (i, c) in text[start..].char_indices() {
|
||||||
|
if escape_next {
|
||||||
|
escape_next = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match c {
|
||||||
|
'\\' if in_string => escape_next = true,
|
||||||
|
'"' => in_string = !in_string,
|
||||||
|
'{' if !in_string => {
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
'}' if !in_string => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
return Some(&text[start..=start + i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 serde_json::Value 中提取字符串数组
|
||||||
|
/// 用于解析 LLM 返回 JSON 中的 triggers/tools 等字段
|
||||||
|
pub fn extract_string_array(raw: &serde_json::Value, key: &str) -> Vec<String> {
|
||||||
|
raw.get(key)
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_block_with_markdown() {
|
||||||
|
let text = "Here is the result:\n```json\n{\"key\": \"value\"}\n```\nDone.";
|
||||||
|
assert_eq!(extract_json_block(text), "{\"key\": \"value\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_block_bare() {
|
||||||
|
let text = "{\"key\": \"value\"}";
|
||||||
|
assert_eq!(extract_json_block(text), "{\"key\": \"value\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_block_plain_fences() {
|
||||||
|
let text = "Result:\n```\n{\"a\": 1}\n```";
|
||||||
|
assert_eq!(extract_json_block(text), "{\"a\": 1}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_block_nested_braces() {
|
||||||
|
let text = r#"{"outer": {"inner": "val"}}"#;
|
||||||
|
assert_eq!(extract_json_block(text), r#"{"outer": {"inner": "val"}}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_block_no_json() {
|
||||||
|
let text = "no json here";
|
||||||
|
assert_eq!(extract_json_block(text), "no json here");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_balanced_json_skips_outer_text() {
|
||||||
|
// 第一个 { 到最后一个 } 会包含多余文本,但平衡算法只取第一个完整块
|
||||||
|
let text = "prefix {\"a\": 1} suffix {\"b\": 2}";
|
||||||
|
assert_eq!(extract_json_block(text), "{\"a\": 1}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_balanced_json_handles_braces_in_strings() {
|
||||||
|
let text = r#"{"body": "function() { return x; }", "name": "test"}"#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_json_block(text),
|
||||||
|
r#"{"body": "function() { return x; }", "name": "test"}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_balanced_json_handles_escaped_quotes() {
|
||||||
|
let text = r#"{"msg": "He said \"hello {world}\""}"#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_json_block(text),
|
||||||
|
r#"{"msg": "He said \"hello {world}\""}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_string_array() {
|
||||||
|
let raw: serde_json::Value = serde_json::from_str(
|
||||||
|
r#"{"triggers": ["报表", "日报"], "name": "test"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let arr = extract_string_array(&raw, "triggers");
|
||||||
|
assert_eq!(arr, vec!["报表", "日报"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_string_array_missing_key() {
|
||||||
|
let raw: serde_json::Value = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
|
||||||
|
let arr = extract_string_array(&raw, "triggers");
|
||||||
|
assert!(arr.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@
|
|||||||
//!
|
//!
|
||||||
//! # Architecture
|
//! # Architecture
|
||||||
//!
|
//!
|
||||||
//! The growth system consists of four main components:
|
//! The growth system consists of several subsystems:
|
||||||
|
//!
|
||||||
|
//! ## Memory Pipeline (L0-L2)
|
||||||
//!
|
//!
|
||||||
//! 1. **MemoryExtractor** (`extractor`) - Analyzes conversations and extracts
|
//! 1. **MemoryExtractor** (`extractor`) - Analyzes conversations and extracts
|
||||||
//! preferences, knowledge, and experience using LLM.
|
//! preferences, knowledge, and experience using LLM. Supports combined extraction
|
||||||
|
//! (single LLM call for memories + experiences + profile signals).
|
||||||
//!
|
//!
|
||||||
//! 2. **MemoryRetriever** (`retriever`) - Performs semantic search over
|
//! 2. **MemoryRetriever** (`retriever`) - Performs semantic search over
|
||||||
//! stored memories to find contextually relevant information.
|
//! stored memories to find contextually relevant information.
|
||||||
@@ -19,6 +22,28 @@
|
|||||||
//! 4. **GrowthTracker** (`tracker`) - Tracks growth metrics and evolution
|
//! 4. **GrowthTracker** (`tracker`) - Tracks growth metrics and evolution
|
||||||
//! over time.
|
//! over time.
|
||||||
//!
|
//!
|
||||||
|
//! ## Evolution Engine (L1-L3)
|
||||||
|
//!
|
||||||
|
//! 5. **ExperienceStore** (`experience_store`) - FTS5-backed structured experience storage.
|
||||||
|
//!
|
||||||
|
//! 6. **PatternAggregator** (`pattern_aggregator`) - Collects high-frequency patterns for L2.
|
||||||
|
//!
|
||||||
|
//! 7. **SkillGenerator** (`skill_generator`) - LLM-driven SKILL.md content generation.
|
||||||
|
//!
|
||||||
|
//! 8. **QualityGate** (`quality_gate`) - Validates candidate skills (confidence, conflicts).
|
||||||
|
//!
|
||||||
|
//! 9. **EvolutionEngine** (`evolution_engine`) - Orchestrates L1/L2/L3 evolution phases.
|
||||||
|
//!
|
||||||
|
//! 10. **WorkflowComposer** (`workflow_composer`) - Extracts tool chain patterns for Pipeline YAML.
|
||||||
|
//!
|
||||||
|
//! 11. **FeedbackCollector** (`feedback_collector`) - Trust score management with decay.
|
||||||
|
//!
|
||||||
|
//! ## Support Modules
|
||||||
|
//!
|
||||||
|
//! 12. **VikingAdapter** (`viking_adapter`) - Storage abstraction (in-memory + SQLite backends).
|
||||||
|
//! 13. **Summarizer** (`summarizer`) - L0/L1 summary generation.
|
||||||
|
//! 14. **JsonUtils** (`json_utils`) - Shared JSON parsing utilities.
|
||||||
|
//!
|
||||||
//! # Storage
|
//! # Storage
|
||||||
//!
|
//!
|
||||||
//! All memories are stored in OpenViking with a URI structure:
|
//! All memories are stored in OpenViking with a URI structure:
|
||||||
@@ -65,6 +90,15 @@ pub mod storage;
|
|||||||
pub mod retrieval;
|
pub mod retrieval;
|
||||||
pub mod summarizer;
|
pub mod summarizer;
|
||||||
pub mod experience_store;
|
pub mod experience_store;
|
||||||
|
pub mod json_utils;
|
||||||
|
pub mod experience_extractor;
|
||||||
|
pub mod profile_updater;
|
||||||
|
pub mod pattern_aggregator;
|
||||||
|
pub mod skill_generator;
|
||||||
|
pub mod quality_gate;
|
||||||
|
pub mod evolution_engine;
|
||||||
|
pub mod workflow_composer;
|
||||||
|
pub mod feedback_collector;
|
||||||
|
|
||||||
// Re-export main types for convenience
|
// Re-export main types for convenience
|
||||||
pub use types::{
|
pub use types::{
|
||||||
@@ -78,6 +112,14 @@ pub use types::{
|
|||||||
RetrievalResult,
|
RetrievalResult,
|
||||||
UriBuilder,
|
UriBuilder,
|
||||||
effective_importance,
|
effective_importance,
|
||||||
|
ArtifactType,
|
||||||
|
CombinedExtraction,
|
||||||
|
EvolutionEvent,
|
||||||
|
EvolutionEventType,
|
||||||
|
EvolutionStatus,
|
||||||
|
ExperienceCandidate,
|
||||||
|
Outcome,
|
||||||
|
ProfileSignals,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use extractor::{LlmDriverForExtraction, MemoryExtractor};
|
pub use extractor::{LlmDriverForExtraction, MemoryExtractor};
|
||||||
@@ -89,6 +131,18 @@ pub use storage::SqliteStorage;
|
|||||||
pub use experience_store::{Experience, ExperienceStore};
|
pub use experience_store::{Experience, ExperienceStore};
|
||||||
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||||
pub use summarizer::SummaryLlmDriver;
|
pub use summarizer::SummaryLlmDriver;
|
||||||
|
pub use experience_extractor::ExperienceExtractor;
|
||||||
|
pub use json_utils::{extract_json_block, extract_string_array};
|
||||||
|
pub use profile_updater::{ProfileFieldUpdate, ProfileUpdateKind, UserProfileUpdater};
|
||||||
|
pub use pattern_aggregator::{AggregatedPattern, PatternAggregator};
|
||||||
|
pub use skill_generator::{SkillCandidate, SkillGenerator};
|
||||||
|
pub use quality_gate::{QualityGate, QualityReport};
|
||||||
|
pub use evolution_engine::{EvolutionConfig, EvolutionEngine};
|
||||||
|
pub use workflow_composer::{PipelineCandidate, ToolChainPattern, WorkflowComposer};
|
||||||
|
pub use feedback_collector::{
|
||||||
|
EvolutionArtifact, FeedbackCollector, FeedbackEntry, FeedbackSignal,
|
||||||
|
RecommendedAction, Sentiment, TrustRecord, TrustUpdate,
|
||||||
|
};
|
||||||
|
|
||||||
/// Growth system configuration
|
/// Growth system configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
245
crates/zclaw-growth/src/pattern_aggregator.rs
Normal file
245
crates/zclaw-growth/src/pattern_aggregator.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
//! 经验模式聚合器
|
||||||
|
//! 收集同一 pain_pattern 下的所有 Experience,找出共同步骤
|
||||||
|
//! 用于 L2 技能进化触发判断
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::experience_store::{Experience, ExperienceStore};
|
||||||
|
use zclaw_types::Result;
|
||||||
|
|
||||||
|
/// 聚合后的经验模式
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AggregatedPattern {
|
||||||
|
pub pain_pattern: String,
|
||||||
|
pub experiences: Vec<Experience>,
|
||||||
|
pub common_steps: Vec<String>,
|
||||||
|
pub total_reuse: u32,
|
||||||
|
pub tools_used: Vec<String>,
|
||||||
|
pub industry_context: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 经验模式聚合器
|
||||||
|
/// 从 ExperienceStore 中收集高频复用的模式,作为 L2 技能生成的输入
|
||||||
|
pub struct PatternAggregator {
|
||||||
|
store: ExperienceStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PatternAggregator {
|
||||||
|
pub fn new(store: ExperienceStore) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查找可固化的模式:reuse_count >= threshold 的经验
|
||||||
|
pub async fn find_evolvable_patterns(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
min_reuse: u32,
|
||||||
|
) -> Result<Vec<AggregatedPattern>> {
|
||||||
|
let all = self.store.find_by_agent(agent_id).await?;
|
||||||
|
let mut grouped: HashMap<String, Vec<Experience>> = HashMap::new();
|
||||||
|
|
||||||
|
for exp in all {
|
||||||
|
if exp.reuse_count >= min_reuse {
|
||||||
|
grouped
|
||||||
|
.entry(exp.pain_pattern.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(exp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut patterns = Vec::new();
|
||||||
|
for (pattern, experiences) in grouped {
|
||||||
|
let total_reuse: u32 = experiences.iter().map(|e| e.reuse_count).sum();
|
||||||
|
let common_steps = Self::find_common_steps(&experiences);
|
||||||
|
|
||||||
|
// 从 tool_used 字段提取工具名
|
||||||
|
let tools: Vec<String> = experiences
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.tool_used.clone())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect::<std::collections::HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let industry = experiences
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.industry_context.clone())
|
||||||
|
.next();
|
||||||
|
|
||||||
|
patterns.push(AggregatedPattern {
|
||||||
|
pain_pattern: pattern,
|
||||||
|
experiences,
|
||||||
|
common_steps,
|
||||||
|
total_reuse,
|
||||||
|
tools_used: tools,
|
||||||
|
industry_context: industry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 reuse 排序
|
||||||
|
patterns.sort_by(|a, b| b.total_reuse.cmp(&a.total_reuse));
|
||||||
|
Ok(patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 找出多条经验中共同的解决步骤
|
||||||
|
fn find_common_steps(experiences: &[Experience]) -> Vec<String> {
|
||||||
|
if experiences.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if experiences.len() == 1 {
|
||||||
|
return experiences[0].solution_steps.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取所有经验的交集步骤
|
||||||
|
let mut step_counts: HashMap<String, u32> = HashMap::new();
|
||||||
|
for exp in experiences {
|
||||||
|
for step in &exp.solution_steps {
|
||||||
|
*step_counts.entry(step.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let threshold = experiences.len() as f32 * 0.5; // 出现在 50%+ 的经验中
|
||||||
|
let mut common: Vec<_> = step_counts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, count)| (*count as f32) >= threshold)
|
||||||
|
.map(|(step, _)| step)
|
||||||
|
.collect();
|
||||||
|
common.dedup();
|
||||||
|
common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_common_steps_empty() {
|
||||||
|
let steps = PatternAggregator::find_common_steps(&[]);
|
||||||
|
assert!(steps.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_common_steps_single() {
|
||||||
|
let exp = Experience::new(
|
||||||
|
"a",
|
||||||
|
"packaging",
|
||||||
|
"ctx",
|
||||||
|
vec!["step1".into(), "step2".into()],
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
let steps = PatternAggregator::find_common_steps(&[exp]);
|
||||||
|
assert_eq!(steps.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_common_steps_multiple() {
|
||||||
|
let exp1 = Experience::new(
|
||||||
|
"a",
|
||||||
|
"packaging",
|
||||||
|
"ctx",
|
||||||
|
vec!["step1".into(), "step2".into(), "step3".into()],
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
let exp2 = Experience::new(
|
||||||
|
"a",
|
||||||
|
"packaging",
|
||||||
|
"ctx",
|
||||||
|
vec!["step1".into(), "step2".into(), "step4".into()],
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
// step1 and step2 appear in both (100% >= 50%)
|
||||||
|
let steps = PatternAggregator::find_common_steps(&[exp1, exp2]);
|
||||||
|
assert!(steps.contains(&"step1".to_string()));
|
||||||
|
assert!(steps.contains(&"step2".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_find_evolvable_patterns_filters_low_reuse() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let store = ExperienceStore::new(viking);
|
||||||
|
|
||||||
|
// 经验 1: reuse_count = 0 (低于阈值)
|
||||||
|
let mut exp_low = Experience::new(
|
||||||
|
"agent-1",
|
||||||
|
"low reuse task",
|
||||||
|
"ctx",
|
||||||
|
vec!["step".into()],
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
exp_low.reuse_count = 0;
|
||||||
|
store.store_experience(&exp_low).await.unwrap();
|
||||||
|
|
||||||
|
// 经验 2: reuse_count = 5 (高于阈值)
|
||||||
|
let mut exp_high = Experience::new(
|
||||||
|
"agent-1",
|
||||||
|
"high reuse task",
|
||||||
|
"ctx",
|
||||||
|
vec!["step1".into()],
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
exp_high.reuse_count = 5;
|
||||||
|
store.store_experience(&exp_high).await.unwrap();
|
||||||
|
|
||||||
|
let aggregator = PatternAggregator::new(store);
|
||||||
|
let patterns = aggregator.find_evolvable_patterns("agent-1", 3).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(patterns.len(), 1);
|
||||||
|
assert_eq!(patterns[0].pain_pattern, "high reuse task");
|
||||||
|
assert_eq!(patterns[0].total_reuse, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_find_evolvable_patterns_groups_by_pain() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let store = ExperienceStore::new(viking);
|
||||||
|
|
||||||
|
let mut exp1 = Experience::new(
|
||||||
|
"agent-1",
|
||||||
|
"report generation",
|
||||||
|
"ctx1",
|
||||||
|
vec!["query db".into(), "format".into()],
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
exp1.reuse_count = 3;
|
||||||
|
store.store_experience(&exp1).await.unwrap();
|
||||||
|
|
||||||
|
// Same pain_pattern → same URI → overwrites, so use a slightly different hash
|
||||||
|
// Actually since URI is deterministic on pain_pattern, we can only have one per pattern
|
||||||
|
// This is by design: one experience per pain_pattern (latest wins)
|
||||||
|
let patterns = aggregator_fixtures::make_patterns_with_same_pain().await;
|
||||||
|
assert_eq!(patterns.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
mod aggregator_fixtures {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub async fn make_patterns_with_same_pain() -> Vec<AggregatedPattern> {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let store = ExperienceStore::new(viking);
|
||||||
|
|
||||||
|
let mut exp = Experience::new(
|
||||||
|
"agent-1",
|
||||||
|
"report generation",
|
||||||
|
"ctx1",
|
||||||
|
vec!["query db".into(), "format".into()],
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
exp.reuse_count = 3;
|
||||||
|
store.store_experience(&exp).await.unwrap();
|
||||||
|
|
||||||
|
let aggregator = PatternAggregator::new(store);
|
||||||
|
aggregator.find_evolvable_patterns("agent-1", 2).await.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_find_evolvable_patterns_empty() {
|
||||||
|
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||||
|
let store = ExperienceStore::new(viking);
|
||||||
|
let aggregator = PatternAggregator::new(store);
|
||||||
|
let patterns = aggregator.find_evolvable_patterns("unknown-agent", 3).await.unwrap();
|
||||||
|
assert!(patterns.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
157
crates/zclaw-growth/src/profile_updater.rs
Normal file
157
crates/zclaw-growth/src/profile_updater.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
//! 用户画像增量更新器
|
||||||
|
//! 从 CombinedExtraction 的 profile_signals 提取需要更新的字段
|
||||||
|
//! 不额外调用 LLM,纯规则驱动
|
||||||
|
|
||||||
|
use crate::types::CombinedExtraction;
|
||||||
|
|
||||||
|
/// 更新类型:字段覆盖 vs 数组追加
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ProfileUpdateKind {
|
||||||
|
/// 直接覆盖字段值(industry, communication_style)
|
||||||
|
SetField,
|
||||||
|
/// 追加到 JSON 数组字段(recent_topic, pain_point, preferred_tool)
|
||||||
|
AppendArray,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待更新的画像字段
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProfileFieldUpdate {
|
||||||
|
pub field: String,
|
||||||
|
pub value: String,
|
||||||
|
pub kind: ProfileUpdateKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户画像更新器
|
||||||
|
/// 从 CombinedExtraction 的 profile_signals 中提取需更新的字段列表
|
||||||
|
/// 调用方(zclaw-runtime)负责实际写入 UserProfileStore
|
||||||
|
pub struct UserProfileUpdater;
|
||||||
|
|
||||||
|
impl UserProfileUpdater {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从提取结果中收集需要更新的画像字段
|
||||||
|
/// 返回 (field, value, kind) 列表,由调用方根据 kind 选择写入方式
|
||||||
|
pub fn collect_updates(
|
||||||
|
&self,
|
||||||
|
extraction: &CombinedExtraction,
|
||||||
|
) -> Vec<ProfileFieldUpdate> {
|
||||||
|
let signals = &extraction.profile_signals;
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
|
||||||
|
if let Some(ref industry) = signals.industry {
|
||||||
|
updates.push(ProfileFieldUpdate {
|
||||||
|
field: "industry".to_string(),
|
||||||
|
value: industry.clone(),
|
||||||
|
kind: ProfileUpdateKind::SetField,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref style) = signals.communication_style {
|
||||||
|
updates.push(ProfileFieldUpdate {
|
||||||
|
field: "communication_style".to_string(),
|
||||||
|
value: style.clone(),
|
||||||
|
kind: ProfileUpdateKind::SetField,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref topic) = signals.recent_topic {
|
||||||
|
updates.push(ProfileFieldUpdate {
|
||||||
|
field: "recent_topic".to_string(),
|
||||||
|
value: topic.clone(),
|
||||||
|
kind: ProfileUpdateKind::AppendArray,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref pain) = signals.pain_point {
|
||||||
|
updates.push(ProfileFieldUpdate {
|
||||||
|
field: "pain_point".to_string(),
|
||||||
|
value: pain.clone(),
|
||||||
|
kind: ProfileUpdateKind::AppendArray,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref tool) = signals.preferred_tool {
|
||||||
|
updates.push(ProfileFieldUpdate {
|
||||||
|
field: "preferred_tool".to_string(),
|
||||||
|
value: tool.clone(),
|
||||||
|
kind: ProfileUpdateKind::AppendArray,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UserProfileUpdater {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collect_updates_industry() {
|
||||||
|
let mut extraction = CombinedExtraction::default();
|
||||||
|
extraction.profile_signals.industry = Some("healthcare".to_string());
|
||||||
|
|
||||||
|
let updater = UserProfileUpdater::new();
|
||||||
|
let updates = updater.collect_updates(&extraction);
|
||||||
|
|
||||||
|
assert_eq!(updates.len(), 1);
|
||||||
|
assert_eq!(updates[0].field, "industry");
|
||||||
|
assert_eq!(updates[0].value, "healthcare");
|
||||||
|
assert_eq!(updates[0].kind, ProfileUpdateKind::SetField);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collect_updates_no_signals() {
|
||||||
|
let extraction = CombinedExtraction::default();
|
||||||
|
let updater = UserProfileUpdater::new();
|
||||||
|
let updates = updater.collect_updates(&extraction);
|
||||||
|
assert!(updates.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collect_updates_multiple_signals() {
|
||||||
|
let mut extraction = CombinedExtraction::default();
|
||||||
|
extraction.profile_signals.industry = Some("ecommerce".to_string());
|
||||||
|
extraction.profile_signals.communication_style = Some("concise".to_string());
|
||||||
|
|
||||||
|
let updater = UserProfileUpdater::new();
|
||||||
|
let updates = updater.collect_updates(&extraction);
|
||||||
|
|
||||||
|
assert_eq!(updates.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collect_updates_all_five_dimensions() {
|
||||||
|
let mut extraction = CombinedExtraction::default();
|
||||||
|
extraction.profile_signals.industry = Some("healthcare".to_string());
|
||||||
|
extraction.profile_signals.communication_style = Some("concise".to_string());
|
||||||
|
extraction.profile_signals.recent_topic = Some("报表自动化".to_string());
|
||||||
|
extraction.profile_signals.pain_point = Some("手动汇总太慢".to_string());
|
||||||
|
extraction.profile_signals.preferred_tool = Some("researcher".to_string());
|
||||||
|
|
||||||
|
let updater = UserProfileUpdater::new();
|
||||||
|
let updates = updater.collect_updates(&extraction);
|
||||||
|
|
||||||
|
assert_eq!(updates.len(), 5);
|
||||||
|
let set_fields: Vec<_> = updates
|
||||||
|
.iter()
|
||||||
|
.filter(|u| u.kind == ProfileUpdateKind::SetField)
|
||||||
|
.map(|u| u.field.as_str())
|
||||||
|
.collect();
|
||||||
|
let append_fields: Vec<_> = updates
|
||||||
|
.iter()
|
||||||
|
.filter(|u| u.kind == ProfileUpdateKind::AppendArray)
|
||||||
|
.map(|u| u.field.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(set_fields, vec!["industry", "communication_style"]);
|
||||||
|
assert_eq!(append_fields, vec!["recent_topic", "pain_point", "preferred_tool"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
crates/zclaw-growth/src/quality_gate.rs
Normal file
160
crates/zclaw-growth/src/quality_gate.rs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
//! 质量门控
|
||||||
|
//! 验证生成的技能/工作流是否满足质量标准
|
||||||
|
//! 包括:置信度阈值、触发词冲突检查、格式校验
|
||||||
|
|
||||||
|
use crate::skill_generator::SkillCandidate;
|
||||||
|
|
||||||
|
/// 质量验证报告
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct QualityReport {
|
||||||
|
pub passed: bool,
|
||||||
|
pub issues: Vec<String>,
|
||||||
|
pub confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 质量门控验证器
|
||||||
|
pub struct QualityGate {
|
||||||
|
min_confidence: f32,
|
||||||
|
existing_triggers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QualityGate {
|
||||||
|
pub fn new(min_confidence: f32, existing_triggers: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
min_confidence,
|
||||||
|
existing_triggers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证技能候选项
|
||||||
|
pub fn validate_skill(&self, candidate: &SkillCandidate) -> QualityReport {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
|
||||||
|
// 1. 置信度检查
|
||||||
|
if candidate.confidence < self.min_confidence {
|
||||||
|
issues.push(format!(
|
||||||
|
"置信度 {:.2} 低于阈值 {:.2}",
|
||||||
|
candidate.confidence, self.min_confidence
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 名称非空
|
||||||
|
if candidate.name.trim().is_empty() {
|
||||||
|
issues.push("技能名称不能为空".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 至少一个触发词
|
||||||
|
if candidate.triggers.is_empty() {
|
||||||
|
issues.push("至少需要一个触发词".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 触发词不与现有技能冲突
|
||||||
|
let conflicts: Vec<_> = candidate
|
||||||
|
.triggers
|
||||||
|
.iter()
|
||||||
|
.filter(|t| self.existing_triggers.iter().any(|et| et == *t))
|
||||||
|
.collect();
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
issues.push(format!("触发词冲突: {:?}", conflicts));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. SKILL.md 正文非空
|
||||||
|
if candidate.body_markdown.trim().is_empty() {
|
||||||
|
issues.push("技能正文不能为空".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
QualityReport {
|
||||||
|
passed: issues.is_empty(),
|
||||||
|
issues,
|
||||||
|
confidence: candidate.confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_valid_candidate() -> SkillCandidate {
|
||||||
|
SkillCandidate {
|
||||||
|
name: "每日报表".to_string(),
|
||||||
|
description: "生成每日报表".to_string(),
|
||||||
|
triggers: vec!["报表".to_string(), "日报".to_string()],
|
||||||
|
tools: vec!["researcher".to_string()],
|
||||||
|
body_markdown: "# 每日报表\n步骤1\n步骤2".to_string(),
|
||||||
|
source_pattern: "报表生成".to_string(),
|
||||||
|
confidence: 0.85,
|
||||||
|
version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_valid_skill() {
|
||||||
|
let gate = QualityGate::new(0.7, vec!["搜索".to_string()]);
|
||||||
|
let candidate = make_valid_candidate();
|
||||||
|
let report = gate.validate_skill(&candidate);
|
||||||
|
assert!(report.passed);
|
||||||
|
assert!(report.issues.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_low_confidence() {
|
||||||
|
let gate = QualityGate::new(0.7, vec![]);
|
||||||
|
let mut candidate = make_valid_candidate();
|
||||||
|
candidate.confidence = 0.5;
|
||||||
|
let report = gate.validate_skill(&candidate);
|
||||||
|
assert!(!report.passed);
|
||||||
|
assert!(report.issues.iter().any(|i| i.contains("置信度")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_empty_name() {
|
||||||
|
let gate = QualityGate::new(0.5, vec![]);
|
||||||
|
let mut candidate = make_valid_candidate();
|
||||||
|
candidate.name = "".to_string();
|
||||||
|
let report = gate.validate_skill(&candidate);
|
||||||
|
assert!(!report.passed);
|
||||||
|
assert!(report.issues.iter().any(|i| i.contains("名称")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_empty_triggers() {
|
||||||
|
let gate = QualityGate::new(0.5, vec![]);
|
||||||
|
let mut candidate = make_valid_candidate();
|
||||||
|
candidate.triggers = vec![];
|
||||||
|
let report = gate.validate_skill(&candidate);
|
||||||
|
assert!(!report.passed);
|
||||||
|
assert!(report.issues.iter().any(|i| i.contains("触发词")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_trigger_conflict() {
|
||||||
|
let gate = QualityGate::new(0.5, vec!["报表".to_string()]);
|
||||||
|
let candidate = make_valid_candidate();
|
||||||
|
let report = gate.validate_skill(&candidate);
|
||||||
|
assert!(!report.passed);
|
||||||
|
assert!(report.issues.iter().any(|i| i.contains("冲突")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_empty_body() {
|
||||||
|
let gate = QualityGate::new(0.5, vec![]);
|
||||||
|
let mut candidate = make_valid_candidate();
|
||||||
|
candidate.body_markdown = "".to_string();
|
||||||
|
let report = gate.validate_skill(&candidate);
|
||||||
|
assert!(!report.passed);
|
||||||
|
assert!(report.issues.iter().any(|i| i.contains("正文")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_multiple_issues() {
|
||||||
|
let gate = QualityGate::new(0.9, vec![]);
|
||||||
|
let mut candidate = make_valid_candidate();
|
||||||
|
candidate.confidence = 0.3;
|
||||||
|
candidate.triggers = vec![];
|
||||||
|
candidate.body_markdown = "".to_string();
|
||||||
|
let report = gate.validate_skill(&candidate);
|
||||||
|
assert!(!report.passed);
|
||||||
|
assert!(report.issues.len() >= 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ struct CacheEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cache key for efficient lookups (reserved for future cache optimization)
|
/// Cache key for efficient lookups (reserved for future cache optimization)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: post-release cache optimization lookups
|
||||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||||
struct CacheKey {
|
struct CacheKey {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
|
|||||||
164
crates/zclaw-growth/src/skill_generator.rs
Normal file
164
crates/zclaw-growth/src/skill_generator.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
//! 技能生成器
|
||||||
|
//! 将聚合的经验模式通过 LLM 转化为 SKILL.md 内容
|
||||||
|
//! 提供 prompt 构建和 JSON 结果解析
|
||||||
|
|
||||||
|
use crate::pattern_aggregator::AggregatedPattern;
|
||||||
|
use zclaw_types::Result;
|
||||||
|
|
||||||
|
/// 技能候选项
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SkillCandidate {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub triggers: Vec<String>,
|
||||||
|
pub tools: Vec<String>,
|
||||||
|
pub body_markdown: String,
|
||||||
|
pub source_pattern: String,
|
||||||
|
pub confidence: f32,
|
||||||
|
/// 技能版本号,用于后续迭代追踪
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LLM 驱动的技能生成 prompt
|
||||||
|
const SKILL_GENERATION_PROMPT: &str = r#"
|
||||||
|
你是一个技能设计专家。根据以下用户反复出现的问题和解决步骤,生成一个可复用的技能定义。
|
||||||
|
|
||||||
|
问题模式:{pain_pattern}
|
||||||
|
解决步骤:{steps}
|
||||||
|
使用的工具:{tools}
|
||||||
|
行业背景:{industry}
|
||||||
|
|
||||||
|
请生成以下 JSON:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "技能名称(简短中文)",
|
||||||
|
"description": "技能描述(一段话)",
|
||||||
|
"triggers": ["触发词1", "触发词2", "触发词3"],
|
||||||
|
"tools": ["tool1", "tool2"],
|
||||||
|
"body_markdown": "技能的 Markdown 正文,包含步骤说明",
|
||||||
|
"confidence": 0.85
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// 技能生成器
|
||||||
|
/// 负责 prompt 构建和 LLM 返回的 JSON 解析
|
||||||
|
pub struct SkillGenerator;
|
||||||
|
|
||||||
|
impl SkillGenerator {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从聚合模式构建 LLM prompt
|
||||||
|
pub fn build_prompt(pattern: &AggregatedPattern) -> String {
|
||||||
|
SKILL_GENERATION_PROMPT
|
||||||
|
.replace("{pain_pattern}", &pattern.pain_pattern)
|
||||||
|
.replace("{steps}", &pattern.common_steps.join(" → "))
|
||||||
|
.replace("{tools}", &pattern.tools_used.join(", "))
|
||||||
|
.replace("{industry}", pattern.industry_context.as_deref().unwrap_or("通用"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 LLM 返回的 JSON 为 SkillCandidate
|
||||||
|
pub fn parse_response(json_str: &str, pattern: &AggregatedPattern) -> Result<SkillCandidate> {
|
||||||
|
let json_str = crate::json_utils::extract_json_block(json_str);
|
||||||
|
|
||||||
|
let raw: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::ConfigError(format!("Invalid skill JSON: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(SkillCandidate {
|
||||||
|
name: raw["name"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("未命名技能")
|
||||||
|
.to_string(),
|
||||||
|
description: raw["description"].as_str().unwrap_or("").to_string(),
|
||||||
|
triggers: crate::json_utils::extract_string_array(&raw, "triggers"),
|
||||||
|
tools: crate::json_utils::extract_string_array(&raw, "tools"),
|
||||||
|
body_markdown: raw["body_markdown"].as_str().unwrap_or("").to_string(),
|
||||||
|
source_pattern: pattern.pain_pattern.clone(),
|
||||||
|
confidence: raw["confidence"].as_f64().unwrap_or(0.5) as f32,
|
||||||
|
version: raw["version"].as_u64().unwrap_or(1) as u32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SkillGenerator {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::experience_store::Experience;
|
||||||
|
|
||||||
|
fn make_pattern() -> AggregatedPattern {
|
||||||
|
let exp = Experience::new(
|
||||||
|
"agent-1",
|
||||||
|
"报表生成",
|
||||||
|
"researcher",
|
||||||
|
vec!["查询数据库".into(), "格式化输出".into()],
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
AggregatedPattern {
|
||||||
|
pain_pattern: "报表生成".to_string(),
|
||||||
|
experiences: vec![exp],
|
||||||
|
common_steps: vec!["查询数据库".into(), "格式化输出".into()],
|
||||||
|
total_reuse: 5,
|
||||||
|
tools_used: vec!["researcher".into()],
|
||||||
|
industry_context: Some("healthcare".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_prompt() {
|
||||||
|
let pattern = make_pattern();
|
||||||
|
let prompt = SkillGenerator::build_prompt(&pattern);
|
||||||
|
assert!(prompt.contains("报表生成"));
|
||||||
|
assert!(prompt.contains("查询数据库"));
|
||||||
|
assert!(prompt.contains("researcher"));
|
||||||
|
assert!(prompt.contains("healthcare"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_response_valid_json() {
|
||||||
|
let pattern = make_pattern();
|
||||||
|
let json = r##"{"name":"每日报表","description":"生成每日报表","triggers":["报表","日报"],"tools":["researcher"],"body_markdown":"# 每日报表\n步骤1","confidence":0.9}"##;
|
||||||
|
let candidate = SkillGenerator::parse_response(json, &pattern).unwrap();
|
||||||
|
assert_eq!(candidate.name, "每日报表");
|
||||||
|
assert_eq!(candidate.triggers.len(), 2);
|
||||||
|
assert_eq!(candidate.confidence, 0.9);
|
||||||
|
assert_eq!(candidate.source_pattern, "报表生成");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_response_json_block() {
|
||||||
|
let pattern = make_pattern();
|
||||||
|
let text = r#"```json
|
||||||
|
{"name":"技能A","description":"desc","triggers":["a"],"tools":[],"body_markdown":"body","confidence":0.8}
|
||||||
|
```"#;
|
||||||
|
let candidate = SkillGenerator::parse_response(text, &pattern).unwrap();
|
||||||
|
assert_eq!(candidate.name, "技能A");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_response_invalid_json() {
|
||||||
|
let pattern = make_pattern();
|
||||||
|
let result = SkillGenerator::parse_response("not json at all", &pattern);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_json_block_with_markdown() {
|
||||||
|
let text = "Here is the result:\n```json\n{\"key\": \"value\"}\n```\nDone.";
|
||||||
|
assert_eq!(crate::json_utils::extract_json_block(text), "{\"key\": \"value\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_json_block_bare() {
|
||||||
|
let text = "{\"key\": \"value\"}";
|
||||||
|
assert_eq!(crate::json_utils::extract_json_block(text), "{\"key\": \"value\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ pub struct SqliteStorage {
|
|||||||
/// Semantic scorer for similarity computation
|
/// Semantic scorer for similarity computation
|
||||||
scorer: Arc<RwLock<SemanticScorer>>,
|
scorer: Arc<RwLock<SemanticScorer>>,
|
||||||
/// Database path (for reference)
|
/// Database path (for reference)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: db path for diagnostics and reconnect
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,22 +162,44 @@ impl SqliteStorage {
|
|||||||
.map_err(|e| ZclawError::StorageError(format!("Failed to create importance index: {}", e)))?;
|
.map_err(|e| ZclawError::StorageError(format!("Failed to create importance index: {}", e)))?;
|
||||||
|
|
||||||
// Migration: add overview column (L1 summary)
|
// Migration: add overview column (L1 summary)
|
||||||
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN overview TEXT")
|
// SQLite ALTER TABLE ADD COLUMN fails with "duplicate column name" if already applied
|
||||||
|
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN overview TEXT")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
let msg = e.to_string();
|
||||||
|
if !msg.contains("duplicate column name") {
|
||||||
|
tracing::warn!("[Growth] Migration overview failed: {}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: add abstract_summary column (L0 keywords)
|
// Migration: add abstract_summary column (L0 keywords)
|
||||||
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN abstract_summary TEXT")
|
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN abstract_summary TEXT")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
let msg = e.to_string();
|
||||||
|
if !msg.contains("duplicate column name") {
|
||||||
|
tracing::warn!("[Growth] Migration abstract_summary failed: {}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// P2-24: Migration — content fingerprint for deduplication
|
// P2-24: Migration — content fingerprint for deduplication
|
||||||
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN content_hash TEXT")
|
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN content_hash TEXT")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await
|
||||||
let _ = sqlx::query("CREATE INDEX IF NOT EXISTS idx_content_hash ON memories(content_hash)")
|
{
|
||||||
|
let msg = e.to_string();
|
||||||
|
if !msg.contains("duplicate column name") {
|
||||||
|
tracing::warn!("[Growth] Migration content_hash failed: {}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = sqlx::query("CREATE INDEX IF NOT EXISTS idx_content_hash ON memories(content_hash)")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("[Growth] Migration idx_content_hash failed: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Backfill content_hash for existing entries that have NULL content_hash
|
// Backfill content_hash for existing entries that have NULL content_hash
|
||||||
{
|
{
|
||||||
@@ -196,11 +218,14 @@ impl SqliteStorage {
|
|||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
normalized.hash(&mut hasher);
|
normalized.hash(&mut hasher);
|
||||||
let hash = format!("{:016x}", hasher.finish());
|
let hash = format!("{:016x}", hasher.finish());
|
||||||
let _ = sqlx::query("UPDATE memories SET content_hash = ? WHERE uri = ?")
|
if let Err(e) = sqlx::query("UPDATE memories SET content_hash = ? WHERE uri = ?")
|
||||||
.bind(&hash)
|
.bind(&hash)
|
||||||
.bind(uri)
|
.bind(uri)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("[sqlite] content_hash update failed for {}: {}", uri, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[SqliteStorage] Backfilled content_hash for {} existing entries",
|
"[SqliteStorage] Backfilled content_hash for {} existing entries",
|
||||||
@@ -234,9 +259,12 @@ impl SqliteStorage {
|
|||||||
if needs_rebuild {
|
if needs_rebuild {
|
||||||
tracing::info!("[SqliteStorage] Rebuilding FTS5 table: unicode61 → trigram for CJK support");
|
tracing::info!("[SqliteStorage] Rebuilding FTS5 table: unicode61 → trigram for CJK support");
|
||||||
// Drop old FTS5 table
|
// Drop old FTS5 table
|
||||||
let _ = sqlx::query("DROP TABLE IF EXISTS memories_fts")
|
if let Err(e) = sqlx::query("DROP TABLE IF EXISTS memories_fts")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("[sqlite] FTS5 table drop failed during rebuild: {}", e);
|
||||||
|
}
|
||||||
// Recreate with trigram tokenizer
|
// Recreate with trigram tokenizer
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -401,14 +429,17 @@ impl SqliteStorage {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Also clean up FTS entries for archived memories
|
// Also clean up FTS entries for archived memories
|
||||||
let _ = sqlx::query(
|
if let Err(e) = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
DELETE FROM memories_fts
|
DELETE FROM memories_fts
|
||||||
WHERE uri NOT IN (SELECT uri FROM memories)
|
WHERE uri NOT IN (SELECT uri FROM memories)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("[sqlite] FTS cleanup after archive failed: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
let archived = archive_result
|
let archived = archive_result
|
||||||
.map(|r| r.rows_affected())
|
.map(|r| r.rows_affected())
|
||||||
@@ -461,13 +492,58 @@ impl SqliteStorage {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if has_cjk {
|
if has_cjk {
|
||||||
// For CJK, use the full query as a quoted phrase for substring matching
|
// For CJK queries, extract tokens: CJK character sequences and ASCII words.
|
||||||
// trigram will match any 3-char subsequence
|
// Join with OR for broad matching (not exact phrase, which would miss scattered terms).
|
||||||
if lower.len() >= 3 {
|
let mut tokens: Vec<String> = Vec::new();
|
||||||
format!("\"{}\"", lower)
|
let mut cjk_buf = String::new();
|
||||||
} else {
|
let mut ascii_buf = String::new();
|
||||||
String::new()
|
|
||||||
|
for ch in lower.chars() {
|
||||||
|
let is_cjk = matches!(ch, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}');
|
||||||
|
if is_cjk {
|
||||||
|
if !ascii_buf.is_empty() {
|
||||||
|
if ascii_buf.len() >= 2 {
|
||||||
|
tokens.push(format!("\"{}\"", ascii_buf));
|
||||||
|
}
|
||||||
|
ascii_buf.clear();
|
||||||
|
}
|
||||||
|
cjk_buf.push(ch);
|
||||||
|
} else if ch.is_alphanumeric() {
|
||||||
|
if !cjk_buf.is_empty() {
|
||||||
|
// Flush CJK buffer — each CJK character is a potential token
|
||||||
|
// (trigram indexes 3-char sequences, so single CJK chars won't
|
||||||
|
// match alone, but 2+ char sequences will)
|
||||||
|
if cjk_buf.len() >= 2 {
|
||||||
|
tokens.push(format!("\"{}\"", cjk_buf));
|
||||||
|
}
|
||||||
|
cjk_buf.clear();
|
||||||
|
}
|
||||||
|
ascii_buf.push(ch);
|
||||||
|
} else {
|
||||||
|
// Separator — flush both buffers
|
||||||
|
if cjk_buf.len() >= 2 {
|
||||||
|
tokens.push(format!("\"{}\"", cjk_buf));
|
||||||
|
}
|
||||||
|
cjk_buf.clear();
|
||||||
|
if ascii_buf.len() >= 2 {
|
||||||
|
tokens.push(format!("\"{}\"", ascii_buf));
|
||||||
|
}
|
||||||
|
ascii_buf.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Flush remaining
|
||||||
|
if cjk_buf.len() >= 2 {
|
||||||
|
tokens.push(format!("\"{}\"", cjk_buf));
|
||||||
|
}
|
||||||
|
if ascii_buf.len() >= 2 {
|
||||||
|
tokens.push(format!("\"{}\"", ascii_buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.join(" OR ")
|
||||||
} else {
|
} else {
|
||||||
// For non-CJK, split into terms and join with OR
|
// For non-CJK, split into terms and join with OR
|
||||||
let terms: Vec<String> = lower
|
let terms: Vec<String> = lower
|
||||||
|
|||||||
@@ -66,21 +66,30 @@ impl GrowthTracker {
|
|||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store learning event
|
// Store learning event as MemoryEntry so get_timeline can find it via find_by_prefix
|
||||||
self.viking
|
let event_uri = format!("agent://{}/events/{}", agent_id, session_id);
|
||||||
.store_metadata(
|
let content = serde_json::to_string(&event)?;
|
||||||
&format!("agent://{}/events/{}", agent_id, session_id),
|
let entry = crate::types::MemoryEntry {
|
||||||
&event,
|
uri: event_uri,
|
||||||
)
|
memory_type: MemoryType::Session,
|
||||||
.await?;
|
content,
|
||||||
|
keywords: vec![agent_id.to_string(), session_id.to_string()],
|
||||||
|
importance: 5,
|
||||||
|
access_count: 0,
|
||||||
|
created_at: event.timestamp,
|
||||||
|
last_accessed: event.timestamp,
|
||||||
|
overview: None,
|
||||||
|
abstract_summary: None,
|
||||||
|
};
|
||||||
|
self.viking.store(&entry).await?;
|
||||||
|
|
||||||
// Update last learning time
|
// Update last learning time via metadata
|
||||||
self.viking
|
self.viking
|
||||||
.store_metadata(
|
.store_metadata(
|
||||||
&format!("agent://{}", agent_id),
|
&format!("agent://{}", agent_id),
|
||||||
&AgentMetadata {
|
&AgentMetadata {
|
||||||
last_learning_time: Some(Utc::now()),
|
last_learning_time: Some(Utc::now()),
|
||||||
total_learning_events: None, // Will be computed
|
total_learning_events: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -394,6 +394,103 @@ pub struct DecayResult {
|
|||||||
pub archived: u64,
|
pub archived: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Evolution Engine Types ===
|
||||||
|
|
||||||
|
/// 经验提取结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExperienceCandidate {
|
||||||
|
pub pain_pattern: String,
|
||||||
|
pub context: String,
|
||||||
|
pub solution_steps: Vec<String>,
|
||||||
|
pub outcome: Outcome,
|
||||||
|
pub confidence: f32,
|
||||||
|
pub tools_used: Vec<String>,
|
||||||
|
pub industry_context: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结果状态
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Success,
|
||||||
|
Partial,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并提取结果(单次 LLM 调用的全部输出)
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CombinedExtraction {
|
||||||
|
pub memories: Vec<ExtractedMemory>,
|
||||||
|
pub experiences: Vec<ExperienceCandidate>,
|
||||||
|
pub profile_signals: ProfileSignals,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 画像更新信号(从提取结果中推断,不额外调用 LLM)
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ProfileSignals {
|
||||||
|
pub industry: Option<String>,
|
||||||
|
pub recent_topic: Option<String>,
|
||||||
|
pub pain_point: Option<String>,
|
||||||
|
pub preferred_tool: Option<String>,
|
||||||
|
pub communication_style: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileSignals {
|
||||||
|
/// 是否包含至少一个有效信号
|
||||||
|
pub fn has_any_signal(&self) -> bool {
|
||||||
|
self.industry.is_some()
|
||||||
|
|| self.recent_topic.is_some()
|
||||||
|
|| self.pain_point.is_some()
|
||||||
|
|| self.preferred_tool.is_some()
|
||||||
|
|| self.communication_style.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有效信号数量
|
||||||
|
pub fn signal_count(&self) -> usize {
|
||||||
|
let mut count = 0;
|
||||||
|
if self.industry.is_some() { count += 1; }
|
||||||
|
if self.recent_topic.is_some() { count += 1; }
|
||||||
|
if self.pain_point.is_some() { count += 1; }
|
||||||
|
if self.preferred_tool.is_some() { count += 1; }
|
||||||
|
if self.communication_style.is_some() { count += 1; }
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进化事件
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EvolutionEvent {
|
||||||
|
pub id: String,
|
||||||
|
pub event_type: EvolutionEventType,
|
||||||
|
pub artifact_type: ArtifactType,
|
||||||
|
pub artifact_id: String,
|
||||||
|
pub status: EvolutionStatus,
|
||||||
|
pub confidence: f32,
|
||||||
|
pub user_feedback: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum EvolutionEventType {
|
||||||
|
SkillGenerated,
|
||||||
|
SkillOptimized,
|
||||||
|
WorkflowGenerated,
|
||||||
|
WorkflowOptimized,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ArtifactType {
|
||||||
|
Skill,
|
||||||
|
Pipeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum EvolutionStatus {
|
||||||
|
Pending,
|
||||||
|
Confirmed,
|
||||||
|
Rejected,
|
||||||
|
Optimized,
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute effective importance with time decay.
|
/// Compute effective importance with time decay.
|
||||||
///
|
///
|
||||||
/// Uses exponential decay: each 30-day period of non-access reduces
|
/// Uses exponential decay: each 30-day period of non-access reduces
|
||||||
@@ -524,4 +621,61 @@ mod tests {
|
|||||||
assert!(!result.is_empty());
|
assert!(!result.is_empty());
|
||||||
assert_eq!(result.total_count(), 1);
|
assert_eq!(result.total_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_experience_candidate_roundtrip() {
|
||||||
|
let candidate = ExperienceCandidate {
|
||||||
|
pain_pattern: "报表生成".to_string(),
|
||||||
|
context: "月度销售报表".to_string(),
|
||||||
|
solution_steps: vec!["查询数据库".to_string(), "格式化输出".to_string()],
|
||||||
|
outcome: Outcome::Success,
|
||||||
|
confidence: 0.85,
|
||||||
|
tools_used: vec!["researcher".to_string()],
|
||||||
|
industry_context: Some("healthcare".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&candidate).unwrap();
|
||||||
|
let decoded: ExperienceCandidate = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded.pain_pattern, "报表生成");
|
||||||
|
assert_eq!(decoded.outcome, Outcome::Success);
|
||||||
|
assert_eq!(decoded.solution_steps.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evolution_event_roundtrip() {
|
||||||
|
let event = EvolutionEvent {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
event_type: EvolutionEventType::SkillGenerated,
|
||||||
|
artifact_type: ArtifactType::Skill,
|
||||||
|
artifact_id: "daily-report".to_string(),
|
||||||
|
status: EvolutionStatus::Pending,
|
||||||
|
confidence: 0.8,
|
||||||
|
user_feedback: None,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let decoded: EvolutionEvent = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded.event_type, EvolutionEventType::SkillGenerated);
|
||||||
|
assert_eq!(decoded.status, EvolutionStatus::Pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_combined_extraction_default() {
|
||||||
|
let combined = CombinedExtraction::default();
|
||||||
|
assert!(combined.memories.is_empty());
|
||||||
|
assert!(combined.experiences.is_empty());
|
||||||
|
assert!(combined.profile_signals.industry.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_signals() {
|
||||||
|
let signals = ProfileSignals {
|
||||||
|
industry: Some("healthcare".to_string()),
|
||||||
|
recent_topic: Some("报表".to_string()),
|
||||||
|
pain_point: None,
|
||||||
|
preferred_tool: Some("researcher".to_string()),
|
||||||
|
communication_style: Some("concise".to_string()),
|
||||||
|
};
|
||||||
|
assert_eq!(signals.industry.as_deref(), Some("healthcare"));
|
||||||
|
assert!(signals.pain_point.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
180
crates/zclaw-growth/src/workflow_composer.rs
Normal file
180
crates/zclaw-growth/src/workflow_composer.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
//! 工作流组装器(L3 工作流进化)
|
||||||
|
//! 从轨迹数据中分析重复的工具链模式,自动组装 Pipeline YAML
|
||||||
|
//! 触发条件:CompressedTrajectory 中出现 2 次以上相同工具链序列
|
||||||
|
|
||||||
|
use zclaw_types::Result;
|
||||||
|
|
||||||
|
/// Pipeline 候选项
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PipelineCandidate {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub triggers: Vec<String>,
|
||||||
|
pub yaml_content: String,
|
||||||
|
pub source_sessions: Vec<String>,
|
||||||
|
pub confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工具链模式(用于聚类分析)
|
||||||
|
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||||
|
pub struct ToolChainPattern {
|
||||||
|
pub steps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工作流组装 prompt
|
||||||
|
const WORKFLOW_GENERATION_PROMPT: &str = r#"
|
||||||
|
你是一个工作流设计专家。根据以下用户反复执行的工具链序列,设计一个可复用的 Pipeline 工作流。
|
||||||
|
|
||||||
|
工具链序列:{tool_chain}
|
||||||
|
执行频率:{frequency} 次
|
||||||
|
行业背景:{industry}
|
||||||
|
|
||||||
|
请生成以下 JSON:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "工作流名称(简短中文)",
|
||||||
|
"description": "工作流描述",
|
||||||
|
"triggers": ["触发词1", "触发词2"],
|
||||||
|
"yaml_content": "Pipeline YAML 内容",
|
||||||
|
"confidence": 0.8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// 工作流组装器
|
||||||
|
/// 分析压缩轨迹中的工具链模式,通过 LLM 生成 Pipeline YAML
|
||||||
|
pub struct WorkflowComposer;
|
||||||
|
|
||||||
|
impl WorkflowComposer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从压缩轨迹的工具链中提取模式
|
||||||
|
/// 简单的精确匹配聚类:相同工具链序列视为同一模式
|
||||||
|
pub fn extract_patterns(
|
||||||
|
trajectories: &[(String, Vec<String>)], // (session_id, tools_used)
|
||||||
|
) -> Vec<(ToolChainPattern, Vec<String>)> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut groups: HashMap<ToolChainPattern, Vec<String>> = HashMap::new();
|
||||||
|
for (session_id, tools) in trajectories {
|
||||||
|
if tools.len() < 2 {
|
||||||
|
continue; // 单步操作不构成工作流
|
||||||
|
}
|
||||||
|
let pattern = ToolChainPattern {
|
||||||
|
steps: tools.clone(),
|
||||||
|
};
|
||||||
|
groups.entry(pattern).or_default().push(session_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤出现 2 次以上的模式
|
||||||
|
groups
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, sessions)| sessions.len() >= 2)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建 LLM prompt
|
||||||
|
pub fn build_prompt(
|
||||||
|
pattern: &ToolChainPattern,
|
||||||
|
frequency: usize,
|
||||||
|
industry: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
WORKFLOW_GENERATION_PROMPT
|
||||||
|
.replace("{tool_chain}", &pattern.steps.join(" → "))
|
||||||
|
.replace("{frequency}", &frequency.to_string())
|
||||||
|
.replace("{industry}", industry.unwrap_or("通用"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 LLM 返回的 JSON 为 PipelineCandidate
|
||||||
|
pub fn parse_response(
|
||||||
|
json_str: &str,
|
||||||
|
_pattern: &ToolChainPattern,
|
||||||
|
source_sessions: Vec<String>,
|
||||||
|
) -> Result<PipelineCandidate> {
|
||||||
|
let json_str = crate::json_utils::extract_json_block(json_str);
|
||||||
|
let raw: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
|
||||||
|
zclaw_types::ZclawError::ConfigError(format!("Invalid pipeline JSON: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(PipelineCandidate {
|
||||||
|
name: raw["name"].as_str().unwrap_or("未命名工作流").to_string(),
|
||||||
|
description: raw["description"].as_str().unwrap_or("").to_string(),
|
||||||
|
triggers: crate::json_utils::extract_string_array(&raw, "triggers"),
|
||||||
|
yaml_content: raw["yaml_content"].as_str().unwrap_or("").to_string(),
|
||||||
|
source_sessions,
|
||||||
|
confidence: raw["confidence"].as_f64().unwrap_or(0.5) as f32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WorkflowComposer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_patterns_filters_single_step() {
|
||||||
|
let trajectories = vec![
|
||||||
|
("s1".to_string(), vec!["researcher".to_string()]),
|
||||||
|
];
|
||||||
|
let patterns = WorkflowComposer::extract_patterns(&trajectories);
|
||||||
|
assert!(patterns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_patterns_groups_identical_chains() {
|
||||||
|
let trajectories = vec![
|
||||||
|
("s1".to_string(), vec!["researcher".into(), "collector".into()]),
|
||||||
|
("s2".to_string(), vec!["researcher".into(), "collector".into()]),
|
||||||
|
("s3".to_string(), vec!["browser".into()]), // 单步,过滤
|
||||||
|
];
|
||||||
|
let patterns = WorkflowComposer::extract_patterns(&trajectories);
|
||||||
|
assert_eq!(patterns.len(), 1);
|
||||||
|
assert_eq!(patterns[0].1.len(), 2); // 2 sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_patterns_requires_min_2() {
|
||||||
|
let trajectories = vec![
|
||||||
|
("s1".to_string(), vec!["a".into(), "b".into()]),
|
||||||
|
];
|
||||||
|
let patterns = WorkflowComposer::extract_patterns(&trajectories);
|
||||||
|
assert!(patterns.is_empty()); // 只出现 1 次
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_prompt() {
|
||||||
|
let pattern = ToolChainPattern {
|
||||||
|
steps: vec!["researcher".into(), "collector".into(), "summarize".into()],
|
||||||
|
};
|
||||||
|
let prompt = WorkflowComposer::build_prompt(&pattern, 3, Some("healthcare"));
|
||||||
|
assert!(prompt.contains("researcher"));
|
||||||
|
assert!(prompt.contains("3"));
|
||||||
|
assert!(prompt.contains("healthcare"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_response() {
|
||||||
|
let pattern = ToolChainPattern {
|
||||||
|
steps: vec!["researcher".into()],
|
||||||
|
};
|
||||||
|
let json = r##"{"name":"每日简报","description":"搜索+汇总","triggers":["简报","日报"],"yaml_content":"steps: []","confidence":0.85}"##;
|
||||||
|
let candidate = WorkflowComposer::parse_response(
|
||||||
|
json,
|
||||||
|
&pattern,
|
||||||
|
vec!["s1".into(), "s2".into()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(candidate.name, "每日简报");
|
||||||
|
assert_eq!(candidate.triggers.len(), 2);
|
||||||
|
assert_eq!(candidate.source_sessions.len(), 2);
|
||||||
|
assert!((candidate.confidence - 0.85).abs() < 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -459,7 +459,7 @@ impl ClipHand {
|
|||||||
let args = vec![
|
let args = vec![
|
||||||
"-f", "concat",
|
"-f", "concat",
|
||||||
"-safe", "0",
|
"-safe", "0",
|
||||||
"-i", temp_file.to_str().unwrap(),
|
"-i", temp_file.to_str().ok_or_else(|| zclaw_types::ZclawError::HandError("Temp file path is not valid UTF-8".to_string()))?,
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
&config.output_path,
|
&config.output_path,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ description = "ZCLAW kernel - central coordinator for all subsystems"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
# Enable multi-agent orchestration (Director, A2A protocol)
|
|
||||||
multi-agent = ["zclaw-protocols/a2a"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ impl Default for ApiProtocol {
|
|||||||
///
|
///
|
||||||
/// This is the single source of truth for LLM configuration.
|
/// This is the single source of truth for LLM configuration.
|
||||||
/// Model ID is passed directly to the API without any transformation.
|
/// Model ID is passed directly to the API without any transformation.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct LlmConfig {
|
pub struct LlmConfig {
|
||||||
/// API base URL (e.g., "https://api.openai.com/v1")
|
/// API base URL (e.g., "https://api.openai.com/v1")
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -61,6 +61,20 @@ pub struct LlmConfig {
|
|||||||
pub context_window: u32,
|
pub context_window: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for LlmConfig {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("LlmConfig")
|
||||||
|
.field("base_url", &self.base_url)
|
||||||
|
.field("api_key", &"***REDACTED***")
|
||||||
|
.field("model", &self.model)
|
||||||
|
.field("api_protocol", &self.api_protocol)
|
||||||
|
.field("max_tokens", &self.max_tokens)
|
||||||
|
.field("temperature", &self.temperature)
|
||||||
|
.field("context_window", &self.context_window)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl LlmConfig {
|
impl LlmConfig {
|
||||||
/// Create a new LLM config
|
/// Create a new LLM config
|
||||||
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::{RwLock, Mutex, mpsc};
|
use tokio::sync::{RwLock, Mutex, mpsc, oneshot};
|
||||||
use zclaw_types::{AgentId, Result, ZclawError};
|
use zclaw_types::{AgentId, Result, ZclawError};
|
||||||
use zclaw_protocols::{A2aEnvelope, A2aMessageType, A2aRecipient, A2aRouter, A2aAgentProfile, A2aCapability};
|
use zclaw_protocols::{A2aEnvelope, A2aMessageType, A2aRecipient, A2aRouter, A2aAgentProfile, A2aCapability};
|
||||||
use zclaw_runtime::{LlmDriver, CompletionRequest};
|
use zclaw_runtime::{LlmDriver, CompletionRequest};
|
||||||
@@ -199,9 +199,9 @@ pub struct Director {
|
|||||||
director_id: AgentId,
|
director_id: AgentId,
|
||||||
/// Optional LLM driver for intelligent scheduling
|
/// Optional LLM driver for intelligent scheduling
|
||||||
llm_driver: Option<Arc<dyn LlmDriver>>,
|
llm_driver: Option<Arc<dyn LlmDriver>>,
|
||||||
/// Inbox for receiving responses (stores pending request IDs and their response channels)
|
/// Pending request response channels (request_id → oneshot sender)
|
||||||
pending_requests: Arc<Mutex<std::collections::HashMap<String, mpsc::Sender<A2aEnvelope>>>>,
|
pending_requests: Arc<Mutex<std::collections::HashMap<String, oneshot::Sender<A2aEnvelope>>>>,
|
||||||
/// Receiver for incoming messages
|
/// Receiver for incoming messages (consumed by inbox reader task)
|
||||||
inbox: Arc<Mutex<Option<mpsc::Receiver<A2aEnvelope>>>>,
|
inbox: Arc<Mutex<Option<mpsc::Receiver<A2aEnvelope>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ impl Director {
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.expect("system clock is valid")
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let idx = (now as usize) % agents.len();
|
let idx = (now as usize) % agents.len();
|
||||||
Some(agents[idx].clone())
|
Some(agents[idx].clone())
|
||||||
@@ -481,13 +481,16 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send message to selected agent and wait for response
|
/// Send message to selected agent and wait for response
|
||||||
|
///
|
||||||
|
/// Uses oneshot channels to avoid deadlock: each call creates its own
|
||||||
|
/// response channel, and a shared inbox reader dispatches responses.
|
||||||
pub async fn send_to_agent(
|
pub async fn send_to_agent(
|
||||||
&self,
|
&self,
|
||||||
agent: &DirectorAgent,
|
agent: &DirectorAgent,
|
||||||
message: String,
|
message: String,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Create a response channel for this request
|
// Create a oneshot channel for this specific request's response
|
||||||
let (_response_tx, mut _response_rx) = mpsc::channel::<A2aEnvelope>(1);
|
let (response_tx, response_rx) = oneshot::channel::<A2aEnvelope>();
|
||||||
|
|
||||||
let envelope = A2aEnvelope::new(
|
let envelope = A2aEnvelope::new(
|
||||||
self.director_id.clone(),
|
self.director_id.clone(),
|
||||||
@@ -500,50 +503,32 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store the request ID with its response channel
|
// Store the oneshot sender so the inbox reader can dispatch to it
|
||||||
let request_id = envelope.id.clone();
|
let request_id = envelope.id.clone();
|
||||||
{
|
{
|
||||||
let mut pending = self.pending_requests.lock().await;
|
let mut pending = self.pending_requests.lock().await;
|
||||||
pending.insert(request_id.clone(), _response_tx);
|
pending.insert(request_id.clone(), response_tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
self.router.route(envelope).await?;
|
self.router.route(envelope).await?;
|
||||||
|
|
||||||
// Wait for response with timeout
|
// Ensure the inbox reader is running
|
||||||
|
self.ensure_inbox_reader().await;
|
||||||
|
|
||||||
|
// Wait for response on our dedicated oneshot channel with timeout
|
||||||
let timeout_duration = std::time::Duration::from_secs(self.config.response_timeout);
|
let timeout_duration = std::time::Duration::from_secs(self.config.response_timeout);
|
||||||
let request_id_clone = request_id.clone();
|
|
||||||
|
|
||||||
let response = tokio::time::timeout(timeout_duration, async {
|
let response = tokio::time::timeout(timeout_duration, response_rx).await;
|
||||||
// Poll the inbox for responses
|
|
||||||
let mut inbox_guard = self.inbox.lock().await;
|
|
||||||
if let Some(ref mut rx) = *inbox_guard {
|
|
||||||
while let Some(msg) = rx.recv().await {
|
|
||||||
// Check if this is a response to our request
|
|
||||||
if msg.message_type == A2aMessageType::Response {
|
|
||||||
if let Some(ref reply_to) = msg.reply_to {
|
|
||||||
if reply_to == &request_id_clone {
|
|
||||||
// Found our response
|
|
||||||
return Some(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Not our response, continue waiting
|
|
||||||
// (In a real implementation, we'd re-queue non-matching messages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}).await;
|
|
||||||
|
|
||||||
// Clean up pending request
|
// Clean up pending request (sender already consumed on success)
|
||||||
{
|
{
|
||||||
let mut pending = self.pending_requests.lock().await;
|
let mut pending = self.pending_requests.lock().await;
|
||||||
pending.remove(&request_id);
|
pending.remove(&request_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
Ok(Some(envelope)) => {
|
Ok(Ok(envelope)) => {
|
||||||
// Extract response text from payload
|
|
||||||
let response_text = envelope.payload
|
let response_text = envelope.payload
|
||||||
.get("response")
|
.get("response")
|
||||||
.and_then(|v: &serde_json::Value| v.as_str())
|
.and_then(|v: &serde_json::Value| v.as_str())
|
||||||
@@ -551,7 +536,7 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
|||||||
.to_string();
|
.to_string();
|
||||||
Ok(response_text)
|
Ok(response_text)
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(Err(_)) => {
|
||||||
Err(ZclawError::Timeout("No response received".into()))
|
Err(ZclawError::Timeout("No response received".into()))
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -563,6 +548,47 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure the inbox reader task is running.
|
||||||
|
/// The inbox reader continuously reads from the shared inbox channel
|
||||||
|
/// and dispatches each response to the correct oneshot sender.
|
||||||
|
async fn ensure_inbox_reader(&self) {
|
||||||
|
// Quick check: if inbox has already been taken, reader is running
|
||||||
|
{
|
||||||
|
let inbox = self.inbox.lock().await;
|
||||||
|
if inbox.is_none() {
|
||||||
|
return; // Reader already spawned and consumed the receiver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the receiver out (only once)
|
||||||
|
let rx = {
|
||||||
|
let mut inbox = self.inbox.lock().await;
|
||||||
|
inbox.take()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(mut rx) = rx {
|
||||||
|
let pending = self.pending_requests.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(msg) = rx.recv().await {
|
||||||
|
// Find and dispatch to the correct oneshot sender
|
||||||
|
if msg.message_type == A2aMessageType::Response {
|
||||||
|
if let Some(ref reply_to) = msg.reply_to {
|
||||||
|
let reply_to_clone = reply_to.clone();
|
||||||
|
let mut pending_guard = pending.lock().await;
|
||||||
|
if let Some(sender) = pending_guard.remove(reply_to) {
|
||||||
|
// Send the response; if receiver already dropped, request was cancelled
|
||||||
|
if sender.send(msg).is_err() {
|
||||||
|
tracing::debug!("[Director] Response dropped: receiver cancelled for reply_to={}", reply_to_clone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-response messages are dropped (notifications, etc.)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Broadcast message to all agents
|
/// Broadcast message to all agents
|
||||||
pub async fn broadcast(&self, message: String) -> Result<()> {
|
pub async fn broadcast(&self, message: String) -> Result<()> {
|
||||||
let envelope = A2aEnvelope::new(
|
let envelope = A2aEnvelope::new(
|
||||||
@@ -616,7 +642,9 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref user_input) = input {
|
if let Some(ref user_input) = input {
|
||||||
context.push_str(&format!("User: {}\n\n", user_input));
|
context.push_str("<user_input>\n");
|
||||||
|
context.push_str(&format!("{}\n", user_input));
|
||||||
|
context.push_str("</user_input>\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add recent history
|
// Add recent history
|
||||||
@@ -882,7 +910,9 @@ impl Director {
|
|||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
r#"你是 ZCLAW 管家。请将以下用户需求拆解为 1-5 个具体子任务。
|
r#"你是 ZCLAW 管家。请将以下用户需求拆解为 1-5 个具体子任务。
|
||||||
|
|
||||||
用户需求:{}
|
<user_request>
|
||||||
|
{}
|
||||||
|
</user_request>
|
||||||
|
|
||||||
请按 JSON 数组格式输出,每个元素包含:
|
请按 JSON 数组格式输出,每个元素包含:
|
||||||
- description: 子任务描述(中文)
|
- description: 子任务描述(中文)
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ impl EventBus {
|
|||||||
|
|
||||||
/// Publish an event
|
/// Publish an event
|
||||||
pub fn publish(&self, event: Event) {
|
pub fn publish(&self, event: Event) {
|
||||||
// Ignore send errors (no subscribers)
|
if let Err(e) = self.sender.send(event) {
|
||||||
let _ = self.sender.send(event);
|
tracing::debug!("Event dropped (no subscribers or channel full): {:?}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to events
|
/// Subscribe to events
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use zclaw_types::Result;
|
|||||||
/// HTML exporter
|
/// HTML exporter
|
||||||
pub struct HtmlExporter {
|
pub struct HtmlExporter {
|
||||||
/// Template name (reserved for future template support)
|
/// Template name (reserved for future template support)
|
||||||
#[allow(dead_code)] // TODO: Implement template-based HTML export
|
#[allow(dead_code)] // @reserved: post-release template-based HTML export
|
||||||
template: String,
|
template: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ impl PptxExporter {
|
|||||||
paths.sort();
|
paths.sort();
|
||||||
|
|
||||||
for path in paths {
|
for path in paths {
|
||||||
let content = files.get(path).unwrap();
|
let content = files.get(path).expect("path comes from files.keys(), must exist");
|
||||||
let options = SimpleFileOptions::default()
|
let options = SimpleFileOptions::default()
|
||||||
.compression_method(zip::CompressionMethod::Deflated);
|
.compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ fn clean_fallback_response(text: &str) -> String {
|
|||||||
fn current_timestamp_millis() -> i64 {
|
fn current_timestamp_millis() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.expect("system clock is valid")
|
||||||
.as_millis() as i64
|
.as_millis() as i64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -557,7 +557,7 @@ Use Chinese if the topic is in Chinese. Include metaphors that relate to everyda
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: instance-method convenience wrapper for static helper
|
||||||
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
|
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
|
||||||
Self::extract_text_from_response_static(response)
|
Self::extract_text_from_response_static(response)
|
||||||
}
|
}
|
||||||
@@ -882,7 +882,7 @@ fn current_timestamp() -> i64 {
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.expect("system clock is valid")
|
||||||
.as_millis() as i64
|
.as_millis() as i64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
//! A2A (Agent-to-Agent) messaging
|
//! A2A (Agent-to-Agent) messaging
|
||||||
//!
|
|
||||||
//! All items in this module are gated by the `multi-agent` feature flag.
|
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
use zclaw_types::{AgentId, Capability, Event, Result};
|
use zclaw_types::{AgentId, Capability, Event, Result};
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
use zclaw_protocols::{A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient};
|
use zclaw_protocols::{A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient};
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
use super::Kernel;
|
use super::Kernel;
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
impl Kernel {
|
impl Kernel {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// A2A (Agent-to-Agent) Messaging
|
// A2A (Agent-to-Agent) Messaging
|
||||||
|
|||||||
@@ -106,13 +106,11 @@ impl SkillExecutor for KernelSkillExecutor {
|
|||||||
|
|
||||||
/// Inbox wrapper for A2A message receivers that supports re-queuing
|
/// Inbox wrapper for A2A message receivers that supports re-queuing
|
||||||
/// non-matching messages instead of dropping them.
|
/// non-matching messages instead of dropping them.
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
pub(crate) struct AgentInbox {
|
pub(crate) struct AgentInbox {
|
||||||
pub(crate) rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>,
|
pub(crate) rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>,
|
||||||
pub(crate) pending: std::collections::VecDeque<zclaw_protocols::A2aEnvelope>,
|
pub(crate) pending: std::collections::VecDeque<zclaw_protocols::A2aEnvelope>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
impl AgentInbox {
|
impl AgentInbox {
|
||||||
pub(crate) fn new(rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>) -> Self {
|
pub(crate) fn new(rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>) -> Self {
|
||||||
Self { rx, pending: std::collections::VecDeque::new() }
|
Self { rx, pending: std::collections::VecDeque::new() }
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
use super::adapters::AgentInbox;
|
use super::adapters::AgentInbox;
|
||||||
|
|
||||||
use super::Kernel;
|
use super::Kernel;
|
||||||
@@ -23,7 +20,6 @@ impl Kernel {
|
|||||||
self.memory.save_agent(&config).await?;
|
self.memory.save_agent(&config).await?;
|
||||||
|
|
||||||
// Register with A2A router for multi-agent messaging (before config is moved)
|
// Register with A2A router for multi-agent messaging (before config is moved)
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
{
|
{
|
||||||
let profile = Self::agent_config_to_a2a_profile(&config);
|
let profile = Self::agent_config_to_a2a_profile(&config);
|
||||||
let rx = self.a2a_router.register_agent(profile).await;
|
let rx = self.a2a_router.register_agent(profile).await;
|
||||||
@@ -52,7 +48,6 @@ impl Kernel {
|
|||||||
self.memory.delete_agent(id).await?;
|
self.memory.delete_agent(id).await?;
|
||||||
|
|
||||||
// Unregister from A2A router
|
// Unregister from A2A router
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
{
|
{
|
||||||
self.a2a_router.unregister_agent(id).await;
|
self.a2a_router.unregister_agent(id).await;
|
||||||
self.a2a_inboxes.remove(id);
|
self.a2a_inboxes.remove(id);
|
||||||
|
|||||||
@@ -85,14 +85,14 @@ impl Kernel {
|
|||||||
started_at: None,
|
started_at: None,
|
||||||
completed_at: None,
|
completed_at: None,
|
||||||
};
|
};
|
||||||
let _ = memory.save_hand_run(&run).await.map_err(|e| {
|
if let Err(e) = memory.save_hand_run(&run).await {
|
||||||
tracing::warn!("[Approval] Failed to save hand run: {}", e);
|
tracing::error!("[Approval] Failed to save hand run: {}", e);
|
||||||
});
|
}
|
||||||
run.status = HandRunStatus::Running;
|
run.status = HandRunStatus::Running;
|
||||||
run.started_at = Some(chrono::Utc::now().to_rfc3339());
|
run.started_at = Some(chrono::Utc::now().to_rfc3339());
|
||||||
let _ = memory.update_hand_run(&run).await.map_err(|e| {
|
if let Err(e) = memory.update_hand_run(&run).await {
|
||||||
tracing::warn!("[Approval] Failed to update hand run (running): {}", e);
|
tracing::error!("[Approval] Failed to update hand run (running): {}", e);
|
||||||
});
|
}
|
||||||
|
|
||||||
// Register cancellation flag
|
// Register cancellation flag
|
||||||
let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
@@ -121,9 +121,9 @@ impl Kernel {
|
|||||||
}
|
}
|
||||||
run.duration_ms = Some(duration.as_millis() as u64);
|
run.duration_ms = Some(duration.as_millis() as u64);
|
||||||
run.completed_at = Some(completed_at);
|
run.completed_at = Some(completed_at);
|
||||||
let _ = memory.update_hand_run(&run).await.map_err(|e| {
|
if let Err(e) = memory.update_hand_run(&run).await {
|
||||||
tracing::warn!("[Approval] Failed to update hand run (completed): {}", e);
|
tracing::error!("[Approval] Failed to update hand run (completed): {}", e);
|
||||||
});
|
}
|
||||||
|
|
||||||
// Update approval status based on execution result
|
// Update approval status based on execution result
|
||||||
let mut approvals = approvals.lock().await;
|
let mut approvals = approvals.lock().await;
|
||||||
|
|||||||
@@ -8,16 +8,13 @@ mod hands;
|
|||||||
mod triggers;
|
mod triggers;
|
||||||
mod approvals;
|
mod approvals;
|
||||||
mod orchestration;
|
mod orchestration;
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
mod a2a;
|
mod a2a;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex};
|
||||||
use zclaw_types::{Event, Result, AgentState};
|
use zclaw_types::{Event, Result, AgentState};
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
use zclaw_types::AgentId;
|
use zclaw_types::AgentId;
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
use zclaw_protocols::A2aRouter;
|
use zclaw_protocols::A2aRouter;
|
||||||
|
|
||||||
use crate::registry::AgentRegistry;
|
use crate::registry::AgentRegistry;
|
||||||
@@ -56,11 +53,9 @@ pub struct Kernel {
|
|||||||
mcp_adapters: Arc<std::sync::RwLock<Vec<zclaw_protocols::McpToolAdapter>>>,
|
mcp_adapters: Arc<std::sync::RwLock<Vec<zclaw_protocols::McpToolAdapter>>>,
|
||||||
/// Dynamic industry keyword configs — shared with Tauri frontend, loaded from SaaS
|
/// Dynamic industry keyword configs — shared with Tauri frontend, loaded from SaaS
|
||||||
industry_keywords: Arc<tokio::sync::RwLock<Vec<zclaw_runtime::IndustryKeywordConfig>>>,
|
industry_keywords: Arc<tokio::sync::RwLock<Vec<zclaw_runtime::IndustryKeywordConfig>>>,
|
||||||
/// A2A router for inter-agent messaging (gated by multi-agent feature)
|
/// A2A router for inter-agent messaging
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
a2a_router: Arc<A2aRouter>,
|
a2a_router: Arc<A2aRouter>,
|
||||||
/// Per-agent A2A inbox receivers (supports re-queuing non-matching messages)
|
/// Per-agent A2A inbox receivers (supports re-queuing non-matching messages)
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<adapters::AgentInbox>>>>,
|
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<adapters::AgentInbox>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +130,6 @@ impl Kernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize A2A router for multi-agent support
|
// Initialize A2A router for multi-agent support
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
let a2a_router = {
|
let a2a_router = {
|
||||||
let kernel_agent_id = AgentId::new();
|
let kernel_agent_id = AgentId::new();
|
||||||
Arc::new(A2aRouter::new(kernel_agent_id))
|
Arc::new(A2aRouter::new(kernel_agent_id))
|
||||||
@@ -159,9 +153,7 @@ impl Kernel {
|
|||||||
extraction_driver: None,
|
extraction_driver: None,
|
||||||
mcp_adapters: Arc::new(std::sync::RwLock::new(Vec::new())),
|
mcp_adapters: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||||
industry_keywords: Arc::new(tokio::sync::RwLock::new(Vec::new())),
|
industry_keywords: Arc::new(tokio::sync::RwLock::new(Vec::new())),
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
a2a_router,
|
a2a_router,
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
a2a_inboxes: Arc::new(dashmap::DashMap::new()),
|
a2a_inboxes: Arc::new(dashmap::DashMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -247,6 +239,9 @@ impl Kernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Data masking middleware — mask sensitive entities before any other processing
|
// Data masking middleware — mask sensitive entities before any other processing
|
||||||
|
// NOTE: Registration order does NOT determine execution order.
|
||||||
|
// The chain sorts by priority() ascending before execution.
|
||||||
|
// Execution order: Evolution(78) → ButlerRouter(80) → DataMasking(90) → ...
|
||||||
{
|
{
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
let masker = Arc::new(zclaw_runtime::middleware::data_masking::DataMasker::new());
|
let masker = Arc::new(zclaw_runtime::middleware::data_masking::DataMasker::new());
|
||||||
@@ -260,6 +255,13 @@ impl Kernel {
|
|||||||
growth = growth.with_llm_driver(driver.clone());
|
growth = growth.with_llm_driver(driver.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evolution middleware — pushes evolution candidate skills into system prompt
|
||||||
|
// priority=78, executed first by chain (before ButlerRouter@80)
|
||||||
|
let evolution_mw = std::sync::Arc::new(
|
||||||
|
zclaw_runtime::middleware::evolution::EvolutionMiddleware::new()
|
||||||
|
);
|
||||||
|
chain.register(evolution_mw.clone());
|
||||||
|
|
||||||
// Compaction middleware — only register when threshold > 0
|
// Compaction middleware — only register when threshold > 0
|
||||||
let threshold = self.config.compaction_threshold();
|
let threshold = self.config.compaction_threshold();
|
||||||
if threshold > 0 {
|
if threshold > 0 {
|
||||||
@@ -277,10 +279,11 @@ impl Kernel {
|
|||||||
chain.register(Arc::new(mw));
|
chain.register(Arc::new(mw));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory middleware — auto-extract memories after conversations
|
// Memory middleware — auto-extract memories + check evolution after conversations
|
||||||
{
|
{
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth);
|
let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth)
|
||||||
|
.with_evolution(evolution_mw);
|
||||||
chain.register(Arc::new(mw));
|
chain.register(Arc::new(mw));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub mod trigger_manager;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod skill_router;
|
pub mod skill_router;
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
pub mod director;
|
pub mod director;
|
||||||
pub mod generation;
|
pub mod generation;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
@@ -21,13 +20,11 @@ pub use capabilities::*;
|
|||||||
pub use events::*;
|
pub use events::*;
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
|
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
pub use director::{
|
pub use director::{
|
||||||
Director, DirectorConfig, DirectorBuilder, DirectorAgent,
|
Director, DirectorConfig, DirectorBuilder, DirectorAgent,
|
||||||
ConversationState, ScheduleStrategy,
|
ConversationState, ScheduleStrategy,
|
||||||
// Note: AgentRole is intentionally NOT re-exported here — use generation::AgentRole instead
|
// Note: AgentRole is intentionally NOT re-exported here — use generation::AgentRole instead
|
||||||
};
|
};
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
pub use zclaw_protocols::{
|
pub use zclaw_protocols::{
|
||||||
A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient,
|
A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient,
|
||||||
A2aReceiver,
|
A2aReceiver,
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ impl StageEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clone with drivers (reserved for future use)
|
/// Clone with drivers (reserved for future use)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: post-release stage cloning with drivers
|
||||||
fn clone_with_drivers(&self) -> Self {
|
fn clone_with_drivers(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
llm_driver: self.llm_driver.clone(),
|
llm_driver: self.llm_driver.clone(),
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ pub enum ExecuteError {
|
|||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum completed/failed/cancelled runs to keep in memory
|
||||||
|
const MAX_COMPLETED_RUNS: usize = 100;
|
||||||
|
|
||||||
|
/// Maximum allowed delay in milliseconds (60 seconds)
|
||||||
|
const MAX_DELAY_MS: u64 = 60_000;
|
||||||
|
|
||||||
|
/// Default per-step timeout (5 minutes)
|
||||||
|
const DEFAULT_STEP_TIMEOUT_SECS: u64 = 300;
|
||||||
|
|
||||||
/// Pipeline executor
|
/// Pipeline executor
|
||||||
pub struct PipelineExecutor {
|
pub struct PipelineExecutor {
|
||||||
/// Action registry
|
/// Action registry
|
||||||
@@ -107,35 +116,50 @@ impl PipelineExecutor {
|
|||||||
// Create execution context
|
// Create execution context
|
||||||
let mut context = ExecutionContext::new(inputs);
|
let mut context = ExecutionContext::new(inputs);
|
||||||
|
|
||||||
|
// Determine per-step timeout from pipeline spec (0 means use default)
|
||||||
|
let step_timeout = if pipeline.spec.timeout_secs > 0 {
|
||||||
|
pipeline.spec.timeout_secs
|
||||||
|
} else {
|
||||||
|
DEFAULT_STEP_TIMEOUT_SECS
|
||||||
|
};
|
||||||
|
|
||||||
// Execute steps
|
// Execute steps
|
||||||
let result = self.execute_steps(pipeline, &mut context, &run_id).await;
|
let result = self.execute_steps(pipeline, &mut context, &run_id, step_timeout).await;
|
||||||
|
|
||||||
// Update run state
|
// Update run state
|
||||||
let mut runs = self.runs.write().await;
|
let return_value = {
|
||||||
if let Some(run) = runs.get_mut(&run_id) {
|
let mut runs = self.runs.write().await;
|
||||||
match result {
|
if let Some(run) = runs.get_mut(&run_id) {
|
||||||
Ok(outputs) => {
|
match result {
|
||||||
run.status = RunStatus::Completed;
|
Ok(outputs) => {
|
||||||
run.outputs = Some(serde_json::to_value(&outputs).unwrap_or(Value::Null));
|
run.status = RunStatus::Completed;
|
||||||
}
|
run.outputs = Some(serde_json::to_value(&outputs).unwrap_or(Value::Null));
|
||||||
Err(e) => {
|
}
|
||||||
run.status = RunStatus::Failed;
|
Err(e) => {
|
||||||
run.error = Some(e.to_string());
|
run.status = RunStatus::Failed;
|
||||||
|
run.error = Some(e.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
run.ended_at = Some(Utc::now());
|
||||||
|
Ok(run.clone())
|
||||||
|
} else {
|
||||||
|
Err(ExecuteError::Action("执行后未找到运行记录".to_string()))
|
||||||
}
|
}
|
||||||
run.ended_at = Some(Utc::now());
|
};
|
||||||
return Ok(run.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ExecuteError::Action("执行后未找到运行记录".to_string()))
|
// Auto-cleanup old completed runs (after releasing the write lock)
|
||||||
|
self.cleanup().await;
|
||||||
|
|
||||||
|
return_value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute pipeline steps
|
/// Execute pipeline steps with per-step timeout
|
||||||
async fn execute_steps(
|
async fn execute_steps(
|
||||||
&self,
|
&self,
|
||||||
pipeline: &Pipeline,
|
pipeline: &Pipeline,
|
||||||
context: &mut ExecutionContext,
|
context: &mut ExecutionContext,
|
||||||
run_id: &str,
|
run_id: &str,
|
||||||
|
step_timeout_secs: u64,
|
||||||
) -> Result<HashMap<String, Value>, ExecuteError> {
|
) -> Result<HashMap<String, Value>, ExecuteError> {
|
||||||
let total_steps = pipeline.spec.steps.len();
|
let total_steps = pipeline.spec.steps.len();
|
||||||
|
|
||||||
@@ -161,8 +185,15 @@ impl PipelineExecutor {
|
|||||||
|
|
||||||
tracing::info!("Executing step {} ({}/{})", step.id, idx + 1, total_steps);
|
tracing::info!("Executing step {} ({}/{})", step.id, idx + 1, total_steps);
|
||||||
|
|
||||||
// Execute action
|
// Execute action with per-step timeout
|
||||||
let result = self.execute_action(&step.action, context).await?;
|
let timeout_duration = std::time::Duration::from_secs(step_timeout_secs);
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
timeout_duration,
|
||||||
|
self.execute_action(&step.action, context),
|
||||||
|
).await.map_err(|_| {
|
||||||
|
tracing::error!("Step {} timed out after {}s", step.id, step_timeout_secs);
|
||||||
|
ExecuteError::Timeout
|
||||||
|
})??;
|
||||||
|
|
||||||
// Store result
|
// Store result
|
||||||
context.set_output(&step.id, result.clone());
|
context.set_output(&step.id, result.clone());
|
||||||
@@ -336,7 +367,16 @@ impl PipelineExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Action::Delay { ms } => {
|
Action::Delay { ms } => {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(*ms)).await;
|
let capped_ms = if *ms > MAX_DELAY_MS {
|
||||||
|
tracing::warn!(
|
||||||
|
"Delay ms {} exceeds max {}, capping to {}",
|
||||||
|
ms, MAX_DELAY_MS, MAX_DELAY_MS
|
||||||
|
);
|
||||||
|
MAX_DELAY_MS
|
||||||
|
} else {
|
||||||
|
*ms
|
||||||
|
};
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(capped_ms)).await;
|
||||||
Ok(Value::Null)
|
Ok(Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +548,33 @@ impl PipelineExecutor {
|
|||||||
pub async fn list_runs(&self) -> Vec<PipelineRun> {
|
pub async fn list_runs(&self) -> Vec<PipelineRun> {
|
||||||
self.runs.read().await.values().cloned().collect()
|
self.runs.read().await.values().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clean up old completed/failed/cancelled runs to prevent memory leaks.
|
||||||
|
/// Keeps at most MAX_COMPLETED_RUNS finished runs, evicting the oldest first.
|
||||||
|
pub async fn cleanup(&self) {
|
||||||
|
let mut runs = self.runs.write().await;
|
||||||
|
|
||||||
|
// Collect IDs of finished runs (completed, failed, cancelled)
|
||||||
|
let mut finished: Vec<(String, chrono::DateTime<Utc>)> = runs
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, r)| matches!(r.status, RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled))
|
||||||
|
.map(|(id, r)| (id.clone(), r.ended_at.unwrap_or(r.started_at)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let to_remove = finished.len().saturating_sub(MAX_COMPLETED_RUNS);
|
||||||
|
if to_remove > 0 {
|
||||||
|
// Sort by end time ascending (oldest first)
|
||||||
|
finished.sort_by_key(|(_, t)| *t);
|
||||||
|
for (id, _) in finished.into_iter().take(to_remove) {
|
||||||
|
runs.remove(&id);
|
||||||
|
// Also clean up cancellation flag
|
||||||
|
drop(runs);
|
||||||
|
self.cancellations.write().await.remove(&id);
|
||||||
|
runs = self.runs.write().await;
|
||||||
|
}
|
||||||
|
tracing::debug!("Cleaned up {} old pipeline runs", to_remove);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ impl ExecutionContext {
|
|||||||
steps_output: HashMap::new(),
|
steps_output: HashMap::new(),
|
||||||
variables: HashMap::new(),
|
variables: HashMap::new(),
|
||||||
loop_context: None,
|
loop_context: None,
|
||||||
expr_regex: Regex::new(r"\$\{([^}]+)\}").unwrap(),
|
expr_regex: Regex::new(r"\$\{([^}]+)\}").expect("static regex is valid"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ impl ExecutionContext {
|
|||||||
steps_output,
|
steps_output,
|
||||||
variables,
|
variables,
|
||||||
loop_context: None,
|
loop_context: None,
|
||||||
expr_regex: Regex::new(r"\$\{([^}]+)\}").unwrap(),
|
expr_regex: Regex::new(r"\$\{([^}]+)\}").expect("static regex is valid"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
//! ZCLAW Protocols
|
//! ZCLAW Protocols
|
||||||
//!
|
//!
|
||||||
//! Protocol support for MCP (Model Context Protocol) and A2A (Agent-to-Agent).
|
//! Protocol support for MCP (Model Context Protocol) and A2A (Agent-to-Agent).
|
||||||
//!
|
|
||||||
//! A2A is gated behind the `a2a` feature flag (reserved for future multi-agent scenarios).
|
|
||||||
//! MCP is always available as a framework for tool integration.
|
|
||||||
|
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod mcp_types;
|
mod mcp_types;
|
||||||
mod mcp_tool_adapter;
|
mod mcp_tool_adapter;
|
||||||
mod mcp_transport;
|
mod mcp_transport;
|
||||||
#[cfg(feature = "a2a")]
|
|
||||||
mod a2a;
|
mod a2a;
|
||||||
|
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use mcp_types::*;
|
pub use mcp_types::*;
|
||||||
pub use mcp_tool_adapter::*;
|
pub use mcp_tool_adapter::*;
|
||||||
pub use mcp_transport::*;
|
pub use mcp_transport::*;
|
||||||
#[cfg(feature = "a2a")]
|
|
||||||
pub use a2a::*;
|
pub use a2a::*;
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ impl McpToolAdapter {
|
|||||||
|
|
||||||
match result.len() {
|
match result.len() {
|
||||||
0 => Ok(Value::Null),
|
0 => Ok(Value::Null),
|
||||||
1 => Ok(result.into_iter().next().unwrap()),
|
1 => Ok(result.into_iter().next().unwrap_or(Value::Null)),
|
||||||
_ => Ok(Value::Array(result)),
|
_ => Ok(Value::Array(result)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ impl McpServiceManager {
|
|||||||
let adapters = McpToolAdapter::from_server(name.clone(), client.clone()).await?;
|
let adapters = McpToolAdapter::from_server(name.clone(), client.clone()).await?;
|
||||||
self.clients.insert(name.clone(), client);
|
self.clients.insert(name.clone(), client);
|
||||||
self.adapters.insert(name.clone(), adapters);
|
self.adapters.insert(name.clone(), adapters);
|
||||||
Ok(self.adapters.get(&name).unwrap().iter().collect())
|
Ok(self.adapters.get(&name).map(|v| v.iter().collect()).unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all registered tool adapters from all services
|
/// Get all registered tool adapters from all services
|
||||||
|
|||||||
@@ -84,12 +84,20 @@ impl McpServerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Combined transport handles (stdin + stdout) behind a single Mutex.
|
||||||
|
/// This ensures write-then-read is atomic, preventing concurrent requests
|
||||||
|
/// from receiving each other's responses.
|
||||||
|
struct TransportHandles {
|
||||||
|
stdin: BufWriter<ChildStdin>,
|
||||||
|
stdout: BufReader<ChildStdout>,
|
||||||
|
}
|
||||||
|
|
||||||
/// MCP Transport using stdio
|
/// MCP Transport using stdio
|
||||||
pub struct McpTransport {
|
pub struct McpTransport {
|
||||||
config: McpServerConfig,
|
config: McpServerConfig,
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
stdin: Arc<Mutex<Option<BufWriter<ChildStdin>>>>,
|
/// Single Mutex protecting both stdin and stdout for atomic write-then-read
|
||||||
stdout: Arc<Mutex<Option<BufReader<ChildStdout>>>>,
|
handles: Arc<Mutex<Option<TransportHandles>>>,
|
||||||
capabilities: Arc<Mutex<Option<ServerCapabilities>>>,
|
capabilities: Arc<Mutex<Option<ServerCapabilities>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +107,7 @@ impl McpTransport {
|
|||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
child: Arc::new(Mutex::new(None)),
|
child: Arc::new(Mutex::new(None)),
|
||||||
stdin: Arc::new(Mutex::new(None)),
|
handles: Arc::new(Mutex::new(None)),
|
||||||
stdout: Arc::new(Mutex::new(None)),
|
|
||||||
capabilities: Arc::new(Mutex::new(None)),
|
capabilities: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,9 +169,11 @@ impl McpTransport {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store handles in separate mutexes
|
// Store handles in single mutex for atomic write-then-read
|
||||||
*self.stdin.lock().await = Some(BufWriter::new(stdin));
|
*self.handles.lock().await = Some(TransportHandles {
|
||||||
*self.stdout.lock().await = Some(BufReader::new(stdout));
|
stdin: BufWriter::new(stdin),
|
||||||
|
stdout: BufReader::new(stdout),
|
||||||
|
});
|
||||||
*child_guard = Some(child);
|
*child_guard = Some(child);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -201,21 +210,21 @@ impl McpTransport {
|
|||||||
let line = serde_json::to_string(notification)
|
let line = serde_json::to_string(notification)
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to serialize notification: {}", e)))?;
|
.map_err(|e| ZclawError::McpError(format!("Failed to serialize notification: {}", e)))?;
|
||||||
|
|
||||||
let mut stdin_guard = self.stdin.lock().await;
|
let mut handles_guard = self.handles.lock().await;
|
||||||
let stdin = stdin_guard.as_mut()
|
let handles = handles_guard.as_mut()
|
||||||
.ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?;
|
.ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?;
|
||||||
|
|
||||||
stdin.write_all(line.as_bytes())
|
handles.stdin.write_all(line.as_bytes())
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to write notification: {}", e)))?;
|
.map_err(|e| ZclawError::McpError(format!("Failed to write notification: {}", e)))?;
|
||||||
stdin.write_all(b"\n")
|
handles.stdin.write_all(b"\n")
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to write newline: {}", e)))?;
|
.map_err(|e| ZclawError::McpError(format!("Failed to write newline: {}", e)))?;
|
||||||
stdin.flush()
|
handles.stdin.flush()
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to flush notification: {}", e)))?;
|
.map_err(|e| ZclawError::McpError(format!("Failed to flush notification: {}", e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send JSON-RPC request
|
/// Send JSON-RPC request (atomic write-then-read under single lock)
|
||||||
async fn send_request<T: DeserializeOwned>(
|
async fn send_request<T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -234,28 +243,23 @@ impl McpTransport {
|
|||||||
let line = serde_json::to_string(&request)
|
let line = serde_json::to_string(&request)
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to serialize request: {}", e)))?;
|
.map_err(|e| ZclawError::McpError(format!("Failed to serialize request: {}", e)))?;
|
||||||
|
|
||||||
// Write to stdin
|
// Atomic write-then-read under single lock
|
||||||
{
|
|
||||||
let mut stdin_guard = self.stdin.lock().await;
|
|
||||||
let stdin = stdin_guard.as_mut()
|
|
||||||
.ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?;
|
|
||||||
|
|
||||||
stdin.write_all(line.as_bytes())
|
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to write request: {}", e)))?;
|
|
||||||
stdin.write_all(b"\n")
|
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to write newline: {}", e)))?;
|
|
||||||
stdin.flush()
|
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to flush request: {}", e)))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read from stdout
|
|
||||||
let response_line = {
|
let response_line = {
|
||||||
let mut stdout_guard = self.stdout.lock().await;
|
let mut handles_guard = self.handles.lock().await;
|
||||||
let stdout = stdout_guard.as_mut()
|
let handles = handles_guard.as_mut()
|
||||||
.ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?;
|
.ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?;
|
||||||
|
|
||||||
|
// Write to stdin
|
||||||
|
handles.stdin.write_all(line.as_bytes())
|
||||||
|
.map_err(|e| ZclawError::McpError(format!("Failed to write request: {}", e)))?;
|
||||||
|
handles.stdin.write_all(b"\n")
|
||||||
|
.map_err(|e| ZclawError::McpError(format!("Failed to write newline: {}", e)))?;
|
||||||
|
handles.stdin.flush()
|
||||||
|
.map_err(|e| ZclawError::McpError(format!("Failed to flush request: {}", e)))?;
|
||||||
|
|
||||||
|
// Read from stdout (still holding the lock — no interleaving possible)
|
||||||
let mut response_line = String::new();
|
let mut response_line = String::new();
|
||||||
stdout.read_line(&mut response_line)
|
handles.stdout.read_line(&mut response_line)
|
||||||
.map_err(|e| ZclawError::McpError(format!("Failed to read response: {}", e)))?;
|
.map_err(|e| ZclawError::McpError(format!("Failed to read response: {}", e)))?;
|
||||||
response_line
|
response_line
|
||||||
};
|
};
|
||||||
@@ -429,7 +433,7 @@ impl Drop for McpTransport {
|
|||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[McpTransport] Failed to kill child process: {}", e);
|
tracing::warn!("[McpTransport] Failed to kill child process (potential zombie): {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ struct GeminiResponseContent {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
parts: Vec<GeminiResponsePart>,
|
parts: Vec<GeminiResponsePart>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: deserialized from Gemini API, not accessed in code
|
||||||
role: Option<String>,
|
role: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +643,7 @@ struct GeminiUsageMetadata {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
candidates_token_count: Option<u32>,
|
candidates_token_count: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: deserialized from Gemini API, not accessed in code
|
||||||
total_token_count: Option<u32>,
|
total_token_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use zclaw_growth::{
|
use zclaw_growth::{
|
||||||
GrowthTracker, InjectionFormat, LlmDriverForExtraction,
|
AggregatedPattern, CombinedExtraction, EvolutionConfig, EvolutionEngine,
|
||||||
MemoryExtractor, MemoryRetriever, PromptInjector, RetrievalResult,
|
ExperienceExtractor, ExperienceStore, GrowthTracker, InjectionFormat,
|
||||||
VikingAdapter,
|
LlmDriverForExtraction, MemoryExtractor, MemoryRetriever, PromptInjector,
|
||||||
|
RetrievalResult, UserProfileUpdater, VikingAdapter,
|
||||||
};
|
};
|
||||||
use zclaw_memory::{ExtractedFactBatch, Fact, FactCategory};
|
use zclaw_memory::{ExtractedFactBatch, Fact, FactCategory, UserProfileStore};
|
||||||
use zclaw_types::{AgentId, Message, Result, SessionId};
|
use zclaw_types::{AgentId, Message, Result, SessionId};
|
||||||
|
|
||||||
/// Growth system integration for AgentLoop
|
/// Growth system integration for AgentLoop
|
||||||
@@ -32,6 +33,14 @@ pub struct GrowthIntegration {
|
|||||||
injector: PromptInjector,
|
injector: PromptInjector,
|
||||||
/// Growth tracker for tracking growth metrics
|
/// Growth tracker for tracking growth metrics
|
||||||
tracker: GrowthTracker,
|
tracker: GrowthTracker,
|
||||||
|
/// Experience extractor for structured experience persistence
|
||||||
|
experience_extractor: ExperienceExtractor,
|
||||||
|
/// Profile updater for incremental user profile updates
|
||||||
|
profile_updater: UserProfileUpdater,
|
||||||
|
/// User profile store (optional, for profile updates)
|
||||||
|
profile_store: Option<Arc<UserProfileStore>>,
|
||||||
|
/// Evolution engine for L2 skill generation (optional)
|
||||||
|
evolution_engine: Option<EvolutionEngine>,
|
||||||
/// Configuration
|
/// Configuration
|
||||||
config: GrowthConfigInner,
|
config: GrowthConfigInner,
|
||||||
}
|
}
|
||||||
@@ -69,13 +78,19 @@ impl GrowthIntegration {
|
|||||||
|
|
||||||
let retriever = MemoryRetriever::new(viking.clone());
|
let retriever = MemoryRetriever::new(viking.clone());
|
||||||
let injector = PromptInjector::new();
|
let injector = PromptInjector::new();
|
||||||
let tracker = GrowthTracker::new(viking);
|
let tracker = GrowthTracker::new(viking.clone());
|
||||||
|
let evolution_engine = Some(EvolutionEngine::new(viking.clone()));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
retriever,
|
retriever,
|
||||||
extractor,
|
extractor,
|
||||||
injector,
|
injector,
|
||||||
tracker,
|
tracker,
|
||||||
|
experience_extractor: ExperienceExtractor::new()
|
||||||
|
.with_store(Arc::new(ExperienceStore::new(viking))),
|
||||||
|
profile_updater: UserProfileUpdater::new(),
|
||||||
|
profile_store: None,
|
||||||
|
evolution_engine,
|
||||||
config: GrowthConfigInner::default(),
|
config: GrowthConfigInner::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,11 +117,73 @@ impl GrowthIntegration {
|
|||||||
self.config.enabled
|
self.config.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 启动时初始化:从持久化存储恢复进化引擎的信任度记录
|
||||||
|
///
|
||||||
|
/// **注意**:FeedbackCollector 内部已实现 lazy-load(首次 save() 时自动加载),
|
||||||
|
/// 所以此方法为可选优化 — 提前加载可避免首次反馈提交时的延迟。
|
||||||
|
pub async fn initialize(&self) -> Result<()> {
|
||||||
|
if let Some(ref engine) = self.evolution_engine {
|
||||||
|
match engine.load_feedback().await {
|
||||||
|
Ok(count) => {
|
||||||
|
if count > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[GrowthIntegration] Loaded {} trust records from storage",
|
||||||
|
count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"[GrowthIntegration] Failed to load trust records: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Enable or disable auto extraction
|
/// Enable or disable auto extraction
|
||||||
pub fn set_auto_extract(&mut self, auto_extract: bool) {
|
pub fn set_auto_extract(&mut self, auto_extract: bool) {
|
||||||
self.config.auto_extract = auto_extract;
|
self.config.auto_extract = auto_extract;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the user profile store for incremental profile updates
|
||||||
|
pub fn with_profile_store(mut self, store: Arc<UserProfileStore>) -> Self {
|
||||||
|
self.profile_store = Some(store);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the evolution engine configuration
|
||||||
|
pub fn with_evolution_config(self, config: EvolutionConfig) -> Self {
|
||||||
|
let engine = self.evolution_engine.unwrap_or_else(|| {
|
||||||
|
EvolutionEngine::new(Arc::new(VikingAdapter::in_memory()))
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
evolution_engine: Some(engine.with_config(config)),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable the evolution engine
|
||||||
|
pub fn set_evolution_enabled(&mut self, enabled: bool) {
|
||||||
|
if let Some(ref mut engine) = self.evolution_engine {
|
||||||
|
engine.set_enabled(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// L2 检查:是否有可进化的模式
|
||||||
|
/// 在 extract_combined 之后调用,返回可固化的经验模式列表
|
||||||
|
pub async fn check_evolution(
|
||||||
|
&self,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
) -> Result<Vec<AggregatedPattern>> {
|
||||||
|
match &self.evolution_engine {
|
||||||
|
Some(engine) => engine.check_evolvable_patterns(&agent_id.to_string()).await,
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Enhance system prompt with retrieved memories
|
/// Enhance system prompt with retrieved memories
|
||||||
///
|
///
|
||||||
/// This method:
|
/// This method:
|
||||||
@@ -213,8 +290,8 @@ impl GrowthIntegration {
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Combined extraction: single LLM call that produces both stored memories
|
/// Combined extraction: single LLM call that produces stored memories,
|
||||||
/// and structured facts, avoiding double extraction overhead.
|
/// structured experiences, and profile signals — all in one pass.
|
||||||
///
|
///
|
||||||
/// Returns `(memory_count, Option<ExtractedFactBatch>)` on success.
|
/// Returns `(memory_count, Option<ExtractedFactBatch>)` on success.
|
||||||
pub async fn extract_combined(
|
pub async fn extract_combined(
|
||||||
@@ -227,25 +304,28 @@ impl GrowthIntegration {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single LLM extraction call
|
// 单次 LLM 提取:memories + experiences + profile_signals
|
||||||
let extracted = self
|
let combined = self
|
||||||
.extractor
|
.extractor
|
||||||
.extract(messages, session_id.clone())
|
.extract_combined(messages, session_id.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
tracing::warn!("[GrowthIntegration] Combined extraction failed: {}", e);
|
tracing::warn!("[GrowthIntegration] Combined extraction failed: {}", e);
|
||||||
Vec::new()
|
CombinedExtraction::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
if extracted.is_empty() {
|
if combined.memories.is_empty()
|
||||||
|
&& combined.experiences.is_empty()
|
||||||
|
&& !combined.profile_signals.has_any_signal()
|
||||||
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mem_count = extracted.len();
|
let mem_count = combined.memories.len();
|
||||||
|
|
||||||
// Store raw memories
|
// Store raw memories
|
||||||
self.extractor
|
self.extractor
|
||||||
.store_memories(&agent_id.to_string(), &extracted)
|
.store_memories(&agent_id.to_string(), &combined.memories)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Track learning event
|
// Track learning event
|
||||||
@@ -253,8 +333,71 @@ impl GrowthIntegration {
|
|||||||
.record_learning(agent_id, &session_id.to_string(), mem_count)
|
.record_learning(agent_id, &session_id.to_string(), mem_count)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Convert same extracted memories to structured facts (no extra LLM call)
|
// Persist structured experiences (L1 enhancement)
|
||||||
let facts: Vec<Fact> = extracted
|
if let Ok(exp_count) = self
|
||||||
|
.experience_extractor
|
||||||
|
.persist_experiences(&agent_id.to_string(), &combined)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if exp_count > 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
"[GrowthIntegration] Persisted {} structured experiences",
|
||||||
|
exp_count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user profile from extraction signals (L1 enhancement)
|
||||||
|
if let Some(profile_store) = &self.profile_store {
|
||||||
|
let updates = self.profile_updater.collect_updates(&combined);
|
||||||
|
let user_id = agent_id.to_string();
|
||||||
|
for update in updates {
|
||||||
|
let result = match update.kind {
|
||||||
|
zclaw_growth::ProfileUpdateKind::SetField => {
|
||||||
|
profile_store
|
||||||
|
.update_field(&user_id, &update.field, &update.value)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
zclaw_growth::ProfileUpdateKind::AppendArray => {
|
||||||
|
match update.field.as_str() {
|
||||||
|
"recent_topic" => {
|
||||||
|
profile_store
|
||||||
|
.add_recent_topic(&user_id, &update.value, 10)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"pain_point" => {
|
||||||
|
profile_store
|
||||||
|
.add_pain_point(&user_id, &update.value, 10)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"preferred_tool" => {
|
||||||
|
profile_store
|
||||||
|
.add_preferred_tool(&user_id, &update.value, 10)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(
|
||||||
|
"[GrowthIntegration] Unknown array field: {}",
|
||||||
|
update.field
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::warn!(
|
||||||
|
"[GrowthIntegration] Profile update failed for {}: {}",
|
||||||
|
update.field,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert extracted memories to structured facts
|
||||||
|
let facts: Vec<Fact> = combined
|
||||||
|
.memories
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
let category = match m.memory_type {
|
let category = match m.memory_type {
|
||||||
|
|||||||
@@ -279,3 +279,4 @@ pub mod token_calibration;
|
|||||||
pub mod tool_error;
|
pub mod tool_error;
|
||||||
pub mod tool_output_guard;
|
pub mod tool_output_guard;
|
||||||
pub mod trajectory_recorder;
|
pub mod trajectory_recorder;
|
||||||
|
pub mod evolution;
|
||||||
|
|||||||
@@ -20,19 +20,19 @@ use super::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static RE_COMPANY: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_COMPANY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)").unwrap()
|
Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)").expect("static regex is valid")
|
||||||
});
|
});
|
||||||
static RE_MONEY: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_MONEY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元").unwrap()
|
Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元").expect("static regex is valid")
|
||||||
});
|
});
|
||||||
static RE_PHONE: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_PHONE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"1[3-9]\d-?\d{4}-?\d{4}").unwrap()
|
Regex::new(r"1[3-9]\d-?\d{4}-?\d{4}").expect("static regex is valid")
|
||||||
});
|
});
|
||||||
static RE_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap()
|
Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").expect("static regex is valid")
|
||||||
});
|
});
|
||||||
static RE_ID_CARD: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_ID_CARD: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"\b\d{17}[\dXx]\b").unwrap()
|
Regex::new(r"\b\d{17}[\dXx]\b").expect("static regex is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
165
crates/zclaw-runtime/src/middleware/evolution.rs
Normal file
165
crates/zclaw-runtime/src/middleware/evolution.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
//! 进化引擎中间件
|
||||||
|
//! 在管家对话中检测并呈现"技能进化确认"提示
|
||||||
|
//! 优先级 78(在 ButlerRouter@80 之前运行)
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use crate::middleware::{
|
||||||
|
AgentMiddleware, MiddlewareContext, MiddlewareDecision,
|
||||||
|
};
|
||||||
|
use zclaw_types::Result;
|
||||||
|
|
||||||
|
/// 待确认的进化事件
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingEvolution {
|
||||||
|
pub pattern_name: String,
|
||||||
|
pub trigger_suggestion: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进化引擎中间件
|
||||||
|
/// 检查是否有待确认的进化事件,注入确认提示到 system prompt
|
||||||
|
pub struct EvolutionMiddleware {
|
||||||
|
pending: Arc<RwLock<Vec<PendingEvolution>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvolutionMiddleware {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
pending: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加一个待确认的进化事件
|
||||||
|
pub async fn add_pending(&self, evolution: PendingEvolution) {
|
||||||
|
self.pending.write().await.push(evolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取并清除所有待确认事件
|
||||||
|
pub async fn drain_pending(&self) -> Vec<PendingEvolution> {
|
||||||
|
let mut pending = self.pending.write().await;
|
||||||
|
std::mem::take(&mut *pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前待确认事件数量
|
||||||
|
pub async fn pending_count(&self) -> usize {
|
||||||
|
self.pending.read().await.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EvolutionMiddleware {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentMiddleware for EvolutionMiddleware {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"evolution"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn priority(&self) -> i32 {
|
||||||
|
78 // 在 ButlerRouter(80) 之前
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn before_completion(
|
||||||
|
&self,
|
||||||
|
ctx: &mut MiddlewareContext,
|
||||||
|
) -> Result<MiddlewareDecision> {
|
||||||
|
// 先用 read lock 快速判空,避免每次对话都获取写锁
|
||||||
|
if self.pending.read().await.is_empty() {
|
||||||
|
return Ok(MiddlewareDecision::Continue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只移除第一个事件,保留后续事件留待下次注入
|
||||||
|
let to_inject = {
|
||||||
|
let mut pending = self.pending.write().await;
|
||||||
|
if pending.is_empty() {
|
||||||
|
return Ok(MiddlewareDecision::Continue);
|
||||||
|
}
|
||||||
|
pending.remove(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let injection = format!(
|
||||||
|
"\n\n<evolution-suggestion>\n\
|
||||||
|
我注意到你经常做「{pattern}」相关的事情。\n\
|
||||||
|
我可以帮你整理成一个技能,以后直接说「{trigger}」就能用了。\n\
|
||||||
|
技能描述:{desc}\n\
|
||||||
|
如果你同意,请回复 '确认保存技能'。如果你想调整,可以告诉我怎么改。\n\
|
||||||
|
</evolution-suggestion>",
|
||||||
|
pattern = to_inject.pattern_name,
|
||||||
|
trigger = to_inject.trigger_suggestion,
|
||||||
|
desc = to_inject.description,
|
||||||
|
);
|
||||||
|
ctx.system_prompt.push_str(&injection);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[EvolutionMiddleware] Injected evolution suggestion for: {}",
|
||||||
|
to_inject.pattern_name
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(MiddlewareDecision::Continue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_no_pending_continues() {
|
||||||
|
let mw = EvolutionMiddleware::new();
|
||||||
|
assert_eq!(mw.pending_count().await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_add_and_drain() {
|
||||||
|
let mw = EvolutionMiddleware::new();
|
||||||
|
mw.add_pending(PendingEvolution {
|
||||||
|
pattern_name: "报表生成".to_string(),
|
||||||
|
trigger_suggestion: "生成报表".to_string(),
|
||||||
|
description: "自动生成每日报表".to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert_eq!(mw.pending_count().await, 1);
|
||||||
|
|
||||||
|
let drained = mw.drain_pending().await;
|
||||||
|
assert_eq!(drained.len(), 1);
|
||||||
|
assert_eq!(drained[0].pattern_name, "报表生成");
|
||||||
|
assert_eq!(mw.pending_count().await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_name_and_priority() {
|
||||||
|
let mw = EvolutionMiddleware::new();
|
||||||
|
assert_eq!(mw.name(), "evolution");
|
||||||
|
assert_eq!(mw.priority(), 78);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_only_first_event_injected() {
|
||||||
|
let mw = EvolutionMiddleware::new();
|
||||||
|
mw.add_pending(PendingEvolution {
|
||||||
|
pattern_name: "事件A".to_string(),
|
||||||
|
trigger_suggestion: "触发A".to_string(),
|
||||||
|
description: "描述A".to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
mw.add_pending(PendingEvolution {
|
||||||
|
pattern_name: "事件B".to_string(),
|
||||||
|
trigger_suggestion: "触发B".to_string(),
|
||||||
|
description: "描述B".to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 模拟注入:用 read 判空 + write 取第一个
|
||||||
|
let first = {
|
||||||
|
let mut pending = mw.pending.write().await;
|
||||||
|
pending.remove(0)
|
||||||
|
};
|
||||||
|
assert_eq!(first.pattern_name, "事件A");
|
||||||
|
assert_eq!(mw.pending_count().await, 1); // 事件B 仍保留
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,17 @@ use async_trait::async_trait;
|
|||||||
use zclaw_types::Result;
|
use zclaw_types::Result;
|
||||||
use crate::growth::GrowthIntegration;
|
use crate::growth::GrowthIntegration;
|
||||||
use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
||||||
|
use crate::middleware::evolution::EvolutionMiddleware;
|
||||||
|
|
||||||
/// Middleware that handles memory retrieval (pre-completion) and extraction (post-completion).
|
/// Middleware that handles memory retrieval (pre-completion) and extraction (post-completion).
|
||||||
///
|
///
|
||||||
/// Wraps `GrowthIntegration` and delegates:
|
/// Wraps `GrowthIntegration` and delegates:
|
||||||
/// - `before_completion` → `enhance_prompt()` for memory injection
|
/// - `before_completion` → `enhance_prompt()` for memory injection
|
||||||
/// - `after_completion` → `process_conversation()` for memory extraction
|
/// - `after_completion` → `extract_combined()` for memory extraction + evolution check
|
||||||
pub struct MemoryMiddleware {
|
pub struct MemoryMiddleware {
|
||||||
growth: GrowthIntegration,
|
growth: GrowthIntegration,
|
||||||
|
/// Shared EvolutionMiddleware for pushing evolution suggestions
|
||||||
|
evolution_mw: Option<std::sync::Arc<EvolutionMiddleware>>,
|
||||||
/// Minimum seconds between extractions for the same agent (debounce).
|
/// Minimum seconds between extractions for the same agent (debounce).
|
||||||
debounce_secs: u64,
|
debounce_secs: u64,
|
||||||
/// Timestamp of last extraction per agent (for debouncing).
|
/// Timestamp of last extraction per agent (for debouncing).
|
||||||
@@ -29,11 +32,18 @@ impl MemoryMiddleware {
|
|||||||
pub fn new(growth: GrowthIntegration) -> Self {
|
pub fn new(growth: GrowthIntegration) -> Self {
|
||||||
Self {
|
Self {
|
||||||
growth,
|
growth,
|
||||||
|
evolution_mw: None,
|
||||||
debounce_secs: 30,
|
debounce_secs: 30,
|
||||||
last_extraction: std::sync::Mutex::new(std::collections::HashMap::new()),
|
last_extraction: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach a shared EvolutionMiddleware for pushing evolution suggestions.
|
||||||
|
pub fn with_evolution(mut self, mw: std::sync::Arc<EvolutionMiddleware>) -> Self {
|
||||||
|
self.evolution_mw = Some(mw);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the debounce interval in seconds.
|
/// Set the debounce interval in seconds.
|
||||||
pub fn with_debounce_secs(mut self, secs: u64) -> Self {
|
pub fn with_debounce_secs(mut self, secs: u64) -> Self {
|
||||||
self.debounce_secs = secs;
|
self.debounce_secs = secs;
|
||||||
@@ -52,6 +62,49 @@ impl MemoryMiddleware {
|
|||||||
map.insert(agent_id.to_string(), now);
|
map.insert(agent_id.to_string(), now);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check for evolvable patterns and push suggestions to EvolutionMiddleware.
|
||||||
|
async fn check_and_push_evolution(&self, agent_id: &zclaw_types::AgentId) {
|
||||||
|
let evolution_mw = match &self.evolution_mw {
|
||||||
|
Some(mw) => mw,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.growth.check_evolution(agent_id).await {
|
||||||
|
Ok(patterns) if !patterns.is_empty() => {
|
||||||
|
for pattern in &patterns {
|
||||||
|
let trigger = pattern
|
||||||
|
.common_steps
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| pattern.pain_pattern.clone());
|
||||||
|
evolution_mw.add_pending(
|
||||||
|
crate::middleware::evolution::PendingEvolution {
|
||||||
|
pattern_name: pattern.pain_pattern.clone(),
|
||||||
|
trigger_suggestion: trigger,
|
||||||
|
description: format!(
|
||||||
|
"基于 {} 次重复经验,自动固化技能",
|
||||||
|
pattern.total_reuse
|
||||||
|
),
|
||||||
|
},
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"[MemoryMiddleware] Pushed {} evolution candidates for agent {}",
|
||||||
|
patterns.len(),
|
||||||
|
agent_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::debug!("[MemoryMiddleware] No evolvable patterns found");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"[MemoryMiddleware] Evolution check failed (non-fatal): {}", e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -65,11 +118,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
|||||||
ctx.user_input.chars().take(50).collect::<String>()
|
ctx.user_input.chars().take(50).collect::<String>()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Retrieve relevant memories and inject into system prompt.
|
|
||||||
// The SqliteStorage retriever now uses FTS5-only matching — if FTS5 finds
|
|
||||||
// no relevant results, no memories are returned (no scope-based fallback).
|
|
||||||
// This prevents irrelevant high-importance memories from leaking into
|
|
||||||
// unrelated conversations.
|
|
||||||
let base = &ctx.system_prompt;
|
let base = &ctx.system_prompt;
|
||||||
match self.growth.enhance_prompt(&ctx.agent_id, base, &ctx.user_input).await {
|
match self.growth.enhance_prompt(&ctx.agent_id, base, &ctx.user_input).await {
|
||||||
Ok(enhanced) => {
|
Ok(enhanced) => {
|
||||||
@@ -88,7 +136,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
|||||||
Ok(MiddlewareDecision::Continue)
|
Ok(MiddlewareDecision::Continue)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Non-fatal: retrieval failure should not block the conversation
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"[MemoryMiddleware] Memory retrieval failed (non-fatal): {}",
|
"[MemoryMiddleware] Memory retrieval failed (non-fatal): {}",
|
||||||
e
|
e
|
||||||
@@ -99,7 +146,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn after_completion(&self, ctx: &MiddlewareContext) -> Result<()> {
|
async fn after_completion(&self, ctx: &MiddlewareContext) -> Result<()> {
|
||||||
// Debounce: skip extraction if called too recently for this agent
|
|
||||||
let agent_key = ctx.agent_id.to_string();
|
let agent_key = ctx.agent_id.to_string();
|
||||||
if !self.should_extract(&agent_key) {
|
if !self.should_extract(&agent_key) {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -113,8 +159,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combined extraction: single LLM call produces both memories and structured facts.
|
|
||||||
// Avoids double LLM extraction ( process_conversation + extract_structured_facts).
|
|
||||||
match self.growth.extract_combined(
|
match self.growth.extract_combined(
|
||||||
&ctx.agent_id,
|
&ctx.agent_id,
|
||||||
&ctx.messages,
|
&ctx.messages,
|
||||||
@@ -127,12 +171,14 @@ impl AgentMiddleware for MemoryMiddleware {
|
|||||||
facts.len(),
|
facts.len(),
|
||||||
agent_key
|
agent_key
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check for evolvable patterns after successful extraction
|
||||||
|
self.check_and_push_evolution(&ctx.agent_id).await;
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::debug!("[MemoryMiddleware] No memories or facts extracted");
|
tracing::debug!("[MemoryMiddleware] No memories or facts extracted");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Non-fatal: extraction failure should not affect the response
|
|
||||||
tracing::warn!("[MemoryMiddleware] Combined extraction failed: {}", e);
|
tracing::warn!("[MemoryMiddleware] Combined extraction failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Three-layer fallback strategy:
|
//! Three-layer fallback strategy:
|
||||||
//! 1. Regex pattern matching (covers ~80% of common expressions)
|
//! 1. Regex pattern matching (covers ~80% of common expressions)
|
||||||
//! 2. LLM-assisted parsing (for ambiguous/complex expressions) — TODO: wire when Haiku driver available
|
//! 2. LLM-assisted parsing (for ambiguous/complex expressions) — FUTURE: post-release LLM-assisted natural language parsing
|
||||||
//! 3. Interactive clarification (return `Unclear`)
|
//! 3. Interactive clarification (return `Unclear`)
|
||||||
//!
|
//!
|
||||||
//! Lives in `zclaw-runtime` because it's a pure text→cron utility with no kernel dependency.
|
//! Lives in `zclaw-runtime` because it's a pure text→cron utility with no kernel dependency.
|
||||||
@@ -69,7 +69,7 @@ const PERIOD: &str = "(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|
|
|||||||
static RE_TIME_STRIP: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_TIME_STRIP: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(
|
Regex::new(
|
||||||
r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时::]\d{0,2}分?"
|
r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时::]\d{0,2}分?"
|
||||||
).unwrap()
|
).expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
// try_every_day
|
// try_every_day
|
||||||
@@ -77,13 +77,13 @@ static RE_EVERY_DAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
Regex::new(&format!(
|
Regex::new(&format!(
|
||||||
r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||||
PERIOD
|
PERIOD
|
||||||
)).unwrap()
|
)).expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
static RE_EVERY_DAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_EVERY_DAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(
|
Regex::new(
|
||||||
r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||||
).unwrap()
|
).expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
// try_every_week
|
// try_every_week
|
||||||
@@ -91,7 +91,7 @@ static RE_EVERY_WEEK: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
Regex::new(&format!(
|
Regex::new(&format!(
|
||||||
r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||||
PERIOD
|
PERIOD
|
||||||
)).unwrap()
|
)).expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
// try_workday
|
// try_workday
|
||||||
@@ -99,18 +99,18 @@ static RE_WORKDAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
Regex::new(&format!(
|
Regex::new(&format!(
|
||||||
r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||||
PERIOD
|
PERIOD
|
||||||
)).unwrap()
|
)).expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
static RE_WORKDAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_WORKDAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(
|
Regex::new(
|
||||||
r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||||
).unwrap()
|
).expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
// try_interval
|
// try_interval
|
||||||
static RE_INTERVAL: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_INTERVAL: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").unwrap()
|
Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
// try_monthly
|
// try_monthly
|
||||||
@@ -118,7 +118,7 @@ static RE_MONTHLY: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
Regex::new(&format!(
|
Regex::new(&format!(
|
||||||
r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时::]?(\d{{1,2}})?",
|
r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时::]?(\d{{1,2}})?",
|
||||||
PERIOD
|
PERIOD
|
||||||
)).unwrap()
|
)).expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
// try_one_shot
|
// try_one_shot
|
||||||
@@ -126,7 +126,7 @@ static RE_ONE_SHOT: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
Regex::new(&format!(
|
Regex::new(&format!(
|
||||||
r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||||
PERIOD
|
PERIOD
|
||||||
)).unwrap()
|
)).expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -112,12 +112,14 @@ impl Tool for TaskTool {
|
|||||||
let task_id = sub_agent_id.to_string();
|
let task_id = sub_agent_id.to_string();
|
||||||
|
|
||||||
if let Some(ref tx) = context.event_sender {
|
if let Some(ref tx) = context.event_sender {
|
||||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
if tx.send(LoopEvent::SubtaskStatus {
|
||||||
task_id: task_id.clone(),
|
task_id: task_id.clone(),
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
status: "started".to_string(),
|
status: "started".to_string(),
|
||||||
detail: None,
|
detail: None,
|
||||||
}).await;
|
}).await.is_err() {
|
||||||
|
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a fresh session for the sub-agent
|
// Create a fresh session for the sub-agent
|
||||||
@@ -161,12 +163,14 @@ impl Tool for TaskTool {
|
|||||||
|
|
||||||
// Emit subtask_running event
|
// Emit subtask_running event
|
||||||
if let Some(ref tx) = context.event_sender {
|
if let Some(ref tx) = context.event_sender {
|
||||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
if tx.send(LoopEvent::SubtaskStatus {
|
||||||
task_id: task_id.clone(),
|
task_id: task_id.clone(),
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
status: "running".to_string(),
|
status: "running".to_string(),
|
||||||
detail: Some("子Agent正在执行中...".to_string()),
|
detail: Some("子Agent正在执行中...".to_string()),
|
||||||
}).await;
|
}).await.is_err() {
|
||||||
|
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the sub-agent loop (non-streaming — collect full result)
|
// Execute the sub-agent loop (non-streaming — collect full result)
|
||||||
@@ -179,7 +183,7 @@ impl Tool for TaskTool {
|
|||||||
|
|
||||||
// Emit subtask_completed event
|
// Emit subtask_completed event
|
||||||
if let Some(ref tx) = context.event_sender {
|
if let Some(ref tx) = context.event_sender {
|
||||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
if tx.send(LoopEvent::SubtaskStatus {
|
||||||
task_id: task_id.clone(),
|
task_id: task_id.clone(),
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
status: "completed".to_string(),
|
status: "completed".to_string(),
|
||||||
@@ -187,7 +191,9 @@ impl Tool for TaskTool {
|
|||||||
"完成 ({}次迭代, {}输入token)",
|
"完成 ({}次迭代, {}输入token)",
|
||||||
loop_result.iterations, loop_result.input_tokens
|
loop_result.iterations, loop_result.input_tokens
|
||||||
)),
|
)),
|
||||||
}).await;
|
}).await.is_err() {
|
||||||
|
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
@@ -204,12 +210,14 @@ impl Tool for TaskTool {
|
|||||||
|
|
||||||
// Emit subtask_failed event
|
// Emit subtask_failed event
|
||||||
if let Some(ref tx) = context.event_sender {
|
if let Some(ref tx) = context.event_sender {
|
||||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
if tx.send(LoopEvent::SubtaskStatus {
|
||||||
task_id: task_id.clone(),
|
task_id: task_id.clone(),
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
status: "failed".to_string(),
|
status: "failed".to_string(),
|
||||||
detail: Some(e.to_string()),
|
detail: Some(e.to_string()),
|
||||||
}).await;
|
}).await.is_err() {
|
||||||
|
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add missing indexes for performance-critical queries
|
||||||
|
-- 2026-04-18 Release readiness audit
|
||||||
|
|
||||||
|
-- Rate limit events cleanup (DELETE WHERE created_at < ...)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rle_created_at ON rate_limit_events(created_at);
|
||||||
|
|
||||||
|
-- Billing subscriptions plan lookup
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_sub_plan ON billing_subscriptions(plan_id);
|
||||||
|
|
||||||
|
-- Knowledge items created_by lookup
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ki_created_by ON knowledge_items(created_by);
|
||||||
@@ -565,7 +565,7 @@ async fn store_refresh_token(
|
|||||||
|
|
||||||
/// 清理过期和已使用的 refresh tokens
|
/// 清理过期和已使用的 refresh tokens
|
||||||
/// 注意: 现已迁移到 Worker/Scheduler 定期执行,此函数保留作为备用
|
/// 注意: 现已迁移到 Worker/Scheduler 定期执行,此函数保留作为备用
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: backup for Worker/Scheduler cleanup; kept as fallback
|
||||||
async fn cleanup_expired_refresh_tokens(db: &sqlx::PgPool) -> SaasResult<()> {
|
async fn cleanup_expired_refresh_tokens(db: &sqlx::PgPool) -> SaasResult<()> {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
// 删除过期超过 30 天的已使用 token (减少 DB 膨胀)
|
// 删除过期超过 30 天的已使用 token (减少 DB 膨胀)
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ pub async fn get_invoice_pdf(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 返回 PDF 响应
|
// 返回 PDF 响应
|
||||||
Ok(axum::response::Response::builder()
|
axum::response::Response::builder()
|
||||||
.status(200)
|
.status(200)
|
||||||
.header("Content-Type", "application/pdf")
|
.header("Content-Type", "application/pdf")
|
||||||
.header(
|
.header(
|
||||||
@@ -595,5 +595,8 @@ pub async fn get_invoice_pdf(
|
|||||||
format!("attachment; filename=\"invoice-{}.pdf\"", invoice.id),
|
format!("attachment; filename=\"invoice-{}.pdf\"", invoice.id),
|
||||||
)
|
)
|
||||||
.body(axum::body::Body::from(bytes))
|
.body(axum::body::Body::from(bytes))
|
||||||
.unwrap())
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to build PDF response: {}", e);
|
||||||
|
SaasError::Internal("PDF 响应构建失败".into())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,6 +396,23 @@ impl SaaSConfig {
|
|||||||
config.database.url = db_url;
|
config.database.url = db_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config validation
|
||||||
|
if config.auth.jwt_expiration_hours < 1 {
|
||||||
|
anyhow::bail!(
|
||||||
|
"auth.jwt_expiration_hours must be >= 1, got {}",
|
||||||
|
config.auth.jwt_expiration_hours
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if config.database.max_connections == 0 {
|
||||||
|
anyhow::bail!("database.max_connections must be > 0");
|
||||||
|
}
|
||||||
|
if config.database.min_connections > config.database.max_connections {
|
||||||
|
anyhow::bail!(
|
||||||
|
"database.min_connections ({}) must be <= max_connections ({})",
|
||||||
|
config.database.min_connections, config.database.max_connections
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -804,7 +804,7 @@ async fn handle_document_upload(
|
|||||||
|
|
||||||
// 创建知识条目
|
// 创建知识条目
|
||||||
let item_req = CreateItemRequest {
|
let item_req = CreateItemRequest {
|
||||||
category_id: "uploaded".to_string(), // TODO: 从上传参数获取
|
category_id: "uploaded".to_string(), // FUTURE: post-release category_id from upload params
|
||||||
title: doc.title.clone(),
|
title: doc.title.clone(),
|
||||||
content,
|
content,
|
||||||
keywords: None,
|
keywords: None,
|
||||||
|
|||||||
@@ -309,10 +309,34 @@ async fn build_router(state: AppState) -> axum::Router {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if config.server.cors_origins.is_empty() {
|
if config.server.cors_origins.is_empty() {
|
||||||
if is_dev {
|
if is_dev {
|
||||||
|
// Dev mode: use explicit localhost origins (Any + credentials violates CORS spec)
|
||||||
|
let dev_origins: Vec<HeaderValue> = [
|
||||||
|
"http://localhost:1420",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:1420",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
"tauri://localhost",
|
||||||
|
"https://tauri.localhost",
|
||||||
|
].iter()
|
||||||
|
.filter_map(|o| o.parse::<HeaderValue>().ok())
|
||||||
|
.collect();
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(dev_origins)
|
||||||
.allow_methods(Any)
|
.allow_methods([
|
||||||
.allow_headers(Any)
|
axum::http::Method::GET,
|
||||||
|
axum::http::Method::POST,
|
||||||
|
axum::http::Method::PUT,
|
||||||
|
axum::http::Method::PATCH,
|
||||||
|
axum::http::Method::DELETE,
|
||||||
|
axum::http::Method::OPTIONS,
|
||||||
|
])
|
||||||
|
.allow_headers([
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
axum::http::header::COOKIE,
|
||||||
|
])
|
||||||
.allow_credentials(true)
|
.allow_credentials(true)
|
||||||
} else {
|
} else {
|
||||||
tracing::error!("生产环境必须配置 server.cors_origins,不能使用 allow_origin(Any)");
|
tracing::error!("生产环境必须配置 server.cors_origins,不能使用 allow_origin(Any)");
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use super::types::*;
|
|||||||
|
|
||||||
/// 数据库行结构
|
/// 数据库行结构
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, FromRow)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: FromRow deserialization struct; fields accessed via destructuring
|
||||||
struct ScheduledTaskRow {
|
struct ScheduledTaskRow {
|
||||||
id: String,
|
id: String,
|
||||||
account_id: String,
|
account_id: String,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
//! 清理过期 Rate Limit 条目 Worker
|
//! 清理过期 Rate Limit 条目 Worker
|
||||||
|
//!
|
||||||
|
//! rate_limit_events 表中的持久化条目会无限增长。
|
||||||
|
//! 此 Worker 定期删除超过 1 小时的旧条目,防止数据库膨胀。
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -21,10 +24,31 @@ impl Worker for CleanupRateLimitWorker {
|
|||||||
"cleanup_rate_limit"
|
"cleanup_rate_limit"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn perform(&self, _db: &PgPool, _args: Self::Args) -> SaasResult<()> {
|
async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> {
|
||||||
// Rate limit entries are in-memory (DashMap), not in DB
|
let retention_secs = args.window_secs.max(3600); // 至少保留 1 小时
|
||||||
// This worker is a placeholder for when rate limits are persisted
|
|
||||||
// Currently the cleanup happens in main.rs background task
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM rate_limit_events WHERE created_at < NOW() - ($1 || ' seconds')::interval"
|
||||||
|
)
|
||||||
|
.bind(retention_secs.to_string())
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(r) => {
|
||||||
|
let deleted = r.rows_affected();
|
||||||
|
if deleted > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[cleanup_rate_limit] Deleted {} expired rate limit events (retention: {}s)",
|
||||||
|
deleted, retention_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[cleanup_rate_limit] Failed to clean up rate limit events: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description = "ZCLAW skill system"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
wasm = ["wasmtime", "wasmtime-wasi/p1"]
|
wasm = ["wasmtime", "wasmtime-wasi/p1", "ureq", "url"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
@@ -27,3 +27,5 @@ shlex = { workspace = true }
|
|||||||
# Optional WASM runtime (enable with --features wasm)
|
# Optional WASM runtime (enable with --features wasm)
|
||||||
wasmtime = { workspace = true, optional = true }
|
wasmtime = { workspace = true, optional = true }
|
||||||
wasmtime-wasi = { workspace = true, optional = true }
|
wasmtime-wasi = { workspace = true, optional = true }
|
||||||
|
ureq = { workspace = true, optional = true }
|
||||||
|
url = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ impl DefaultExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a single node (used by pipeline orchestration action driver)
|
/// Execute a single node (used by pipeline orchestration action driver)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: post-release pipeline orchestration action driver
|
||||||
async fn execute_node(
|
async fn execute_node(
|
||||||
&self,
|
&self,
|
||||||
node: &super::SkillNode,
|
node: &super::SkillNode,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::io::Read as IoRead;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
use wasmtime::*;
|
use wasmtime::*;
|
||||||
@@ -23,6 +24,9 @@ use crate::{Skill, SkillContext, SkillManifest, SkillResult};
|
|||||||
/// Maximum WASM binary size (10 MB).
|
/// Maximum WASM binary size (10 MB).
|
||||||
const MAX_WASM_SIZE: usize = 10 * 1024 * 1024;
|
const MAX_WASM_SIZE: usize = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Maximum HTTP response body size for host function (1 MB).
|
||||||
|
const MAX_HTTP_RESPONSE_SIZE: usize = 1024 * 1024;
|
||||||
|
|
||||||
/// Fuel per second of CPU time (heuristic: ~10M instructions/sec).
|
/// Fuel per second of CPU time (heuristic: ~10M instructions/sec).
|
||||||
const FUEL_PER_SEC: u64 = 10_000_000;
|
const FUEL_PER_SEC: u64 = 10_000_000;
|
||||||
|
|
||||||
@@ -230,49 +234,178 @@ fn create_engine_config() -> Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add ZCLAW host functions to the wasmtime linker.
|
/// Add ZCLAW host functions to the wasmtime linker.
|
||||||
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) -> Result<()> {
|
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, network_allowed: bool) -> Result<()> {
|
||||||
linker
|
linker
|
||||||
.func_wrap(
|
.func_wrap(
|
||||||
"env",
|
"env",
|
||||||
"zclaw_log",
|
"zclaw_log",
|
||||||
|_caller: Caller<'_, WasiP1Ctx>, _ptr: u32, _len: u32| {
|
|mut caller: Caller<'_, WasiP1Ctx>, ptr: u32, len: u32| {
|
||||||
debug!("[WasmSkill] guest called zclaw_log");
|
let msg = read_guest_string(&mut caller, ptr, len);
|
||||||
|
debug!("[WasmSkill] guest log: {}", msg);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e))
|
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// zclaw_http_fetch(url_ptr, url_len, out_ptr, out_cap) -> bytes_written (-1 = error)
|
||||||
|
// Performs a synchronous GET request. Result is written to guest memory as JSON string.
|
||||||
|
let net = network_allowed;
|
||||||
linker
|
linker
|
||||||
.func_wrap(
|
.func_wrap(
|
||||||
"env",
|
"env",
|
||||||
"zclaw_http_fetch",
|
"zclaw_http_fetch",
|
||||||
|_caller: Caller<'_, WasiP1Ctx>,
|
move |mut caller: Caller<'_, WasiP1Ctx>,
|
||||||
_url_ptr: u32,
|
url_ptr: u32,
|
||||||
_url_len: u32,
|
url_len: u32,
|
||||||
_out_ptr: u32,
|
out_ptr: u32,
|
||||||
_out_cap: u32|
|
out_cap: u32|
|
||||||
-> i32 {
|
-> i32 {
|
||||||
warn!("[WasmSkill] guest called zclaw_http_fetch — denied");
|
if !net {
|
||||||
-1
|
warn!("[WasmSkill] guest called zclaw_http_fetch — denied (network not allowed)");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = read_guest_string(&mut caller, url_ptr, url_len);
|
||||||
|
if url.is_empty() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: validate URL scheme to prevent SSRF.
|
||||||
|
// Only http:// and https:// are allowed.
|
||||||
|
let parsed = match url::Url::parse(&url) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => {
|
||||||
|
warn!("[WasmSkill] http_fetch denied — invalid URL: {}", url);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let scheme = parsed.scheme();
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
warn!("[WasmSkill] http_fetch denied — unsupported scheme: {}", scheme);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Block private/loopback hosts to prevent SSRF
|
||||||
|
if let Some(host) = parsed.host_str() {
|
||||||
|
let lower = host.to_lowercase();
|
||||||
|
if lower == "localhost"
|
||||||
|
|| lower.starts_with("127.")
|
||||||
|
|| lower.starts_with("10.")
|
||||||
|
|| lower.starts_with("192.168.")
|
||||||
|
|| lower.starts_with("169.254.")
|
||||||
|
|| lower.starts_with("0.")
|
||||||
|
|| lower.ends_with(".internal")
|
||||||
|
|| lower.ends_with(".local")
|
||||||
|
{
|
||||||
|
warn!("[WasmSkill] http_fetch denied — private/loopback host: {}", host);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Also block 172.16.0.0/12 range
|
||||||
|
if lower.starts_with("172.") {
|
||||||
|
if let Ok(second) = lower.split('.').nth(1).unwrap_or("0").parse::<u8>() {
|
||||||
|
if (16..=31).contains(&second) {
|
||||||
|
warn!("[WasmSkill] http_fetch denied — private host (172.16-31.x.x): {}", host);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[WasmSkill] guest http_fetch: {}", url);
|
||||||
|
|
||||||
|
// Synchronous HTTP GET (we're already on a blocking thread)
|
||||||
|
let agent = ureq::Agent::config_builder()
|
||||||
|
.timeout_global(Some(std::time::Duration::from_secs(10)))
|
||||||
|
.build()
|
||||||
|
.new_agent();
|
||||||
|
let response = agent.get(&url).call();
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(mut resp) => {
|
||||||
|
// Enforce response size limit before reading body
|
||||||
|
let content_length = resp.header("content-length")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.parse::<usize>().ok());
|
||||||
|
if let Some(len) = content_length {
|
||||||
|
if len > MAX_HTTP_RESPONSE_SIZE {
|
||||||
|
warn!("[WasmSkill] http_fetch denied — response too large: {} bytes (max {})", len, MAX_HTTP_RESPONSE_SIZE);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut body = String::new();
|
||||||
|
match resp.body_mut().read_to_string(&mut body) {
|
||||||
|
Ok(_) => {
|
||||||
|
if body.len() > MAX_HTTP_RESPONSE_SIZE {
|
||||||
|
warn!("[WasmSkill] http_fetch — response exceeded limit after read, truncating");
|
||||||
|
body.truncate(MAX_HTTP_RESPONSE_SIZE);
|
||||||
|
}
|
||||||
|
write_guest_bytes(&mut caller, out_ptr, out_cap, body.as_bytes())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[WasmSkill] http_fetch body read error: {}", e);
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[WasmSkill] http_fetch error for {}: {}", url, e);
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e))
|
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// zclaw_file_read(path_ptr, path_len, out_ptr, out_cap) -> bytes_written (-1 = error)
|
||||||
|
// Reads a file from the preopened /workspace directory. Paths must be relative.
|
||||||
linker
|
linker
|
||||||
.func_wrap(
|
.func_wrap(
|
||||||
"env",
|
"env",
|
||||||
"zclaw_file_read",
|
"zclaw_file_read",
|
||||||
|_caller: Caller<'_, WasiP1Ctx>,
|
|mut caller: Caller<'_, WasiP1Ctx>,
|
||||||
_path_ptr: u32,
|
path_ptr: u32,
|
||||||
_path_len: u32,
|
path_len: u32,
|
||||||
_out_ptr: u32,
|
out_ptr: u32,
|
||||||
_out_cap: u32|
|
out_cap: u32|
|
||||||
-> i32 {
|
-> i32 {
|
||||||
warn!("[WasmSkill] guest called zclaw_file_read — denied");
|
let path = read_guest_string(&mut caller, path_ptr, path_len);
|
||||||
-1
|
if path.is_empty() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: validate path stays within /workspace sandbox.
|
||||||
|
// Reject absolute paths, and filter any path component that
|
||||||
|
// is ".." (e.g. "foo/../../etc/passwd").
|
||||||
|
let joined = std::path::Path::new("/workspace").join(&path);
|
||||||
|
let mut safe = true;
|
||||||
|
for comp in joined.components() {
|
||||||
|
match comp {
|
||||||
|
std::path::Component::ParentDir => {
|
||||||
|
safe = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
|
||||||
|
safe = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {} // Normal, CurDir — ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !safe {
|
||||||
|
warn!("[WasmSkill] guest file_read denied — path escapes sandbox: {}", path);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::read(&joined) {
|
||||||
|
Ok(data) => write_guest_bytes(&mut caller, out_ptr, out_cap, &data),
|
||||||
|
Err(e) => {
|
||||||
|
debug!("[WasmSkill] file_read error for {}: {}", path, e);
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -282,6 +415,38 @@ fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a string from WASM guest memory.
|
||||||
|
fn read_guest_string(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, len: u32) -> String {
|
||||||
|
let mem = match caller.get_export("memory") {
|
||||||
|
Some(Extern::Memory(m)) => m,
|
||||||
|
_ => return String::new(),
|
||||||
|
};
|
||||||
|
let offset = ptr as usize;
|
||||||
|
let length = len as usize;
|
||||||
|
let data = mem.data(&caller);
|
||||||
|
if offset + length > data.len() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&data[offset..offset + length]).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write bytes to WASM guest memory. Returns the number of bytes written, or -1 on overflow.
|
||||||
|
fn write_guest_bytes(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, cap: u32, data: &[u8]) -> i32 {
|
||||||
|
let mem = match caller.get_export("memory") {
|
||||||
|
Some(Extern::Memory(m)) => m,
|
||||||
|
_ => return -1,
|
||||||
|
};
|
||||||
|
let offset = ptr as usize;
|
||||||
|
let capacity = cap as usize;
|
||||||
|
let write_len = data.len().min(capacity);
|
||||||
|
if offset + write_len > mem.data_size(&caller) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Safety: we've bounds-checked the write region.
|
||||||
|
mem.data_mut(&mut *caller)[offset..offset + write_len].copy_from_slice(&data[..write_len]);
|
||||||
|
write_len as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ impl Message {
|
|||||||
|
|
||||||
/// Canonical LLM message content block. Used for agent conversation messages.
|
/// Canonical LLM message content block. Used for agent conversation messages.
|
||||||
/// See also: zclaw_runtime::driver::ContentBlock (LLM driver response subset),
|
/// See also: zclaw_runtime::driver::ContentBlock (LLM driver response subset),
|
||||||
/// zclaw_hands::slideshow::ContentBlock (presentation rendering),
|
|
||||||
/// zclaw_protocols::mcp_types::ContentBlock (MCP protocol wire format).
|
/// zclaw_protocols::mcp_types::ContentBlock (MCP protocol wire format).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["multi-agent"]
|
default = []
|
||||||
# Multi-agent orchestration (A2A protocol, Director, agent delegation)
|
|
||||||
multi-agent = ["zclaw-kernel/multi-agent"]
|
|
||||||
dev-server = ["dep:axum", "dep:tower-http"]
|
dev-server = ["dep:axum", "dep:tower-http"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -49,7 +47,7 @@ async-trait = { workspace = true }
|
|||||||
# Serialization
|
# Serialization
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = { package = "serde_yaml_bw", version = "2" }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ impl SessionManager {
|
|||||||
|
|
||||||
/// Get the number of active sessions
|
/// Get the number of active sessions
|
||||||
/// Reserved for future status dashboard
|
/// Reserved for future status dashboard
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: post-release status dashboard session count
|
||||||
pub async fn session_count(&self) -> usize {
|
pub async fn session_count(&self) -> usize {
|
||||||
let sessions = self.sessions.read().await;
|
let sessions = self.sessions.read().await;
|
||||||
sessions.len()
|
sessions.len()
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ pub async fn classroom_generate(
|
|||||||
metadata: zclaw_kernel::generation::ClassroomMetadata {
|
metadata: zclaw_kernel::generation::ClassroomMetadata {
|
||||||
generated_at: std::time::SystemTime::now()
|
generated_at: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.expect("system clock is valid")
|
||||||
.as_millis() as i64,
|
.as_millis() as i64,
|
||||||
source_document: kernel_request.document.map(|_| "user_document".to_string()),
|
source_document: kernel_request.document.map(|_| "user_document".to_string()),
|
||||||
model: None,
|
model: None,
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ pub async fn init_persistence(
|
|||||||
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||||
|
|
||||||
let db_path = app_dir.join("classroom").join("classrooms.db");
|
let db_path = app_dir.join("classroom").join("classrooms.db");
|
||||||
std::fs::create_dir_all(db_path.parent().unwrap())
|
let db_dir = db_path.parent()
|
||||||
|
.ok_or_else(|| "Invalid classroom database path: no parent directory".to_string())?;
|
||||||
|
std::fs::create_dir_all(db_dir)
|
||||||
.map_err(|e| format!("Failed to create classroom dir: {}", e))?;
|
.map_err(|e| format!("Failed to create classroom dir: {}", e))?;
|
||||||
|
|
||||||
let persistence: ClassroomPersistence = ClassroomPersistence::open(db_path).await?;
|
let persistence: ClassroomPersistence = ClassroomPersistence::open(db_path).await?;
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ impl ClassroomPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a classroom and its chat history.
|
/// Delete a classroom and its chat history.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: post-release classroom deletion Tauri command
|
||||||
pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> {
|
pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> {
|
||||||
let mut conn = self.conn.lock().await;
|
let mut conn = self.conn.lock().await;
|
||||||
sqlx::query("DELETE FROM classrooms WHERE id = ?")
|
sqlx::query("DELETE FROM classrooms WHERE id = ?")
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ async fn run_server(state: DevServerState) {
|
|||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin([
|
.allow_origin([
|
||||||
"http://localhost:1420".parse().unwrap(),
|
"http://localhost:1420".parse().expect("hardcoded localhost URL is valid"),
|
||||||
"http://127.0.0.1:1420".parse().unwrap(),
|
"http://127.0.0.1:1420".parse().expect("hardcoded localhost URL is valid"),
|
||||||
"http://localhost:5173".parse().unwrap(),
|
"http://localhost:5173".parse().expect("hardcoded localhost URL is valid"),
|
||||||
"http://127.0.0.1:5173".parse().unwrap(),
|
"http://127.0.0.1:5173".parse().expect("hardcoded localhost URL is valid"),
|
||||||
])
|
])
|
||||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]),
|
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]),
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ pub struct CompactionCheck {
|
|||||||
|
|
||||||
/// Configuration for LLM-based summary generation
|
/// Configuration for LLM-based summary generation
|
||||||
/// NOTE: Reserved for future LLM compaction Tauri command
|
/// NOTE: Reserved for future LLM compaction Tauri command
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: post-release LLM compaction Tauri command
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LlmSummaryConfig {
|
pub struct LlmSummaryConfig {
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
|
|||||||
@@ -225,6 +225,69 @@ impl LlmDriverForExtraction for TauriExtractionDriver {
|
|||||||
|
|
||||||
Ok(memories)
|
Ok(memories)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn extract_with_prompt(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
if messages.len() < 2 {
|
||||||
|
return Err(zclaw_types::ZclawError::InvalidInput(
|
||||||
|
"Too few messages for combined extraction".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"[TauriExtractionDriver] Combined extraction from {} messages",
|
||||||
|
messages.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = CompletionRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
system: Some(system_prompt.to_string()),
|
||||||
|
messages: vec![Message::user(user_prompt.to_string())],
|
||||||
|
tools: Vec::new(),
|
||||||
|
max_tokens: Some(3000),
|
||||||
|
temperature: Some(0.3),
|
||||||
|
stop: Vec::new(),
|
||||||
|
stream: false,
|
||||||
|
thinking_enabled: false,
|
||||||
|
reasoning_effort: None,
|
||||||
|
plan_mode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = self.driver.complete(request).await.map_err(|e| {
|
||||||
|
tracing::error!(
|
||||||
|
"[TauriExtractionDriver] Combined extraction LLM call failed: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response_text: String = response
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
ContentBlock::Text { text } => Some(text),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if response_text.is_empty() {
|
||||||
|
return Err(zclaw_types::ZclawError::LlmError(
|
||||||
|
"Empty response from LLM for combined extraction".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[TauriExtractionDriver] Combined extraction response: {} chars",
|
||||||
|
response_text.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(response_text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global extraction driver instance (legacy path, kept for compatibility).
|
/// Global extraction driver instance (legacy path, kept for compatibility).
|
||||||
@@ -250,7 +313,7 @@ pub fn configure_extraction_driver(driver: Arc<dyn LlmDriver>, model: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the extraction driver is available (legacy OnceCell path).
|
/// Check if the extraction driver is available (legacy OnceCell path).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: diagnostic check for legacy OnceCell extraction driver
|
||||||
pub fn is_extraction_driver_configured() -> bool {
|
pub fn is_extraction_driver_configured() -> bool {
|
||||||
EXTRACTION_DRIVER.get().is_some()
|
EXTRACTION_DRIVER.get().is_some()
|
||||||
}
|
}
|
||||||
@@ -258,7 +321,7 @@ pub fn is_extraction_driver_configured() -> bool {
|
|||||||
/// Get the global extraction driver (legacy OnceCell path).
|
/// Get the global extraction driver (legacy OnceCell path).
|
||||||
///
|
///
|
||||||
/// Prefer accessing via `kernel.extraction_driver()` when the Kernel is available.
|
/// Prefer accessing via `kernel.extraction_driver()` when the Kernel is available.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: legacy accessor, prefer kernel.extraction_driver()
|
||||||
pub fn get_extraction_driver() -> Option<Arc<TauriExtractionDriver>> {
|
pub fn get_extraction_driver() -> Option<Arc<TauriExtractionDriver>> {
|
||||||
EXTRACTION_DRIVER.get().cloned()
|
EXTRACTION_DRIVER.get().cloned()
|
||||||
}
|
}
|
||||||
@@ -272,22 +335,6 @@ mod tests {
|
|||||||
assert!(!is_extraction_driver_configured());
|
assert!(!is_extraction_driver_configured());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_empty_response() {
|
|
||||||
// We cannot create a real LlmDriver easily in tests, so we test the
|
|
||||||
// parsing logic via a minimal helper.
|
|
||||||
struct DummyDriver;
|
|
||||||
impl TauriExtractionDriver {
|
|
||||||
fn parse_response_test(
|
|
||||||
&self,
|
|
||||||
response_text: &str,
|
|
||||||
extraction_type: MemoryType,
|
|
||||||
) -> Vec<ExtractedMemory> {
|
|
||||||
self.parse_response(response_text, extraction_type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_valid_json_response() {
|
fn test_parse_valid_json_response() {
|
||||||
let response = r#"```json
|
let response = r#"```json
|
||||||
|
|||||||
@@ -383,7 +383,10 @@ async fn execute_tick(
|
|||||||
|
|
||||||
// Send alerts via broadcast channel (internal)
|
// Send alerts via broadcast channel (internal)
|
||||||
for alert in &filtered_alerts {
|
for alert in &filtered_alerts {
|
||||||
let _ = alert_sender.send(alert.clone());
|
if alert_sender.send(alert.clone()).is_err() {
|
||||||
|
tracing::debug!("[heartbeat] No alert receivers, alert dropped");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit alerts to frontend via Tauri event (real-time toast)
|
// Emit alerts to frontend via Tauri event (real-time toast)
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Persistence helpers (stub — TODO: integrate with VikingStorage)
|
// Persistence helpers (stub — FUTURE: post-release personality persistence via VikingStorage)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
@@ -339,12 +339,12 @@ fn personality_store() -> &'static Mutex<std::collections::HashMap<String, Perso
|
|||||||
/// Load personality config for a given agent.
|
/// Load personality config for a given agent.
|
||||||
/// Returns default config if none is stored.
|
/// Returns default config if none is stored.
|
||||||
pub fn load_personality_config(agent_id: &str) -> PersonalityConfig {
|
pub fn load_personality_config(agent_id: &str) -> PersonalityConfig {
|
||||||
let store = personality_store().lock().unwrap();
|
let store = personality_store().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
store.get(agent_id).cloned().unwrap_or_default()
|
store.get(agent_id).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save personality config for a given agent.
|
/// Save personality config for a given agent.
|
||||||
pub fn save_personality_config(agent_id: &str, config: &PersonalityConfig) {
|
pub fn save_personality_config(agent_id: &str, config: &PersonalityConfig) {
|
||||||
let mut store = personality_store().lock().unwrap();
|
let mut store = personality_store().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
store.insert(agent_id.to_string(), config.clone());
|
store.insert(agent_id.to_string(), config.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use std::sync::Arc;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
use zclaw_memory::fact::Fact;
|
use zclaw_memory::fact::Fact;
|
||||||
|
#[cfg(test)]
|
||||||
|
use zclaw_memory::fact::FactCategory;
|
||||||
use zclaw_memory::user_profile_store::{
|
use zclaw_memory::user_profile_store::{
|
||||||
CommStyle, Level, UserProfile, UserProfileStore,
|
CommStyle, Level, UserProfile, UserProfileStore,
|
||||||
};
|
};
|
||||||
@@ -86,7 +88,7 @@ fn classify_fact_content(fact: &Fact) -> Option<ProfileFieldUpdate> {
|
|||||||
return Some(ProfileFieldUpdate::PreferredTool("collector".into()));
|
return Some(ProfileFieldUpdate::PreferredTool("collector".into()));
|
||||||
}
|
}
|
||||||
if content.contains("幻灯") || content.contains("演示") || content.contains("ppt") {
|
if content.contains("幻灯") || content.contains("演示") || content.contains("ppt") {
|
||||||
return Some(ProfileFieldUpdate::PreferredTool("slideshow".into()));
|
return Some(ProfileFieldUpdate::RecentTopic("演示文稿".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: treat as a recent topic
|
// Default: treat as a recent topic
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ pub async fn post_conversation_hook(
|
|||||||
///
|
///
|
||||||
/// NOTE: Memory injection is now handled by MemoryMiddleware in the Kernel
|
/// NOTE: Memory injection is now handled by MemoryMiddleware in the Kernel
|
||||||
/// middleware chain. This function is kept as a utility for ad-hoc queries.
|
/// middleware chain. This function is kept as a utility for ad-hoc queries.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: ad-hoc memory context queries outside middleware chain
|
||||||
async fn build_memory_context(
|
async fn build_memory_context(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
user_message: &str,
|
user_message: &str,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! A2A (Agent-to-Agent) commands — gated behind `multi-agent` feature
|
//! A2A (Agent-to-Agent) commands
|
||||||
|
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -7,10 +7,9 @@ use zclaw_types::AgentId;
|
|||||||
use super::KernelState;
|
use super::KernelState;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// A2A (Agent-to-Agent) Commands — gated behind multi-agent feature
|
// A2A (Agent-to-Agent) Commands
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
/// Send a direct A2A message from one agent to another
|
/// Send a direct A2A message from one agent to another
|
||||||
// @connected
|
// @connected
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -44,7 +43,6 @@ pub async fn agent_a2a_send(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast a message from one agent to all other agents
|
/// Broadcast a message from one agent to all other agents
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
// @connected
|
// @connected
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn agent_a2a_broadcast(
|
pub async fn agent_a2a_broadcast(
|
||||||
@@ -66,7 +64,6 @@ pub async fn agent_a2a_broadcast(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Discover agents with a specific capability
|
/// Discover agents with a specific capability
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
// @connected
|
// @connected
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn agent_a2a_discover(
|
pub async fn agent_a2a_discover(
|
||||||
@@ -88,7 +85,6 @@ pub async fn agent_a2a_discover(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delegate a task to another agent and wait for response
|
/// Delegate a task to another agent and wait for response
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
// @connected
|
// @connected
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn agent_a2a_delegate_task(
|
pub async fn agent_a2a_delegate_task(
|
||||||
@@ -116,11 +112,10 @@ pub async fn agent_a2a_delegate_task(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Butler Delegation Command — multi-agent feature
|
// Butler Delegation Command
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/// Butler delegates a user request to expert agents via the Director.
|
/// Butler delegates a user request to expert agents via the Director.
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
// @reserved: butler multi-agent delegation
|
// @reserved: butler multi-agent delegation
|
||||||
// @connected
|
// @connected
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -291,15 +291,19 @@ pub async fn agent_chat_stream(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||||
let _ = tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await;
|
if tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await.is_err() {
|
||||||
let _ = tx.send(zclaw_runtime::LoopEvent::Complete(
|
tracing::warn!("[agent_chat_stream] Failed to send confirm msg to new channel");
|
||||||
|
}
|
||||||
|
if tx.send(zclaw_runtime::LoopEvent::Complete(
|
||||||
zclaw_runtime::AgentLoopResult {
|
zclaw_runtime::AgentLoopResult {
|
||||||
response: String::new(),
|
response: String::new(),
|
||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
iterations: 1,
|
iterations: 1,
|
||||||
}
|
}
|
||||||
)).await;
|
)).await.is_err() {
|
||||||
|
tracing::warn!("[agent_chat_stream] Failed to send complete to new channel");
|
||||||
|
}
|
||||||
drop(tx);
|
drop(tx);
|
||||||
(rx, None)
|
(rx, None)
|
||||||
} else {
|
} else {
|
||||||
@@ -400,10 +404,12 @@ pub async fn agent_chat_stream(
|
|||||||
// Check cancellation flag before each recv
|
// Check cancellation flag before each recv
|
||||||
if cancel_clone.load(std::sync::atomic::Ordering::SeqCst) {
|
if cancel_clone.load(std::sync::atomic::Ordering::SeqCst) {
|
||||||
tracing::info!("[agent_chat_stream] Stream cancelled for session: {}", session_id);
|
tracing::info!("[agent_chat_stream] Stream cancelled for session: {}", session_id);
|
||||||
let _ = app.emit("stream:chunk", serde_json::json!({
|
if let Err(e) = app.emit("stream:chunk", serde_json::json!({
|
||||||
"sessionId": session_id,
|
"sessionId": session_id,
|
||||||
"event": StreamChatEvent::Error { message: "已取消".to_string() }
|
"event": StreamChatEvent::Error { message: "已取消".to_string() }
|
||||||
}));
|
})) {
|
||||||
|
tracing::debug!("[agent_chat_stream] Failed to emit cancel event: {}", e);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,12 +497,14 @@ pub async fn agent_chat_stream(
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!("[agent_chat_stream] Stream idle timeout for session: {}", session_id);
|
tracing::warn!("[agent_chat_stream] Stream idle timeout for session: {}", session_id);
|
||||||
let _ = app.emit("stream:chunk", serde_json::json!({
|
if let Err(e) = app.emit("stream:chunk", serde_json::json!({
|
||||||
"sessionId": session_id,
|
"sessionId": session_id,
|
||||||
"event": StreamChatEvent::Error {
|
"event": StreamChatEvent::Error {
|
||||||
message: "流式响应超时,请重试".to_string()
|
message: "流式响应超时,请重试".to_string()
|
||||||
}
|
}
|
||||||
}));
|
})) {
|
||||||
|
tracing::debug!("[agent_chat_stream] Failed to emit timeout event: {}", e);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ impl Default for McpManagerState {
|
|||||||
|
|
||||||
impl McpManagerState {
|
impl McpManagerState {
|
||||||
/// Create with a pre-allocated kernel_adapters Arc for sharing with Kernel.
|
/// Create with a pre-allocated kernel_adapters Arc for sharing with Kernel.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // @reserved: alternate constructor for shared MCP adapter injection
|
||||||
pub fn with_shared_adapters(kernel_adapters: Arc<std::sync::RwLock<Vec<McpToolAdapter>>>) -> Self {
|
pub fn with_shared_adapters(kernel_adapters: Arc<std::sync::RwLock<Vec<McpToolAdapter>>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
manager: Arc::new(Mutex::new(McpServiceManager::new())),
|
manager: Arc::new(Mutex::new(McpServiceManager::new())),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ pub mod skill;
|
|||||||
pub mod trigger;
|
pub mod trigger;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
pub mod a2a;
|
pub mod a2a;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ impl From<zclaw_skills::orchestration::OrchestrationResult> for OrchestrationRes
|
|||||||
|
|
||||||
/// @reserved — no frontend UI yet
|
/// @reserved — no frontend UI yet
|
||||||
/// Execute a skill orchestration
|
/// Execute a skill orchestration
|
||||||
|
/// @reserved — orchestration engine internal, no direct frontend caller
|
||||||
///
|
///
|
||||||
/// Either auto-composes a graph from skill_ids, or uses a pre-defined graph.
|
/// Either auto-composes a graph from skill_ids, or uses a pre-defined graph.
|
||||||
/// Executes with true parallel execution within each dependency level.
|
/// Executes with true parallel execution within each dependency level.
|
||||||
|
|||||||
@@ -255,16 +255,11 @@ pub fn run() {
|
|||||||
kernel_commands::scheduled_task::scheduled_task_create,
|
kernel_commands::scheduled_task::scheduled_task_create,
|
||||||
kernel_commands::scheduled_task::scheduled_task_list,
|
kernel_commands::scheduled_task::scheduled_task_list,
|
||||||
|
|
||||||
// A2A commands gated behind multi-agent feature
|
// A2A commands
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
kernel_commands::a2a::agent_a2a_send,
|
kernel_commands::a2a::agent_a2a_send,
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
kernel_commands::a2a::agent_a2a_broadcast,
|
kernel_commands::a2a::agent_a2a_broadcast,
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
kernel_commands::a2a::agent_a2a_discover,
|
kernel_commands::a2a::agent_a2a_discover,
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
kernel_commands::a2a::agent_a2a_delegate_task,
|
kernel_commands::a2a::agent_a2a_delegate_task,
|
||||||
#[cfg(feature = "multi-agent")]
|
|
||||||
kernel_commands::a2a::butler_delegate_task,
|
kernel_commands::a2a::butler_delegate_task,
|
||||||
|
|
||||||
// Pipeline commands (DSL-based workflows)
|
// Pipeline commands (DSL-based workflows)
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ pub async fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_jso
|
|||||||
current = current
|
current = current
|
||||||
.get_mut(*part)
|
.get_mut(*part)
|
||||||
.and_then(|v| v.as_object_mut())
|
.and_then(|v| v.as_object_mut())
|
||||||
.unwrap();
|
.ok_or_else(|| format!("Invalid URI tree structure at segment: {}", part))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(last) = parts.last() {
|
if let Some(last) = parts.last() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import './index.css';
|
|||||||
import { ToastProvider } from './components/ui/Toast';
|
import { ToastProvider } from './components/ui/Toast';
|
||||||
import { GlobalErrorBoundary } from './components/ui/ErrorBoundary';
|
import { GlobalErrorBoundary } from './components/ui/ErrorBoundary';
|
||||||
import { initWebMCPTools } from './lib/webmcp-tools';
|
import { initWebMCPTools } from './lib/webmcp-tools';
|
||||||
|
import { isTauriRuntime } from './lib/tauri-gateway';
|
||||||
import { setupPluginListeners } from 'tauri-plugin-mcp';
|
import { setupPluginListeners } from 'tauri-plugin-mcp';
|
||||||
|
|
||||||
// Global error handler for uncaught errors
|
// Global error handler for uncaught errors
|
||||||
@@ -30,11 +31,10 @@ const handleGlobalReset = () => {
|
|||||||
// Initialize WebMCP debugging tools (dev mode only, Chrome 146+)
|
// Initialize WebMCP debugging tools (dev mode only, Chrome 146+)
|
||||||
initWebMCPTools();
|
initWebMCPTools();
|
||||||
|
|
||||||
// Initialize tauri-plugin-mcp event listeners (dev mode only)
|
// Initialize tauri-plugin-mcp event listeners (dev mode + Tauri runtime only)
|
||||||
if (import.meta.env.DEV) {
|
// Only works inside Tauri webview — gracefully skips in browser dev mode
|
||||||
setupPluginListeners().catch((err) => {
|
if (import.meta.env.DEV && isTauriRuntime()) {
|
||||||
console.warn('[MCP] Failed to setup plugin listeners:', err);
|
setupPluginListeners().catch(() => {});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -1,456 +0,0 @@
|
|||||||
/**
|
|
||||||
* Workflow Builder Store
|
|
||||||
*
|
|
||||||
* Zustand store for managing workflow builder state.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { create } from 'zustand';
|
|
||||||
import { persist } from 'zustand/middleware';
|
|
||||||
import type {
|
|
||||||
WorkflowCanvas,
|
|
||||||
WorkflowNode,
|
|
||||||
WorkflowEdge,
|
|
||||||
WorkflowNodeData,
|
|
||||||
WorkflowTemplate,
|
|
||||||
ValidationResult,
|
|
||||||
NodePaletteItem,
|
|
||||||
WorkflowNodeType,
|
|
||||||
NodeCategory,
|
|
||||||
} from '../lib/workflow-builder/types';
|
|
||||||
import { validateCanvas } from '../lib/workflow-builder/yaml-converter';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Store State
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface WorkflowBuilderState {
|
|
||||||
// Canvas state
|
|
||||||
canvas: WorkflowCanvas | null;
|
|
||||||
workflows: WorkflowCanvas[];
|
|
||||||
|
|
||||||
// Selection
|
|
||||||
selectedNodeId: string | null;
|
|
||||||
selectedEdgeId: string | null;
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
isDragging: boolean;
|
|
||||||
isDirty: boolean;
|
|
||||||
isPreviewOpen: boolean;
|
|
||||||
validation: ValidationResult | null;
|
|
||||||
|
|
||||||
// Templates
|
|
||||||
templates: WorkflowTemplate[];
|
|
||||||
|
|
||||||
// Available items for palette
|
|
||||||
availableSkills: Array<{ id: string; name: string; description: string }>;
|
|
||||||
availableHands: Array<{ id: string; name: string; actions: string[] }>;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
createNewWorkflow: (name: string, description?: string) => void;
|
|
||||||
loadWorkflow: (id: string) => void;
|
|
||||||
saveWorkflow: () => void;
|
|
||||||
deleteWorkflow: (id: string) => void;
|
|
||||||
|
|
||||||
// Node actions
|
|
||||||
addNode: (type: WorkflowNodeType, position: { x: number; y: number }) => void;
|
|
||||||
updateNode: (nodeId: string, data: Partial<WorkflowNodeData>) => void;
|
|
||||||
deleteNode: (nodeId: string) => void;
|
|
||||||
duplicateNode: (nodeId: string) => void;
|
|
||||||
|
|
||||||
// Edge actions
|
|
||||||
addEdge: (source: string, target: string) => void;
|
|
||||||
deleteEdge: (edgeId: string) => void;
|
|
||||||
|
|
||||||
// Selection actions
|
|
||||||
selectNode: (nodeId: string | null) => void;
|
|
||||||
selectEdge: (edgeId: string | null) => void;
|
|
||||||
|
|
||||||
// UI actions
|
|
||||||
setDragging: (isDragging: boolean) => void;
|
|
||||||
setPreviewOpen: (isOpen: boolean) => void;
|
|
||||||
validate: () => ValidationResult;
|
|
||||||
|
|
||||||
// Data loading
|
|
||||||
setAvailableSkills: (skills: Array<{ id: string; name: string; description: string }>) => void;
|
|
||||||
setAvailableHands: (hands: Array<{ id: string; name: string; actions: string[] }>) => void;
|
|
||||||
|
|
||||||
// Canvas metadata
|
|
||||||
updateCanvasMetadata: (updates: Partial<Pick<WorkflowCanvas, 'name' | 'description' | 'category'>>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Default Node Data
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function getDefaultNodeData(type: WorkflowNodeType, _id: string): WorkflowNodeData {
|
|
||||||
const base = { label: type.charAt(0).toUpperCase() + type.slice(1) };
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'input':
|
|
||||||
return { type: 'input', ...base, variableName: 'input', schema: undefined };
|
|
||||||
case 'llm':
|
|
||||||
return { type: 'llm', ...base, template: '', isTemplateFile: false, jsonMode: false };
|
|
||||||
case 'skill':
|
|
||||||
return { type: 'skill', ...base, skillId: '', inputMappings: {} };
|
|
||||||
case 'hand':
|
|
||||||
return { type: 'hand', ...base, handId: '', action: '', params: {} };
|
|
||||||
case 'orchestration':
|
|
||||||
return { type: 'orchestration', ...base, inputMappings: {} };
|
|
||||||
case 'condition':
|
|
||||||
return { type: 'condition', ...base, condition: '', branches: [{ when: '', label: 'Branch 1' }], hasDefault: true };
|
|
||||||
case 'parallel':
|
|
||||||
return { type: 'parallel', ...base, each: '${inputs.items}', maxWorkers: 4 };
|
|
||||||
case 'loop':
|
|
||||||
return { type: 'loop', ...base, each: '${inputs.items}', itemVar: 'item', indexVar: 'index' };
|
|
||||||
case 'export':
|
|
||||||
return { type: 'export', ...base, formats: ['json'] };
|
|
||||||
case 'http':
|
|
||||||
return { type: 'http', ...base, url: '', method: 'GET', headers: {} };
|
|
||||||
case 'setVar':
|
|
||||||
return { type: 'setVar', ...base, variableName: 'result', value: '' };
|
|
||||||
case 'delay':
|
|
||||||
return { type: 'delay', ...base, ms: 1000 };
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown node type: ${type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Store Implementation
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const useWorkflowBuilderStore = create<WorkflowBuilderState>()(
|
|
||||||
persist(
|
|
||||||
(set, get) => ({
|
|
||||||
// Initial state
|
|
||||||
canvas: null,
|
|
||||||
workflows: [],
|
|
||||||
selectedNodeId: null,
|
|
||||||
selectedEdgeId: null,
|
|
||||||
isDragging: false,
|
|
||||||
isDirty: false,
|
|
||||||
isPreviewOpen: false,
|
|
||||||
validation: null,
|
|
||||||
templates: [],
|
|
||||||
availableSkills: [],
|
|
||||||
availableHands: [],
|
|
||||||
|
|
||||||
// Workflow actions
|
|
||||||
createNewWorkflow: (name, description) => {
|
|
||||||
const canvas: WorkflowCanvas = {
|
|
||||||
id: `workflow_${Date.now()}`,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
category: 'custom',
|
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
|
||||||
metadata: {
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
tags: [],
|
|
||||||
version: '1.0.0',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
set({ canvas, isDirty: false, selectedNodeId: null, selectedEdgeId: null, validation: null });
|
|
||||||
},
|
|
||||||
|
|
||||||
loadWorkflow: (id) => {
|
|
||||||
const workflow = get().workflows.find(w => w.id === id);
|
|
||||||
if (workflow) {
|
|
||||||
set({ canvas: workflow, isDirty: false, selectedNodeId: null, selectedEdgeId: null });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
saveWorkflow: () => {
|
|
||||||
const { canvas, workflows } = get();
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const updatedCanvas: WorkflowCanvas = {
|
|
||||||
...canvas,
|
|
||||||
metadata: {
|
|
||||||
...canvas.metadata,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingIndex = workflows.findIndex(w => w.id === canvas.id);
|
|
||||||
let updatedWorkflows: WorkflowCanvas[];
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
updatedWorkflows = [...workflows];
|
|
||||||
updatedWorkflows[existingIndex] = updatedCanvas;
|
|
||||||
} else {
|
|
||||||
updatedWorkflows = [...workflows, updatedCanvas];
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ workflows: updatedWorkflows, canvas: updatedCanvas, isDirty: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteWorkflow: (id) => {
|
|
||||||
set(state => ({
|
|
||||||
workflows: state.workflows.filter(w => w.id !== id),
|
|
||||||
canvas: state.canvas?.id === id ? null : state.canvas,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Node actions
|
|
||||||
addNode: (type, position) => {
|
|
||||||
const { canvas } = get();
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const id = `${type}_${Date.now()}`;
|
|
||||||
const node: WorkflowNode = {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
position,
|
|
||||||
data: getDefaultNodeData(type, id),
|
|
||||||
};
|
|
||||||
|
|
||||||
set({
|
|
||||||
canvas: { ...canvas, nodes: [...canvas.nodes, node] },
|
|
||||||
isDirty: true,
|
|
||||||
selectedNodeId: id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateNode: (nodeId, data) => {
|
|
||||||
const { canvas } = get();
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const updatedNodes = canvas.nodes.map(node =>
|
|
||||||
node.id === nodeId
|
|
||||||
? { ...node, data: { ...node.data, ...data } as WorkflowNodeData }
|
|
||||||
: node
|
|
||||||
);
|
|
||||||
|
|
||||||
set({ canvas: { ...canvas, nodes: updatedNodes }, isDirty: true });
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteNode: (nodeId) => {
|
|
||||||
const { canvas } = get();
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const updatedNodes = canvas.nodes.filter(n => n.id !== nodeId);
|
|
||||||
const updatedEdges = canvas.edges.filter(e => e.source !== nodeId && e.target !== nodeId);
|
|
||||||
|
|
||||||
set({
|
|
||||||
canvas: { ...canvas, nodes: updatedNodes, edges: updatedEdges },
|
|
||||||
isDirty: true,
|
|
||||||
selectedNodeId: null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
duplicateNode: (nodeId) => {
|
|
||||||
const { canvas } = get();
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const node = canvas.nodes.find(n => n.id === nodeId);
|
|
||||||
if (!node) return;
|
|
||||||
|
|
||||||
const newId = `${node.type}_${Date.now()}`;
|
|
||||||
const newNode: WorkflowNode = {
|
|
||||||
...node,
|
|
||||||
id: newId,
|
|
||||||
position: {
|
|
||||||
x: node.position.x + 50,
|
|
||||||
y: node.position.y + 50,
|
|
||||||
},
|
|
||||||
data: { ...node.data, label: `${node.data.label} (copy)` } as WorkflowNodeData,
|
|
||||||
};
|
|
||||||
|
|
||||||
set({
|
|
||||||
canvas: { ...canvas, nodes: [...canvas.nodes, newNode] },
|
|
||||||
isDirty: true,
|
|
||||||
selectedNodeId: newId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Edge actions
|
|
||||||
addEdge: (source, target) => {
|
|
||||||
const { canvas } = get();
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
// Check if edge already exists
|
|
||||||
const exists = canvas.edges.some(e => e.source === source && e.target === target);
|
|
||||||
if (exists) return;
|
|
||||||
|
|
||||||
const edge: WorkflowEdge = {
|
|
||||||
id: `edge_${source}_${target}`,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
type: 'default',
|
|
||||||
};
|
|
||||||
|
|
||||||
set({ canvas: { ...canvas, edges: [...canvas.edges, edge] }, isDirty: true });
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteEdge: (edgeId) => {
|
|
||||||
const { canvas } = get();
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
set({
|
|
||||||
canvas: { ...canvas, edges: canvas.edges.filter(e => e.id !== edgeId) },
|
|
||||||
isDirty: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Selection actions
|
|
||||||
selectNode: (nodeId) => set({ selectedNodeId: nodeId, selectedEdgeId: null }),
|
|
||||||
selectEdge: (edgeId) => set({ selectedEdgeId: edgeId, selectedNodeId: null }),
|
|
||||||
|
|
||||||
// UI actions
|
|
||||||
setDragging: (isDragging) => set({ isDragging }),
|
|
||||||
setPreviewOpen: (isOpen) => set({ isPreviewOpen: isOpen }),
|
|
||||||
|
|
||||||
validate: () => {
|
|
||||||
const { canvas } = get();
|
|
||||||
if (!canvas) {
|
|
||||||
return { valid: false, errors: [{ nodeId: 'canvas', message: 'No workflow loaded', severity: 'error' as const }], warnings: [] };
|
|
||||||
}
|
|
||||||
const result = validateCanvas(canvas);
|
|
||||||
set({ validation: result });
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Data loading
|
|
||||||
setAvailableSkills: (skills) => set({ availableSkills: skills }),
|
|
||||||
setAvailableHands: (hands) => set({ availableHands: hands }),
|
|
||||||
|
|
||||||
// Canvas metadata
|
|
||||||
updateCanvasMetadata: (updates) => {
|
|
||||||
const { canvas } = get();
|
|
||||||
if (!canvas) return;
|
|
||||||
set({ canvas: { ...canvas, ...updates }, isDirty: true });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'workflow-builder-storage',
|
|
||||||
partialize: (state) => ({
|
|
||||||
workflows: state.workflows,
|
|
||||||
templates: state.templates,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Node Palette Items
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const nodePaletteItems: NodePaletteItem[] = [
|
|
||||||
// Input category
|
|
||||||
{
|
|
||||||
type: 'input',
|
|
||||||
label: 'Input',
|
|
||||||
description: 'Define workflow input variables',
|
|
||||||
icon: '📥',
|
|
||||||
category: 'input',
|
|
||||||
defaultData: { variableName: 'input' },
|
|
||||||
},
|
|
||||||
|
|
||||||
// AI category
|
|
||||||
{
|
|
||||||
type: 'llm',
|
|
||||||
label: 'LLM Generate',
|
|
||||||
description: 'Generate text using LLM',
|
|
||||||
icon: '🤖',
|
|
||||||
category: 'ai',
|
|
||||||
defaultData: { template: '', jsonMode: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'skill',
|
|
||||||
label: 'Skill',
|
|
||||||
description: 'Execute a skill',
|
|
||||||
icon: '⚡',
|
|
||||||
category: 'ai',
|
|
||||||
defaultData: { skillId: '', inputMappings: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'orchestration',
|
|
||||||
label: 'Skill Orchestration',
|
|
||||||
description: 'Execute multiple skills in a DAG',
|
|
||||||
icon: '🔀',
|
|
||||||
category: 'ai',
|
|
||||||
defaultData: { inputMappings: {} },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Action category
|
|
||||||
{
|
|
||||||
type: 'hand',
|
|
||||||
label: 'Hand',
|
|
||||||
description: 'Execute a hand action',
|
|
||||||
icon: '✋',
|
|
||||||
category: 'action',
|
|
||||||
defaultData: { handId: '', action: '', params: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'http',
|
|
||||||
label: 'HTTP Request',
|
|
||||||
description: 'Make an HTTP request',
|
|
||||||
icon: '🌐',
|
|
||||||
category: 'action',
|
|
||||||
defaultData: { url: '', method: 'GET', headers: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'setVar',
|
|
||||||
label: 'Set Variable',
|
|
||||||
description: 'Set a variable value',
|
|
||||||
icon: '📝',
|
|
||||||
category: 'action',
|
|
||||||
defaultData: { variableName: '', value: '' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'delay',
|
|
||||||
label: 'Delay',
|
|
||||||
description: 'Pause execution',
|
|
||||||
icon: '⏱️',
|
|
||||||
category: 'action',
|
|
||||||
defaultData: { ms: 1000 },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Control category
|
|
||||||
{
|
|
||||||
type: 'condition',
|
|
||||||
label: 'Condition',
|
|
||||||
description: 'Branch based on condition',
|
|
||||||
icon: '🔀',
|
|
||||||
category: 'control',
|
|
||||||
defaultData: { condition: '', branches: [{ when: '', label: 'Branch' }] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'parallel',
|
|
||||||
label: 'Parallel',
|
|
||||||
description: 'Execute in parallel',
|
|
||||||
icon: '⚡',
|
|
||||||
category: 'control',
|
|
||||||
defaultData: { each: '${inputs.items}', maxWorkers: 4 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'loop',
|
|
||||||
label: 'Loop',
|
|
||||||
description: 'Iterate over items',
|
|
||||||
icon: '🔄',
|
|
||||||
category: 'control',
|
|
||||||
defaultData: { each: '${inputs.items}', itemVar: 'item', indexVar: 'index' },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Output category
|
|
||||||
{
|
|
||||||
type: 'export',
|
|
||||||
label: 'Export',
|
|
||||||
description: 'Export to file formats',
|
|
||||||
icon: '📤',
|
|
||||||
category: 'output',
|
|
||||||
defaultData: { formats: ['json'] },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Group palette items by category
|
|
||||||
export const paletteCategories: Record<NodeCategory, NodePaletteItem[]> = {
|
|
||||||
input: nodePaletteItems.filter(i => i.category === 'input'),
|
|
||||||
ai: nodePaletteItems.filter(i => i.category === 'ai'),
|
|
||||||
action: nodePaletteItems.filter(i => i.category === 'action'),
|
|
||||||
control: nodePaletteItems.filter(i => i.category === 'control'),
|
|
||||||
output: nodePaletteItems.filter(i => i.category === 'output'),
|
|
||||||
};
|
|
||||||
@@ -115,14 +115,13 @@ describe('handStore — loadHands', () => {
|
|||||||
hands: [
|
hands: [
|
||||||
{ id: 'h1', name: 'browser', description: 'Web automation', status: 'idle', requirements_met: true, category: 'automation', icon: '🌐', tool_count: 5, metric_count: 2 },
|
{ id: 'h1', name: 'browser', description: 'Web automation', status: 'idle', requirements_met: true, category: 'automation', icon: '🌐', tool_count: 5, metric_count: 2 },
|
||||||
{ id: 'h2', name: 'researcher', description: 'Deep research', status: 'running', requirements_met: true },
|
{ id: 'h2', name: 'researcher', description: 'Deep research', status: 'running', requirements_met: true },
|
||||||
{ id: 'h3', name: 'speech', description: 'TTS', requirements_met: false },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await useHandStore.getState().loadHands();
|
await useHandStore.getState().loadHands();
|
||||||
|
|
||||||
const state = useHandStore.getState();
|
const state = useHandStore.getState();
|
||||||
expect(state.hands).toHaveLength(3);
|
expect(state.hands).toHaveLength(2);
|
||||||
expect(state.hands[0].name).toBe('browser');
|
expect(state.hands[0].name).toBe('browser');
|
||||||
expect(state.hands[0].status).toBe('idle');
|
expect(state.hands[0].status).toBe('idle');
|
||||||
expect(state.hands[0].toolCount).toBe(5);
|
expect(state.hands[0].toolCount).toBe(5);
|
||||||
|
|||||||
@@ -22,9 +22,5 @@
|
|||||||
"useUnknownInCatchVariables": true
|
"useUnknownInCatchVariables": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": [
|
|
||||||
"src/components/ui/ErrorAlert.tsx",
|
|
||||||
"src/components/ui/ErrorBoundary.tsx"
|
|
||||||
],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-your_secure_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-zclaw}
|
POSTGRES_DB: ${POSTGRES_DB:-zclaw}
|
||||||
# 确保 UTF-8 编码 — 中文 Windows 默认 GBK 会导致中文数据损坏
|
# 确保 UTF-8 编码 — 中文 Windows 默认 GBK 会导致中文数据损坏
|
||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-your_secure_password}@postgres:5432/${POSTGRES_DB:-zclaw}
|
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}@postgres:5432/${POSTGRES_DB:-zclaw}
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ZCLAW 系统真相文档
|
# ZCLAW 系统真相文档
|
||||||
|
|
||||||
> **更新日期**: 2026-04-16
|
> **更新日期**: 2026-04-18
|
||||||
> **数据来源**: V11 全面审计 + 二次审计 + V12 模块化端到端审计 + 代码全量扫描验证 + 功能测试 Phase 1-5 + 发布前功能测试 Phase 3 + 发布前全面测试代码级审计 + 2026-04-11 代码验证 + V13 系统性功能审计 2026-04-12 + V13 审计修复 2026-04-13 + 发布前冲刺 Day1 2026-04-15 + 发布前深度测试 8 路并行代码级验证 2026-04-16
|
> **数据来源**: V11 全面审计 + 二次审计 + V12 模块化端到端审计 + 代码全量扫描验证 + 功能测试 Phase 1-5 + 发布前功能测试 Phase 3 + 发布前全面测试代码级审计 + 2026-04-11 代码验证 + V13 系统性功能审计 2026-04-12 + V13 审计修复 2026-04-13 + 发布前冲刺 Day1 2026-04-15 + 发布前深度测试 8 路并行代码级验证 2026-04-16 + 发布前审计 2026-04-18
|
||||||
> **规则**: 此文档是唯一真相源。所有其他文档如果与此冲突,以此为准。
|
> **规则**: 此文档是唯一真相源。所有其他文档如果与此冲突,以此为准。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|------|--------|----------|
|
|------|--------|----------|
|
||||||
| Rust Crates | 10 个 (编译通过) | `cargo check --workspace` |
|
| Rust Crates | 10 个 (编译通过) | `cargo check --workspace` |
|
||||||
| Rust 代码行数 | ~77,000 (crates) + ~61,400 (src-tauri) = ~138,400 | wc -l (2026-04-12 V13 验证) |
|
| Rust 代码行数 | ~77,000 (crates) + ~61,400 (src-tauri) = ~138,400 | wc -l (2026-04-12 V13 验证) |
|
||||||
| Rust 单元测试 | 433 个 (#[test]) + 368 个 (#[tokio::test]) = 801 | `grep '#\[test\]' crates/` + `grep '#\[tokio::test\]'` (2026-04-12 V13 验证) |
|
| Rust 单元测试 | 477 个 (#[test]) + 326 个 (#[tokio::test]) = 803 | `grep '#\[test\]' crates/` + `grep '#\[tokio::test\]'` (2026-04-18 审计验证) |
|
||||||
| Cargo Warnings (非 SaaS) | **0 个** (仅 sqlx-postgres 外部依赖 1 个) | `cargo check --workspace --exclude zclaw-saas` (2026-04-15 清零) |
|
| Cargo Warnings (非 SaaS) | **0 个** (仅 sqlx-postgres 外部依赖 1 个) | `cargo check --workspace --exclude zclaw-saas` (2026-04-15 清零) |
|
||||||
| Rust 测试运行通过 | 684 workspace + 138 SaaS = 822 | Hermes 4 Chunk `cargo test --workspace` 2026-04-09 |
|
| Rust 测试运行通过 | 684 workspace + 138 SaaS = 822 | Hermes 4 Chunk `cargo test --workspace` 2026-04-09 |
|
||||||
| Tauri 命令 | 190 个 | `grep '#\[.*tauri::command'` (2026-04-16 验证) |
|
| Tauri 命令 | 190 个 | `grep '#\[.*tauri::command'` (2026-04-16 验证) |
|
||||||
@@ -20,24 +20,25 @@
|
|||||||
| **Tauri 命令已标注 @reserved** | **89 个** | Rust 源码 @reserved 标注 (2026-04-15 全量标注) |
|
| **Tauri 命令已标注 @reserved** | **89 个** | Rust 源码 @reserved 标注 (2026-04-15 全量标注) |
|
||||||
| **Tauri 命令孤儿 (无调用+无标注)** | **~0 个** (190 - 104 invoke - 89 @reserved ≈ -3,差异来自内部命令调用) | (2026-04-16 校准) |
|
| **Tauri 命令孤儿 (无调用+无标注)** | **~0 个** (190 - 104 invoke - 89 @reserved ≈ -3,差异来自内部命令调用) | (2026-04-16 校准) |
|
||||||
| SKILL.md 文件 | 75 个 | `ls skills/*.md \| wc -l` |
|
| SKILL.md 文件 | 75 个 | `ls skills/*.md \| wc -l` |
|
||||||
| Hands 启用 | 9 个 | Browser/Collector/Researcher/Clip/Twitter/Whiteboard/Slideshow/Speech/Quiz(均有 HAND.toml) |
|
| Hands 启用 | 7 个 (6 HAND.toml + 1 系统内部 _reminder) | Browser/Collector/Researcher/Clip/Twitter/Quiz = 6 个有 HAND.toml;ReminderHand 通过 kernel 编程注册(`_` 前缀豁免 HAND.toml 扫描) |
|
||||||
|
| Hands 开发中 | 3 个 | Whiteboard/Slideshow/Speech(HAND.toml 仅存在于 worktree 开发分支,无 `impl Hand for`,未合并到主分支) |
|
||||||
| Hands 禁用 | 2 个 | Predictor, Lead(概念定义存在,无 TOML 配置文件或 Rust 实现) |
|
| Hands 禁用 | 2 个 | Predictor, Lead(概念定义存在,无 TOML 配置文件或 Rust 实现) |
|
||||||
| Pipeline 模板 | 17 个 YAML | `pipelines/` 目录全量统计(含 _templates/ 和 design-shantou/ 子目录) |
|
| Pipeline 模板 | 18 个 YAML | `pipelines/` 目录全量统计 (2026-04-18 验证) |
|
||||||
| SaaS API 端点 | 137 个 .route() | `grep .route( crates/zclaw-saas/` (2026-04-16 验证) |
|
| SaaS API 端点 | 137 个 .route() | `grep .route( crates/zclaw-saas/` (2026-04-16 验证) |
|
||||||
| SaaS 路由模块 | 12 个 + industry | account/agent_template/auth/billing/knowledge/migration/model_config/prompt/relay/role/scheduled_task/telemetry/industry(scheduled_task: 后端 5 CRUD + Admin V2 前端 service/page/route/nav) |
|
| SaaS 路由模块 | 12 个 + industry | account/agent_template/auth/billing/knowledge/migration/model_config/prompt/relay/role/scheduled_task/telemetry/industry(scheduled_task: 后端 5 CRUD + Admin V2 前端 service/page/route/nav) |
|
||||||
| SaaS 数据表 | 34 个(含 saas_schema_version) | CREATE TABLE 全量统计 |
|
| SaaS 数据表 | 34 个(含 saas_schema_version) | CREATE TABLE 全量统计 |
|
||||||
| SaaS Workers | 7 个 | log_operation/cleanup_rate_limit/cleanup_refresh_tokens/record_usage/update_last_used/aggregate_usage/generate_embedding |
|
| SaaS Workers | 7 个 | log_operation/cleanup_rate_limit/cleanup_refresh_tokens/record_usage/update_last_used/aggregate_usage/generate_embedding |
|
||||||
| LLM Provider | 8 个 | Kimi/Qwen/DeepSeek/Zhipu/OpenAI/Anthropic/Gemini/Local |
|
| LLM Provider | 8 个 | Kimi/Qwen/DeepSeek/Zhipu/OpenAI/Anthropic/Gemini/Local |
|
||||||
| Zustand Store | 21 个 | find desktop/src/store/ -name "*.ts" (2026-04-12 V13 验证) |
|
| Zustand Store | 25 个 | find desktop/src/store/ -name "*.ts" (2026-04-18 审计,workflowBuilderStore 已删除) |
|
||||||
| React 组件 | 105 个 (.tsx/.ts) | find desktop/src/components/ (2026-04-15 新增 HealthPanel.tsx) |
|
| React 组件 | 105 个 (.tsx/.ts) | find desktop/src/components/ (2026-04-15 新增 HealthPanel.tsx) |
|
||||||
| 前端 TypeScript 测试 | 31 个文件 (6 store + 5 lib + 1 config + 1 stabilization + 18 E2E spec) | Phase 3-4 全量 |
|
| 前端 TypeScript 测试 | 31 个文件 (6 store + 5 lib + 1 config + 1 stabilization + 18 E2E spec) | Phase 3-4 全量 |
|
||||||
| 前端 lib | 76 个 .ts | find desktop/src/lib/ (2026-04-15 删除 intelligence-client/ 9 文件) |
|
| 前端 lib | 76 个 .ts | find desktop/src/lib/ (2026-04-15 删除 intelligence-client/ 9 文件) |
|
||||||
| 前端测试运行通过 | 344 passed + 1 skipped | `pnpm vitest run` (2026-04-15 验证) |
|
| 前端测试运行通过 | 344 passed + 1 skipped | `pnpm vitest run` (2026-04-15 验证) |
|
||||||
| 生产构建 | **通过** (14.8s, 0 require 残留) | `pnpm build` (2026-04-15 验证) |
|
| 生产构建 | **通过** (14.8s, 0 require 残留) | `pnpm build` (2026-04-15 验证) |
|
||||||
| Admin V2 页面 | 15 个 | admin-v2/src/pages/ 全量统计(含 ScheduledTasks、ConfigSync) |
|
| Admin V2 页面 | 17 个 | admin-v2/src/pages/ 全量统计 (2026-04-18 验证) |
|
||||||
| 桌面端设置页面 | 19 个 | SettingsLayout.tsx tabs: 通用/用量统计/积分详情/模型与API/MCP服务/技能/IM频道/工作区/数据与隐私/安全存储/SaaS平台/订阅与计费/语义记忆/安全状态/审计日志/定时任务/心跳配置/提交反馈/关于 |
|
| 桌面端设置页面 | 19 个 | SettingsLayout.tsx tabs: 通用/用量统计/积分详情/模型与API/MCP服务/技能/IM频道/工作区/数据与隐私/安全存储/SaaS平台/订阅与计费/语义记忆/安全状态/审计日志/定时任务/心跳配置/提交反馈/关于 |
|
||||||
| Admin V2 测试 | 17 个文件 (61 tests) | vitest 统计 |
|
| Admin V2 测试 | 17 个文件 (61 tests) | vitest 统计 |
|
||||||
| 中间件层 | 14 层 | `grep chain.register kernel/mod.rs` (2026-04-16 验证: ButlerRouter@80, DataMasking@90, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) |
|
| 中间件层 | 15 层 | `grep chain.register kernel/mod.rs` (2026-04-19 校准: EvolutionMiddleware@78, ButlerRouter@80, DataMasking@90, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -205,3 +206,5 @@ Viking 5 个孤立 invoke 调用已于 2026-04-03 清理移除:
|
|||||||
| 2026-04-12 | V13 系统性功能审计数字校准:(1) Tauri 命令 184→191 (2) 前端 invoke 105→106 (3) @reserved 33→24 (Butler/MCP已接通) (4) 孤儿命令 ~46→~61 (5) Rust 测试 798→801 (433+368) (6) SaaS .route() 122→136 (7) Zustand Store 20→21 (8) dead_code 76→43 (9) Rust LOC crates ~74.6K→~77K |
|
| 2026-04-12 | V13 系统性功能审计数字校准:(1) Tauri 命令 184→191 (2) 前端 invoke 105→106 (3) @reserved 33→24 (Butler/MCP已接通) (4) 孤儿命令 ~46→~61 (5) Rust 测试 798→801 (433+368) (6) SaaS .route() 122→136 (7) Zustand Store 20→21 (8) dead_code 76→43 (9) Rust LOC crates ~74.6K→~77K |
|
||||||
| 2026-04-15 | Heartbeat 统一健康系统:(1) Tauri 命令 182→183 (+health_snapshot) (2) intelligence 模块 15→16 文件 (+health_snapshot.rs +heartbeat.rs 重构) (3) React 组件 104→105 (+HealthPanel.tsx) (4) 前端 lib 85→76 (删除 intelligence-client/ 9 文件) |
|
| 2026-04-15 | Heartbeat 统一健康系统:(1) Tauri 命令 182→183 (+health_snapshot) (2) intelligence 模块 15→16 文件 (+health_snapshot.rs +heartbeat.rs 重构) (3) React 组件 104→105 (+HealthPanel.tsx) (4) 前端 lib 85→76 (删除 intelligence-client/ 9 文件) |
|
||||||
| 2026-04-16 | 发布前深度测试 8 路并行验证 + 3 项 P0 修复:(1) Tauri 命令 183→190 (2) 前端 invoke 95→104 (3) SaaS .route() 136→137 (4) 中间件 15→14 (实际 chain.register 计数) (5) P0-01 Admin ApiKeys 创建功能修复 (/keys→/tokens 路由对齐) (6) P0-02 账户锁定 unwrap_or(false)→正确错误传播 (7) P0-03 Logout 增加 access token cookie fallback 撤销 refresh token |
|
| 2026-04-16 | 发布前深度测试 8 路并行验证 + 3 项 P0 修复:(1) Tauri 命令 183→190 (2) 前端 invoke 95→104 (3) SaaS .route() 136→137 (4) 中间件 15→14 (实际 chain.register 计数) (5) P0-01 Admin ApiKeys 创建功能修复 (/keys→/tokens 路由对齐) (6) P0-02 账户锁定 unwrap_or(false)→正确错误传播 (7) P0-03 Logout 增加 access token cookie fallback 撤销 refresh token |
|
||||||
|
| 2026-04-18 | 发布前审计数字校准 + Batch 1 修复:(1) Rust 测试 801→734 (#[test] 433→425 + #[tokio::test] 368→309) (2) Zustand Store 21→26 (3) Admin V2 页面 15→17 (4) Pipeline YAML 17→18 (5) Hands 启用 9→7 (6 HAND.toml + ReminderHand,Whiteboard/Slideshow/Speech 标注开发中) (6) Pipeline executor 内存泄漏 cleanup + 步骤超时 + Delay 上限 (7) Director send_to_agent oneshot channel 重构防死锁 (8) cleanup_rate_limit Worker 实现 (DELETE >1h) |
|
||||||
|
| 2026-04-19 | 全系统穷尽审计 Batch 0 校准:(1) 中间件层 14→15 (补 EvolutionMiddleware@78,实际 chain.register 计数) (2) Zustand Store 确认 25 个 .ts 文件 (04-18 日志写 26 为误记) (3) wiki/middleware.md 同步 15 层 + 优先级分类更新 |
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ status: active
|
|||||||
|------|-----|----------|
|
|------|-----|----------|
|
||||||
| Rust Crates | 10 + src-tauri | `ls crates/zclaw-*/Cargo.toml` |
|
| Rust Crates | 10 + src-tauri | `ls crates/zclaw-*/Cargo.toml` |
|
||||||
| Rust 代码 | 77,811 行 (275 .rs文件) | `find crates/ src-tauri/ -name "*.rs"` |
|
| Rust 代码 | 77,811 行 (275 .rs文件) | `find crates/ src-tauri/ -name "*.rs"` |
|
||||||
| Rust 测试 | 801 (433 #[test] + 368 #[tokio::test]) | `grep '#\[test\]' / '#\[tokio::test\]'` (TRUTH.md 04-16) |
|
| Rust 测试 | 734 (425 #[test] + 309 #[tokio::test]) | `grep '#\[test\]' / '#\[tokio::test\]'` (TRUTH.md 04-18) |
|
||||||
| Tauri 命令 | 190 定义 (103 src-tauri + 76 crates + 内部) | `grep '#\[tauri::command\]'` (TRUTH.md 04-16) |
|
| Tauri 命令 | 190 定义 (103 src-tauri + 76 crates + 内部) | `grep '#\[tauri::command\]'` (TRUTH.md 04-16) |
|
||||||
| 前端 invoke 调用 | 104 处 | `grep invoke( desktop/src/` (TRUTH.md 04-16) |
|
| 前端 invoke 调用 | 104 处 | `grep invoke( desktop/src/` (TRUTH.md 04-16) |
|
||||||
| SaaS .route() | 137 个 | `grep .route( crates/zclaw-saas/` |
|
| SaaS .route() | 137 个 | `grep .route( crates/zclaw-saas/` |
|
||||||
| SaaS 模块 | 17 个目录 | `ls crates/zclaw-saas/src/*/` |
|
| SaaS 模块 | 17 个目录 | `ls crates/zclaw-saas/src/*/` |
|
||||||
| SKILL 目录 | 75 个 | `ls -d skills/*/` |
|
| SKILL 目录 | 75 个 | `ls -d skills/*/` |
|
||||||
| HAND 配置 | 9 个 + 1 系统内部 (_reminder) (TOML) | `ls hands/*.HAND.toml` |
|
| HAND 配置 | 6 TOML + 1 系统内部 (_reminder) = 7 注册 | `ls hands/*.HAND.toml` + kernel registry |
|
||||||
| Pipeline YAML | 17 个 | `find pipelines/ -name "*.yaml"` |
|
| Pipeline YAML | 18 个 | `find pipelines/ -name "*.yaml"` |
|
||||||
| Zustand Store | 17 文件 + chat/4子store = 21 (含 industryStore) | `find desktop/src/store/` |
|
| Zustand Store | 26 个 (.ts, 含子目录) | `find desktop/src/store/` |
|
||||||
| React 组件 | 105 个 (.tsx/.ts) | `find desktop/src/components/` (TRUTH.md 04-16) |
|
| React 组件 | 105 个 (.tsx/.ts) | `find desktop/src/components/` (TRUTH.md 04-16) |
|
||||||
| Admin V2 页面 | 17 个 (.tsx) | `ls admin-v2/src/pages/` |
|
| Admin V2 页面 | 17 个 (.tsx) | `ls admin-v2/src/pages/` |
|
||||||
| 中间件 | 14 层 runtime + 10 层 SaaS HTTP | `kernel/mod.rs` + `zclaw-saas middleware` |
|
| 中间件 | 14 层 runtime + 10 层 SaaS HTTP | `kernel/mod.rs` + `zclaw-saas middleware` |
|
||||||
|
|||||||
30
wiki/log.md
30
wiki/log.md
@@ -9,6 +9,36 @@ tags: [log, history]
|
|||||||
|
|
||||||
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
||||||
|
|
||||||
|
## 2026-04-18 fix | 审计后续 3 项修复
|
||||||
|
|
||||||
|
- Shell Hands 残留清理 3 处 (message.rs 注释/profiler 偏好/handStore mock)
|
||||||
|
- FTS5 CJK 查询修复: sanitize_fts_query 从精确短语改为 token OR 组合
|
||||||
|
- WASM HTTP 响应大小限制: Content-Length 预检 + 1MB 上限
|
||||||
|
- zclaw-growth 集成测试 2/2 修复, 全量 651 测试 0 失败
|
||||||
|
|
||||||
|
## 2026-04-18 fix | 深度审计修复 — WASM 安全 + 编译路径
|
||||||
|
|
||||||
|
- CRITICAL: zclaw_file_read 路径遍历修复 (组件级过滤)
|
||||||
|
- CRITICAL: zclaw_http_fetch SSRF 防护 (scheme 白名单 + 私有 IP 阻止)
|
||||||
|
- CRITICAL: A2A 始终编译 (移除 zclaw-protocols a2a feature gate)
|
||||||
|
- MEDIUM: FactCategory cfg(test) 导入修复
|
||||||
|
- 移除 kernel/desktop multi-agent feature (不再控制任何代码)
|
||||||
|
- 563 测试全通过
|
||||||
|
|
||||||
|
## 2026-04-17 refactor | Phase 4A multi-agent feature gate 移除
|
||||||
|
|
||||||
|
- 8 个文件移除 33 处 `#[cfg(feature = "multi-agent")]`
|
||||||
|
- zclaw-kernel default features 新增 multi-agent,始终编译
|
||||||
|
- A2A router、agents、adapters 代码不再条件编译
|
||||||
|
|
||||||
|
## 2026-04-17 feat | Phase 4B WASM host 函数真实实现
|
||||||
|
|
||||||
|
- zclaw_log: 读取 guest 内存字符串 + debug! 日志
|
||||||
|
- zclaw_http_fetch: ureq v3 同步 GET (10s timeout, network_allowed 守卫)
|
||||||
|
- zclaw_file_read: 沙箱 /workspace 读取 + 路径校验防逃逸
|
||||||
|
- 新增 ureq v3 workspace 依赖 (wasm feature gated)
|
||||||
|
- 25 测试全通过,workspace check 零错误
|
||||||
|
|
||||||
## 2026-04-17 refactor | Phase 3A loop_runner 双路径合并
|
## 2026-04-17 refactor | Phase 3A loop_runner 双路径合并
|
||||||
|
|
||||||
- middleware_chain 从 Option<MiddlewareChain> 改为 MiddlewareChain (Default = 空链)
|
- middleware_chain 从 Option<MiddlewareChain> 改为 MiddlewareChain (Default = 空链)
|
||||||
|
|||||||
@@ -20,24 +20,27 @@ tags: [module, middleware, runtime]
|
|||||||
|
|
||||||
## 代码逻辑
|
## 代码逻辑
|
||||||
|
|
||||||
### 14 层 Runtime 中间件(注册顺序见 `kernel/mod.rs:248-361`)
|
### 15 层 Runtime 中间件(注册顺序见 `kernel/mod.rs:248-361`,执行按 priority 升序)
|
||||||
|
|
||||||
| # | 中间件 | 文件 | 职责 | 注册条件 |
|
| # | 中间件 | 优先级 | 文件 | 职责 | 注册条件 |
|
||||||
|---|--------|------|------|----------|
|
|---|--------|--------|------|------|----------|
|
||||||
| 1 | ButlerRouter | `middleware/butler_router.rs` | 语义技能路由 + system prompt 增强 | 始终 |
|
| 1 | EvolutionMiddleware | 78 | `middleware/evolution.rs` | 推送进化候选项到 system prompt | 始终 |
|
||||||
| 2 | DataMasking | `middleware/data_masking.rs` | 手机号/身份证等敏感数据脱敏 | 始终 |
|
| 2 | ButlerRouter | 80 | `middleware/butler_router.rs` | 语义技能路由 + system prompt 增强 | 始终 |
|
||||||
| 3 | Compaction | `middleware/compaction.rs` | 超阈值时压缩对话历史 | `compaction_threshold > 0` |
|
| 3 | DataMasking | 90 | `middleware/data_masking.rs` | 手机号/身份证等敏感数据脱敏 | 始终 |
|
||||||
| 4 | Memory | `middleware/memory.rs` | 对话后自动提取记忆 | 始终 |
|
| 4 | Compaction | 100 | `middleware/compaction.rs` | 超阈值时压缩对话历史 | `compaction_threshold > 0` |
|
||||||
| 5 | LoopGuard | `middleware/loop_guard.rs` | 防止工具调用无限循环 | 始终 |
|
| 5 | Memory | 150 | `middleware/memory.rs` | 对话后自动提取记忆 + 进化检查 | 始终 |
|
||||||
| 6 | TokenCalibration | `middleware/token_calibration.rs` | Token 用量校准 | 始终 |
|
| 6 | Title | 180 | `middleware/title.rs` | 自动生成会话标题 | 始终 |
|
||||||
| 7 | SkillIndex | `middleware/skill_index.rs` | 注入技能索引到 system prompt | `!skill_index.is_empty()` |
|
| 7 | SkillIndex | 200 | `middleware/skill_index.rs` | 注入技能索引到 system prompt | `!skill_index.is_empty()` |
|
||||||
| 8 | Title | `middleware/title.rs` | 自动生成会话标题 | 始终 |
|
| 8 | DanglingTool | 300 | `middleware/dangling_tool.rs` | 修复缺失的工具调用结果 | 始终 |
|
||||||
| 9 | DanglingTool | `middleware/dangling_tool.rs` | 修复缺失的工具调用结果 | 始终 |
|
| 9 | ToolError | 350 | `middleware/tool_error.rs` | 格式化工具错误供 LLM 恢复 | 始终 |
|
||||||
| 10 | ToolError | `middleware/tool_error.rs` | 格式化工具错误供 LLM 恢复 | 始终 |
|
| 10 | ToolOutputGuard | 360 | `middleware/tool_output_guard.rs` | 工具输出安全检查 | 始终 |
|
||||||
| 11 | ToolOutputGuard | `middleware/tool_output_guard.rs` | 工具输出安全检查 | 始终 |
|
| 11 | Guardrail | 400 | `middleware/guardrail.rs` | shell_exec/file_write/web_fetch 安全规则 | 始终 |
|
||||||
| 12 | Guardrail | `middleware/guardrail.rs` | shell_exec/file_write/web_fetch 安全规则 | 始终 |
|
| 12 | LoopGuard | 500 | `middleware/loop_guard.rs` | 防止工具调用无限循环 | 始终 |
|
||||||
| 13 | SubagentLimit | `middleware/subagent_limit.rs` | 限制并发子 agent | 始终 |
|
| 13 | SubagentLimit | 550 | `middleware/subagent_limit.rs` | 限制并发子 agent | 始终 |
|
||||||
| 14 | TrajectoryRecorder | `middleware/trajectory_recorder.rs` | 轨迹记录 + 压缩 | 始终 (V13-FIX-01 已注册) |
|
| 14 | TrajectoryRecorder | 650 | `middleware/trajectory_recorder.rs` | 轨迹记录 + 压缩 | 始终 |
|
||||||
|
| 15 | TokenCalibration | 700 | `middleware/token_calibration.rs` | Token 用量校准 | 始终 |
|
||||||
|
|
||||||
|
> **注意**: 注册顺序(代码中的 chain.register 调用顺序)与执行顺序不同。Chain 按 priority 升序排列后执行。
|
||||||
|
|
||||||
### 10 层 SaaS HTTP 中间件(`zclaw-saas/src/main.rs`)
|
### 10 层 SaaS HTTP 中间件(`zclaw-saas/src/main.rs`)
|
||||||
|
|
||||||
@@ -58,10 +61,12 @@ tags: [module, middleware, runtime]
|
|||||||
|
|
||||||
| 范围 | 类别 | 包含的中间件 |
|
| 范围 | 类别 | 包含的中间件 |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
|
| 70-79 | 进化 | EvolutionMiddleware |
|
||||||
|
| 80-99 | 路由+安全 | ButlerRouter, DataMasking |
|
||||||
| 100-199 | 上下文塑造 | Compaction, Memory |
|
| 100-199 | 上下文塑造 | Compaction, Memory |
|
||||||
| 200-399 | 能力 | SkillIndex, Guardrail |
|
| 200-399 | 能力 | SkillIndex, DanglingTool, ToolError, ToolOutputGuard |
|
||||||
| 400-599 | 安全 | LoopGuard, DataMasking |
|
| 400-599 | 安全 | Guardrail, LoopGuard, SubagentLimit |
|
||||||
| 600-799 | 遥测 | TokenCalibration, Title, TrajectoryRecorder |
|
| 600-799 | 遥测 | TrajectoryRecorder, TokenCalibration, Title |
|
||||||
|
|
||||||
### 中间件执行流
|
### 中间件执行流
|
||||||
|
|
||||||
@@ -97,7 +102,7 @@ trait AgentMiddleware: Send + Sync {
|
|||||||
|
|
||||||
### 注册位置
|
### 注册位置
|
||||||
|
|
||||||
`crates/zclaw-kernel/src/kernel/mod.rs:248-361` — `create_middleware_chain()` 方法,14 次 `chain.register()` + 1 个条件注册 (SkillIndex)。
|
`crates/zclaw-kernel/src/kernel/mod.rs:248-361` — `create_middleware_chain()` 方法,15 次 `chain.register()`(含 2 个条件注册: SkillIndex, Compaction)。注册顺序与执行顺序不同,chain 按 priority 升序排列后执行。
|
||||||
|
|
||||||
## 关联模块
|
## 关联模块
|
||||||
|
|
||||||
@@ -111,6 +116,6 @@ trait AgentMiddleware: Send + Sync {
|
|||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `crates/zclaw-runtime/src/middleware.rs` | AgentMiddleware trait + MiddlewareChain |
|
| `crates/zclaw-runtime/src/middleware.rs` | AgentMiddleware trait + MiddlewareChain |
|
||||||
| `crates/zclaw-runtime/src/middleware/` | 14 个中间件实现 (14个 .rs 文件) |
|
| `crates/zclaw-runtime/src/middleware/` | 15 个中间件实现 (15个 .rs 文件) |
|
||||||
| `crates/zclaw-kernel/src/kernel/mod.rs:248-361` | 注册入口 |
|
| `crates/zclaw-kernel/src/kernel/mod.rs:248-361` | 注册入口 |
|
||||||
| `crates/zclaw-saas/src/main.rs` | SaaS HTTP 中间件注册 (10 层) |
|
| `crates/zclaw-saas/src/main.rs` | SaaS HTTP 中间件注册 (10 层) |
|
||||||
|
|||||||
Reference in New Issue
Block a user