Compare commits

34 Commits

Author SHA1 Message Date
iven
dc7a1d5400 chore(deps): upgrade sqlx 0.7→0.8 + libsqlite3-sys 0.27→0.30
Unifies dual sqlx versions caused by pgvector 0.4 pulling sqlx 0.8.x
as indirect dependency. Zero source code changes required, 719/719
tests pass.

Batch 3 of audit plan (plans/stateless-petting-rossum.md).
2026-04-19 11:15:05 +08:00
iven
d9b0b4f4f7 fix(audit): Batch 7-9 dead_code 标注 + TODO 清理 + 文档同步
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Batch 7: dead_code 标注统一 (16 处)
- crates/ 9 处: growth, kernel, pipeline, runtime, saas, skills
- src-tauri/ 7 处: classroom, intelligence, browser, mcp
- 统一格式: #[allow(dead_code)] // @reserved: <原因>

Batch 7+: EvolutionEngine L2/L3 10 个未使用 pub 函数
- 全部标注 @reserved: EvolutionEngine L2/L3, post-release integration

Batch 9: TODO → FUTURE 标记 (4 处)
- html.rs: template-based export
- nl_schedule.rs: LLM-assisted parsing
- knowledge/handlers.rs: category_id from upload
- personality_detector.rs: VikingStorage persistence

Batch 5+: Cargo.lock 更新 (serde_yaml_bw 迁移)

全量测试通过: 719 passed, 0 failed
2026-04-19 08:54:57 +08:00
iven
edd6dd5fc8 fix(audit): Batch 4-6 中间件注释 + 依赖迁移 + 安全加固
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Batch 4:
- kernel/mod.rs: 添加中间件注册顺序≠执行顺序注释
- EvolutionMiddleware 注册处标注 priority=78

Batch 5:
- desktop/src-tauri/Cargo.toml: serde_yaml 0.9 (deprecated) → serde_yaml_bw 2.x

Batch 6:
- saas/main.rs: CORS 开发模式改为显式 localhost origins (修复 Any+credentials 违规)
- docker-compose.yml: 移除默认弱密码 your_secure_password,改为必填校验
- director.rs: 用户输入添加 <user_input>/<user_request> 边界标记防注入

全量测试通过: 719 passed, 0 failed
2026-04-19 08:46:12 +08:00
iven
4329bae1ea fix(audit): Batch 2 生产代码 unwrap 替换 (20 处)
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P0 修复:
- viking_commands.rs: URI 路径构建 unwrap → ok_or_else 错误传播
- clip.rs: 临时文件路径 unwrap → ok_or_else (防 Windows 中文路径 panic)

P1 修复:
- personality_detector.rs: Mutex lock unwrap → unwrap_or_else 防中毒传播
- pptx.rs: HashMap.get unwrap → expect (来自 keys() 迭代)

P2 修复:
- 4 处 SystemTime.unwrap → expect("system clock is valid")
- 4 处 dev_server URL.parse.unwrap → expect("hardcoded URL is valid")
- 9 处 nl_schedule Regex.unwrap → expect("static regex is valid")
- 5 处 data_masking Regex.unwrap → expect("static regex is valid")
- 2 处 pipeline/state Regex.unwrap → expect("static regex is valid")

全量测试通过: 719 passed, 0 failed
2026-04-19 08:38:09 +08:00
iven
924ad5a6ec fix(audit): Batch 0-1 文档校准 + let _ = 静默错误修复
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Batch 0:
- TRUTH.md 中间件层 14→15 (补 EvolutionMiddleware@78)
- wiki/middleware.md 同步 15 层 + 优先级分类更新
- Store 数字确认 25 个

Batch 1:
- approvals.rs: 3 处 map_err+let _ = 简化为 if let Err
- director.rs: oneshot send 失败添加 debug 日志
- task.rs: 4 处子任务状态更新添加 debug 日志
- chat.rs: 流消息发送和事件 emit 添加 warn/debug 日志
- heartbeat.rs: 告警广播添加 debug 日志 + break 优化

