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)
|
||||
|
||||
ZCLAW 提供 11 个自主能力包(9 启用 + 2 禁用):
|
||||
ZCLAW 提供 12 个自主能力包(7 已注册 + 3 开发中 + 2 禁用):
|
||||
|
||||
| Hand | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||
| Researcher | 深度研究 | ✅ 可用 |
|
||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||
| Speech | 语音合成 | ✅ 可用(Browser TTS 前端集成完成) |
|
||||
| Quiz | 测验生成 | ✅ 可用 |
|
||||
| _reminder | 系统内部提醒 | ✅ 可用(kernel 编程注册,无 HAND.toml) |
|
||||
| Whiteboard | 白板演示 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||
| Slideshow | 幻灯片生成 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||
| Speech | 语音合成 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
|
||||
**触发 Hand 时:**
|
||||
1. 检查依赖是否满足
|
||||
@@ -541,10 +542,10 @@ refactor(store): 统一 Store 数据获取方式
|
||||
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
|
||||
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
|
||||
| 聊天流 (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 失效机制 |
|
||||
| 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 + 语义路由 |
|
||||
| 中间件链 | ✅ 稳定 | 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)
|
||||
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
|
||||
- **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)
|
||||
|
||||
### 最近变更
|
||||
|
||||
1. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
||||
2. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||
2. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||
3. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite
|
||||
4. [04-06] 4 个发布前 bug 修复 (身份覆盖/模型配置/agent同步/自动身份)
|
||||
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-16] 3 项 P0 修复 + 5 项 E2E Bug 修复 + Agent 面板刷新 + TRUTH.md 数字校准
|
||||
3. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
||||
4. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||
5. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||
6. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
|
||||
320
Cargo.lock
generated
320
Cargo.lock
generated
@@ -61,19 +61,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -179,7 +166,7 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
@@ -235,7 +222,7 @@ version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -253,7 +240,7 @@ dependencies = [
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
@@ -1606,9 +1593,9 @@ dependencies = [
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serde_yaml_bw",
|
||||
"sha2",
|
||||
"sqlx 0.7.4",
|
||||
"sqlx",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-mcp",
|
||||
@@ -1974,12 +1961,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -1997,7 +1978,7 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -2699,10 +2680,6 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -2726,15 +2703,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
@@ -2773,9 +2741,6 @@ name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -3555,9 +3520,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -4358,12 +4323,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -4426,7 +4385,7 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b"
|
||||
dependencies = [
|
||||
"sqlx 0.8.6",
|
||||
"sqlx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5492,6 +5451,7 @@ version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5912,19 +5872,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_yaml_bw"
|
||||
version = "2.5.3"
|
||||
@@ -6187,78 +6134,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sqlx"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
|
||||
dependencies = [
|
||||
"sqlx-core 0.8.6",
|
||||
"sqlx-macros 0.8.6",
|
||||
"sqlx-postgres 0.8.6",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6269,16 +6155,17 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-intrusive",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hashbrown 0.15.5",
|
||||
"hashlink 0.10.0",
|
||||
"hashlink",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"memchr",
|
||||
@@ -6289,23 +6176,12 @@ dependencies = [
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"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]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.8.6"
|
||||
@@ -6314,37 +6190,11 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sqlx-core 0.8.6",
|
||||
"sqlx-macros-core 0.8.6",
|
||||
"sqlx-core",
|
||||
"sqlx-macros-core",
|
||||
"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]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.8.6"
|
||||
@@ -6361,20 +6211,23 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx-core 0.8.6",
|
||||
"sqlx-postgres 0.8.6",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
"syn 2.0.117",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.7.4"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
|
||||
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.21.7",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -6403,48 +6256,9 @@ dependencies = [
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core 0.7.4",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 1.0.69",
|
||||
"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",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"whoami",
|
||||
]
|
||||
@@ -6459,6 +6273,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
@@ -6479,7 +6294,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core 0.8.6",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
@@ -6488,9 +6303,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.7.4"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
|
||||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
@@ -6504,10 +6319,11 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sqlx-core 0.7.4",
|
||||
"serde_urlencoded",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7824,12 +7640,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unicode_categories"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -7840,12 +7650,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml-norway"
|
||||
version = "0.2.15"
|
||||
@@ -7858,6 +7662,35 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -7895,6 +7728,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -9573,7 +9412,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
@@ -9630,7 +9469,7 @@ dependencies = [
|
||||
"libsqlite3-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx 0.7.4",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
@@ -9696,7 +9535,7 @@ dependencies = [
|
||||
"libsqlite3-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx 0.7.4",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -9723,7 +9562,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zclaw-hands",
|
||||
"zclaw-kernel",
|
||||
"zclaw-runtime",
|
||||
"zclaw-skills",
|
||||
"zclaw-types",
|
||||
@@ -9809,7 +9647,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"sqlx 0.7.4",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -9840,6 +9678,8 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ureq",
|
||||
"url",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
|
||||
@@ -57,12 +57,15 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
|
||||
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
|
||||
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
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 = "2"
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ function Sidebar({
|
||||
const isActive =
|
||||
item.path === '/'
|
||||
? activePath === '/'
|
||||
: activePath.startsWith(item.path)
|
||||
: activePath === item.path || activePath.startsWith(item.path + '/')
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { DashboardStats } from '@/types'
|
||||
|
||||
export const statsService = {
|
||||
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.
|
||||
#[serde(default)]
|
||||
pub source_trigger: Option<String>,
|
||||
/// Primary tool/skill used to resolve this pain point.
|
||||
#[serde(default)]
|
||||
pub tool_used: Option<String>,
|
||||
}
|
||||
|
||||
impl Experience {
|
||||
@@ -72,6 +75,7 @@ impl Experience {
|
||||
updated_at: now,
|
||||
industry_context: None,
|
||||
source_trigger: None,
|
||||
tool_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +113,11 @@ impl ExperienceStore {
|
||||
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
|
||||
/// `agent_id + pain_pattern`, ensuring one experience per pattern.
|
||||
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 {
|
||||
keywords.push(industry.clone());
|
||||
}
|
||||
if let Some(ref tool) = exp.tool_used {
|
||||
keywords.push(tool.clone());
|
||||
}
|
||||
|
||||
let entry = MemoryEntry {
|
||||
uri,
|
||||
|
||||
@@ -19,6 +19,34 @@ pub trait LlmDriverForExtraction: Send + Sync {
|
||||
messages: &[Message],
|
||||
extraction_type: MemoryType,
|
||||
) -> 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
|
||||
@@ -85,13 +113,10 @@ impl MemoryExtractor {
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<ExtractedMemory>> {
|
||||
// Check if LLM driver is available
|
||||
let _llm_driver = match &self.llm_driver {
|
||||
Some(driver) => driver,
|
||||
None => {
|
||||
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
if self.llm_driver.is_none() {
|
||||
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -227,6 +252,299 @@ impl MemoryExtractor {
|
||||
tracing::info!("[MemoryExtractor] Stored {} memories to OpenViking", 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
|
||||
@@ -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#"
|
||||
分析以下对话,提取用户的偏好设置。关注:
|
||||
- 沟通风格偏好(简洁/详细、正式/随意)
|
||||
@@ -362,11 +729,103 @@ mod tests {
|
||||
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]
|
||||
fn test_prompts_available() {
|
||||
assert!(!prompts::get_extraction_prompt(MemoryType::Preference).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::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
|
||||
//!
|
||||
//! 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
|
||||
//! 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
|
||||
//! stored memories to find contextually relevant information.
|
||||
@@ -19,6 +22,28 @@
|
||||
//! 4. **GrowthTracker** (`tracker`) - Tracks growth metrics and evolution
|
||||
//! 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
|
||||
//!
|
||||
//! All memories are stored in OpenViking with a URI structure:
|
||||
@@ -65,6 +90,15 @@ pub mod storage;
|
||||
pub mod retrieval;
|
||||
pub mod summarizer;
|
||||
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
|
||||
pub use types::{
|
||||
@@ -78,6 +112,14 @@ pub use types::{
|
||||
RetrievalResult,
|
||||
UriBuilder,
|
||||
effective_importance,
|
||||
ArtifactType,
|
||||
CombinedExtraction,
|
||||
EvolutionEvent,
|
||||
EvolutionEventType,
|
||||
EvolutionStatus,
|
||||
ExperienceCandidate,
|
||||
Outcome,
|
||||
ProfileSignals,
|
||||
};
|
||||
|
||||
pub use extractor::{LlmDriverForExtraction, MemoryExtractor};
|
||||
@@ -89,6 +131,18 @@ pub use storage::SqliteStorage;
|
||||
pub use experience_store::{Experience, ExperienceStore};
|
||||
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||
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
|
||||
#[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)
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: post-release cache optimization lookups
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
struct CacheKey {
|
||||
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
|
||||
scorer: Arc<RwLock<SemanticScorer>>,
|
||||
/// Database path (for reference)
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: db path for diagnostics and reconnect
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -162,22 +162,44 @@ impl SqliteStorage {
|
||||
.map_err(|e| ZclawError::StorageError(format!("Failed to create importance index: {}", e)))?;
|
||||
|
||||
// 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)
|
||||
.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)
|
||||
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)
|
||||
.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
|
||||
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)
|
||||
.await;
|
||||
let _ = sqlx::query("CREATE INDEX IF NOT EXISTS idx_content_hash ON memories(content_hash)")
|
||||
.await
|
||||
{
|
||||
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)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[Growth] Migration idx_content_hash failed: {}", e);
|
||||
}
|
||||
|
||||
// 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();
|
||||
normalized.hash(&mut hasher);
|
||||
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(uri)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[sqlite] content_hash update failed for {}: {}", uri, e);
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"[SqliteStorage] Backfilled content_hash for {} existing entries",
|
||||
@@ -234,9 +259,12 @@ impl SqliteStorage {
|
||||
if needs_rebuild {
|
||||
tracing::info!("[SqliteStorage] Rebuilding FTS5 table: unicode61 → trigram for CJK support");
|
||||
// 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)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[sqlite] FTS5 table drop failed during rebuild: {}", e);
|
||||
}
|
||||
// Recreate with trigram tokenizer
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -401,14 +429,17 @@ impl SqliteStorage {
|
||||
.await;
|
||||
|
||||
// Also clean up FTS entries for archived memories
|
||||
let _ = sqlx::query(
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM memories_fts
|
||||
WHERE uri NOT IN (SELECT uri FROM memories)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[sqlite] FTS cleanup after archive failed: {}", e);
|
||||
}
|
||||
|
||||
let archived = archive_result
|
||||
.map(|r| r.rows_affected())
|
||||
@@ -461,13 +492,58 @@ impl SqliteStorage {
|
||||
});
|
||||
|
||||
if has_cjk {
|
||||
// For CJK, use the full query as a quoted phrase for substring matching
|
||||
// trigram will match any 3-char subsequence
|
||||
if lower.len() >= 3 {
|
||||
format!("\"{}\"", lower)
|
||||
} else {
|
||||
String::new()
|
||||
// For CJK queries, extract tokens: CJK character sequences and ASCII words.
|
||||
// Join with OR for broad matching (not exact phrase, which would miss scattered terms).
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
let mut cjk_buf = String::new();
|
||||
let mut ascii_buf = 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 {
|
||||
// For non-CJK, split into terms and join with OR
|
||||
let terms: Vec<String> = lower
|
||||
|
||||
@@ -66,21 +66,30 @@ impl GrowthTracker {
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
// Store learning event
|
||||
self.viking
|
||||
.store_metadata(
|
||||
&format!("agent://{}/events/{}", agent_id, session_id),
|
||||
&event,
|
||||
)
|
||||
.await?;
|
||||
// Store learning event as MemoryEntry so get_timeline can find it via find_by_prefix
|
||||
let event_uri = format!("agent://{}/events/{}", agent_id, session_id);
|
||||
let content = serde_json::to_string(&event)?;
|
||||
let entry = crate::types::MemoryEntry {
|
||||
uri: event_uri,
|
||||
memory_type: MemoryType::Session,
|
||||
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
|
||||
.store_metadata(
|
||||
&format!("agent://{}", agent_id),
|
||||
&AgentMetadata {
|
||||
last_learning_time: Some(Utc::now()),
|
||||
total_learning_events: None, // Will be computed
|
||||
total_learning_events: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -394,6 +394,103 @@ pub struct DecayResult {
|
||||
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.
|
||||
///
|
||||
/// Uses exponential decay: each 30-day period of non-access reduces
|
||||
@@ -524,4 +621,61 @@ mod tests {
|
||||
assert!(!result.is_empty());
|
||||
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![
|
||||
"-f", "concat",
|
||||
"-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",
|
||||
&config.output_path,
|
||||
];
|
||||
|
||||
@@ -9,8 +9,6 @@ description = "ZCLAW kernel - central coordinator for all subsystems"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable multi-agent orchestration (Director, A2A protocol)
|
||||
multi-agent = ["zclaw-protocols/a2a"]
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Default for ApiProtocol {
|
||||
///
|
||||
/// This is the single source of truth for LLM configuration.
|
||||
/// Model ID is passed directly to the API without any transformation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct LlmConfig {
|
||||
/// API base URL (e.g., "https://api.openai.com/v1")
|
||||
pub base_url: String,
|
||||
@@ -61,6 +61,20 @@ pub struct LlmConfig {
|
||||
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 {
|
||||
/// Create a new LLM config
|
||||
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 serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{RwLock, Mutex, mpsc};
|
||||
use tokio::sync::{RwLock, Mutex, mpsc, oneshot};
|
||||
use zclaw_types::{AgentId, Result, ZclawError};
|
||||
use zclaw_protocols::{A2aEnvelope, A2aMessageType, A2aRecipient, A2aRouter, A2aAgentProfile, A2aCapability};
|
||||
use zclaw_runtime::{LlmDriver, CompletionRequest};
|
||||
@@ -199,9 +199,9 @@ pub struct Director {
|
||||
director_id: AgentId,
|
||||
/// Optional LLM driver for intelligent scheduling
|
||||
llm_driver: Option<Arc<dyn LlmDriver>>,
|
||||
/// Inbox for receiving responses (stores pending request IDs and their response channels)
|
||||
pending_requests: Arc<Mutex<std::collections::HashMap<String, mpsc::Sender<A2aEnvelope>>>>,
|
||||
/// Receiver for incoming messages
|
||||
/// Pending request response channels (request_id → oneshot sender)
|
||||
pending_requests: Arc<Mutex<std::collections::HashMap<String, oneshot::Sender<A2aEnvelope>>>>,
|
||||
/// Receiver for incoming messages (consumed by inbox reader task)
|
||||
inbox: Arc<Mutex<Option<mpsc::Receiver<A2aEnvelope>>>>,
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ impl Director {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.expect("system clock is valid")
|
||||
.as_nanos();
|
||||
let idx = (now as usize) % agents.len();
|
||||
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
|
||||
///
|
||||
/// 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(
|
||||
&self,
|
||||
agent: &DirectorAgent,
|
||||
message: String,
|
||||
) -> Result<String> {
|
||||
// Create a response channel for this request
|
||||
let (_response_tx, mut _response_rx) = mpsc::channel::<A2aEnvelope>(1);
|
||||
// Create a oneshot channel for this specific request's response
|
||||
let (response_tx, response_rx) = oneshot::channel::<A2aEnvelope>();
|
||||
|
||||
let envelope = A2aEnvelope::new(
|
||||
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 mut pending = self.pending_requests.lock().await;
|
||||
pending.insert(request_id.clone(), _response_tx);
|
||||
pending.insert(request_id.clone(), response_tx);
|
||||
}
|
||||
|
||||
// Send the request
|
||||
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 request_id_clone = request_id.clone();
|
||||
|
||||
let response = tokio::time::timeout(timeout_duration, async {
|
||||
// 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;
|
||||
let response = tokio::time::timeout(timeout_duration, response_rx).await;
|
||||
|
||||
// Clean up pending request
|
||||
// Clean up pending request (sender already consumed on success)
|
||||
{
|
||||
let mut pending = self.pending_requests.lock().await;
|
||||
pending.remove(&request_id);
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(Some(envelope)) => {
|
||||
// Extract response text from payload
|
||||
Ok(Ok(envelope)) => {
|
||||
let response_text = envelope.payload
|
||||
.get("response")
|
||||
.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();
|
||||
Ok(response_text)
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(Err(_)) => {
|
||||
Err(ZclawError::Timeout("No response received".into()))
|
||||
}
|
||||
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
|
||||
pub async fn broadcast(&self, message: String) -> Result<()> {
|
||||
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 {
|
||||
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
|
||||
@@ -882,7 +910,9 @@ impl Director {
|
||||
let prompt = format!(
|
||||
r#"你是 ZCLAW 管家。请将以下用户需求拆解为 1-5 个具体子任务。
|
||||
|
||||
用户需求:{}
|
||||
<user_request>
|
||||
{}
|
||||
</user_request>
|
||||
|
||||
请按 JSON 数组格式输出,每个元素包含:
|
||||
- description: 子任务描述(中文)
|
||||
|
||||
@@ -17,8 +17,9 @@ impl EventBus {
|
||||
|
||||
/// Publish an event
|
||||
pub fn publish(&self, event: Event) {
|
||||
// Ignore send errors (no subscribers)
|
||||
let _ = self.sender.send(event);
|
||||
if let Err(e) = self.sender.send(event) {
|
||||
tracing::debug!("Event dropped (no subscribers or channel full): {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to events
|
||||
|
||||
@@ -14,7 +14,7 @@ use zclaw_types::Result;
|
||||
/// HTML exporter
|
||||
pub struct HtmlExporter {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -490,7 +490,7 @@ impl PptxExporter {
|
||||
paths.sort();
|
||||
|
||||
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()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ fn clean_fallback_response(text: &str) -> String {
|
||||
fn current_timestamp_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.expect("system clock is valid")
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
|
||||
@@ -557,7 +557,7 @@ Use Chinese if the topic is in Chinese. Include metaphors that relate to everyda
|
||||
.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 {
|
||||
Self::extract_text_from_response_static(response)
|
||||
}
|
||||
@@ -882,7 +882,7 @@ fn current_timestamp() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.expect("system clock is valid")
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
//! 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};
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_protocols::{A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient};
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use super::Kernel;
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
impl Kernel {
|
||||
// ============================================================
|
||||
// A2A (Agent-to-Agent) Messaging
|
||||
|
||||
@@ -106,13 +106,11 @@ impl SkillExecutor for KernelSkillExecutor {
|
||||
|
||||
/// Inbox wrapper for A2A message receivers that supports re-queuing
|
||||
/// non-matching messages instead of dropping them.
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub(crate) struct AgentInbox {
|
||||
pub(crate) rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>,
|
||||
pub(crate) pending: std::collections::VecDeque<zclaw_protocols::A2aEnvelope>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
impl AgentInbox {
|
||||
pub(crate) fn new(rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>) -> Self {
|
||||
Self { rx, pending: std::collections::VecDeque::new() }
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use tokio::sync::Mutex;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use super::adapters::AgentInbox;
|
||||
|
||||
use super::Kernel;
|
||||
@@ -23,7 +20,6 @@ impl Kernel {
|
||||
self.memory.save_agent(&config).await?;
|
||||
|
||||
// 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 rx = self.a2a_router.register_agent(profile).await;
|
||||
@@ -52,7 +48,6 @@ impl Kernel {
|
||||
self.memory.delete_agent(id).await?;
|
||||
|
||||
// Unregister from A2A router
|
||||
#[cfg(feature = "multi-agent")]
|
||||
{
|
||||
self.a2a_router.unregister_agent(id).await;
|
||||
self.a2a_inboxes.remove(id);
|
||||
|
||||
@@ -85,14 +85,14 @@ impl Kernel {
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
};
|
||||
let _ = memory.save_hand_run(&run).await.map_err(|e| {
|
||||
tracing::warn!("[Approval] Failed to save hand run: {}", e);
|
||||
});
|
||||
if let Err(e) = memory.save_hand_run(&run).await {
|
||||
tracing::error!("[Approval] Failed to save hand run: {}", e);
|
||||
}
|
||||
run.status = HandRunStatus::Running;
|
||||
run.started_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
let _ = memory.update_hand_run(&run).await.map_err(|e| {
|
||||
tracing::warn!("[Approval] Failed to update hand run (running): {}", e);
|
||||
});
|
||||
if let Err(e) = memory.update_hand_run(&run).await {
|
||||
tracing::error!("[Approval] Failed to update hand run (running): {}", e);
|
||||
}
|
||||
|
||||
// Register cancellation flag
|
||||
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.completed_at = Some(completed_at);
|
||||
let _ = memory.update_hand_run(&run).await.map_err(|e| {
|
||||
tracing::warn!("[Approval] Failed to update hand run (completed): {}", e);
|
||||
});
|
||||
if let Err(e) = memory.update_hand_run(&run).await {
|
||||
tracing::error!("[Approval] Failed to update hand run (completed): {}", e);
|
||||
}
|
||||
|
||||
// Update approval status based on execution result
|
||||
let mut approvals = approvals.lock().await;
|
||||
|
||||
@@ -8,16 +8,13 @@ mod hands;
|
||||
mod triggers;
|
||||
mod approvals;
|
||||
mod orchestration;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
mod a2a;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use zclaw_types::{Event, Result, AgentState};
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_types::AgentId;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_protocols::A2aRouter;
|
||||
|
||||
use crate::registry::AgentRegistry;
|
||||
@@ -56,11 +53,9 @@ pub struct Kernel {
|
||||
mcp_adapters: Arc<std::sync::RwLock<Vec<zclaw_protocols::McpToolAdapter>>>,
|
||||
/// Dynamic industry keyword configs — shared with Tauri frontend, loaded from SaaS
|
||||
industry_keywords: Arc<tokio::sync::RwLock<Vec<zclaw_runtime::IndustryKeywordConfig>>>,
|
||||
/// A2A router for inter-agent messaging (gated by multi-agent feature)
|
||||
#[cfg(feature = "multi-agent")]
|
||||
/// A2A router for inter-agent messaging
|
||||
a2a_router: Arc<A2aRouter>,
|
||||
/// 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>>>>,
|
||||
}
|
||||
|
||||
@@ -135,7 +130,6 @@ impl Kernel {
|
||||
}
|
||||
|
||||
// Initialize A2A router for multi-agent support
|
||||
#[cfg(feature = "multi-agent")]
|
||||
let a2a_router = {
|
||||
let kernel_agent_id = AgentId::new();
|
||||
Arc::new(A2aRouter::new(kernel_agent_id))
|
||||
@@ -159,9 +153,7 @@ impl Kernel {
|
||||
extraction_driver: None,
|
||||
mcp_adapters: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||
industry_keywords: Arc::new(tokio::sync::RwLock::new(Vec::new())),
|
||||
#[cfg(feature = "multi-agent")]
|
||||
a2a_router,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
a2a_inboxes: Arc::new(dashmap::DashMap::new()),
|
||||
})
|
||||
}
|
||||
@@ -247,6 +239,9 @@ impl Kernel {
|
||||
}
|
||||
|
||||
// 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;
|
||||
let masker = Arc::new(zclaw_runtime::middleware::data_masking::DataMasker::new());
|
||||
@@ -260,6 +255,13 @@ impl Kernel {
|
||||
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
|
||||
let threshold = self.config.compaction_threshold();
|
||||
if threshold > 0 {
|
||||
@@ -277,10 +279,11 @@ impl Kernel {
|
||||
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;
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ pub mod trigger_manager;
|
||||
pub mod config;
|
||||
pub mod scheduler;
|
||||
pub mod skill_router;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub mod director;
|
||||
pub mod generation;
|
||||
pub mod export;
|
||||
@@ -21,13 +20,11 @@ pub use capabilities::*;
|
||||
pub use events::*;
|
||||
pub use config::*;
|
||||
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub use director::{
|
||||
Director, DirectorConfig, DirectorBuilder, DirectorAgent,
|
||||
ConversationState, ScheduleStrategy,
|
||||
// Note: AgentRole is intentionally NOT re-exported here — use generation::AgentRole instead
|
||||
};
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub use zclaw_protocols::{
|
||||
A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient,
|
||||
A2aReceiver,
|
||||
|
||||
@@ -589,7 +589,7 @@ impl StageEngine {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
Self {
|
||||
llm_driver: self.llm_driver.clone(),
|
||||
|
||||
@@ -40,6 +40,15 @@ pub enum ExecuteError {
|
||||
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
|
||||
pub struct PipelineExecutor {
|
||||
/// Action registry
|
||||
@@ -107,35 +116,50 @@ impl PipelineExecutor {
|
||||
// Create execution context
|
||||
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
|
||||
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
|
||||
let mut runs = self.runs.write().await;
|
||||
if let Some(run) = runs.get_mut(&run_id) {
|
||||
match result {
|
||||
Ok(outputs) => {
|
||||
run.status = RunStatus::Completed;
|
||||
run.outputs = Some(serde_json::to_value(&outputs).unwrap_or(Value::Null));
|
||||
}
|
||||
Err(e) => {
|
||||
run.status = RunStatus::Failed;
|
||||
run.error = Some(e.to_string());
|
||||
let return_value = {
|
||||
let mut runs = self.runs.write().await;
|
||||
if let Some(run) = runs.get_mut(&run_id) {
|
||||
match result {
|
||||
Ok(outputs) => {
|
||||
run.status = RunStatus::Completed;
|
||||
run.outputs = Some(serde_json::to_value(&outputs).unwrap_or(Value::Null));
|
||||
}
|
||||
Err(e) => {
|
||||
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(
|
||||
&self,
|
||||
pipeline: &Pipeline,
|
||||
context: &mut ExecutionContext,
|
||||
run_id: &str,
|
||||
step_timeout_secs: u64,
|
||||
) -> Result<HashMap<String, Value>, ExecuteError> {
|
||||
let total_steps = pipeline.spec.steps.len();
|
||||
|
||||
@@ -161,8 +185,15 @@ impl PipelineExecutor {
|
||||
|
||||
tracing::info!("Executing step {} ({}/{})", step.id, idx + 1, total_steps);
|
||||
|
||||
// Execute action
|
||||
let result = self.execute_action(&step.action, context).await?;
|
||||
// Execute action with per-step timeout
|
||||
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
|
||||
context.set_output(&step.id, result.clone());
|
||||
@@ -336,7 +367,16 @@ impl PipelineExecutor {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -508,6 +548,33 @@ impl PipelineExecutor {
|
||||
pub async fn list_runs(&self) -> Vec<PipelineRun> {
|
||||
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)]
|
||||
|
||||
@@ -48,7 +48,7 @@ impl ExecutionContext {
|
||||
steps_output: HashMap::new(),
|
||||
variables: HashMap::new(),
|
||||
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,
|
||||
variables,
|
||||
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
|
||||
//!
|
||||
//! 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_types;
|
||||
mod mcp_tool_adapter;
|
||||
mod mcp_transport;
|
||||
#[cfg(feature = "a2a")]
|
||||
mod a2a;
|
||||
|
||||
pub use mcp::*;
|
||||
pub use mcp_types::*;
|
||||
pub use mcp_tool_adapter::*;
|
||||
pub use mcp_transport::*;
|
||||
#[cfg(feature = "a2a")]
|
||||
pub use a2a::*;
|
||||
|
||||
@@ -130,7 +130,7 @@ impl McpToolAdapter {
|
||||
|
||||
match result.len() {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ impl McpServiceManager {
|
||||
let adapters = McpToolAdapter::from_server(name.clone(), client.clone()).await?;
|
||||
self.clients.insert(name.clone(), client);
|
||||
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
|
||||
|
||||
@@ -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
|
||||
pub struct McpTransport {
|
||||
config: McpServerConfig,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
stdin: Arc<Mutex<Option<BufWriter<ChildStdin>>>>,
|
||||
stdout: Arc<Mutex<Option<BufReader<ChildStdout>>>>,
|
||||
/// Single Mutex protecting both stdin and stdout for atomic write-then-read
|
||||
handles: Arc<Mutex<Option<TransportHandles>>>,
|
||||
capabilities: Arc<Mutex<Option<ServerCapabilities>>>,
|
||||
}
|
||||
|
||||
@@ -99,8 +107,7 @@ impl McpTransport {
|
||||
Self {
|
||||
config,
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
stdin: Arc::new(Mutex::new(None)),
|
||||
stdout: Arc::new(Mutex::new(None)),
|
||||
handles: Arc::new(Mutex::new(None)),
|
||||
capabilities: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
@@ -162,9 +169,11 @@ impl McpTransport {
|
||||
});
|
||||
}
|
||||
|
||||
// Store handles in separate mutexes
|
||||
*self.stdin.lock().await = Some(BufWriter::new(stdin));
|
||||
*self.stdout.lock().await = Some(BufReader::new(stdout));
|
||||
// Store handles in single mutex for atomic write-then-read
|
||||
*self.handles.lock().await = Some(TransportHandles {
|
||||
stdin: BufWriter::new(stdin),
|
||||
stdout: BufReader::new(stdout),
|
||||
});
|
||||
*child_guard = Some(child);
|
||||
|
||||
Ok(())
|
||||
@@ -201,21 +210,21 @@ impl McpTransport {
|
||||
let line = serde_json::to_string(notification)
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to serialize notification: {}", e)))?;
|
||||
|
||||
let mut stdin_guard = self.stdin.lock().await;
|
||||
let stdin = stdin_guard.as_mut()
|
||||
let mut handles_guard = self.handles.lock().await;
|
||||
let handles = handles_guard.as_mut()
|
||||
.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)))?;
|
||||
stdin.write_all(b"\n")
|
||||
handles.stdin.write_all(b"\n")
|
||||
.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)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send JSON-RPC request
|
||||
/// Send JSON-RPC request (atomic write-then-read under single lock)
|
||||
async fn send_request<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -234,28 +243,23 @@ impl McpTransport {
|
||||
let line = serde_json::to_string(&request)
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to serialize request: {}", e)))?;
|
||||
|
||||
// Write to stdin
|
||||
{
|
||||
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
|
||||
// Atomic write-then-read under single lock
|
||||
let response_line = {
|
||||
let mut stdout_guard = self.stdout.lock().await;
|
||||
let stdout = stdout_guard.as_mut()
|
||||
let mut handles_guard = self.handles.lock().await;
|
||||
let handles = handles_guard.as_mut()
|
||||
.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();
|
||||
stdout.read_line(&mut response_line)
|
||||
handles.stdout.read_line(&mut response_line)
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to read response: {}", e)))?;
|
||||
response_line
|
||||
};
|
||||
@@ -429,7 +433,7 @@ impl Drop for McpTransport {
|
||||
let _ = child.wait();
|
||||
}
|
||||
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)]
|
||||
parts: Vec<GeminiResponsePart>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: deserialized from Gemini API, not accessed in code
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ struct GeminiUsageMetadata {
|
||||
#[serde(default)]
|
||||
candidates_token_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: deserialized from Gemini API, not accessed in code
|
||||
total_token_count: Option<u32>,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
use zclaw_growth::{
|
||||
GrowthTracker, InjectionFormat, LlmDriverForExtraction,
|
||||
MemoryExtractor, MemoryRetriever, PromptInjector, RetrievalResult,
|
||||
VikingAdapter,
|
||||
AggregatedPattern, CombinedExtraction, EvolutionConfig, EvolutionEngine,
|
||||
ExperienceExtractor, ExperienceStore, GrowthTracker, InjectionFormat,
|
||||
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};
|
||||
|
||||
/// Growth system integration for AgentLoop
|
||||
@@ -32,6 +33,14 @@ pub struct GrowthIntegration {
|
||||
injector: PromptInjector,
|
||||
/// Growth tracker for tracking growth metrics
|
||||
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
|
||||
config: GrowthConfigInner,
|
||||
}
|
||||
@@ -69,13 +78,19 @@ impl GrowthIntegration {
|
||||
|
||||
let retriever = MemoryRetriever::new(viking.clone());
|
||||
let injector = PromptInjector::new();
|
||||
let tracker = GrowthTracker::new(viking);
|
||||
let tracker = GrowthTracker::new(viking.clone());
|
||||
let evolution_engine = Some(EvolutionEngine::new(viking.clone()));
|
||||
|
||||
Self {
|
||||
retriever,
|
||||
extractor,
|
||||
injector,
|
||||
tracker,
|
||||
experience_extractor: ExperienceExtractor::new()
|
||||
.with_store(Arc::new(ExperienceStore::new(viking))),
|
||||
profile_updater: UserProfileUpdater::new(),
|
||||
profile_store: None,
|
||||
evolution_engine,
|
||||
config: GrowthConfigInner::default(),
|
||||
}
|
||||
}
|
||||
@@ -102,11 +117,73 @@ impl GrowthIntegration {
|
||||
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
|
||||
pub fn set_auto_extract(&mut self, auto_extract: bool) {
|
||||
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
|
||||
///
|
||||
/// This method:
|
||||
@@ -213,8 +290,8 @@ impl GrowthIntegration {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Combined extraction: single LLM call that produces both stored memories
|
||||
/// and structured facts, avoiding double extraction overhead.
|
||||
/// Combined extraction: single LLM call that produces stored memories,
|
||||
/// structured experiences, and profile signals — all in one pass.
|
||||
///
|
||||
/// Returns `(memory_count, Option<ExtractedFactBatch>)` on success.
|
||||
pub async fn extract_combined(
|
||||
@@ -227,25 +304,28 @@ impl GrowthIntegration {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Single LLM extraction call
|
||||
let extracted = self
|
||||
// 单次 LLM 提取:memories + experiences + profile_signals
|
||||
let combined = self
|
||||
.extractor
|
||||
.extract(messages, session_id.clone())
|
||||
.extract_combined(messages, session_id.clone())
|
||||
.await
|
||||
.unwrap_or_else(|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);
|
||||
}
|
||||
|
||||
let mem_count = extracted.len();
|
||||
let mem_count = combined.memories.len();
|
||||
|
||||
// Store raw memories
|
||||
self.extractor
|
||||
.store_memories(&agent_id.to_string(), &extracted)
|
||||
.store_memories(&agent_id.to_string(), &combined.memories)
|
||||
.await?;
|
||||
|
||||
// Track learning event
|
||||
@@ -253,8 +333,71 @@ impl GrowthIntegration {
|
||||
.record_learning(agent_id, &session_id.to_string(), mem_count)
|
||||
.await?;
|
||||
|
||||
// Convert same extracted memories to structured facts (no extra LLM call)
|
||||
let facts: Vec<Fact> = extracted
|
||||
// Persist structured experiences (L1 enhancement)
|
||||
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()
|
||||
.map(|m| {
|
||||
let category = match m.memory_type {
|
||||
|
||||
@@ -279,3 +279,4 @@ pub mod token_calibration;
|
||||
pub mod tool_error;
|
||||
pub mod tool_output_guard;
|
||||
pub mod trajectory_recorder;
|
||||
pub mod evolution;
|
||||
|
||||
@@ -20,19 +20,19 @@ use super::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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(|| {
|
||||
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(|| {
|
||||
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(|| {
|
||||
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(|| {
|
||||
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 crate::growth::GrowthIntegration;
|
||||
use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
||||
use crate::middleware::evolution::EvolutionMiddleware;
|
||||
|
||||
/// Middleware that handles memory retrieval (pre-completion) and extraction (post-completion).
|
||||
///
|
||||
/// Wraps `GrowthIntegration` and delegates:
|
||||
/// - `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 {
|
||||
growth: GrowthIntegration,
|
||||
/// Shared EvolutionMiddleware for pushing evolution suggestions
|
||||
evolution_mw: Option<std::sync::Arc<EvolutionMiddleware>>,
|
||||
/// Minimum seconds between extractions for the same agent (debounce).
|
||||
debounce_secs: u64,
|
||||
/// Timestamp of last extraction per agent (for debouncing).
|
||||
@@ -29,11 +32,18 @@ impl MemoryMiddleware {
|
||||
pub fn new(growth: GrowthIntegration) -> Self {
|
||||
Self {
|
||||
growth,
|
||||
evolution_mw: None,
|
||||
debounce_secs: 30,
|
||||
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.
|
||||
pub fn with_debounce_secs(mut self, secs: u64) -> Self {
|
||||
self.debounce_secs = secs;
|
||||
@@ -52,6 +62,49 @@ impl MemoryMiddleware {
|
||||
map.insert(agent_id.to_string(), now);
|
||||
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]
|
||||
@@ -65,11 +118,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
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;
|
||||
match self.growth.enhance_prompt(&ctx.agent_id, base, &ctx.user_input).await {
|
||||
Ok(enhanced) => {
|
||||
@@ -88,7 +136,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
Ok(MiddlewareDecision::Continue)
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: retrieval failure should not block the conversation
|
||||
tracing::warn!(
|
||||
"[MemoryMiddleware] Memory retrieval failed (non-fatal): {}",
|
||||
e
|
||||
@@ -99,7 +146,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
}
|
||||
|
||||
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();
|
||||
if !self.should_extract(&agent_key) {
|
||||
tracing::debug!(
|
||||
@@ -113,8 +159,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
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(
|
||||
&ctx.agent_id,
|
||||
&ctx.messages,
|
||||
@@ -127,12 +171,14 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
facts.len(),
|
||||
agent_key
|
||||
);
|
||||
|
||||
// Check for evolvable patterns after successful extraction
|
||||
self.check_and_push_evolution(&ctx.agent_id).await;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!("[MemoryMiddleware] No memories or facts extracted");
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: extraction failure should not affect the response
|
||||
tracing::warn!("[MemoryMiddleware] Combined extraction failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Three-layer fallback strategy:
|
||||
//! 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`)
|
||||
//!
|
||||
//! 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(|| {
|
||||
Regex::new(
|
||||
r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时::]\d{0,2}分?"
|
||||
).unwrap()
|
||||
).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_every_day
|
||||
@@ -77,13 +77,13 @@ static RE_EVERY_DAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
static RE_EVERY_DAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||
).unwrap()
|
||||
).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_every_week
|
||||
@@ -91,7 +91,7 @@ static RE_EVERY_WEEK: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_workday
|
||||
@@ -99,18 +99,18 @@ static RE_WORKDAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
static RE_WORKDAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||
).unwrap()
|
||||
).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_interval
|
||||
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
|
||||
@@ -118,7 +118,7 @@ static RE_MONTHLY: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时::]?(\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_one_shot
|
||||
@@ -126,7 +126,7 @@ static RE_ONE_SHOT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -112,12 +112,14 @@ impl Tool for TaskTool {
|
||||
let task_id = sub_agent_id.to_string();
|
||||
|
||||
if let Some(ref tx) = context.event_sender {
|
||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
||||
if tx.send(LoopEvent::SubtaskStatus {
|
||||
task_id: task_id.clone(),
|
||||
description: description.to_string(),
|
||||
status: "started".to_string(),
|
||||
detail: None,
|
||||
}).await;
|
||||
}).await.is_err() {
|
||||
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||
}
|
||||
}
|
||||
|
||||
// Create a fresh session for the sub-agent
|
||||
@@ -161,12 +163,14 @@ impl Tool for TaskTool {
|
||||
|
||||
// Emit subtask_running event
|
||||
if let Some(ref tx) = context.event_sender {
|
||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
||||
if tx.send(LoopEvent::SubtaskStatus {
|
||||
task_id: task_id.clone(),
|
||||
description: description.to_string(),
|
||||
status: "running".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)
|
||||
@@ -179,7 +183,7 @@ impl Tool for TaskTool {
|
||||
|
||||
// Emit subtask_completed event
|
||||
if let Some(ref tx) = context.event_sender {
|
||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
||||
if tx.send(LoopEvent::SubtaskStatus {
|
||||
task_id: task_id.clone(),
|
||||
description: description.to_string(),
|
||||
status: "completed".to_string(),
|
||||
@@ -187,7 +191,9 @@ impl Tool for TaskTool {
|
||||
"完成 ({}次迭代, {}输入token)",
|
||||
loop_result.iterations, loop_result.input_tokens
|
||||
)),
|
||||
}).await;
|
||||
}).await.is_err() {
|
||||
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
@@ -204,12 +210,14 @@ impl Tool for TaskTool {
|
||||
|
||||
// Emit subtask_failed event
|
||||
if let Some(ref tx) = context.event_sender {
|
||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
||||
if tx.send(LoopEvent::SubtaskStatus {
|
||||
task_id: task_id.clone(),
|
||||
description: description.to_string(),
|
||||
status: "failed".to_string(),
|
||||
detail: Some(e.to_string()),
|
||||
}).await;
|
||||
}).await.is_err() {
|
||||
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// 注意: 现已迁移到 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<()> {
|
||||
let now = chrono::Utc::now();
|
||||
// 删除过期超过 30 天的已使用 token (减少 DB 膨胀)
|
||||
|
||||
@@ -587,7 +587,7 @@ pub async fn get_invoice_pdf(
|
||||
})?;
|
||||
|
||||
// 返回 PDF 响应
|
||||
Ok(axum::response::Response::builder()
|
||||
axum::response::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", "application/pdf")
|
||||
.header(
|
||||
@@ -595,5 +595,8 @@ pub async fn get_invoice_pdf(
|
||||
format!("attachment; filename=\"invoice-{}.pdf\"", invoice.id),
|
||||
)
|
||||
.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 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -804,7 +804,7 @@ async fn handle_document_upload(
|
||||
|
||||
// 创建知识条目
|
||||
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(),
|
||||
content,
|
||||
keywords: None,
|
||||
|
||||
@@ -309,10 +309,34 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
.unwrap_or(false);
|
||||
if config.server.cors_origins.is_empty() {
|
||||
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()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
.allow_origin(dev_origins)
|
||||
.allow_methods([
|
||||
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)
|
||||
} else {
|
||||
tracing::error!("生产环境必须配置 server.cors_origins,不能使用 allow_origin(Any)");
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::types::*;
|
||||
|
||||
/// 数据库行结构
|
||||
#[derive(Debug, FromRow)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: FromRow deserialization struct; fields accessed via destructuring
|
||||
struct ScheduledTaskRow {
|
||||
id: String,
|
||||
account_id: String,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
//! 清理过期 Rate Limit 条目 Worker
|
||||
//!
|
||||
//! rate_limit_events 表中的持久化条目会无限增长。
|
||||
//! 此 Worker 定期删除超过 1 小时的旧条目,防止数据库膨胀。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
@@ -21,10 +24,31 @@ impl Worker for CleanupRateLimitWorker {
|
||||
"cleanup_rate_limit"
|
||||
}
|
||||
|
||||
async fn perform(&self, _db: &PgPool, _args: Self::Args) -> SaasResult<()> {
|
||||
// Rate limit entries are in-memory (DashMap), not in DB
|
||||
// This worker is a placeholder for when rate limits are persisted
|
||||
// Currently the cleanup happens in main.rs background task
|
||||
async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> {
|
||||
let retention_secs = args.window_secs.max(3600); // 至少保留 1 小时
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "ZCLAW skill system"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
wasm = ["wasmtime", "wasmtime-wasi/p1"]
|
||||
wasm = ["wasmtime", "wasmtime-wasi/p1", "ureq", "url"]
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
@@ -27,3 +27,5 @@ shlex = { workspace = true }
|
||||
# Optional WASM runtime (enable with --features wasm)
|
||||
wasmtime = { 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)
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: post-release pipeline orchestration action driver
|
||||
async fn execute_node(
|
||||
&self,
|
||||
node: &super::SkillNode,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::io::Read as IoRead;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, warn};
|
||||
use wasmtime::*;
|
||||
@@ -23,6 +24,9 @@ use crate::{Skill, SkillContext, SkillManifest, SkillResult};
|
||||
/// Maximum WASM binary size (10 MB).
|
||||
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).
|
||||
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.
|
||||
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
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_log",
|
||||
|_caller: Caller<'_, WasiP1Ctx>, _ptr: u32, _len: u32| {
|
||||
debug!("[WasmSkill] guest called zclaw_log");
|
||||
|mut caller: Caller<'_, WasiP1Ctx>, ptr: u32, len: u32| {
|
||||
let msg = read_guest_string(&mut caller, ptr, len);
|
||||
debug!("[WasmSkill] guest log: {}", msg);
|
||||
},
|
||||
)
|
||||
.map_err(|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
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_http_fetch",
|
||||
|_caller: Caller<'_, WasiP1Ctx>,
|
||||
_url_ptr: u32,
|
||||
_url_len: u32,
|
||||
_out_ptr: u32,
|
||||
_out_cap: u32|
|
||||
-> i32 {
|
||||
warn!("[WasmSkill] guest called zclaw_http_fetch — denied");
|
||||
-1
|
||||
move |mut caller: Caller<'_, WasiP1Ctx>,
|
||||
url_ptr: u32,
|
||||
url_len: u32,
|
||||
out_ptr: u32,
|
||||
out_cap: u32|
|
||||
-> i32 {
|
||||
if !net {
|
||||
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| {
|
||||
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
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_file_read",
|
||||
|_caller: Caller<'_, WasiP1Ctx>,
|
||||
_path_ptr: u32,
|
||||
_path_len: u32,
|
||||
_out_ptr: u32,
|
||||
_out_cap: u32|
|
||||
|mut caller: Caller<'_, WasiP1Ctx>,
|
||||
path_ptr: u32,
|
||||
path_len: u32,
|
||||
out_ptr: u32,
|
||||
out_cap: u32|
|
||||
-> i32 {
|
||||
warn!("[WasmSkill] guest called zclaw_file_read — denied");
|
||||
-1
|
||||
let path = read_guest_string(&mut caller, path_ptr, path_len);
|
||||
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| {
|
||||
@@ -282,6 +415,38 @@ fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) ->
|
||||
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)]
|
||||
mod tests {
|
||||
|
||||
@@ -116,7 +116,6 @@ impl Message {
|
||||
|
||||
/// Canonical LLM message content block. Used for agent conversation messages.
|
||||
/// 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).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
|
||||
@@ -16,9 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[features]
|
||||
default = ["multi-agent"]
|
||||
# Multi-agent orchestration (A2A protocol, Director, agent delegation)
|
||||
multi-agent = ["zclaw-kernel/multi-agent"]
|
||||
default = []
|
||||
dev-server = ["dep:axum", "dep:tower-http"]
|
||||
|
||||
[dependencies]
|
||||
@@ -49,7 +47,7 @@ async-trait = { workspace = true }
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
serde_yaml = { package = "serde_yaml_bw", version = "2" }
|
||||
toml = "0.8"
|
||||
|
||||
# HTTP client
|
||||
|
||||
@@ -168,7 +168,7 @@ impl SessionManager {
|
||||
|
||||
/// Get the number of active sessions
|
||||
/// 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 {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.len()
|
||||
|
||||
@@ -207,7 +207,7 @@ pub async fn classroom_generate(
|
||||
metadata: zclaw_kernel::generation::ClassroomMetadata {
|
||||
generated_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.expect("system clock is valid")
|
||||
.as_millis() as i64,
|
||||
source_document: kernel_request.document.map(|_| "user_document".to_string()),
|
||||
model: None,
|
||||
|
||||
@@ -55,7 +55,9 @@ pub async fn init_persistence(
|
||||
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||
|
||||
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))?;
|
||||
|
||||
let persistence: ClassroomPersistence = ClassroomPersistence::open(db_path).await?;
|
||||
|
||||
@@ -101,7 +101,7 @@ impl ClassroomPersistence {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut conn = self.conn.lock().await;
|
||||
sqlx::query("DELETE FROM classrooms WHERE id = ?")
|
||||
|
||||
@@ -48,10 +48,10 @@ async fn run_server(state: DevServerState) {
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin([
|
||||
"http://localhost:1420".parse().unwrap(),
|
||||
"http://127.0.0.1:1420".parse().unwrap(),
|
||||
"http://localhost:5173".parse().unwrap(),
|
||||
"http://127.0.0.1:5173".parse().unwrap(),
|
||||
"http://localhost:1420".parse().expect("hardcoded localhost URL is valid"),
|
||||
"http://127.0.0.1:1420".parse().expect("hardcoded localhost URL is valid"),
|
||||
"http://localhost:5173".parse().expect("hardcoded localhost URL is valid"),
|
||||
"http://127.0.0.1:5173".parse().expect("hardcoded localhost URL is valid"),
|
||||
])
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]),
|
||||
|
||||
@@ -99,7 +99,7 @@ pub struct CompactionCheck {
|
||||
|
||||
/// Configuration for LLM-based summary generation
|
||||
/// 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)]
|
||||
pub struct LlmSummaryConfig {
|
||||
pub provider: String,
|
||||
|
||||
@@ -225,6 +225,69 @@ impl LlmDriverForExtraction for TauriExtractionDriver {
|
||||
|
||||
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).
|
||||
@@ -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).
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: diagnostic check for legacy OnceCell extraction driver
|
||||
pub fn is_extraction_driver_configured() -> bool {
|
||||
EXTRACTION_DRIVER.get().is_some()
|
||||
}
|
||||
@@ -258,7 +321,7 @@ pub fn is_extraction_driver_configured() -> bool {
|
||||
/// Get the global extraction driver (legacy OnceCell path).
|
||||
///
|
||||
/// 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>> {
|
||||
EXTRACTION_DRIVER.get().cloned()
|
||||
}
|
||||
@@ -272,22 +335,6 @@ mod tests {
|
||||
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]
|
||||
fn test_parse_valid_json_response() {
|
||||
let response = r#"```json
|
||||
|
||||
@@ -383,7 +383,10 @@ async fn execute_tick(
|
||||
|
||||
// Send alerts via broadcast channel (internal)
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
@@ -339,12 +339,12 @@ fn personality_store() -> &'static Mutex<std::collections::HashMap<String, Perso
|
||||
/// Load personality config for a given agent.
|
||||
/// Returns default config if none is stored.
|
||||
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()
|
||||
}
|
||||
|
||||
/// Save personality config for a given agent.
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::sync::Arc;
|
||||
use chrono::Utc;
|
||||
use tracing::{debug, warn};
|
||||
use zclaw_memory::fact::Fact;
|
||||
#[cfg(test)]
|
||||
use zclaw_memory::fact::FactCategory;
|
||||
use zclaw_memory::user_profile_store::{
|
||||
CommStyle, Level, UserProfile, UserProfileStore,
|
||||
};
|
||||
@@ -86,7 +88,7 @@ fn classify_fact_content(fact: &Fact) -> Option<ProfileFieldUpdate> {
|
||||
return Some(ProfileFieldUpdate::PreferredTool("collector".into()));
|
||||
}
|
||||
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
|
||||
|
||||
@@ -206,7 +206,7 @@ pub async fn post_conversation_hook(
|
||||
///
|
||||
/// NOTE: Memory injection is now handled by MemoryMiddleware in the Kernel
|
||||
/// 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(
|
||||
agent_id: &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 tauri::State;
|
||||
@@ -7,10 +7,9 @@ use zclaw_types::AgentId;
|
||||
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
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
@@ -44,7 +43,6 @@ pub async fn agent_a2a_send(
|
||||
}
|
||||
|
||||
/// Broadcast a message from one agent to all other agents
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn agent_a2a_broadcast(
|
||||
@@ -66,7 +64,6 @@ pub async fn agent_a2a_broadcast(
|
||||
}
|
||||
|
||||
/// Discover agents with a specific capability
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
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
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
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.
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @reserved: butler multi-agent delegation
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
|
||||
@@ -291,15 +291,19 @@ pub async fn agent_chat_stream(
|
||||
);
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
let _ = tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await;
|
||||
let _ = tx.send(zclaw_runtime::LoopEvent::Complete(
|
||||
if tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await.is_err() {
|
||||
tracing::warn!("[agent_chat_stream] Failed to send confirm msg to new channel");
|
||||
}
|
||||
if tx.send(zclaw_runtime::LoopEvent::Complete(
|
||||
zclaw_runtime::AgentLoopResult {
|
||||
response: String::new(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: 1,
|
||||
}
|
||||
)).await;
|
||||
)).await.is_err() {
|
||||
tracing::warn!("[agent_chat_stream] Failed to send complete to new channel");
|
||||
}
|
||||
drop(tx);
|
||||
(rx, None)
|
||||
} else {
|
||||
@@ -400,10 +404,12 @@ pub async fn agent_chat_stream(
|
||||
// Check cancellation flag before each recv
|
||||
if cancel_clone.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
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,
|
||||
"event": StreamChatEvent::Error { message: "已取消".to_string() }
|
||||
}));
|
||||
})) {
|
||||
tracing::debug!("[agent_chat_stream] Failed to emit cancel event: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -491,12 +497,14 @@ pub async fn agent_chat_stream(
|
||||
}
|
||||
Err(_) => {
|
||||
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,
|
||||
"event": StreamChatEvent::Error {
|
||||
message: "流式响应超时,请重试".to_string()
|
||||
}
|
||||
}));
|
||||
})) {
|
||||
tracing::debug!("[agent_chat_stream] Failed to emit timeout event: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ impl Default for McpManagerState {
|
||||
|
||||
impl McpManagerState {
|
||||
/// 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 {
|
||||
Self {
|
||||
manager: Arc::new(Mutex::new(McpServiceManager::new())),
|
||||
|
||||
@@ -19,7 +19,6 @@ pub mod skill;
|
||||
pub mod trigger;
|
||||
pub mod workspace;
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub mod a2a;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -204,6 +204,7 @@ impl From<zclaw_skills::orchestration::OrchestrationResult> for OrchestrationRes
|
||||
|
||||
/// @reserved — no frontend UI yet
|
||||
/// 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.
|
||||
/// 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_list,
|
||||
|
||||
// A2A commands gated behind multi-agent feature
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// A2A commands
|
||||
kernel_commands::a2a::agent_a2a_send,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
kernel_commands::a2a::agent_a2a_broadcast,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
kernel_commands::a2a::agent_a2a_discover,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
kernel_commands::a2a::agent_a2a_delegate_task,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
kernel_commands::a2a::butler_delegate_task,
|
||||
|
||||
// Pipeline commands (DSL-based workflows)
|
||||
|
||||
@@ -489,7 +489,7 @@ pub async fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_jso
|
||||
current = current
|
||||
.get_mut(*part)
|
||||
.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() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import './index.css';
|
||||
import { ToastProvider } from './components/ui/Toast';
|
||||
import { GlobalErrorBoundary } from './components/ui/ErrorBoundary';
|
||||
import { initWebMCPTools } from './lib/webmcp-tools';
|
||||
import { isTauriRuntime } from './lib/tauri-gateway';
|
||||
import { setupPluginListeners } from 'tauri-plugin-mcp';
|
||||
|
||||
// Global error handler for uncaught errors
|
||||
@@ -30,11 +31,10 @@ const handleGlobalReset = () => {
|
||||
// Initialize WebMCP debugging tools (dev mode only, Chrome 146+)
|
||||
initWebMCPTools();
|
||||
|
||||
// Initialize tauri-plugin-mcp event listeners (dev mode only)
|
||||
if (import.meta.env.DEV) {
|
||||
setupPluginListeners().catch((err) => {
|
||||
console.warn('[MCP] Failed to setup plugin listeners:', err);
|
||||
});
|
||||
// Initialize tauri-plugin-mcp event listeners (dev mode + Tauri runtime only)
|
||||
// Only works inside Tauri webview — gracefully skips in browser dev mode
|
||||
if (import.meta.env.DEV && isTauriRuntime()) {
|
||||
setupPluginListeners().catch(() => {});
|
||||
}
|
||||
|
||||
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: [
|
||||
{ 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: 'h3', name: 'speech', description: 'TTS', requirements_met: false },
|
||||
],
|
||||
});
|
||||
|
||||
await useHandStore.getState().loadHands();
|
||||
|
||||
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].status).toBe('idle');
|
||||
expect(state.hands[0].toolCount).toBe(5);
|
||||
|
||||
@@ -22,9 +22,5 @@
|
||||
"useUnknownInCatchVariables": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"src/components/ui/ErrorAlert.tsx",
|
||||
"src/components/ui/ErrorBoundary.tsx"
|
||||
],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ services:
|
||||
|
||||
environment:
|
||||
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}
|
||||
# 确保 UTF-8 编码 — 中文 Windows 默认 GBK 会导致中文数据损坏
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
- .env
|
||||
|
||||
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:
|
||||
postgres:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ZCLAW 系统真相文档
|
||||
|
||||
> **更新日期**: 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
|
||||
> **数据来源**: 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 代码行数 | ~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 清零) |
|
||||
| Rust 测试运行通过 | 684 workspace + 138 SaaS = 822 | Hermes 4 Chunk `cargo test --workspace` 2026-04-09 |
|
||||
| Tauri 命令 | 190 个 | `grep '#\[.*tauri::command'` (2026-04-16 验证) |
|
||||
@@ -20,24 +20,25 @@
|
||||
| **Tauri 命令已标注 @reserved** | **89 个** | Rust 源码 @reserved 标注 (2026-04-15 全量标注) |
|
||||
| **Tauri 命令孤儿 (无调用+无标注)** | **~0 个** (190 - 104 invoke - 89 @reserved ≈ -3,差异来自内部命令调用) | (2026-04-16 校准) |
|
||||
| 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 实现) |
|
||||
| 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 路由模块 | 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 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 |
|
||||
| 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) |
|
||||
| 前端 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 文件) |
|
||||
| 前端测试运行通过 | 344 passed + 1 skipped | `pnpm vitest run` (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平台/订阅与计费/语义记忆/安全状态/审计日志/定时任务/心跳配置/提交反馈/关于 |
|
||||
| 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-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-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 代码 | 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) |
|
||||
| 前端 invoke 调用 | 104 处 | `grep invoke( desktop/src/` (TRUTH.md 04-16) |
|
||||
| SaaS .route() | 137 个 | `grep .route( crates/zclaw-saas/` |
|
||||
| SaaS 模块 | 17 个目录 | `ls crates/zclaw-saas/src/*/` |
|
||||
| SKILL 目录 | 75 个 | `ls -d skills/*/` |
|
||||
| HAND 配置 | 9 个 + 1 系统内部 (_reminder) (TOML) | `ls hands/*.HAND.toml` |
|
||||
| Pipeline YAML | 17 个 | `find pipelines/ -name "*.yaml"` |
|
||||
| Zustand Store | 17 文件 + chat/4子store = 21 (含 industryStore) | `find desktop/src/store/` |
|
||||
| HAND 配置 | 6 TOML + 1 系统内部 (_reminder) = 7 注册 | `ls hands/*.HAND.toml` + kernel registry |
|
||||
| Pipeline YAML | 18 个 | `find pipelines/ -name "*.yaml"` |
|
||||
| Zustand Store | 26 个 (.ts, 含子目录) | `find desktop/src/store/` |
|
||||
| React 组件 | 105 个 (.tsx/.ts) | `find desktop/src/components/` (TRUTH.md 04-16) |
|
||||
| Admin V2 页面 | 17 个 (.tsx) | `ls admin-v2/src/pages/` |
|
||||
| 中间件 | 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 操作记录。格式: `## [日期] 类型 | 描述`
|
||||
|
||||
## 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 双路径合并
|
||||
|
||||
- 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 增强 | 始终 |
|
||||
| 2 | DataMasking | `middleware/data_masking.rs` | 手机号/身份证等敏感数据脱敏 | 始终 |
|
||||
| 3 | Compaction | `middleware/compaction.rs` | 超阈值时压缩对话历史 | `compaction_threshold > 0` |
|
||||
| 4 | Memory | `middleware/memory.rs` | 对话后自动提取记忆 | 始终 |
|
||||
| 5 | LoopGuard | `middleware/loop_guard.rs` | 防止工具调用无限循环 | 始终 |
|
||||
| 6 | TokenCalibration | `middleware/token_calibration.rs` | Token 用量校准 | 始终 |
|
||||
| 7 | SkillIndex | `middleware/skill_index.rs` | 注入技能索引到 system prompt | `!skill_index.is_empty()` |
|
||||
| 8 | Title | `middleware/title.rs` | 自动生成会话标题 | 始终 |
|
||||
| 9 | DanglingTool | `middleware/dangling_tool.rs` | 修复缺失的工具调用结果 | 始终 |
|
||||
| 10 | ToolError | `middleware/tool_error.rs` | 格式化工具错误供 LLM 恢复 | 始终 |
|
||||
| 11 | ToolOutputGuard | `middleware/tool_output_guard.rs` | 工具输出安全检查 | 始终 |
|
||||
| 12 | Guardrail | `middleware/guardrail.rs` | shell_exec/file_write/web_fetch 安全规则 | 始终 |
|
||||
| 13 | SubagentLimit | `middleware/subagent_limit.rs` | 限制并发子 agent | 始终 |
|
||||
| 14 | TrajectoryRecorder | `middleware/trajectory_recorder.rs` | 轨迹记录 + 压缩 | 始终 (V13-FIX-01 已注册) |
|
||||
| # | 中间件 | 优先级 | 文件 | 职责 | 注册条件 |
|
||||
|---|--------|--------|------|------|----------|
|
||||
| 1 | EvolutionMiddleware | 78 | `middleware/evolution.rs` | 推送进化候选项到 system prompt | 始终 |
|
||||
| 2 | ButlerRouter | 80 | `middleware/butler_router.rs` | 语义技能路由 + system prompt 增强 | 始终 |
|
||||
| 3 | DataMasking | 90 | `middleware/data_masking.rs` | 手机号/身份证等敏感数据脱敏 | 始终 |
|
||||
| 4 | Compaction | 100 | `middleware/compaction.rs` | 超阈值时压缩对话历史 | `compaction_threshold > 0` |
|
||||
| 5 | Memory | 150 | `middleware/memory.rs` | 对话后自动提取记忆 + 进化检查 | 始终 |
|
||||
| 6 | Title | 180 | `middleware/title.rs` | 自动生成会话标题 | 始终 |
|
||||
| 7 | SkillIndex | 200 | `middleware/skill_index.rs` | 注入技能索引到 system prompt | `!skill_index.is_empty()` |
|
||||
| 8 | DanglingTool | 300 | `middleware/dangling_tool.rs` | 修复缺失的工具调用结果 | 始终 |
|
||||
| 9 | ToolError | 350 | `middleware/tool_error.rs` | 格式化工具错误供 LLM 恢复 | 始终 |
|
||||
| 10 | ToolOutputGuard | 360 | `middleware/tool_output_guard.rs` | 工具输出安全检查 | 始终 |
|
||||
| 11 | Guardrail | 400 | `middleware/guardrail.rs` | shell_exec/file_write/web_fetch 安全规则 | 始终 |
|
||||
| 12 | LoopGuard | 500 | `middleware/loop_guard.rs` | 防止工具调用无限循环 | 始终 |
|
||||
| 13 | SubagentLimit | 550 | `middleware/subagent_limit.rs` | 限制并发子 agent | 始终 |
|
||||
| 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`)
|
||||
|
||||
@@ -58,10 +61,12 @@ tags: [module, middleware, runtime]
|
||||
|
||||
| 范围 | 类别 | 包含的中间件 |
|
||||
|------|------|-------------|
|
||||
| 70-79 | 进化 | EvolutionMiddleware |
|
||||
| 80-99 | 路由+安全 | ButlerRouter, DataMasking |
|
||||
| 100-199 | 上下文塑造 | Compaction, Memory |
|
||||
| 200-399 | 能力 | SkillIndex, Guardrail |
|
||||
| 400-599 | 安全 | LoopGuard, DataMasking |
|
||||
| 600-799 | 遥测 | TokenCalibration, Title, TrajectoryRecorder |
|
||||
| 200-399 | 能力 | SkillIndex, DanglingTool, ToolError, ToolOutputGuard |
|
||||
| 400-599 | 安全 | Guardrail, LoopGuard, SubagentLimit |
|
||||
| 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/` | 14 个中间件实现 (14个 .rs 文件) |
|
||||
| `crates/zclaw-runtime/src/middleware/` | 15 个中间件实现 (15个 .rs 文件) |
|
||||
| `crates/zclaw-kernel/src/kernel/mod.rs:248-361` | 注册入口 |
|
||||
| `crates/zclaw-saas/src/main.rs` | SaaS HTTP 中间件注册 (10 层) |
|
||||
|
||||
Reference in New Issue
Block a user