全量测试通过: 719 passed, 0 failed
2026-04-19 08:30:33 +08:00
iven
e94235c4f9 fix(growth): Evolution Engine 穷尽审计 3CRITICAL + 3HIGH 全部修复
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
C-01: ExperienceExtractor 接入 ExperienceStore
- GrowthIntegration.new() 创建 ExperienceExtractor 时注入 ExperienceStore
- 经验持久化路径打通:extract_combined → persist_experiences → ExperienceStore

C-02+C-03: 进化触发链路全链路接通
- create_middleware_chain() 注册 EvolutionMiddleware (priority 78)
- MemoryMiddleware 持有 Arc<EvolutionMiddleware> 共享引用
- after_completion 中调用 check_evolution() → 推送 PendingEvolution
- EvolutionMiddleware 在下次对话前注入进化建议到 system prompt

H-01: FeedbackCollector loaded 标志修复
- load() 失败时保留 loaded=false,下次 save 重试
- 日志级别 debug → warn

H-03: FeedbackCollector 内部可变性
- EvolutionEngine.feedback 改为 Arc<Mutex<FeedbackCollector>>
- submit_feedback() 从 &mut self → &self,支持中间件 &self 调用路径
- GrowthIntegration.initialize() 从 &mut self → &self

H-05: 删除空测试 test_parse_empty_response (无 assert)

H-06: infer_experiences_from_memories() fallback
- Outcome::Success → Outcome::Partial (反映推断不确定性)
2026-04-19 00:43:02 +08:00
iven
72b3206a6b fix(growth): AUD-11 反馈信任度启动集成 + AUD-12 日志格式优化
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
AUD-11: FeedbackCollector 内部 lazy-load 机制
- save() 首次调用自动从持久化存储加载信任度记录
- load() 使用 or_insert 策略,不覆盖内存中较新记录
- GrowthIntegration.initialize() 保留为可选优化入口
- 移除无法在 &self 中间件中使用的 ensure_feedback_loaded

AUD-12: 日志格式优化
- ProfileSignals 新增 signal_count() 方法
- extractor.rs 使用 signal_count() 替代 has_any_signal() as usize
2026-04-19 00:15:50 +08:00
iven
0fd78ac321 fix: 全面审计修复 — P0 功能缺陷 + P1 代码质量
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P0 功能修复:
- stats: Admin V2 仪表盘 API 路径修正 (/stats/dashboard → /admin/dashboard)
- mcp: 桌面端 MCP 插件增加 isTauriRuntime() 守卫,避免浏览器模式崩溃
- admin: 侧边栏高亮逻辑修复 (startsWith → 精确匹配+子路径)

P1 代码质量:
- 删除 workflowBuilderStore.ts 死代码 (456行,零引用)
- sqlite.rs 3 处 SQL 静默失败改为 tracing::warn! 日志
- mcp_tool_adapter 2 处 unwrap 改为安全回退
- orchestration_execute 添加 @reserved 标注
- TRUTH.md 测试数字校准 (734→803),Store 数 26→25
2026-04-18 23:57:03 +08:00
iven
ab4d06c4d6 fix(growth): 审计修复 — CRITICAL 编译错误 + LOW 静默数据丢失
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- CRITICAL: extraction_adapter.rs extract_with_prompt() 使用不存在的
  zclaw_types::Error::msg(),改为 ZclawError::InvalidInput/ZclawError::LlmError
- LOW: feedback_collector.rs save() 中 serde_json::to_string().unwrap_or_default()
  改为显式错误处理 + warn 日志 + continue,避免静默存空数据
2026-04-18 23:30:58 +08:00
iven
1595290db2 fix(growth): MEDIUM-12 ProfileUpdater 补齐 5 维度画像更新
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
根因: ProfileUpdater 只处理 industry 和 communication_style 2/5 维度,
跳过 recent_topic、pain_point、preferred_tool。

修复:
- ProfileFieldUpdate 添加 kind 字段 (SetField | AppendArray)
- collect_updates() 现在处理全部 5 个维度:
  - industry, communication_style → SetField (直接覆盖)
  - recent_topic, pain_point, preferred_tool → AppendArray (追加去重)
- growth.rs 根据 ProfileUpdateKind 分派到不同的 UserProfileStore 方法:
  - SetField → update_field()
  - AppendArray → add_recent_topic() / add_pain_point() / add_preferred_tool()
- ProfileUpdateKind re-exported from lib.rs

测试: test_collect_updates_all_five_dimensions 验证 5 个维度 + 2 种更新类型
2026-04-18 23:07:31 +08:00
iven
2c0602e0e6 fix(growth): HIGH-3 FeedbackCollector 信任度持久化
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
根因: FeedbackCollector 用纯内存 HashMap 存储信任度记录,重启后归零。

修复:
- FeedbackCollector 添加 viking: Option<Arc<VikingAdapter>> 字段
- 添加 with_viking() 构造器
- 添加 save(): 遍历 trust_records → MemoryEntry → VikingAdapter 存储
- 添加 load(): find_by_prefix 反序列化回 HashMap
- EvolutionEngine::new()/from_experience_store() 传入 VikingAdapter
- submit_feedback() 改为 async,提交后自动调用 save()
- 添加 load_feedback() 供启动时恢复

测试: save_and_load_roundtrip + load_without_viking + save_without_viking
2026-04-18 23:03:31 +08:00
iven
f358f14f12 fix(growth): 穷尽审计修复 — tracker timeline 断链 + 文档更新
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P1-1: tracker.rs record_learning 改为通过 MemoryEntry 存储
      (之前用 store_metadata 存但 get_timeline 用 find_by_prefix 读,
       两条路径不交叉,timeline 永远返回空)

P2-4: extractor.rs 移除未使用的 _llm_driver 绑定,改为 is_none() 检查

P2-5: lib.rs 模块文档更新,反映实际 17 个模块而非原始 4 个

profile_updater.rs: 添加注释说明只收集 update_field 支持的字段

测试: zclaw-growth 137 tests, zclaw-runtime 87 tests, 0 failures
2026-04-18 23:01:04 +08:00
iven
7cdcfaddb0 fix(growth): MEDIUM-10 Experience 添加 tool_used 字段
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
根因: Experience 结构体没有 tool_used 字段,PatternAggregator 从
context 字段提取工具名(语义混淆),导致工具信息不准确。

修复:
- experience_store.rs: Experience 添加 tool_used: Option<String> 字段
  (#[serde(default)] 兼容旧数据),Experience::new() 初始化为 None
- experience_extractor.rs: persist_experiences() 从 ExperienceCandidate
  的 tools_used[0] 填充 tool_used,同时填充 industry_context
- pattern_aggregator.rs: 改用 tool_used 字段提取工具名,不再误用 context
- store_experience() 将 tool_used 加入 keywords 提升搜索命中率
2026-04-18 22:58:47 +08:00
iven
3c6581f915 fix(growth): HIGH-6 修复 extract_combined 合并提取空壳
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
根因: growth.rs 构造 CombinedExtraction 时硬编码 experiences: Vec::new()
和 profile_signals: default(),导致 L1 结构化经验不被提取、L2 技能进化
没有输入数据、整个进化引擎无法端到端工作。

修复:
- extractor.rs: 添加 COMBINED_EXTRACTION_PROMPT 统一 prompt,单次 LLM 调用
  同时输出 memories + experiences + profile_signals
- extractor.rs: 添加 parse_combined_response() 解析 LLM JSON 响应
- LlmDriverForExtraction trait: 添加 extract_with_prompt() 方法(默认不支持,
  退化到现有 extract() + 启发式推断)
- MemoryExtractor: 添加 extract_combined() 方法,优先单次调用,失败则退化
- growth.rs: extract_combined() 使用新的合并提取替代硬编码空值
- TauriExtractionDriver: 实现 extract_with_prompt()
- ProfileSignals: 添加 has_any_signal() 方法
- types.rs: ProfileSignals 无 structural 变化(字段已存在)

测试: 4 个新测试(parse_combined_response_full/minimal/invalid +
extract_combined_fallback),11 个 extractor 测试全部通过
2026-04-18 22:56:42 +08:00
iven
cb727fdcc7 fix(growth): 二次审计修复 — 6项 CRITICAL/HIGH/MEDIUM 全部修复
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CRITICAL-1/2: json_utils 花括号匹配改为括号平衡算法
  - 处理字符串字面量中的花括号和转义引号
  - 新增 5 个测试(平衡匹配、字符串内花括号、转义引号、extract_string_array)

HIGH-4: EvolutionMiddleware 只取第一个事件(remove(0)),不丢弃后续
HIGH-5: EvolutionMiddleware 先 read() 判空再 write(),减少锁竞争
HIGH-7: from_experience_store 使用传入 store 的 viking 实例(不再忽略参数)
  - ExperienceStore 新增 viking() getter

MEDIUM-9: skill_generator + workflow_composer JSON 数组解析去重
  - 新增 json_utils::extract_string_array() 共享函数
MEDIUM-14: EvolutionMiddleware 注入文本去除多余缩进空格

测试: zclaw-growth 133 tests, zclaw-runtime 87 tests, workspace 0 failures
2026-04-18 22:30:10 +08:00
iven
a9ea9d8691 fix(growth): Evolution Engine 审计修复 — 7项全部完成
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
HIGH-1: 提取共享 json_utils.rs,skill_generator/workflow_composer 去重
HIGH-2: FeedbackCollector Vec→HashMap,消除 unwrap() panic 风险
HIGH-3: ProfileUpdater 改为 collect_updates() 返回字段列表,
        growth.rs 直接 async 调用 update_field(),不再用 no-op 闭包
MEDIUM-1: EvolutionMiddleware 注入后自动 drain,防止重复注入
MEDIUM-2: PatternAggregator tools 提取改为直接收集 context 值
MEDIUM-3: evolution_engine.rs 移除 4 个未使用 imports
MEDIUM-4: workflow_composer parse_response pattern 参数加下划线
MEDIUM-7: SkillCandidate 添加 version 字段(默认=1)

测试: zclaw-growth 128 tests, zclaw-runtime 86 tests, workspace 0 failures
2026-04-18 22:15:43 +08:00
iven
f97e6fdbb6 feat: Evolution Engine Phase 3-5 — WorkflowComposer+FeedbackCollector+EvolutionMiddleware+反馈闭环
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Phase 3:
- EvolutionMiddleware (priority 78): 管家对话中注入进化确认提示
- GrowthIntegration.check_evolution() API 串入

Phase 4:
- WorkflowComposer: 轨迹工具链模式聚类 + Pipeline YAML prompt 构建 + JSON 解析
- EvolutionEngine.analyze_trajectory_patterns() L3 入口

Phase 5:
- FeedbackCollector: 反馈信号收集 + 信任度管理 + 推荐(Optimize/Archive/Promote)
- EvolutionEngine 反馈闭环方法: submit_feedback/get_artifacts_needing_optimization

新增 12 个测试(111→123),全 workspace 701 测试通过。
2026-04-18 21:27:59 +08:00
iven
7d03e6a90c feat(runtime): GrowthIntegration 串入 EvolutionEngine — L2 触发检查 API 2026-04-18 21:17:48 +08:00
iven
415abf9e66 feat(growth): L2 技能进化核心 — PatternAggregator+SkillGenerator+QualityGate+EvolutionEngine
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- PatternAggregator: 经验模式聚合,找出 reuse_count>=threshold 的可固化模式
- SkillGenerator: LLM prompt 构建 + JSON 解析 + 自动提取 JSON 块
- QualityGate: 置信度/冲突/格式质量门控
- EvolutionEngine: 中枢调度器,协调 L2 触发检查+技能生成+质量验证

新增 24 个测试(87→111),全 workspace 0 error。
2026-04-18 21:09:48 +08:00
iven
8d218e9ab9 feat(runtime): GrowthIntegration 串入 ExperienceExtractor + ProfileUpdater
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-18 21:01:04 +08:00
iven
e2d44ecf52 feat(growth): ExperienceExtractor + ProfileUpdater — 结构化经验提取和画像增量更新 2026-04-18 20:51:17 +08:00
iven
8ec6ca5990 feat(growth): 扩展 LlmDriverForExtraction — 新增 extract_combined_all 默认实现 2026-04-18 20:48:09 +08:00
iven
7e8eb64c4a feat(growth): 新增 Evolution Engine 核心类型 — ExperienceCandidate/CombinedExtraction/EvolutionEvent 2026-04-18 20:47:30 +08:00
iven
e88c51fd85 docs(wiki): 发布前审计数值校准 — TRUTH/CLAUDE/wiki 三端同步
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
TRUTH.md:
- #[test] 433→425, #[tokio::test] 368→309 (2026-04-18 验证)
- Zustand Store 21→26, Admin V2 页面 15→17
- Pipeline YAML 17→18
- Hands 启用 9→7 (6 HAND.toml + _reminder),Whiteboard/Slideshow/Speech 标注开发中

CLAUDE.md §6:
- Hands 12 个能力包 (7 注册 + 3 开发中 + 2 禁用)
- §13 架构快照同步

wiki/index.md:
- 关键数字同步更新
2026-04-18 14:09:47 +08:00
iven
e10549a1b9 fix: 发布前审计 Batch 2 — Debug遮蔽 + unwrap + 静默吞错 + MCP锁 + 索引 + Config验证
安全:
- LlmConfig 自定义 Debug impl,api_key 显示为 "***REDACTED***"
- tsconfig.json 移除 ErrorBoundary.tsx 排除项(安全关键组件)
- billing/handlers.rs Response builder unwrap → map_err 错误传播
- classroom_commands/mod.rs db_path.parent().unwrap() → ok_or_else

静默吞错:
- approvals.rs 3处 warn→error(审批状态丢失是严重事件)
- events.rs publish() 添加 Event dropped debug 日志
- mcp_transport.rs eprintln→tracing::warn (僵尸进程风险)
- zclaw-growth sqlite.rs 4处迁移:区分 duplicate column name 与真实错误

MCP Transport:
- 合并 stdin+stdout 为单一 Mutex<TransportHandles>
- send_request write-then-read 原子化,防止并发响应错配

数据库:
- 新迁移 20260418000001: idx_rle_created_at + idx_billing_sub_plan + idx_ki_created_by

配置验证:
- SaaSConfig::load() 添加 jwt_expiration_hours>=1, max_connections>0, min<=max
2026-04-18 14:09:36 +08:00
iven
f3fb5340b5 fix: 发布前审计 Batch 1 — Pipeline 内存泄漏/超时 + Director 死锁 + Rate Limit Worker
Pipeline executor:
- 添加 cleanup() 方法,MAX_COMPLETED_RUNS=100 上限淘汰旧记录
- 每步执行添加 tokio::time::timeout(使用 PipelineSpec.timeout_secs,默认 300s)
- Delay ms 上限 60000,超出 warn 并截断

Director send_to_agent:
- 重构为 oneshot::channel 响应模式,避免 inbox + pending_requests 锁竞争
- 添加 ensure_inbox_reader() 独立任务分发响应到对应 oneshot sender

cleanup_rate_limit Worker:
- 实现 Worker body: DELETE FROM rate_limit_events WHERE created_at < NOW() - INTERVAL '1 hour'

651 tests passed, 0 failed
2026-04-18 14:09:16 +08:00
iven
35a11504d7 docs(wiki): 审计后续修复记录
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-18 09:24:25 +08:00
iven
450569dc88 fix: 审计后续 3 项修复 — 残留清理 + FTS5 CJK + HTTP 大小限制
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
1. Shell Hands 残留清理 (3处):
   - message.rs: 移除过时的 zclaw_hands::slideshow 注释
   - user_profiler.rs: slideshow 偏好改为 RecentTopic
   - handStore.test.ts: 移除 speech mock 数据 (3→2)

2. zclaw-growth FTS5 CJK 查询修复:
   - sanitize_fts_query CJK 路径从精确短语改为 token OR 组合
   - "Rust 编程" → "rust" OR "编程" (之前是 "rust 编程" 精确匹配)
   - 修复 test_memory_lifecycle + test_semantic_search_ranking

3. WASM HTTP 响应大小限制:
   - Content-Length 预检 + 读取后截断 (1MB 上限)
   - read_to_string 改为显式错误处理

651 测试全通过,0 失败。
2026-04-18 09:23:58 +08:00
iven
3a24455401 docs(wiki): 深度审计修复记录
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-18 09:11:37 +08:00
iven
4e4eefdde1 fix: 深度审计修复 — WASM 安全加固 + A2A 编译路径 + 测试编译
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CRITICAL:
- zclaw_file_read: 路径遍历修复 — 组件级过滤替代前缀检查
- zclaw_http_fetch: SSRF 防护 — URL scheme 白名单 + 私有IP段阻止
- Phase 4A: 移除 zclaw-protocols a2a feature gate, A2A 始终编译
- 移除 kernel/desktop multi-agent feature (不再控制任何代码)

MEDIUM:
- user_profiler: FactCategory cfg(test) 导入修复 (563 测试全通过)
2026-04-18 09:11:15 +08:00
iven
0522f2bf95 docs: CLAUDE.md 架构快照更新 — 记忆管道 E2E + 最近变更排序
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-18 08:18:39 +08:00
iven
04f70c797d docs(wiki): Phase 4A/4B 记录 — multi-agent gate 移除 + WASM host 函数
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-18 08:18:28 +08:00
iven
a685e97b17 feat(skills): WASM host 函数真实实现 — zclaw_log/http_fetch/file_read (Phase 4B)
替换 stub 为真实实现:
- zclaw_log: 读取 guest 内存并 log
- zclaw_http_fetch: ureq v3 同步 GET (10s timeout, network_allowed 守卫)
- zclaw_file_read: 沙箱 /workspace 目录读取 (路径校验防逃逸)
添加 ureq v3 workspace 依赖, 25 测试全通过。
2026-04-18 08:18:08 +08:00
iven
2037809196 refactor(kernel): 移除 multi-agent feature gate — 33处 cfg 全部删除 (Phase 4A)
8 个文件移除 #[cfg(feature = "multi-agent")],zclaw-kernel default features
新增 multi-agent。A2A 路由、agents、adapters 现在始终编译。
2026-04-18 08:17:58 +08:00
90 changed files with 3958 additions and 1093 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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),
}

View 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);
}
}

View File

@@ -0,0 +1,119 @@
//! 结构化经验提取器
//! 从对话中提取 ExperienceCandidatepain_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 个通过置信度过滤
}
}

View File

@@ -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,

View File

@@ -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());
}
}

View 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);
}
}

View 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());
}
}

View File

@@ -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)]

View 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());
}
}

View 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"]);
}
}

View 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);
}
}

View File

@@ -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,

View 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\"}");
}
}

View File

@@ -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

View File

@@ -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?;

View File

@@ -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());
}
}

View 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);
}
}

View File

@@ -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,
];

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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: 子任务描述(中文)

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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);

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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() }

View File

@@ -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);

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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)]

View File

@@ -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"),
}
}

View File

@@ -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::*;

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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>,
}

View File

@@ -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 {

View File

@@ -279,3 +279,4 @@ pub mod token_calibration;
pub mod tool_error;
pub mod tool_output_guard;
pub mod trajectory_recorder;
pub mod evolution;

View File

@@ -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")
});
// ---------------------------------------------------------------------------

View 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 仍保留
}
}

View File

@@ -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);
}
}

View File

@@ -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")
});
// ---------------------------------------------------------------------------

View File

@@ -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!({

View File

@@ -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);

View File

@@ -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 膨胀)

View File

@@ -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())
})
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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)");

View File

@@ -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,

View File

@@ -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(())
}
}

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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")]

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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?;

View File

@@ -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 = ?")

View File

@@ -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]),

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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());
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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]

View File

@@ -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;
}
}

View File

@@ -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())),

View File

@@ -19,7 +19,6 @@ pub mod skill;
pub mod trigger;
pub mod workspace;
#[cfg(feature = "multi-agent")]
pub mod a2a;
// ---------------------------------------------------------------------------

View File

@@ -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.

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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(

View File

@@ -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'),
};

View File

@@ -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);

View File

@@ -22,9 +22,5 @@
"useUnknownInCatchVariables": true
},
"include": ["src"],
"exclude": [
"src/components/ui/ErrorAlert.tsx",
"src/components/ui/ErrorBoundary.tsx"
],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -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:

View File

@@ -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.tomlReminderHand 通过 kernel 编程注册(`_` 前缀豁免 HAND.toml 扫描 |
| Hands 开发中 | 3 个 | Whiteboard/Slideshow/SpeechHAND.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/industryscheduled_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 + ReminderHandWhiteboard/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 层 + 优先级分类更新 |

View File

@@ -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` |

View File

@@ -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 = 空链)

View File

@@ -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 层) |