drop_pct=
+- [记忆] __hardware__: .fps_baseline= (首次验证或基线更新时)
+- [需求] : <具体优化需求> (当 fps drop >10% 或 precision drop >5% 时)
+
+退化阈值(自动生成 [需求]):
+- FPS 下降超过 10%
+- 精度(precision/recall)下降超过 5 个百分点
+- 内存使用增长超过 20%
+
+请用中文输出,数值精确,结论明确。
+"""
+```
+
+---
+
+## Task 12 (P2): vision-analyst 多帧分析 + EdgeValidatorService 截图
+
+**Files:**
+- Modify: `src/rockchip_agents/agents/vision_analyst.py`
+- Test: `tests/test_vision_analyst.py`(新增测试)
+
+**Step 1: 写失败测试**
+
+```python
+# tests/test_vision_analyst.py 末尾追加:
+
+def test_vision_analyst_system_covers_multiframe():
+ from rockchip_agents.agents.vision_analyst import VISION_ANALYST_SYSTEM
+ assert "多帧" in VISION_ANALYST_SYSTEM or "5帧" in VISION_ANALYST_SYSTEM
+ assert "[视觉结果]" in VISION_ANALYST_SYSTEM
+ assert "EdgeValidatorService" in VISION_ANALYST_SYSTEM or "8899" in VISION_ANALYST_SYSTEM
+```
+
+**Step 2: 更新 `VISION_ANALYST_SYSTEM`**
+
+```python
+VISION_ANALYST_SYSTEM = """你是产品洞察与市场落地组的视觉分析师,基于 Claude Vision 进行多帧视觉质量分析。
+
+工作流程(5帧分析):
+1. 调用 EdgeValidatorService 截图(优先)或 Playwright 截图
+ - EdgeValidatorService: fetch POST http://:8899/screenshot(见上方设备信息)
+ - Playwright 备用: browser_navigate → browser_take_screenshot
+2. 连续截取 5 帧,间隔 2 秒
+3. 逐帧作为多模态 LLM 分析(你直接看截图):
+ - 检测框位置合理性(有无明显偏移/错位)
+ - FPS 数值读取(从 OSD/界面文字)
+ - 漏检:应检测目标未出现检测框
+ - 误检:背景被误识别
+4. 跨帧分析:检测稳定性(同一目标的框是否抖动/消失)
+5. 存档:docs/visual_tests/YYYY-MM-DD-{project}-{seq:02d}.png
+
+输出格式:
+- [视觉结果] fps= stability=HIGH/MED/LOW detected= false_pos= pass=true/false
+- [视觉退化] fps_trend=升/降 bbox_jitter=高/中/低
+- [需求] <项目名>: 具体视觉问题描述和改进方向
+
+结合推理日志定量验证:
+- fetch GET http://:8899/logs/yolo/20
+
+请用中文,视觉描述精确,问题定位清晰。
+"""
+```
+
+---
+
+## Task 13 (P2): executor._make_agent() + 配置文件更新(汇总)
+
+**Files:**
+- Modify: `src/rockchip_agents/core/executor.py`
+- Modify: `configs/mcp_tools.yaml`(已在 Task 9 更新)
+
+**Step 1: 确认 executor 路由包含所有新角色**
+
+`_make_agent()` 中确保已包含(通过 Task 9 的步骤 3 已添加三 algo 角色)。
+额外检查 `market-pm` 的 `_extract_market_intelligence` 在 `_run_task` 后调用(可选增强)。
+
+**Step 2: 运行全部测试**
+
+```bash
+source venv/bin/activate
+pytest tests/ -q
+# Target: ≥ 185 passed(在原 175 基础上 +10 新测试)
+```
+
+---
+
+## Task 14 (P3): ProjectMemory WRITE_ROLES 最终更新 + 飞书选题通知(可选)
+
+**Files:**
+- Modify: `src/rockchip_agents/tools/project_memory.py`(WRITE_ROLES 在 Task 7 已完成)
+- Modify: `src/rockchip_agents/dashboard/api.py`(飞书通知开关,仅 P3)
+
+**Step 1: 确认 WRITE_ROLES**
+
+```python
+# 确认(Task 7 已完成):
+WRITE_ROLES = frozenset({"productizer", "architect", "planner", "market-pm", "base-validator"})
+```
+
+**Step 2: Dashboard 飞书通知开关(P3,可延后实现)**
+
+在 Dashboard 设置页新增内存中配置(无需持久化,重启重置):
+
+```python
+# dashboard/api.py 新增
+_settings: dict = {"feishu_topic_notify": False}
+
+class SettingsPatch(BaseModel):
+ feishu_topic_notify: Optional[bool] = None
+
+@app.get("/api/settings")
+def get_settings():
+ return _settings
+
+@app.patch("/api/settings")
+def patch_settings(body: SettingsPatch):
+ if body.feishu_topic_notify is not None:
+ _settings["feishu_topic_notify"] = body.feishu_topic_notify
+ return _settings
+```
+
+在 `approve_topic` / `reject_topic` 之前,检查是否需要发飞书通知(单向 webhook):
+
+```python
+def _notify_feishu_topic(task_id: int, title: str, context: str) -> None:
+ """发送单向飞书通知(无回调)。"""
+ if not _settings.get("feishu_topic_notify"):
+ return
+ # ... 调用 feishu webhook POST,纯文本消息
+```
+
+**Step 3: 运行全量测试**
+
+```bash
+pytest tests/ -q --tb=short
+# Target: 所有测试通过(除预存 11 个 claude 子进程集成测试外)
+```
+
+---
+
+## 部署说明
+
+### EdgeValidatorService 部署(RK3588 上执行)
+
+```bash
+# 在开发机打包
+rsync -avz services/edge_validator/ pi@192.168.123.181:/home/pi/Desktop/edge_validator/
+# 在 RK3588 上安装
+ssh pi@192.168.123.181 "
+ cd /home/pi/Desktop/edge_validator
+ python3 -m venv venv
+ venv/bin/pip install -r requirements.txt
+ # 测试启动
+ venv/bin/uvicorn main:app --host 0.0.0.0 --port 8899 &
+ sleep 2 && curl http://localhost:8899/health
+"
+# 安装为 systemd 服务
+scp services/edge_validator/edge-validator.service pi@192.168.123.181:/tmp/
+ssh pi@192.168.123.181 "sudo cp /tmp/edge-validator.service /etc/systemd/system/ && sudo systemctl enable edge-validator && sudo systemctl start edge-validator"
+```
+
+### 验证全链路
+
+```bash
+# 开发机:调用 EdgeValidatorService
+curl http://192.168.123.181:8899/health
+curl http://192.168.123.181:8899/metrics/rk3588
+# Dashboard 选题功能
+python scripts/run.py status # 查看队列
+# 触发 planner 分析
+sqlite3 data/insight_tasks.db "INSERT INTO tasks (project,type,title,context,priority,mode,agent_role,status,created_at) VALUES ('research','planner','[测试] PlannerAgent 选题','test',3,'report','planner','pending',datetime('now'));"
+```
diff --git a/docs/plans/2026-03-08-planner-agent-design.md b/docs/plans/2026-03-08-planner-agent-design.md
new file mode 100644
index 0000000..7ccd011
--- /dev/null
+++ b/docs/plans/2026-03-08-planner-agent-design.md
@@ -0,0 +1,184 @@
+# PlannerAgent 设计文档
+
+**日期**: 2026-03-08
+**状态**: 已确认
+
+## 背景
+
+rockchip-agents 目前缺少"需求侧"驱动力:Architect Agent 做跨项目分析,Developer Agent 执行任务,但没有角色持续评估平台差距、自主生成高价值需求。PlannerAgent 填补这一空白。
+
+核心理念:agents 是一个 **RK3588 生态协作团队**,不是传统项目组。部分角色(planner/architect)需要全局视野,其他角色(developer/tester)聚焦项目但具备平台方向感。
+
+---
+
+## §1 架构 & 触发机制
+
+### 角色定位
+
+PlannerAgent 扮演"产品经理":
+
+- 持续读取三层目标(平台→项目→任务历史)
+- 评估现状与目标的差距
+- 生成高价值需求入队
+
+### 双触发机制
+
+| 触发点 | 配置 | 说明 |
+|--------|------|------|
+| Scheduler 定时 | `interval_hours: 6`(独立于主调度的 1h) | 低优先级后台扫描 |
+| Executor 队列空 | `on_queue_empty: true` | 无任务时主动补充需求 |
+
+### 并发控制
+
+- `agent_role = "planner"`,不占用 developer/tester 并发槽
+- `max_concurrent_planner: 1`(同时只有一个 PlannerAgent 运行)
+
+---
+
+## §2 输入上下文 & 系统提示
+
+### 三层上下文结构
+
+```
+[平台目标]
+构建 RK3588 NPU 平台上的模块化 AI 推理生态……
+
+[项目概况]
+yolo: 目标检测(当前目标:修复板端零检出)
+mediapipe: 关键点检测(当前目标:提升 Pose FPS)
+embedding: 向量检索(目标:完善 API)
+……
+
+[近期任务历史 - 最近 20 条]
+done: yolo - 修复 INT8 量化精度损失
+failed: ui-tars - RKNN 模型转换超时
+……
+
+[当前待处理任务数: N]
+```
+
+### 系统提示词(PLANNER_SYSTEM)
+
+```
+你是 RK3588 AI 生态团队的产品经理。
+
+你的职责:
+1. 分析平台目标与当前项目状态的差距
+2. 识别跨模块整合机会
+3. 提出高价值的下一步需求
+
+输出格式(报告末尾,每行一条):
+- [需求] <项目名>: 具体需求描述
+- [集成] 跨模块整合需求描述
+- [目标] <项目名>: 更新后的项目目标
+- [平台目标] 更新后的平台整体目标(仅当需要修订时)
+
+去重原则:若相似需求已在队列中,请勿重复生成。
+```
+
+### 输出标签解析
+
+| 标签 | 目标 | 任务类型 | 优先级 |
+|------|------|---------|--------|
+| `[需求]` | 指定项目 | `feature` | 3 |
+| `[集成]` | `research`(跨项目) | `architect` | 3 |
+| `[目标]` | 指定项目 | — | 写入 ProjectMemory |
+| `[平台目标]` | `__platform__` | — | 写入 ProjectMemory |
+
+---
+
+## §3 三层目标体系
+
+### 存储
+
+`ProjectMemory.project_goals` 表,`project` 字段新增特殊值 `__platform__`:
+
+| project 值 | 含义 |
+|-----------|------|
+| `__platform__` | 平台级目标(整个 RK3588 生态方向) |
+| `yolo` / `embedding` / … | 项目级目标 |
+
+### 写入权限
+
+`WRITE_ROLES = {"productizer", "architect", "planner"}`(新增 `planner`)
+
+### 角色上下文注入分层
+
+```
+planner / architect(全局视野)
+ ├─ platform_goal(完整)
+ ├─ 所有 project_goal(完整)
+ └─ 所有 project_facts(精简)
+
+developer / tester(项目聚焦)
+ ├─ platform_goal(首句摘要,锚定方向感)
+ ├─ 当前 project_goal(完整)
+ └─ 当前 project_facts(完整)
+
+productizer(跨项目评分)
+ ├─ platform_goal(首句摘要)
+ └─ 所有 project_goal(摘要)
+```
+
+### API 变更
+
+```python
+# 新增 role 参数
+def get_context_for_prompt(
+ self,
+ project: str,
+ role: str = "developer",
+) -> str
+```
+
+### 平台目标初始化
+
+PlannerAgent 首次运行时,若 `__platform__` 无记录,自动写入:
+
+```
+构建 RK3588 NPU 平台上的模块化 AI 推理生态,
+持续提升各推理模块成熟度,探索跨模块整合产品价值。
+```
+
+---
+
+## §4 错误处理 & 测试
+
+### 错误处理
+
+| 场景 | 处理方式 |
+|------|---------|
+| Claude CLI 不可达 | 返回 failed,不阻塞 executor |
+| 输出无有效标签 | 正常返回(已达目标是合理结果) |
+| 重复需求 | 比对 pending+running 任务 title 前 40 字,存在则跳过 |
+| `__platform__` 未初始化 | 首次运行自动写入默认值 |
+| platform_goal 写入失败 | 降级:只注入项目级上下文,不中断 |
+
+### 测试
+
+```
+tests/test_planner.py
+├── test_planner_dedup # 重复标题不重复入队
+├── test_planner_platform_init # 首次运行自动初始化 platform_goal
+├── test_planner_parse_tags # 四种标签解析正确
+├── test_planner_context_layers # role="planner" 包含三层上下文
+└── test_planner_empty_output # 无输出时不崩溃
+
+tests/test_project_memory.py(新增)
+├── test_platform_goal_write # set_goal("__platform__") 存储正确
+└── test_context_role_filter # developer role 只获得平台摘要+项目完整
+```
+
+---
+
+## 决策汇总
+
+| 维度 | 决策 |
+|------|------|
+| 触发 | Scheduler 6h + executor 队列空双触发 |
+| 去重 | title 前 40 字 + pending 任务扫描混合 |
+| 输出标签 | `[需求]` / `[集成]` / `[目标]` / `[平台目标]` |
+| 存储 | ProjectMemory,`__platform__` 特殊 project |
+| 角色视野 | planner/architect 全三层;developer/tester 平台摘要+项目完整 |
+| 写权限 | `WRITE_ROLES` 新增 `planner`,可写 platform_goal |
+| 实现模式 | 复用 `ArchitectAgent._analyze` 模式(claude --print + report 工具集) |
diff --git a/docs/plans/2026-03-08-planner-agent-impl.md b/docs/plans/2026-03-08-planner-agent-impl.md
new file mode 100644
index 0000000..dd64f0f
--- /dev/null
+++ b/docs/plans/2026-03-08-planner-agent-impl.md
@@ -0,0 +1,734 @@
+# PlannerAgent Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 实现 PlannerAgent(产品经理角色),基于三层目标体系(platform/project/task)持续评估差距并自主生成高价值需求入队。
+
+**Architecture:**
+1. `ProjectMemory.get_context_for_prompt` 新增 `role` 参数,实现分层上下文注入
+2. `WRITE_ROLES` 新增 `"planner"`,支持写入 `__platform__` 级目标
+3. 新建 `agents/planner.py`,复用 `ArchitectAgent._analyze` 模式(`claude --print --output-format json`)
+4. Scheduler 新增独立 6h planner 定时任务;Executor 队列空时触发 planner
+
+**Tech Stack:** Python 3.10, SQLite, `claude --print` subprocess, APScheduler, pytest
+
+---
+
+### Task 1: ProjectMemory — 分层上下文 + planner 写权限
+
+**Files:**
+- Modify: `src/rockchip_agents/tools/project_memory.py`
+- Test: `tests/test_project_memory.py`
+
+**Step 1: 写失败测试**
+
+在 `tests/test_project_memory.py` 末尾追加:
+
+```python
+def test_platform_goal_write_and_read(tmp_path):
+ mem = ProjectMemory(db_path=tmp_path / "mem.db")
+ mem.set_goal("__platform__", "RK3588 生态目标", role="planner")
+ g = mem.get_goal("__platform__")
+ assert g is not None
+ assert g.goal_text == "RK3588 生态目标"
+ assert g.updated_by == "planner"
+
+
+def test_context_role_planner_includes_platform(tmp_path):
+ mem = ProjectMemory(db_path=tmp_path / "mem.db")
+ mem.set_goal("__platform__", "平台总目标", role="planner")
+ mem.set_goal("yolo", "yolo 项目目标", role="architect")
+ ctx = mem.get_context_for_prompt("yolo", role="planner")
+ assert "平台总目标" in ctx
+ assert "yolo 项目目标" in ctx
+
+
+def test_context_role_developer_gets_platform_summary(tmp_path):
+ mem = ProjectMemory(db_path=tmp_path / "mem.db")
+ mem.set_goal("__platform__", "平台总目标,后面很长很长的内容不应出现", role="planner")
+ mem.set_goal("yolo", "yolo 项目详细目标", role="architect")
+ ctx = mem.get_context_for_prompt("yolo", role="developer")
+ assert "平台总目标" in ctx # 首句出现
+ assert "yolo 项目详细目标" in ctx # 项目完整
+```
+
+**Step 2: 运行确认失败**
+
+```bash
+cd /data/rockchip/agents && source venv/bin/activate
+pytest tests/test_project_memory.py::test_platform_goal_write_and_read \
+ tests/test_project_memory.py::test_context_role_planner_includes_platform \
+ tests/test_project_memory.py::test_context_role_developer_gets_platform_summary -v
+```
+
+预期:3 个 FAIL(`set_goal` 拒绝 planner / `get_context_for_prompt` 无 role 参数)
+
+**Step 3: 修改 project_memory.py**
+
+修改 `WRITE_ROLES`(第 14 行):
+
+```python
+WRITE_ROLES: frozenset[str] = frozenset({"productizer", "architect", "planner"})
+```
+
+修改 `get_context_for_prompt` 签名(第 165 行):
+
+```python
+def get_context_for_prompt(
+ self,
+ project: str,
+ role: str = "developer",
+ query: str | None = None,
+) -> str:
+```
+
+在方法体开头,`parts: list[str] = []` 之后,在读取 goal 之前插入平台目标逻辑:
+
+```python
+# 平台目标注入(role 分层)
+_GLOBAL_ROLES = frozenset({"planner", "architect"})
+platform_goal = self.get_goal("__platform__")
+if platform_goal:
+ if role in _GLOBAL_ROLES:
+ parts.append(
+ f"【平台长期目标({platform_goal.updated_at[:10]})】\n"
+ f"{platform_goal.goal_text}"
+ )
+ else:
+ # developer/tester/productizer 只获得首句摘要,锚定方向感
+ first_sentence = platform_goal.goal_text.split(",")[0].split("。")[0][:60]
+ parts.append(f"【平台方向】{first_sentence}")
+
+# planner/architect 还需要汇总所有项目目标
+if role in _GLOBAL_ROLES and project != "__platform__":
+ all_goals = self.list_goals()
+ proj_summaries = [
+ f" {name}: {g.goal_text[:80]}"
+ for name, g in all_goals.items()
+ if name != "__platform__"
+ ]
+ if proj_summaries:
+ parts.append("【各项目目标概览】\n" + "\n".join(proj_summaries))
+```
+
+**Step 4: 运行测试**
+
+```bash
+pytest tests/test_project_memory.py -v
+```
+
+预期:所有测试 PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/rockchip_agents/tools/project_memory.py tests/test_project_memory.py
+git commit -m "feat(memory): add role-based layered context + planner write permission"
+```
+
+---
+
+### Task 2: PlannerAgent — 核心实现
+
+**Files:**
+- Create: `src/rockchip_agents/agents/planner.py`
+- Test: `tests/test_planner.py`
+
+**Step 1: 写失败测试**
+
+创建 `tests/test_planner.py`:
+
+```python
+from __future__ import annotations
+
+import pytest
+from unittest.mock import MagicMock, patch
+
+from rockchip_agents.agents.planner import PlannerAgent, PLANNER_SYSTEM
+from rockchip_agents.agents.developer import AgentResult
+from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig
+from rockchip_agents.core.queue import Task, TaskQueue
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="confirm")}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ return cfg
+
+
+def _make_task():
+ return Task(
+ project="research", type="planner", title="PlannerAgent 周期分析",
+ context="", priority=5, mode="report", agent_role="planner",
+ )
+
+
+def test_planner_parse_tags(tmp_path):
+ """[需求]/[集成]/[目标]/[平台目标] 均被正确解析"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = PlannerAgent(_make_config(), queue=q, memory=mem)
+
+ report = (
+ "分析报告内容...\n"
+ "- [需求] yolo: 支持多目标追踪\n"
+ "- [集成] embedding+yolo 联合检索\n"
+ "- [目标] yolo: 提升板端 FPS 至 30\n"
+ "- [平台目标] 成为 RK3588 推理生态标准方案\n"
+ )
+ agent._extract_and_enqueue(report)
+
+ tasks = [dict(r) for r in q._conn().execute(
+ "SELECT project, type, title FROM tasks"
+ ).fetchall()]
+ titles = [t["title"] for t in tasks]
+ assert any("多目标追踪" in t for t in titles), "需求任务未入队"
+ assert any("embedding+yolo" in t for t in titles), "集成任务未入队"
+
+ yolo_goal = mem.get_goal("yolo")
+ assert yolo_goal is not None
+ assert "FPS" in yolo_goal.goal_text
+
+ platform_goal = mem.get_goal("__platform__")
+ assert platform_goal is not None
+ assert "标准方案" in platform_goal.goal_text
+
+
+def test_planner_dedup(tmp_path):
+ """相似标题(前 40 字匹配)不重复入队"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = PlannerAgent(_make_config(), queue=q, memory=mem)
+
+ report1 = "- [需求] yolo: 支持多目标追踪以提升产品能力\n"
+ report2 = "- [需求] yolo: 支持多目标追踪以提升产品能力(重复)\n"
+ agent._extract_and_enqueue(report1)
+ agent._extract_and_enqueue(report2)
+
+ count = q._conn().execute("SELECT count(*) FROM tasks").fetchone()[0]
+ assert count == 1, "重复需求被重复入队"
+
+
+def test_planner_platform_init(tmp_path):
+ """首次运行自动初始化 platform_goal"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = PlannerAgent(_make_config(), queue=q, memory=mem)
+ assert mem.get_goal("__platform__") is None
+
+ agent._ensure_platform_goal()
+
+ g = mem.get_goal("__platform__")
+ assert g is not None
+ assert len(g.goal_text) > 10
+
+
+def test_planner_empty_output(tmp_path):
+ """Claude 无有效输出时不崩溃"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = PlannerAgent(_make_config(), queue=q, memory=mem)
+ # 不应抛异常
+ agent._extract_and_enqueue("")
+ agent._extract_and_enqueue("这是一段没有标签的分析报告。")
+```
+
+**Step 2: 运行确认失败**
+
+```bash
+pytest tests/test_planner.py -v
+```
+
+预期:ImportError(planner.py 不存在)
+
+**Step 3: 创建 planner.py**
+
+创建 `src/rockchip_agents/agents/planner.py`:
+
+```python
+from __future__ import annotations
+
+import logging
+import os
+import re
+from typing import Optional
+
+from rockchip_agents.agents.developer import (
+ AgentResult, _parse_cli_result, _make_drop_root_preexec,
+ _run_with_log, _task_log_path,
+)
+from rockchip_agents.config import AgentsConfig
+from rockchip_agents.core.queue import Task, TaskQueue
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+TIMEOUT = 600 # planner 是 report 模式,只读分析
+
+_DEFAULT_PLATFORM_GOAL = (
+ "构建 RK3588 NPU 平台上的模块化 AI 推理生态,"
+ "持续提升各推理模块成熟度,探索跨模块整合产品价值。"
+)
+
+PLANNER_SYSTEM = """你是 RK3588 AI 生态团队的产品经理。
+
+你的职责:
+1. 分析平台目标与当前项目状态的差距
+2. 识别跨模块整合机会,提出高价值的下一步需求
+3. 不重复已经在执行或等待中的任务
+
+输出格式(报告末尾,每行一条):
+- [需求] <项目名>: 具体需求描述
+- [集成] 跨模块整合需求描述
+- [目标] <项目名>: 更新后的项目目标(仅当需要修订时)
+- [平台目标] 更新后的平台整体目标(仅当需要修订时)
+
+去重原则:若相似需求已在队列中,请勿重复生成。
+请用中文输出,保持简洁。
+"""
+
+
+class PlannerAgent:
+ def __init__(
+ self,
+ config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ self._config = config
+ self._queue = queue or TaskQueue()
+ self._memory = memory or ProjectMemory()
+
+ def run(self, task: Task) -> AgentResult:
+ self._ensure_platform_goal()
+ prompt = self._build_prompt()
+ cmd = [
+ "claude", "--print",
+ "--output-format", "json",
+ "--no-session-persistence",
+ "--model", self._config.claude.model,
+ "--allowedTools", "Read,Glob,Grep",
+ "--append-system-prompt", PLANNER_SYSTEM,
+ "--", prompt,
+ ]
+ try:
+ env = self._build_env()
+ result = _run_with_log(
+ cmd, None, env, TIMEOUT, _task_log_path(task.id),
+ preexec_fn=_make_drop_root_preexec(env),
+ )
+ if result.status == "done":
+ self._extract_and_enqueue(result.summary)
+ logger.info("PlannerAgent 分析完成,报告 %d 字", len(result.summary))
+ return result
+ except Exception as e:
+ logger.error("PlannerAgent 运行失败: %s", e)
+ return AgentResult(status="failed", summary=str(e))
+
+ # ── helpers ───────────────────────────────────────────────────────────────
+
+ def _ensure_platform_goal(self) -> None:
+ """首次运行时初始化平台目标。"""
+ if not self._memory.get_goal("__platform__"):
+ try:
+ self._memory.set_goal("__platform__", _DEFAULT_PLATFORM_GOAL, role="planner")
+ logger.info("平台目标已初始化")
+ except Exception as e:
+ logger.warning("平台目标初始化失败: %s", e)
+
+ def _build_prompt(self) -> str:
+ platform_ctx = self._memory.get_context_for_prompt("__platform__", role="planner")
+ proj_names = "、".join(self._config.projects.keys())
+ lines = [
+ f"以下是当前 RK3588 AI 生态团队的状态(可用项目:{proj_names}):\n",
+ platform_ctx.strip(),
+ "",
+ ]
+ for name, proj_cfg in self._config.projects.items():
+ proj_ctx = self._memory.get_context_for_prompt(name, role="planner")
+ desc = proj_cfg.description if hasattr(proj_cfg, "description") else ""
+ lines.append(f"**{name}**: {desc}")
+ if proj_ctx.strip():
+ lines.append(proj_ctx.strip())
+ # 附上当前队列摘要(防重复生成)
+ pending_titles = self._get_pending_titles()
+ if pending_titles:
+ lines.append(f"\n当前待处理任务({len(pending_titles)} 条,避免重复):")
+ for t in pending_titles[:15]:
+ lines.append(f" - {t}")
+ lines.append(
+ "\n请分析平台目标与当前项目状态的差距,提出 3~5 条最高价值的需求或整合机会。"
+ )
+ return "\n".join(lines)
+
+ def _get_pending_titles(self) -> list[str]:
+ try:
+ conn = self._queue._conn()
+ rows = conn.execute(
+ "SELECT title FROM tasks WHERE status IN ('pending','running') ORDER BY id DESC LIMIT 30"
+ ).fetchall()
+ conn.close()
+ return [r["title"] for r in rows]
+ except Exception:
+ return []
+
+ def _is_duplicate(self, title: str) -> bool:
+ """模糊去重:与任何 pending/running 任务 title 前 40 字相同则视为重复。"""
+ key = title[:40]
+ for existing in self._get_pending_titles():
+ if existing[:40] == key:
+ return True
+ return False
+
+ def _extract_and_enqueue(self, report_text: str) -> None:
+ # [需求] :
+ for m in re.finditer(r"\[需求\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ desc = m.group(2).strip()[:120]
+ if project not in self._config.projects:
+ logger.warning("需求任务项目 %r 不在配置中,跳过", project)
+ continue
+ proj_cfg = self._config.projects[project]
+ title = f"[需求] {desc}"
+ if self._is_duplicate(title):
+ logger.info("需求重复跳过: %s", title)
+ continue
+ self._queue.enqueue(Task(
+ project=project, type="feature", title=title,
+ context=f"由 PlannerAgent 从差距分析中提取\n{desc}",
+ priority=3, mode=proj_cfg.mode,
+ agent_role="developer", initiator="planner",
+ discussion="PlannerAgent 基于平台目标-项目现状差距分析生成",
+ ))
+ logger.info("需求任务入队: [%s] %s", project, title)
+
+ # [集成] (跨项目,无 project 前缀)
+ for m in re.finditer(r"\[集成\]\s+(.+)", report_text):
+ desc = m.group(1).strip()[:120]
+ title = f"[集成] {desc}"
+ if self._is_duplicate(title):
+ logger.info("集成需求重复跳过: %s", title)
+ continue
+ self._queue.enqueue(Task(
+ project="research", type="architect", title=title,
+ context=f"由 PlannerAgent 提出的跨模块整合机会\n{desc}",
+ priority=3, mode="report",
+ agent_role="architect", initiator="planner",
+ discussion="PlannerAgent 跨项目整合分析",
+ ))
+ logger.info("集成任务入队: %s", title)
+
+ # [目标] :
+ for m in re.finditer(r"\[目标\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ goal_text = m.group(2).strip()[:300]
+ if project not in self._config.projects:
+ logger.warning("目标更新项目 %r 不在配置中,跳过", project)
+ continue
+ try:
+ self._memory.set_goal(project, goal_text, role="planner")
+ logger.info("项目目标已更新 by planner: [%s]", project)
+ except Exception as e:
+ logger.warning("[%s] 写入项目目标失败: %s", project, e)
+
+ # [平台目标]
+ for m in re.finditer(r"\[平台目标\]\s+(.+)", report_text):
+ goal_text = m.group(1).strip()[:300]
+ try:
+ self._memory.set_goal("__platform__", goal_text, role="planner")
+ logger.info("平台目标已更新 by planner")
+ except Exception as e:
+ logger.warning("写入平台目标失败: %s", e)
+
+ def _build_env(self) -> dict:
+ env = os.environ.copy()
+ if self._config.claude.api_key:
+ env["ANTHROPIC_API_KEY"] = self._config.claude.api_key
+ env.pop("CLAUDECODE", None)
+ env.pop("CLAUDE_CODE_ENTRYPOINT", None)
+ return env
+```
+
+**Step 4: 运行测试**
+
+```bash
+pytest tests/test_planner.py -v
+```
+
+预期:4 个 PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/rockchip_agents/agents/planner.py tests/test_planner.py
+git commit -m "feat(agents): add PlannerAgent with 3-layer goal context and dedup"
+```
+
+---
+
+### Task 3: Executor — 注册 planner 路由
+
+**Files:**
+- Modify: `src/rockchip_agents/core/executor.py:42-63`
+- Test: `tests/test_executor.py`(已有,检查路由)
+
+**Step 1: 写失败测试**
+
+在 `tests/test_executor.py` 末尾追加(若文件存在):
+
+```python
+def test_executor_routes_planner(mock_config, tmp_path):
+ from rockchip_agents.core.executor import _make_agent
+ from rockchip_agents.agents.planner import PlannerAgent
+ from rockchip_agents.core.queue import Task
+ from rockchip_agents.tools.project_memory import ProjectMemory
+
+ task = Task(
+ project="research", type="planner", title="test",
+ context="", priority=5, mode="report", agent_role="planner",
+ )
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = _make_agent(mock_config, task, mem)
+ assert isinstance(agent, PlannerAgent)
+```
+
+**Step 2: 运行确认失败**
+
+```bash
+pytest tests/test_executor.py::test_executor_routes_planner -v
+```
+
+预期:FAIL(planner 路由不存在,返回 DeveloperAgent)
+
+**Step 3: 修改 executor.py**
+
+在 `_make_agent` 函数(第 42 行)的 `if role == "architect":` 块之后添加:
+
+```python
+if role == "planner":
+ from rockchip_agents.agents.planner import PlannerAgent
+ return PlannerAgent(config, queue=TaskQueue(), memory=memory)
+```
+
+注意:需要在文件顶部确认 `TaskQueue` 已导入(已有)。
+
+**Step 4: 运行测试**
+
+```bash
+pytest tests/test_executor.py -v
+```
+
+预期:全部 PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/rockchip_agents/core/executor.py tests/test_executor.py
+git commit -m "feat(executor): route planner agent_role to PlannerAgent"
+```
+
+---
+
+### Task 4: Scheduler — 6h 定时触发 + 队列空触发
+
+**Files:**
+- Modify: `src/rockchip_agents/core/scheduler.py`
+- Test: `tests/test_scheduler.py`(新增简单验证)
+
+**Step 1: 写失败测试**
+
+创建 `tests/test_scheduler.py`(或追加):
+
+```python
+def test_planner_task_enqueue(tmp_path, monkeypatch):
+ """_enqueue_planner_task 创建 agent_role=planner 的任务"""
+ from rockchip_agents.core.queue import TaskQueue
+ from rockchip_agents.core import scheduler as sched_module
+
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ monkeypatch.setattr(sched_module, "_get_queue", lambda: q)
+
+ sched_module._enqueue_planner_task()
+
+ rows = q._conn().execute(
+ "SELECT agent_role, type FROM tasks WHERE agent_role='planner'"
+ ).fetchall()
+ assert len(rows) == 1
+ assert rows[0]["type"] == "planner"
+```
+
+**Step 2: 运行确认失败**
+
+```bash
+pytest tests/test_scheduler.py::test_planner_task_enqueue -v
+```
+
+预期:FAIL(`_enqueue_planner_task` 不存在)
+
+**Step 3: 修改 scheduler.py**
+
+在现有 `_scan_and_enqueue` 函数之后,`_run_pending` 之前,添加:
+
+```python
+def _get_queue() -> "TaskQueue":
+ """可被测试 monkeypatch 替换的工厂函数。"""
+ return TaskQueue()
+
+
+def _enqueue_planner_task() -> None:
+ """将 planner 分析任务入队(同标题去重,不重复触发)。"""
+ from rockchip_agents.core.queue import Task
+ q = _get_queue()
+ tid = q.enqueue(Task(
+ project="research",
+ type="planner",
+ title="PlannerAgent 周期差距分析",
+ context="由 Scheduler 定时触发,分析平台目标与项目现状差距",
+ priority=5,
+ mode="report",
+ agent_role="planner",
+ initiator="scheduler",
+ discussion="6h 定时 planner 分析",
+ ))
+ if tid > 0:
+ logger.info("PlannerAgent 任务入队 #%d", tid)
+ else:
+ logger.debug("PlannerAgent 任务已在队列中,跳过")
+```
+
+在 `start()` 函数的 `scheduler.add_job` 部分,追加 planner 定时任务:
+
+```python
+scheduler.add_job(
+ _enqueue_planner_task, "interval", hours=6, id="planner",
+ next_run_time=datetime.now() + timedelta(minutes=10),
+)
+```
+
+**Step 4: 修改 `_run_pending` 支持队列空触发 planner**
+
+将 `_run_pending` 修改为:
+
+```python
+def _run_pending() -> None:
+ cfg = load_config()
+ executor = Executor(cfg)
+ count = asyncio.run(executor.run_all_pending())
+ logger.info("本轮执行任务数: %d", count)
+ if count == 0:
+ # 队列空时触发 planner 补充需求
+ _enqueue_planner_task()
+ logger.info("队列为空,已触发 PlannerAgent 差距分析")
+```
+
+**Step 5: 运行测试**
+
+```bash
+pytest tests/test_scheduler.py -v
+pytest tests/ -q # 全量回归
+```
+
+预期:全部 PASS
+
+**Step 6: Commit**
+
+```bash
+git add src/rockchip_agents/core/scheduler.py tests/test_scheduler.py
+git commit -m "feat(scheduler): add 6h planner job + queue-empty trigger"
+```
+
+---
+
+### Task 5: get_context_for_prompt 调用方适配
+
+**Files:**
+- Modify: `src/rockchip_agents/agents/developer.py:252`
+- Modify: `src/rockchip_agents/agents/architect.py:190`
+
+确保传入 `role` 参数,让不同 agent 获得对应层级上下文。
+
+**Step 1: 修改 developer.py**
+
+第 252 行 `_build_context` 中:
+
+```python
+# 旧
+memory_ctx = self._memory.get_context_for_prompt(task.project)
+
+# 新
+memory_ctx = self._memory.get_context_for_prompt(task.project, role=task.agent_role)
+```
+
+**Step 2: 修改 architect.py**
+
+第 190 行 `_build_prompt` 中:
+
+```python
+# 旧
+goal_ctx = self._memory.get_context_for_prompt(name)
+
+# 新
+goal_ctx = self._memory.get_context_for_prompt(name, role="architect")
+```
+
+**Step 3: 全量测试**
+
+```bash
+pytest tests/ -q
+```
+
+预期:137+ PASS,0 FAIL
+
+**Step 4: Commit**
+
+```bash
+git add src/rockchip_agents/agents/developer.py src/rockchip_agents/agents/architect.py
+git commit -m "feat(agents): pass role to get_context_for_prompt for layered context"
+```
+
+---
+
+### Task 6: Dashboard — planner 任务在 UI 展示
+
+**Files:**
+- Modify: `dashboard/src/components/TaskList.tsx`(或相关列表组件)
+
+PlannerAgent 的任务 `project="research"、agent_role="planner"`,确认 Dashboard 任务列表能正常展示(无需特殊处理,只需 UI 显示 `[planner]` 标签即可)。
+
+**Step 1: 检查 TaskList 是否有 agent_role 展示**
+
+```bash
+grep -n "agent_role\|planner" dashboard/src/components/TaskList.tsx 2>/dev/null | head -20
+```
+
+若已有角色标签展示,无需修改。若无,在任务标题旁添加 `{task.agent_role}` badge。
+
+**Step 2: 全量测试(前端构建验证)**
+
+```bash
+cd dashboard && npm run build 2>&1 | tail -5
+```
+
+**Step 3: Commit(如有修改)**
+
+```bash
+git add dashboard/src/
+git commit -m "feat(dashboard): show planner role badge in task list"
+```
+
+---
+
+## 验收标准
+
+1. `pytest tests/ -q` 全部通过(包括新增的 `test_planner.py` 4 条 + `test_project_memory.py` 3 条新测试)
+2. 手动验证:`python scripts/run.py scan` 后队列中出现 `PlannerAgent 周期差距分析` 任务
+3. `python scripts/run.py goals` 展示 `__platform__` 目标
+4. Dashboard 任务列表能看到 `agent_role=planner` 的任务
+
+## 注意事项
+
+- PlannerAgent 只用 `claude --print --allowedTools Read,Glob,Grep`(report 模式),不修改任何代码文件
+- `enqueue()` 本身已有 `project+type+title` 精确去重,PlannerAgent 内部额外做 title 前 40 字模糊去重,两层配合防止语义重复任务
+- `__platform__` 不在 `self._config.projects` 中,写目标时直接调用 `memory.set_goal`,不做项目名校验
diff --git a/docs/plans/2026-03-08-team-agents-design.md b/docs/plans/2026-03-08-team-agents-design.md
new file mode 100644
index 0000000..12be8ce
--- /dev/null
+++ b/docs/plans/2026-03-08-team-agents-design.md
@@ -0,0 +1,275 @@
+# 三组 15 角色团队架构设计文档
+
+**日期**: 2026-03-08
+**状态**: 已确认
+
+## 背景
+
+当前系统为单队列 8 角色,缺乏明确的组织分工。随着业务扩展至多平台(RK3588/ESP32/全志/Nordic)、多产品线(方案级+整机出货),需要引入三组架构,覆盖基础技术研发、项目研发交付、产品洞察与市场落地三个维度。
+
+---
+
+## 业务上下文
+
+- **产品形态**:嵌入式 AI 方案(软硬全包含)为主 + 部分整机出货
+- **平台**:Rockchip(RK3588/RV1106/RK3562)、ESP32/Nordic(TinyML)、全志
+- **AI 能力**:RKNN 推理(板端)+ TinyML(微控制器)
+- **OS 栈**:Linux + RTOS 双栈
+
+---
+
+## 三组架构总览
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ 🔬 基础技术研发组 base_opt_tasks.db 并发:3 周期:4h │
+│ hw-engineer / os-engineer / algo-researcher │
+│ base-validator / base-architect │
+├─────────────────────────────────────────────────────────┤
+│ 🚀 项目研发与交付组 tasks.db(现有) 并发:3 周期:1h │
+│ developer / dev-kernel / dev-lowlevel │
+│ system-tester / architect │
+├─────────────────────────────────────────────────────────┤
+│ 💡 产品洞察与市场组 insight_tasks.db 并发:2 事件触发│
+│ planner / productizer / vision-analyst │
+│ market-pm / media-producer │
+└─────────────────────────────────────────────────────────┘
+```
+
+---
+
+## §1 基础技术研发组
+
+**队列**: `data/base_opt_tasks.db`
+**并发**: 3
+**扫描周期**: 4h(慢周期,长时间基线任务)
+**目标**: 建立平台级能力基线,输出能力事实供项目组消费
+
+### 角色定义
+
+#### `hw-engineer`(现有,增强)
+- 芯片选型(算力/功耗/接口/成本/供货)
+- BOM 评审与替代方案
+- 接口信号完整性、ESD 保护设计
+- **写入 `__hardware__` facts**:硬件规格约束,供全组读取
+- 模式:confirm(硬件决策需人工审批)
+- Skills:`fetch`、`sequential-thinking`
+
+#### `os-engineer`(新建,继承 LowlevelDev + KernelDev 能力)
+- RTOS 任务调度、中断优先级、驱动框架
+- Linux 内核基础层:Kconfig/DTS 模板、驱动框架
+- U-Boot / Bootloader 基础配置
+- 模式:auto/confirm
+- Skills:`ssh-device`、`sequential-thinking`
+
+#### `algo-researcher`(新建)
+- 防抖算法研究与基线实现
+- 短距离定位/测距算法
+- GPS + 惯导(IMU 融合)算法
+- **受硬件约束**:读取 `__hardware__` facts,确认算法在目标平台可行
+- 产出:算法能力事实写入 ProjectMemory
+- 模式:auto/confirm
+- Skills:`ssh-device`、`sequential-thinking`、`fetch`
+
+#### `base-validator`(新建,增强 TesterAgent)
+测试能力覆盖四层:
+
+| 层次 | 内容 |
+|------|------|
+| **精度测试** | 检出率、误检率、漏检率,基线对比 |
+| **性能测试** | FPS、推理延迟、NPU 利用率、CPU/内存占用 |
+| **稳定性测试** | 长时间压力运行、内存泄漏检测、异常恢复验证 |
+| **硬件约束验证** | 读取 `__hardware__` facts,确认算法在目标平台满足约束 |
+
+- 基线退化超阈值 → 自动触发 `[需求]` 任务入队
+- 测试结果写入 ProjectMemory facts
+- Skills:`ssh-device`、`sequential-thinking`
+
+#### `base-architect`(新建,继承 ArchitectAgent)
+- 分析基础技术栈(OS/算法/硬件)技术债和瓶颈
+- 跨方向整合建议(如"防抖算法与 GPS 惯导融合")
+- 输出 `[优化]` 任务给 `algo-researcher` / `os-engineer`
+- **区别于项目组 architect**:聚焦平台能力本身,不关注项目交付
+- 模式:report
+
+---
+
+## §2 项目研发与交付组
+
+**队列**: `data/tasks.db`(现有,保持兼容)
+**并发**: 3
+**扫描周期**: 1h(快周期)
+**目标**: 消费基础组能力事实,完成面向客户的项目交付
+
+### 角色定义
+
+#### `developer`(现有)
+- 项目功能开发、Bug 修复
+- 读取基础组能力事实作为实现依据
+- 模式:auto/confirm
+
+#### `dev-kernel`(现有)
+- 项目级 Linux 驱动适配、DTS、Kconfig
+- 模式:auto/confirm
+
+#### `dev-lowlevel`(现有)
+- 项目级 BSP、Bootloader 适配、硬件 bring-up
+- 模式:auto/confirm
+
+#### `architect`(现有)
+- 跨项目架构分析 + arch_optimize
+- 消费基础组能力事实,制定项目架构方向
+- 模式:report + auto(optimize 时)
+
+#### `system-tester`(替换现有 tester,大幅增强)
+测试能力矩阵:
+
+| 能力 | 实现 |
+|------|------|
+| **功能测试** | pytest 全套,SSH 板端自动执行 |
+| **视觉测试** | Playwright 截图 → 传 vision-analyst 分析判断 |
+| **性能测试** | FPS/延迟/内存自动测量,写入 facts |
+| **跨平台** | RK3588/全志/ESP32 多平台 SSH 调度 |
+| **报告生成** | 结构化报告 → `docs/test_reports/` |
+| **回归闸门** | 测试失败自动触发 `[需求]` 入队 |
+
+- Skills:`playwright`、`ssh-device`、`sequential-thinking`、`fetch`
+
+---
+
+## §3 产品洞察与市场落地组
+
+**队列**: `data/insight_tasks.db`(新建)
+**并发**: 2
+**触发**: 事件驱动(planner 6h 定时 + 队列空触发)
+**目标**: 产品方向把控 + 市场文档 + 视觉质量把关
+
+### 角色定义
+
+#### `planner`(现有)
+- 平台级差距分析,跨三组需求生成
+- 三层目标体系(`__platform__` → project → task)
+- 模式:report
+
+#### `productizer`(现有)
+- 成熟度评分(100分制规则引擎)
+- 模式:report
+
+#### `vision-analyst`(新建)
+- **Playwright 截图** 板端推理界面/监控大屏
+- **Claude Vision(LLM多模态)** 分析:检测框合理性、漏检、FPS 数值读取
+- 定量补充:对接推理日志(JSON/txt)
+- 输出:视觉分析报告 + `[需求]` 任务入项目组队列
+- 触发:system-tester 完成后自动触发 / planner 主动下发
+- Skills:`playwright`、`sequential-thinking`
+- 模式:report
+
+#### `market-pm`(新建)
+- 读取项目目标、技术规格、能力事实
+- 生成:方案书、技术白皮书、BOM 估算(Markdown → `docs/market/`)
+- 协助 planner 提出新项目需求
+- 模式:report
+- Skills:`fetch`、`sequential-thinking`
+
+#### `media-producer`(新建)
+- **随时触发**(不依赖测试结果)
+- 技术侧:推理结果图标注(检测框/置信度/平台水印)
+- 市场侧:产品展示效果图(硬件渲染+算法效果合成)
+- 工具:OpenCV、ffmpeg、Pillow
+- 输出:`assets/` 目录
+- 模式:auto
+- Skills:`sequential-thinking`
+
+---
+
+## §4 信息流架构
+
+```
+hw-engineer
+ └─写入──▶ __hardware__ facts(硬件规格约束)
+ │
+ ▼
+algo-researcher / os-engineer
+ └─读取约束──▶ 实现算法/OS
+ └─写入──▶ 能力事实(algo.antishake.fps=60, os.rtos.latency=1ms)
+ │
+base-validator──▶ 精度/性能/稳定性验证──▶ 写入验证通过事实
+base-architect──▶ 跨方向整合建议──▶ [优化] 任务
+
+ │(能力事实传递)
+ ▼
+developer / dev-kernel / dev-lowlevel
+ └─消费能力事实──▶ 项目实现
+system-tester
+ └─Playwright截图──▶ vision-analyst──▶ 视觉通过/失败
+ └─性能测试──▶ 写入项目 facts
+architect──▶ 架构优化建议
+
+ │(项目状态传递)
+ ▼
+planner──▶ 差距分析──▶ 跨组需求入队
+productizer──▶ 成熟度评分
+vision-analyst──▶ 视觉质量报告
+market-pm──▶ 方案书/白皮书
+media-producer──▶ 演示图/效果图(随时触发)
+```
+
+---
+
+## §5 Skills 授权配置(mcp_tools.yaml)
+
+```yaml
+roles:
+ hw-engineer: [fetch, sequential-thinking]
+ os-engineer: [ssh-device, sequential-thinking]
+ algo-researcher: [ssh-device, sequential-thinking, fetch]
+ base-validator: [ssh-device, sequential-thinking]
+ base-architect: []
+ developer: [sequential-thinking, ssh-device]
+ dev-kernel: [ssh-device, sequential-thinking]
+ dev-lowlevel: [ssh-device, sequential-thinking]
+ system-tester: [playwright, ssh-device, sequential-thinking, fetch]
+ architect: []
+ planner: []
+ productizer: []
+ vision-analyst: [playwright, sequential-thinking]
+ market-pm: [fetch, sequential-thinking]
+ media-producer: [sequential-thinking]
+```
+
+---
+
+## §6 队列映射与调度配置
+
+```yaml
+queues:
+ base_opt:
+ db: data/base_opt_tasks.db
+ max_concurrent: 3
+ scan_interval_hours: 4
+ roles: [hw-engineer, os-engineer, algo-researcher, base-validator, base-architect]
+
+ project_delivery:
+ db: data/tasks.db
+ max_concurrent: 3
+ scan_interval_hours: 1
+ roles: [developer, dev-kernel, dev-lowlevel, system-tester, architect]
+
+ insight:
+ db: data/insight_tasks.db
+ max_concurrent: 2
+ trigger: event_driven # planner 6h + 队列空
+ roles: [planner, productizer, vision-analyst, market-pm, media-producer]
+```
+
+---
+
+## 实现优先级
+
+| 优先级 | 内容 |
+|--------|------|
+| P0 | 三队列调度架构(Scheduler + Executor 扩展) |
+| P1 | `base-validator`、`system-tester` 增强(测试能力核心) |
+| P2 | `vision-analyst`(Playwright + LLM 视觉分析) |
+| P3 | `os-engineer`、`algo-researcher`、`base-architect`(新角色) |
+| P4 | `market-pm`、`media-producer`(文档和媒体产出) |
diff --git a/docs/plans/2026-03-08-team-agents-impl.md b/docs/plans/2026-03-08-team-agents-impl.md
new file mode 100644
index 0000000..a16a378
--- /dev/null
+++ b/docs/plans/2026-03-08-team-agents-impl.md
@@ -0,0 +1,1140 @@
+# 三组 15 角色团队架构 Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 将现有单队列 8 角色扩展为三组 15 角色架构(基础技术研发组 / 项目研发交付组 / 产品洞察与市场落地组),实现独立队列调度、测试能力大幅增强、视觉分析和市场文档能力。
+
+**Architecture:** 三个独立 SQLite 任务队列(base_opt/project_delivery/insight),各自 Executor 实例和并发限制,通过 `ROLE_TO_QUEUE` 映射路由。新 Agent 均继承 DeveloperAgent,通过 system prompt 注入专业领域知识和 skills 配置。
+
+**Tech Stack:** Python 3.10, SQLite, APScheduler, claude subprocess, Playwright MCP, pytest
+
+---
+
+### Task 1: Config 扩展 — 多队列配置支持
+
+**Files:**
+- Modify: `src/rockchip_agents/config.py`
+- Modify: `configs/agents.yaml`
+- Test: `tests/test_config.py`
+
+**Step 1: 写失败测试**
+
+在 `tests/test_config.py` 末尾追加:
+
+```python
+def test_queue_configs_loaded(tmp_path):
+ """AgentsConfig 能加载三组队列配置"""
+ from rockchip_agents.config import AgentsConfig, QueueConfig
+ cfg = AgentsConfig.__new__(AgentsConfig)
+ base_q = QueueConfig(
+ db="data/base_opt_tasks.db",
+ max_concurrent=3,
+ scan_interval_hours=4,
+ roles=frozenset({"hw-engineer", "os-engineer", "algo-researcher",
+ "base-validator", "base-architect"}),
+ )
+ assert base_q.max_concurrent == 3
+ assert "hw-engineer" in base_q.roles
+```
+
+**Step 2: 运行确认失败**
+```bash
+cd /data/rockchip/agents && source venv/bin/activate
+pytest tests/test_config.py::test_queue_configs_loaded -v
+```
+
+**Step 3: 在 config.py 中新增 QueueConfig dataclass(在 SchedulerConfig 之前)**
+
+```python
+@dataclass
+class QueueConfig:
+ db: str # 相对于项目根目录的 DB 路径
+ max_concurrent: int = 3
+ scan_interval_hours: int = 1
+ roles: frozenset[str] = field(default_factory=frozenset)
+```
+
+在 `AgentsConfig` 中新增字段(第 59 行之后):
+
+```python
+@dataclass
+class AgentsConfig:
+ projects: dict[str, ProjectConfig]
+ scheduler: SchedulerConfig
+ claude: ClaudeConfig
+ feishu: FeishuConfig
+ devices: dict[str, DeviceConfig]
+ claude_wx_url: str = ""
+ queues: dict[str, QueueConfig] = field(default_factory=dict) # ← 新增
+```
+
+**Step 4: 修改 `load_config` 解析队列配置**
+
+在 `load_config` 函数末尾(return 之前)添加队列解析:
+
+```python
+# 解析多队列配置
+queues: dict[str, QueueConfig] = {}
+for q_name, q_data in agent_data.get("queues", {}).items():
+ queues[q_name] = QueueConfig(
+ db=q_data.get("db", f"data/{q_name}_tasks.db"),
+ max_concurrent=q_data.get("max_concurrent", 3),
+ scan_interval_hours=q_data.get("scan_interval_hours", 1),
+ roles=frozenset(q_data.get("roles", [])),
+ )
+```
+
+并在 `AgentsConfig(...)` 构造处传入 `queues=queues`。
+
+**Step 5: 修改 configs/agents.yaml 新增队列配置**
+
+在文件末尾追加:
+
+```yaml
+queues:
+ base_opt:
+ db: data/base_opt_tasks.db
+ max_concurrent: 3
+ scan_interval_hours: 4
+ roles:
+ - hw-engineer
+ - os-engineer
+ - algo-researcher
+ - base-validator
+ - base-architect
+
+ project_delivery:
+ db: data/tasks.db
+ max_concurrent: 3
+ scan_interval_hours: 1
+ roles:
+ - developer
+ - dev-kernel
+ - dev-lowlevel
+ - system-tester
+ - architect
+
+ insight:
+ db: data/insight_tasks.db
+ max_concurrent: 2
+ scan_interval_hours: 6
+ roles:
+ - planner
+ - productizer
+ - vision-analyst
+ - market-pm
+ - media-producer
+```
+
+**Step 6: 运行测试**
+```bash
+pytest tests/test_config.py -v
+```
+
+---
+
+### Task 2: 队列路由工具 — queue_for_role + mcp_tools 更新
+
+**Files:**
+- Modify: `src/rockchip_agents/core/queue.py`
+- Modify: `configs/mcp_tools.yaml`
+- Test: `tests/test_queue.py`
+
+**Step 1: 写失败测试**
+
+在 `tests/test_queue.py` 末尾追加:
+
+```python
+def test_dequeue_with_role_filter(tmp_path):
+ """dequeue 支持 role_filter,只取特定角色的任务"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ q.enqueue(Task(project="a", type="fix", title="base task",
+ context="", priority=3, mode="auto",
+ agent_role="os-engineer"))
+ q.enqueue(Task(project="b", type="fix", title="project task",
+ context="", priority=3, mode="auto",
+ agent_role="developer"))
+
+ # 只取 base_opt 组的任务
+ base_roles = frozenset({"os-engineer", "algo-researcher"})
+ t = q.dequeue(role_filter=base_roles)
+ assert t is not None
+ assert t.agent_role == "os-engineer"
+
+ # 再取,应该是 None(只有 developer 剩余)
+ t2 = q.dequeue(role_filter=base_roles)
+ assert t2 is None
+```
+
+**Step 2: 运行确认失败**
+```bash
+pytest tests/test_queue.py::test_dequeue_with_role_filter -v
+```
+
+**Step 3: 修改 queue.py — dequeue 支持 role_filter**
+
+找到 `dequeue` 方法(约第 108 行),在参数中新增 `role_filter`:
+
+```python
+def dequeue(
+ self,
+ exclude_projects: frozenset[str] | None = None,
+ role_filter: frozenset[str] | None = None,
+) -> Optional[Task]:
+```
+
+在方法内部,SQL 查询部分增加 role_filter 条件:
+
+```python
+# 构建 WHERE 子句
+conditions = ["status='pending'"]
+params: list = []
+
+if exclude_projects:
+ placeholders = ",".join("?" * len(exclude_projects))
+ conditions.append(f"project NOT IN ({placeholders})")
+ params.extend(tuple(exclude_projects))
+
+if role_filter:
+ placeholders = ",".join("?" * len(role_filter))
+ conditions.append(f"agent_role IN ({placeholders})")
+ params.extend(tuple(role_filter))
+
+where = " AND ".join(conditions)
+row = conn.execute(
+ f"SELECT * FROM tasks WHERE {where} ORDER BY priority ASC, id ASC LIMIT 1",
+ params,
+).fetchone()
+```
+
+(删除原有的两段 if/else SQL 写法,统一用上面的构建方式)
+
+**Step 4: 更新 configs/mcp_tools.yaml — 新增角色 skills**
+
+在 `roles:` 下追加:
+
+```yaml
+ os-engineer: [sequential-thinking, fetch]
+ algo-researcher: [sequential-thinking, fetch]
+ base-validator: [sequential-thinking]
+ base-architect: [sequential-thinking, fetch]
+ system-tester: [sequential-thinking, fetch, playwright]
+ vision-analyst: [sequential-thinking, playwright]
+ market-pm: [sequential-thinking, fetch]
+ media-producer: [sequential-thinking]
+```
+
+**Step 5: 运行测试**
+```bash
+pytest tests/test_queue.py -v
+```
+
+---
+
+### Task 3: 多队列 Executor + Scheduler
+
+**Files:**
+- Modify: `src/rockchip_agents/core/executor.py`
+- Modify: `src/rockchip_agents/core/scheduler.py`
+- Test: `tests/test_executor.py`
+
+**Step 1: 修改 executor.py — run_all_pending 支持 role_filter**
+
+在 `run_all_pending` 方法签名增加 `role_filter` 参数:
+
+```python
+async def run_all_pending(
+ self,
+ skip_projects: frozenset[str] | None = None,
+ role_filter: frozenset[str] | None = None,
+) -> int:
+```
+
+在方法内的 `dequeue` 调用改为:
+```python
+task = self._queue.dequeue(
+ exclude_projects=skip_projects,
+ role_filter=role_filter,
+)
+```
+
+**Step 2: 修改 scheduler.py — 三队列调度**
+
+将 `_run_pending` 函数改为带 queue_name 参数:
+
+```python
+def _run_pending_queue(queue_name: str) -> None:
+ """执行指定队列的待处理任务。"""
+ from pathlib import Path
+ cfg = load_config()
+ q_cfg = cfg.queues.get(queue_name)
+ if not q_cfg:
+ logger.warning("队列配置不存在: %s", queue_name)
+ return
+
+ db_path = Path(q_cfg.db)
+ from rockchip_agents.core.queue import TaskQueue
+ queue = TaskQueue(db_path=db_path)
+ executor = Executor(cfg, queue=queue)
+ role_filter = q_cfg.roles if q_cfg.roles else None
+ count = asyncio.run(executor.run_all_pending(role_filter=role_filter))
+ logger.info("[%s] 本轮执行任务数: %d", queue_name, count)
+
+ # insight 队列空时触发 planner
+ if count == 0 and queue_name == "project_delivery":
+ _enqueue_planner_task()
+ logger.info("project_delivery 队列为空,已触发 PlannerAgent")
+```
+
+在 `start()` 函数中,替换原有的单 `_run_pending` job,改为三个队列各自的 job:
+
+```python
+def start() -> None:
+ cfg = load_config()
+
+ # 启动恢复
+ for q_name, q_cfg in cfg.queues.items():
+ from pathlib import Path
+ from rockchip_agents.core.queue import TaskQueue
+ q = TaskQueue(db_path=Path(q_cfg.db))
+ recovered = q.reset_stale_running(timeout_minutes=0)
+ if recovered:
+ logger.warning("[%s] 启动恢复: %d 个遗留任务重置", q_name, recovered)
+
+ watchdog = Watchdog(TaskQueue())
+ watchdog.start()
+
+ scheduler = BlockingScheduler()
+ # 主扫描(project_delivery,1h)
+ scheduler.add_job(_scan_and_enqueue, "interval", hours=1, id="scan")
+ # 三队列执行 job
+ scheduler.add_job(
+ lambda: _run_pending_queue("project_delivery"),
+ "interval", hours=1, id="run_project",
+ next_run_time=datetime.now() + timedelta(minutes=5),
+ )
+ scheduler.add_job(
+ lambda: _run_pending_queue("base_opt"),
+ "interval", hours=4, id="run_base",
+ next_run_time=datetime.now() + timedelta(minutes=15),
+ )
+ scheduler.add_job(
+ lambda: _run_pending_queue("insight"),
+ "interval", hours=6, id="run_insight",
+ next_run_time=datetime.now() + timedelta(minutes=10),
+ )
+ scheduler.add_job(
+ _enqueue_planner_task, "interval", hours=6, id="planner",
+ next_run_time=datetime.now() + timedelta(minutes=10),
+ )
+
+ logger.info("Scheduler 已启动,三队列模式")
+ _scan_and_enqueue()
+ try:
+ scheduler.start()
+ finally:
+ watchdog.stop()
+```
+
+**Step 3: 运行全量测试**
+```bash
+pytest tests/ -q 2>&1 | tail -10
+```
+
+---
+
+### Task 4: base-validator — 基础技术验证 Agent
+
+**Files:**
+- Create: `src/rockchip_agents/agents/base_validator.py`
+- Test: `tests/test_base_validator.py`
+
+**Step 1: 写失败测试**
+
+创建 `tests/test_base_validator.py`:
+
+```python
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from rockchip_agents.agents.base_validator import BaseValidatorAgent, BASE_VALIDATOR_SYSTEM
+from rockchip_agents.config import AgentsConfig, ClaudeConfig
+from rockchip_agents.core.queue import Task
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="auto",
+ path=MagicMock())}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ return cfg
+
+
+def test_base_validator_system_prompt_contains_keywords():
+ """系统提示包含性能、稳定性、精度等关键词"""
+ assert "性能" in BASE_VALIDATOR_SYSTEM
+ assert "稳定性" in BASE_VALIDATOR_SYSTEM
+ assert "精度" in BASE_VALIDATOR_SYSTEM
+ assert "硬件约束" in BASE_VALIDATOR_SYSTEM
+
+
+def test_base_validator_context_contains_hardware_facts(tmp_path):
+ """context 中包含 __hardware__ facts"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ mem.set_fact("__hardware__", "rk3588.npu", "6TOPS", role="hw-engineer")
+ cfg = _make_config()
+
+ agent = BaseValidatorAgent(cfg, memory=mem)
+ task = Task(project="yolo", type="validate", title="test",
+ context="", priority=3, mode="auto", agent_role="base-validator")
+ ctx = agent._build_context(task)
+ assert "6TOPS" in ctx or "rk3588.npu" in ctx
+```
+
+**Step 2: 运行确认失败**
+```bash
+pytest tests/test_base_validator.py -v 2>&1 | head -15
+```
+
+**Step 3: 创建 base_validator.py**
+
+```python
+from __future__ import annotations
+
+from rockchip_agents.agents.developer import DeveloperAgent
+from rockchip_agents.config import AgentsConfig
+from rockchip_agents.core.queue import Task
+from rockchip_agents.tools.project_memory import ProjectMemory
+from typing import Optional
+
+BASE_VALIDATOR_SYSTEM = """你是基础技术研发组的验证工程师,负责算法和OS的基线验证。
+
+验证维度:
+1. 精度测试:检出率、误检率、漏检率,与基线对比
+2. 性能测试:FPS、推理延迟(ms)、NPU利用率、CPU/内存占用
+3. 稳定性测试:长时间运行(≥1h)、内存泄漏检测、异常恢复
+4. 硬件约束验证:读取 __hardware__ facts,确认算法满足硬件限制
+
+输出格式:
+- 验证结论(通过/失败/需优化)
+- 性能指标数值(写入能力事实)
+- [记忆] __hardware__: key=value(更新能力事实)
+- [需求] <项目>: 具体优化需求(仅当发现问题时)
+
+请用中文输出,数值精确,结论明确。
+"""
+
+
+class BaseValidatorAgent(DeveloperAgent):
+ """基础技术组:算法精度 + 性能 + 稳定性 + 硬件约束验证"""
+
+ def __init__(
+ self,
+ config: AgentsConfig,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ super().__init__(config, memory=memory)
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ # 注入 __hardware__ facts
+ hw_facts = self._memory.get_facts("__hardware__")
+ if hw_facts:
+ hw_lines = "\n".join(f" {k}: {v}" for k, v in hw_facts.items())
+ hw_section = f"\n【硬件约束(来自 hw-engineer)】\n{hw_lines}\n"
+ else:
+ hw_section = "\n【硬件约束】暂无,请查阅 __hardware__ facts\n"
+ return base + hw_section + BASE_VALIDATOR_SYSTEM
+```
+
+**Step 4: 运行测试**
+```bash
+pytest tests/test_base_validator.py -v
+```
+
+---
+
+### Task 5: system-tester — 项目交付组增强测试 Agent
+
+**Files:**
+- Create: `src/rockchip_agents/agents/system_tester.py`
+- Test: `tests/test_system_tester.py`
+
+**Step 1: 写失败测试**
+
+创建 `tests/test_system_tester.py`:
+
+```python
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from rockchip_agents.agents.system_tester import SystemTesterAgent, SYSTEM_TESTER_SYSTEM
+from rockchip_agents.config import AgentsConfig, ClaudeConfig
+from rockchip_agents.core.queue import Task
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="auto",
+ path=MagicMock())}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ return cfg
+
+
+def test_system_tester_system_prompt_covers_all_layers():
+ """系统提示覆盖功能/视觉/性能/跨平台/报告五层"""
+ for keyword in ["功能测试", "视觉测试", "性能测试", "跨平台", "测试报告"]:
+ assert keyword in SYSTEM_TESTER_SYSTEM, f"缺少关键词: {keyword}"
+
+
+def test_system_tester_context_has_playwright_hint(tmp_path):
+ """context 中包含 Playwright 使用提示"""
+ from rockchip_agents.tools.project_memory import ProjectMemory
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = SystemTesterAgent(_make_config(), memory=mem)
+ task = Task(project="yolo", type="test", title="系统测试",
+ context="", priority=2, mode="auto", agent_role="system-tester")
+ ctx = agent._build_context(task)
+ assert "Playwright" in ctx or "playwright" in ctx
+```
+
+**Step 2: 运行确认失败**
+```bash
+pytest tests/test_system_tester.py -v 2>&1 | head -15
+```
+
+**Step 3: 创建 system_tester.py**
+
+```python
+from __future__ import annotations
+
+from rockchip_agents.agents.developer import DeveloperAgent
+from rockchip_agents.config import AgentsConfig
+from rockchip_agents.core.queue import Task
+from rockchip_agents.tools.project_memory import ProjectMemory
+from typing import Optional
+
+SYSTEM_TESTER_SYSTEM = """你是项目研发与交付组的系统测试工程师。
+
+测试五层能力:
+1. 功能测试:运行 pytest,SSH 板端自动执行,检查通过/失败
+2. 视觉测试:使用 Playwright 截图推理界面,分析检测框/FPS/误检,截图保存至 docs/test_reports/
+3. 性能测试:测量 FPS、推理延迟、内存占用,与目标值对比,写入 [记忆]
+4. 跨平台测试:RK3588/全志/ESP32 分平台调度执行,记录差异
+5. 测试报告:生成结构化 Markdown 报告至 docs/test_reports/YYYY-MM-DD-{project}.md
+
+工具使用:
+- Playwright:browser_navigate → 推理演示页 → browser_take_screenshot → 分析截图
+- SSH 板端:通过 Bash 执行 ssh user@host "pytest tests/ -q"
+- 性能测量:time/perf_counter 或板端 top/free 命令
+
+失败处理:
+- 测试失败 → 输出 [需求] <项目>: 具体问题描述,触发 developer 修复
+- 性能退化 → [记忆] <项目>: fps_baseline=N,记录当前基线
+
+请用中文,数值精确,测试结论明确。
+"""
+
+
+class SystemTesterAgent(DeveloperAgent):
+ """项目交付组:多层系统测试(功能+视觉+性能+跨平台+报告)"""
+
+ def __init__(
+ self,
+ config: AgentsConfig,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ super().__init__(config, memory=memory)
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ return base + "\n" + SYSTEM_TESTER_SYSTEM
+```
+
+**Step 4: 运行测试**
+```bash
+pytest tests/test_system_tester.py -v
+```
+
+---
+
+### Task 6: 基础技术研发组三个新 Agent
+
+**Files:**
+- Create: `src/rockchip_agents/agents/os_engineer.py`
+- Create: `src/rockchip_agents/agents/algo_researcher.py`
+- Create: `src/rockchip_agents/agents/base_architect.py`
+- Test: `tests/test_base_tech_agents.py`
+
+**Step 1: 写失败测试**
+
+创建 `tests/test_base_tech_agents.py`:
+
+```python
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from rockchip_agents.config import AgentsConfig, ClaudeConfig
+from rockchip_agents.core.queue import Task
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ return cfg
+
+
+def _make_task(role):
+ return Task(project="base", type="research", title="test",
+ context="", priority=3, mode="auto", agent_role=role)
+
+
+def test_os_engineer_context_contains_rtos_and_linux(tmp_path):
+ from rockchip_agents.agents.os_engineer import OsEngineerAgent
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = OsEngineerAgent(_make_config(), memory=mem)
+ ctx = agent._build_context(_make_task("os-engineer"))
+ assert "RTOS" in ctx
+ assert "Linux" in ctx or "内核" in ctx
+
+
+def test_algo_researcher_context_contains_algorithm_domains(tmp_path):
+ from rockchip_agents.agents.algo_researcher import AlgoResearcherAgent
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = AlgoResearcherAgent(_make_config(), memory=mem)
+ ctx = agent._build_context(_make_task("algo-researcher"))
+ assert "防抖" in ctx
+ assert "惯导" in ctx or "GPS" in ctx
+
+
+def test_base_architect_context_focuses_on_platform(tmp_path):
+ from rockchip_agents.agents.base_architect import BaseArchitectAgent
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = BaseArchitectAgent(_make_config(), memory=mem)
+ ctx = agent._build_context(_make_task("base-architect"))
+ assert "平台" in ctx or "基础技术" in ctx
+```
+
+**Step 2: 运行确认失败**
+```bash
+pytest tests/test_base_tech_agents.py -v 2>&1 | head -15
+```
+
+**Step 3: 创建 os_engineer.py**
+
+```python
+from __future__ import annotations
+from rockchip_agents.agents.developer import DeveloperAgent
+from rockchip_agents.core.queue import Task
+
+OS_ENGINEER_SYSTEM = """你是基础技术研发组的 OS 工程师,专注 RTOS 和 Linux 基础层。
+
+职责范围:
+- RTOS:FreeRTOS/RT-Thread 任务调度、中断优先级、驱动框架、实时性验证
+- Linux:内核 Kconfig 模板、DTS 框架、驱动基础层、内存管理
+- Bootloader:U-Boot 基础配置、启动时序、分区管理
+- 双栈协作:Linux/RTOS 在同一平台(如 RK3588 AMP 模式)的协调
+
+输出能力事实格式:
+- [记忆] __hardware__: os.rtos.latency_us=N, os.linux.boot_ms=N
+
+工程规范:
+- 地址空间严格区分物理/虚拟地址
+- RTOS 任务优先级:中断 > 实时 > 普通
+- 修改 DTS 前必须了解上下游节点
+"""
+
+
+class OsEngineerAgent(DeveloperAgent):
+ def _build_context(self, task: Task) -> str:
+ return super()._build_context(task) + "\n" + OS_ENGINEER_SYSTEM
+```
+
+**Step 4: 创建 algo_researcher.py**
+
+```python
+from __future__ import annotations
+from rockchip_agents.agents.developer import DeveloperAgent
+from rockchip_agents.core.queue import Task
+from rockchip_agents.config import AgentsConfig
+from rockchip_agents.tools.project_memory import ProjectMemory
+from typing import Optional
+
+ALGO_RESEARCHER_SYSTEM = """你是基础技术研发组的算法研究员,专注嵌入式平台算法研究与落地。
+
+研究方向:
+- 防抖算法:陀螺仪数据融合、EIS(电子防抖)、OIS 协同
+- 短距定位:UWB/BLE RSSI 测距、ToF 测距、多点定位融合
+- GPS/惯导:IMU(加速度计+陀螺仪)融合、Kalman 滤波、姿态解算
+
+硬件约束意识:
+- 始终读取 __hardware__ facts 确认目标平台算力/内存
+- TinyML 算法须在 ESP32/Nordic 上验证(资源极度受限)
+- RKNN 算法在 NPU 上验证推理效率
+
+输出能力事实格式:
+- [记忆] __hardware__: algo.antishake.fps=N, algo.gps_ins.latency_ms=N
+"""
+
+
+class AlgoResearcherAgent(DeveloperAgent):
+ def __init__(self, config: AgentsConfig,
+ memory: Optional[ProjectMemory] = None) -> None:
+ super().__init__(config, memory=memory)
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ # 注入 __hardware__ 约束
+ hw_facts = self._memory.get_facts("__hardware__")
+ hw_section = ""
+ if hw_facts:
+ lines = "\n".join(f" {k}: {v}" for k, v in hw_facts.items())
+ hw_section = f"\n【硬件约束】\n{lines}\n"
+ return base + hw_section + "\n" + ALGO_RESEARCHER_SYSTEM
+```
+
+**Step 5: 创建 base_architect.py**
+
+```python
+from __future__ import annotations
+from rockchip_agents.agents.architect import ArchitectAgent
+from rockchip_agents.core.queue import Task
+
+BASE_ARCHITECT_SYSTEM = """你是基础技术研发组的架构师,专注平台级技术栈的整体设计。
+
+职责(区别于项目组 architect):
+- 分析 OS/算法/硬件 三个方向的技术债和瓶颈
+- 识别跨方向整合机会(如防抖算法与 GPS 惯导融合)
+- 评估新硬件对现有基础能力的影响
+- 为 os-engineer / algo-researcher 提出具体 [优化] 任务
+
+输出格式:
+- [优化] os-engineer: 具体优化建议
+- [优化] algo-researcher: 具体优化建议
+- [记忆] __platform__: key=value(记录架构决策)
+"""
+
+
+class BaseArchitectAgent(ArchitectAgent):
+ def _build_prompt(self, summaries: dict[str, str]) -> str:
+ base = super()._build_prompt(summaries)
+ return BASE_ARCHITECT_SYSTEM + "\n\n" + base
+```
+
+**Step 6: 运行测试**
+```bash
+pytest tests/test_base_tech_agents.py -v
+pytest tests/ -q 2>&1 | tail -5
+```
+
+---
+
+### Task 7: vision-analyst — Playwright + LLM 视觉分析 Agent
+
+**Files:**
+- Create: `src/rockchip_agents/agents/vision_analyst.py`
+- Test: `tests/test_vision_analyst.py`
+
+**Step 1: 写失败测试**
+
+创建 `tests/test_vision_analyst.py`:
+
+```python
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from rockchip_agents.agents.vision_analyst import VisionAnalystAgent, VISION_ANALYST_SYSTEM
+from rockchip_agents.config import AgentsConfig, ClaudeConfig
+from rockchip_agents.core.queue import Task, TaskQueue
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="auto",
+ path=MagicMock())}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ return cfg
+
+
+def test_vision_analyst_system_prompt_has_playwright_workflow():
+ """提示词包含 Playwright 工作流关键词"""
+ for kw in ["Playwright", "截图", "LLM", "检测框"]:
+ assert kw in VISION_ANALYST_SYSTEM, f"缺少: {kw}"
+
+
+def test_vision_analyst_extract_needs(tmp_path):
+ """从视觉分析报告中提取 [需求] 任务"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = VisionAnalystAgent(_make_config(), queue=q, memory=mem)
+
+ report = "视觉分析结果:检测框偏移明显\n- [需求] yolo: 修正检测框坐标偏移\n"
+ agent._extract_needs(report)
+
+ conn = q._conn()
+ rows = conn.execute("SELECT title FROM tasks").fetchall()
+ conn.close()
+ assert any("检测框坐标偏移" in r["title"] for r in rows)
+```
+
+**Step 2: 运行确认失败**
+```bash
+pytest tests/test_vision_analyst.py -v 2>&1 | head -15
+```
+
+**Step 3: 创建 vision_analyst.py**
+
+```python
+from __future__ import annotations
+
+import logging
+import os
+import re
+from typing import Optional
+
+from rockchip_agents.agents.developer import (
+ AgentResult, _make_drop_root_preexec, _run_with_log, _task_log_path,
+)
+from rockchip_agents.config import AgentsConfig
+from rockchip_agents.core.queue import Task, TaskQueue
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+TIMEOUT = 600
+
+VISION_ANALYST_SYSTEM = """你是产品洞察与市场落地组的视觉分析师。
+
+工作流程:
+1. 使用 Playwright 导航至板端推理演示页面或监控大屏
+2. browser_take_screenshot 截图(保存至 docs/screenshots/)
+3. 作为多模态 LLM,直接分析截图内容:
+ - 检测框位置是否合理(有无明显偏移/错位)
+ - FPS 数值读取与目标值对比
+ - 漏检情况(应检测目标未出现框)
+ - 误检情况(背景被误识别)
+4. 结合推理日志(JSON/txt)做定量验证
+5. 输出分析报告 + 改进需求
+
+输出格式:
+- [需求] <项目名>: 具体视觉问题描述和改进方向
+- 性能数值(FPS、检出率等)
+
+请用中文,视觉描述精确,问题定位清晰。
+"""
+
+
+class VisionAnalystAgent:
+ def __init__(
+ self,
+ config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ self._config = config
+ self._queue = queue or TaskQueue()
+ self._memory = memory or ProjectMemory()
+
+ def run(self, task: Task) -> AgentResult:
+ from rockchip_agents.config import load_mcp_tools
+ mcp_cfg = load_mcp_tools()
+ registry = mcp_cfg.get("registry", {})
+
+ import shutil, json, tempfile
+ mcp_servers = {}
+ for tool in ["playwright", "sequential-thinking"]:
+ if tool in registry:
+ parts = registry[tool].split()
+ if shutil.which(parts[0]):
+ mcp_servers[tool] = {"command": parts[0], "args": parts[1:]}
+
+ tmp_files = []
+ cmd = [
+ "claude", "--print",
+ "--output-format", "json",
+ "--no-session-persistence",
+ "--model", self._config.claude.model,
+ "--dangerously-skip-permissions",
+ "--append-system-prompt", VISION_ANALYST_SYSTEM,
+ ]
+ if mcp_servers:
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".json", delete=False, prefix="mcp_vision_"
+ ) as f:
+ json.dump({"mcpServers": mcp_servers}, f)
+ tmp_files.append(f.name)
+ cmd += ["--mcp-config", tmp_files[-1]]
+ cmd += ["--", self._build_prompt(task)]
+
+ try:
+ env = self._build_env()
+ result = _run_with_log(
+ cmd, None, env, TIMEOUT, _task_log_path(task.id),
+ preexec_fn=_make_drop_root_preexec(env),
+ )
+ if result.status == "done":
+ self._extract_needs(result.summary)
+ return result
+ except Exception as e:
+ logger.error("VisionAnalystAgent 失败: %s", e)
+ return AgentResult(status="failed", summary=str(e))
+ finally:
+ for f in tmp_files:
+ try:
+ os.unlink(f)
+ except OSError:
+ pass
+
+ def _build_prompt(self, task: Task) -> str:
+ proj_cfg = self._config.projects.get(task.project)
+ desc = proj_cfg.description if proj_cfg else task.project
+ return (
+ f"项目: {task.project},描述: {desc}\n"
+ f"任务: {task.title}\n"
+ f"背景: {task.context}\n\n"
+ f"请使用 Playwright 截图并分析推理效果,输出视觉分析报告。"
+ )
+
+ def _extract_needs(self, report_text: str) -> None:
+ """从视觉分析报告提取 [需求] 任务入队"""
+ for m in re.finditer(r"\[需求\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ desc = m.group(2).strip()[:120]
+ if project not in self._config.projects:
+ continue
+ proj_cfg = self._config.projects[project]
+ self._queue.enqueue(Task(
+ project=project, type="fix_bug",
+ title=f"[视觉分析] {desc}",
+ context=f"由 VisionAnalystAgent 发现\n{desc}",
+ priority=2, mode=proj_cfg.mode,
+ agent_role="developer", initiator="vision-analyst",
+ ))
+
+ def _build_env(self) -> dict:
+ env = os.environ.copy()
+ if self._config.claude.api_key:
+ env["ANTHROPIC_API_KEY"] = self._config.claude.api_key
+ env.pop("CLAUDECODE", None)
+ env.pop("CLAUDE_CODE_ENTRYPOINT", None)
+ return env
+```
+
+**Step 4: 运行测试**
+```bash
+pytest tests/test_vision_analyst.py -v
+```
+
+---
+
+### Task 8: market-pm + media-producer
+
+**Files:**
+- Create: `src/rockchip_agents/agents/market_pm.py`
+- Create: `src/rockchip_agents/agents/media_producer.py`
+- Test: `tests/test_market_agents.py`
+
+**Step 1: 写失败测试**
+
+```python
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from rockchip_agents.config import AgentsConfig, ClaudeConfig
+from rockchip_agents.core.queue import Task
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ return cfg
+
+
+def test_market_pm_context_contains_market_domain(tmp_path):
+ from rockchip_agents.agents.market_pm import MarketPmAgent, MARKET_PM_SYSTEM
+ assert "方案书" in MARKET_PM_SYSTEM or "白皮书" in MARKET_PM_SYSTEM
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = MarketPmAgent(_make_config(), memory=mem)
+ task = Task(project="yolo", type="market", title="方案书",
+ context="", priority=3, mode="report", agent_role="market-pm")
+ ctx = agent._build_context(task)
+ assert len(ctx) > 10
+
+
+def test_media_producer_context_contains_tools(tmp_path):
+ from rockchip_agents.agents.media_producer import MediaProducerAgent, MEDIA_PRODUCER_SYSTEM
+ assert "OpenCV" in MEDIA_PRODUCER_SYSTEM or "ffmpeg" in MEDIA_PRODUCER_SYSTEM
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = MediaProducerAgent(_make_config(), memory=mem)
+ task = Task(project="yolo", type="media", title="演示图",
+ context="", priority=3, mode="auto", agent_role="media-producer")
+ ctx = agent._build_context(task)
+ assert len(ctx) > 10
+```
+
+**Step 2: 运行确认失败**
+```bash
+pytest tests/test_market_agents.py -v 2>&1 | head -15
+```
+
+**Step 3: 创建 market_pm.py**
+
+```python
+from __future__ import annotations
+from rockchip_agents.agents.developer import DeveloperAgent
+from rockchip_agents.core.queue import Task
+
+MARKET_PM_SYSTEM = """你是产品洞察与市场落地组的市场 PM,负责面向客户的交付文档。
+
+输出类型:
+- 方案书:技术方案 + 架构图 + 成本估算,保存至 docs/market/{project}-proposal.md
+- 技术白皮书:平台能力 + 算法性能 + 对比竞品,保存至 docs/market/{project}-whitepaper.md
+- BOM 估算:核心器件 + 单价区间 + 替代方案,保存至 docs/market/{project}-bom.md
+
+内容要求:
+- 基于 ProjectMemory 中的能力事实(FPS、精度、平台规格)填写实际数值
+- 避免夸大性描述,数值来自测试报告
+- 格式:Markdown,结构清晰,可直接发给客户
+
+工作流:
+1. 读取项目 docs/ 目录了解现有文档
+2. 读取 ProjectMemory 获取能力数值
+3. 生成或更新对应文档
+"""
+
+
+class MarketPmAgent(DeveloperAgent):
+ def _build_context(self, task: Task) -> str:
+ return super()._build_context(task) + "\n" + MARKET_PM_SYSTEM
+```
+
+**Step 4: 创建 media_producer.py**
+
+```python
+from __future__ import annotations
+from rockchip_agents.agents.developer import DeveloperAgent
+from rockchip_agents.core.queue import Task
+
+MEDIA_PRODUCER_SYSTEM = """你是产品洞察与市场落地组的媒体制作师,随时可被触发。
+
+技术演示侧:
+- 使用 OpenCV(python3 + cv2)在推理结果图上绘制检测框、置信度、平台水印
+- 命令:python3 -c "import cv2; ...",输出至 assets/demo/
+
+市场展示侧:
+- 生成产品效果图:硬件外观图 + 算法效果标注(Pillow 合成)
+- Demo 视频剪辑:ffmpeg -i input.mp4 -vf "drawtext=..." output.mp4
+- 输出至 assets/market/
+
+工作流:
+1. 确认输入素材路径(推理图/视频)
+2. 选择合适的处理方式(cv2/ffmpeg/Pillow)
+3. 执行并保存输出文件
+4. 报告生成的文件路径
+
+注意:可随时触发,不依赖测试结果,兼顾技术和市场两种用途。
+"""
+
+
+class MediaProducerAgent(DeveloperAgent):
+ def _build_context(self, task: Task) -> str:
+ return super()._build_context(task) + "\n" + MEDIA_PRODUCER_SYSTEM
+```
+
+**Step 5: 运行测试**
+```bash
+pytest tests/test_market_agents.py -v
+```
+
+---
+
+### Task 9: Executor 路由 — 注册所有新角色
+
+**Files:**
+- Modify: `src/rockchip_agents/core/executor.py:42-63`
+- Test: `tests/test_executor.py`
+
+**Step 1: 写失败测试**
+
+在 `tests/test_executor.py` 末尾追加:
+
+```python
+@pytest.mark.parametrize("role,expected_class", [
+ ("os-engineer", "OsEngineerAgent"),
+ ("algo-researcher","AlgoResearcherAgent"),
+ ("base-validator", "BaseValidatorAgent"),
+ ("base-architect", "BaseArchitectAgent"),
+ ("system-tester", "SystemTesterAgent"),
+ ("vision-analyst", "VisionAnalystAgent"),
+ ("market-pm", "MarketPmAgent"),
+ ("media-producer", "MediaProducerAgent"),
+])
+def test_executor_routes_new_roles(role, expected_class, mock_config, tmp_path):
+ from rockchip_agents.core.executor import _make_agent
+ from rockchip_agents.core.queue import Task
+ from rockchip_agents.tools.project_memory import ProjectMemory
+
+ task = Task(project="test", type="test", title="t",
+ context="", priority=3, mode="auto", agent_role=role)
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = _make_agent(mock_config, task, mem)
+ assert type(agent).__name__ == expected_class, \
+ f"role={role} 路由到 {type(agent).__name__},期望 {expected_class}"
+```
+
+**Step 2: 运行确认失败**
+```bash
+pytest tests/test_executor.py::test_executor_routes_new_roles -v
+```
+
+**Step 3: 修改 executor.py 的 _make_agent 函数**
+
+在现有 `if role == "planner":` 块之后,追加所有新角色:
+
+```python
+if role == "os-engineer":
+ from rockchip_agents.agents.os_engineer import OsEngineerAgent
+ return OsEngineerAgent(config, memory=memory)
+if role == "algo-researcher":
+ from rockchip_agents.agents.algo_researcher import AlgoResearcherAgent
+ return AlgoResearcherAgent(config, memory=memory)
+if role == "base-validator":
+ from rockchip_agents.agents.base_validator import BaseValidatorAgent
+ return BaseValidatorAgent(config, memory=memory)
+if role == "base-architect":
+ from rockchip_agents.agents.base_architect import BaseArchitectAgent
+ return BaseArchitectAgent(config, memory=memory)
+if role == "system-tester":
+ from rockchip_agents.agents.system_tester import SystemTesterAgent
+ return SystemTesterAgent(config, memory=memory)
+if role == "vision-analyst":
+ from rockchip_agents.agents.vision_analyst import VisionAnalystAgent
+ from rockchip_agents.core.queue import TaskQueue as _TQ
+ return VisionAnalystAgent(config, queue=_TQ(), memory=memory)
+if role == "market-pm":
+ from rockchip_agents.agents.market_pm import MarketPmAgent
+ return MarketPmAgent(config, memory=memory)
+if role == "media-producer":
+ from rockchip_agents.agents.media_producer import MediaProducerAgent
+ return MediaProducerAgent(config, memory=memory)
+```
+
+**Step 4: 全量回归**
+```bash
+pytest tests/ -q 2>&1 | tail -10
+```
+预期:新增测试全部通过,已知 11 个集成失败不变。
+
+---
+
+## 验收标准
+
+1. `pytest tests/ -q` 通过数 ≥ 160(新增 15+ 测试)
+2. `python -c "from rockchip_agents.config import load_config; cfg=load_config(); print(cfg.queues)"` 输出三个队列配置
+3. `python -c "from rockchip_agents.core.executor import _make_agent"` 可导入,15 个角色全部可路由
+4. `sqlite3 data/base_opt_tasks.db ".tables"` 在 Scheduler 启动后自动创建
diff --git a/docs/plans/2026-03-09-env-collector-design.md b/docs/plans/2026-03-09-env-collector-design.md
new file mode 100644
index 0000000..1ec2fb9
--- /dev/null
+++ b/docs/plans/2026-03-09-env-collector-design.md
@@ -0,0 +1,138 @@
+# EnvCollector 设计文档
+
+## 概述
+
+自动收集本机环境变量中的模型路径、SSH 远端设备的模型文件 + 磁盘空间 + 关键包版本,
+持久化存储到 ProjectMemory,供所有 Agent 查询,避免重复下载模型。
+
+---
+
+## 1. 数据模型 & 存储结构
+
+结果全部写入 `ProjectMemory` 的 `__env__` 虚拟项目命名空间(类似现有 `__hardware__`)。
+
+### Key 规范
+
+```
+# 本机环境变量(含 MODEL/RKNN/ONNX/WEIGHT/PATH 关键字的变量)
+__env__.local.envvar. = "/path/to/model.rknn"
+
+# 本机文件系统扫描(/data/ 下递归,max_depth=5)
+__env__.local.models.rknn = "/data/rockchip/yolo/model.rknn,/data/rockchip/embedding/emb.rknn"
+__env__.local.models.onnx = "/data/rockchip/yolo/model.onnx"
+__env__.local.models.pt = ""
+
+# SSH 设备模型文件
+__env__.{device_name}.models.rknn = "/home/pi/Desktop/yolo/model.rknn,/home/pi/models/det.rknn"
+__env__.{device_name}.models.onnx = ""
+
+# SSH 设备磁盘空间
+__env__.{device_name}.disk = "Filesystem /dev/root Size=29G Used=8.2G Avail=19G"
+
+# SSH 设备已安装关键包
+__env__.{device_name}.packages = "rknn-toolkit-lite2==2.3.2,opencv-python==4.13.0,numpy==2.4.2"
+
+# 设备离线时错误记录
+__env__.{device_name}.error = "offline at 2026-03-09T10:30:00"
+
+# 收集时间戳
+__env__.collected_at = "2026-03-09T10:30:00"
+```
+
+多值用逗号分隔,兼容现有 `get_facts()` 返回 dict 的结构,无需改表结构。
+
+---
+
+## 2. 收集逻辑 & 触发机制
+
+### EnvCollector 核心类(tools/env_collector.py)
+
+```python
+class EnvCollector:
+ def collect_local(self, scan_dirs: list[str] = None) -> dict[str, str]:
+ # 1. 扫描 os.environ,过滤含 MODEL/RKNN/ONNX/WEIGHT/PATH 的 key
+ # 2. 文件系统:在 scan_dirs(默认 ["/data/"])下 find *.rknn/*.onnx/*.pt/*.pb
+ # 跳过 venv/__pycache__/.git,max_depth=5
+
+ def collect_device(self, name: str, device: DeviceConfig) -> dict[str, str]:
+ # 复用 DeviceAgent.run_command() 执行:
+ # 1. find $workspace -maxdepth 4 -name "*.rknn" -o -name "*.onnx" ...
+ # 2. df -h | grep -v tmpfs | head -5
+ # 3. pip list 2>/dev/null | grep -E "rknn|torch|opencv|numpy|pillow"
+ # 设备离线时捕获异常,写 error key
+
+ def save(self, facts: dict[str, str], memory: ProjectMemory) -> None:
+ # set_fact("__env__", key, value, role="env-collector")
+ # 最后写 collected_at
+```
+
+### 触发机制
+
+| 触发方式 | 实现位置 | 说明 |
+|---------|---------|------|
+| **手动** | `scripts/run.py collect-env` | 立即执行,输出收集摘要到 stdout |
+| **启动时** | `scheduler.start()` 末尾 daemon thread | 不阻塞调度器启动 |
+| **每日定时** | APScheduler `interval hours=24` | 后台安静更新,失败不影响主流程 |
+
+SSH 扫描失败(设备离线)时:静默跳过,记录 `__env__.{name}.error` 到 ProjectMemory。
+
+---
+
+## 3. Agent 使用方式
+
+### system prompt 注入(_build_device_context 扩展)
+
+`DeveloperAgent._build_device_context()` 末尾追加"已知模型"摘要块:
+
+```
+【已知模型文件(避免重复下载)】
+本机:
+ *.rknn: /data/rockchip/yolo/model.rknn, /data/rockchip/embedding/emb.rknn
+ RKNN_MODEL_PATH=/data/rockchip/models(来自环境变量)
+
+rk3588 (192.168.123.181):
+ *.rknn: /home/pi/Desktop/yolo/model.rknn
+ 磁盘: /dev/root 29G 已用8.2G 剩余19G
+ 已装: rknn-toolkit-lite2==2.3.2, opencv-python==4.13.0
+
+(更新于 2026-03-09 10:30,执行 `python scripts/run.py collect-env` 可刷新)
+```
+
+只在 memory 不为 None 且 `__env__` 有数据时注入(无数据时静默跳过)。
+
+### ProjectMemory 精确查询
+
+其他 Agent(base-validator、vision-analyst)可直接:
+
+```python
+facts = self._memory.get_facts("__env__")
+rk3588_models = facts.get("rk3588.models.rknn", "").split(",")
+disk_info = facts.get("rk3588.disk", "unknown")
+```
+
+### CLI 输出示例
+
+```bash
+$ python scripts/run.py collect-env
+
+[本机]
+ ENV: RKNN_MODEL_PATH=/data/rockchip/models (1 条)
+ *.rknn: 3 个文件
+ *.onnx: 1 个文件
+
+[rk3588 @ 192.168.123.181]
+ *.rknn: 2 个文件
+ 磁盘: 剩余 19G / 29G
+ 关键包: rknn-toolkit-lite2==2.3.2, opencv-python==4.13.0
+
+已保存到 ProjectMemory (__env__)
+```
+
+---
+
+## 实施原则
+
+- **TDD**:先写失败测试,再实现
+- **YAGNI**:不做 AI 分析模型版本、不做模型哈希校验
+- **轻依赖**:只用 paramiko(已有)+ pathlib + os.environ,无额外依赖
+- **容错优先**:任意设备离线或扫描失败不影响主流程
diff --git a/docs/plans/2026-03-09-env-collector-impl.md b/docs/plans/2026-03-09-env-collector-impl.md
new file mode 100644
index 0000000..1a873a4
--- /dev/null
+++ b/docs/plans/2026-03-09-env-collector-impl.md
@@ -0,0 +1,721 @@
+# EnvCollector Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 自动收集本机环境变量模型路径 + SSH 设备模型文件/磁盘/包信息,持久化至 ProjectMemory `__env__` 命名空间,注入 DeveloperAgent 上下文,避免重复下载模型。
+
+**Architecture:** 独立 `EnvCollector` 类(`tools/env_collector.py`),直接复用现有 `DeviceAgent._ssh()` + `ProjectMemory.set_fact()`;CLI / scheduler / developer 三处集成均为薄封装,不引入新依赖。
+
+**Tech Stack:** pathlib + os.environ + paramiko(已有)+ APScheduler(已有)+ ProjectMemory(已有)
+
+---
+
+### Task 1: tools/env_collector.py — 核心收集类
+
+**Files:**
+- Create: `src/rockchip_agents/tools/env_collector.py`
+- Modify: `src/rockchip_agents/tools/project_memory.py`(WRITE_ROLES 添加 "env-collector")
+- Test: `tests/test_env_collector.py`
+
+**Step 1: 写失败测试**
+
+```python
+# tests/test_env_collector.py
+from __future__ import annotations
+import os
+import pytest
+from unittest.mock import MagicMock, patch
+from rockchip_agents.tools.env_collector import EnvCollector
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+
+def test_collect_local_env_vars(tmp_path):
+ """collect_local 应收集含 MODEL/RKNN/ONNX 关键词的环境变量。"""
+ env = {
+ "RKNN_MODEL_PATH": "/data/rockchip/models",
+ "HOME": "/home/pi", # 不含关键词,不应收集
+ "ONNX_WEIGHT_DIR": "/data/weights",
+ }
+ with patch.dict(os.environ, env, clear=True):
+ col = EnvCollector()
+ facts = col.collect_local(scan_dirs=[])
+ assert facts.get("local.envvar.RKNN_MODEL_PATH") == "/data/rockchip/models"
+ assert facts.get("local.envvar.ONNX_WEIGHT_DIR") == "/data/weights"
+ assert "local.envvar.HOME" not in facts
+
+
+def test_collect_local_file_scan(tmp_path):
+ """collect_local 文件扫描应找到 *.rknn / *.onnx / *.pt 文件。"""
+ (tmp_path / "model.rknn").touch()
+ (tmp_path / "sub").mkdir()
+ (tmp_path / "sub" / "det.onnx").touch()
+ col = EnvCollector()
+ facts = col.collect_local(scan_dirs=[str(tmp_path)])
+ assert "/model.rknn" in facts["local.models.rknn"]
+ assert "/det.onnx" in facts["local.models.onnx"]
+ assert facts.get("local.models.pt", "") == ""
+
+
+def test_collect_local_skips_venv(tmp_path):
+ """collect_local 应跳过 venv / __pycache__ 目录。"""
+ venv = tmp_path / "venv" / "lib"
+ venv.mkdir(parents=True)
+ (venv / "ignore.rknn").touch()
+ col = EnvCollector()
+ facts = col.collect_local(scan_dirs=[str(tmp_path)])
+ assert facts.get("local.models.rknn", "") == ""
+
+
+def test_collect_device_success():
+ """collect_device 成功时应返回 models/disk/packages facts。"""
+ from rockchip_agents.config import DeviceConfig
+ dev = DeviceConfig(host="1.2.3.4", user="pi", password="pi",
+ workspace="/home/pi", type="linux", connect="ssh")
+
+ def fake_run(cmd, remote_cwd="", timeout=60):
+ if "find" in cmd:
+ return "[rc=0]\n/home/pi/model.rknn\n"
+ if "df" in cmd:
+ return "[rc=0]\n/dev/root 29G 8.2G 19G\n"
+ if "pip" in cmd:
+ return "[rc=0]\nrknn-toolkit-lite2 2.3.2\nopencv-python 4.13.0\n"
+ return "[rc=0]\n"
+
+ col = EnvCollector()
+ with patch.object(col, "_run_on_device", side_effect=fake_run):
+ facts = col.collect_device("rk3588", dev)
+ assert "/home/pi/model.rknn" in facts["rk3588.models.rknn"]
+ assert "29G" in facts["rk3588.disk"]
+ assert "rknn-toolkit-lite2" in facts["rk3588.packages"]
+
+
+def test_collect_device_offline():
+ """设备离线时应写入 error key,不抛异常。"""
+ from rockchip_agents.config import DeviceConfig
+ dev = DeviceConfig(host="1.2.3.4", user="pi", password="pi",
+ workspace="/home/pi", type="linux", connect="ssh")
+ col = EnvCollector()
+ with patch.object(col, "_run_on_device", side_effect=Exception("Connection refused")):
+ facts = col.collect_device("rk3588", dev)
+ assert "offline" in facts.get("rk3588.error", "")
+
+
+def test_save_writes_to_memory(tmp_path):
+ """save() 应将 facts 写入 ProjectMemory __env__ 命名空间。"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ col = EnvCollector()
+ col.save({"local.models.rknn": "/data/a.rknn"}, mem)
+ stored = mem.get_facts("__env__")
+ assert stored["local.models.rknn"] == "/data/a.rknn"
+ assert "collected_at" in stored
+
+
+def test_save_requires_env_collector_role(tmp_path):
+ """只有 env-collector role 才能写 __env__ facts(需先确认 WRITE_ROLES 已扩展)。"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ # 不应抛 PermissionError
+ mem.set_fact("__env__", "test.key", "val", role="env-collector")
+ assert mem.get_facts("__env__")["test.key"] == "val"
+```
+
+**Step 2: 运行确认失败**
+
+```bash
+source venv/bin/activate
+pytest tests/test_env_collector.py -v
+# Expected: ImportError / AttributeError — 模块不存在
+```
+
+**Step 3: 更新 WRITE_ROLES(project_memory.py)**
+
+在 `src/rockchip_agents/tools/project_memory.py` 找到 `WRITE_ROLES`,添加 `"env-collector"`:
+
+```python
+# 修改前
+WRITE_ROLES: frozenset[str] = frozenset({"productizer", "architect"})
+
+# 修改后
+WRITE_ROLES: frozenset[str] = frozenset({"productizer", "architect", "env-collector"})
+```
+
+**Step 4: 实现 env_collector.py**
+
+```python
+# src/rockchip_agents/tools/env_collector.py
+from __future__ import annotations
+
+import logging
+import os
+from datetime import datetime, timezone
+from pathlib import Path
+
+from rockchip_agents.config import DeviceConfig
+from rockchip_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+# 文件扫描跳过这些目录
+_SKIP_DIRS = {"venv", "__pycache__", ".git", "node_modules", ".tox"}
+# 环境变量关键词过滤
+_ENV_KEYWORDS = {"MODEL", "RKNN", "ONNX", "WEIGHT", "PATH"}
+# 扫描的模型扩展名
+_EXTENSIONS = {".rknn", ".onnx", ".pt", ".pb"}
+
+
+class EnvCollector:
+ """收集本机环境变量 + SSH 设备模型文件信息,写入 ProjectMemory __env__。"""
+
+ def collect_local(self, scan_dirs: list[str] | None = None) -> dict[str, str]:
+ """收集本机环境变量(过滤关键词)+ 文件系统模型文件。"""
+ facts: dict[str, str] = {}
+
+ # 1. 环境变量扫描
+ for key, val in os.environ.items():
+ if any(kw in key.upper() for kw in _ENV_KEYWORDS):
+ facts[f"local.envvar.{key}"] = val
+
+ # 2. 文件系统扫描
+ dirs = scan_dirs if scan_dirs is not None else ["/data/"]
+ found: dict[str, list[str]] = {ext: [] for ext in _EXTENSIONS}
+ for base in dirs:
+ base_path = Path(base)
+ if not base_path.exists():
+ continue
+ self._scan_dir(base_path, found, depth=0)
+
+ for ext, paths in found.items():
+ key = f"local.models.{ext.lstrip('.')}"
+ facts[key] = ",".join(sorted(set(paths)))
+
+ return facts
+
+ def _scan_dir(self, path: Path, found: dict[str, list[str]], depth: int) -> None:
+ if depth > 5:
+ return
+ try:
+ for entry in path.iterdir():
+ if entry.is_dir():
+ if entry.name not in _SKIP_DIRS:
+ self._scan_dir(entry, found, depth + 1)
+ elif entry.is_file() and entry.suffix in _EXTENSIONS:
+ found[entry.suffix].append(str(entry))
+ except PermissionError:
+ pass
+
+ def collect_device(self, name: str, device: DeviceConfig) -> dict[str, str]:
+ """连接 SSH 设备,收集模型文件/磁盘/包信息。"""
+ facts: dict[str, str] = {}
+ try:
+ ws = device.workspace
+
+ # 模型文件
+ find_cmd = (
+ f'find {ws} -maxdepth 4 \\( '
+ f'-name "*.rknn" -o -name "*.onnx" -o -name "*.pt" -o -name "*.pb" '
+ f'\\) 2>/dev/null'
+ )
+ out = self._run_on_device(find_cmd, ws)
+ for ext in (".rknn", ".onnx", ".pt", ".pb"):
+ key_name = ext.lstrip(".")
+ paths = [l for l in out.splitlines() if l.endswith(ext) and not l.startswith("[")]
+ facts[f"{name}.models.{key_name}"] = ",".join(paths)
+
+ # 磁盘空间
+ disk_out = self._run_on_device("df -h | grep -v tmpfs | head -5", ws)
+ lines = [l for l in disk_out.splitlines() if not l.startswith("[")]
+ facts[f"{name}.disk"] = " | ".join(lines).strip()
+
+ # 已安装关键包
+ pkg_out = self._run_on_device(
+ 'pip list 2>/dev/null | grep -iE "rknn|torch|opencv|numpy|pillow"', ws
+ )
+ pkgs = []
+ for line in pkg_out.splitlines():
+ if line.startswith("["):
+ continue
+ parts = line.split()
+ if len(parts) >= 2:
+ pkgs.append(f"{parts[0]}=={parts[1]}")
+ facts[f"{name}.packages"] = ",".join(pkgs)
+
+ except Exception as e:
+ logger.warning("设备 %s 收集失败: %s", name, e)
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
+ facts[f"{name}.error"] = f"offline at {ts}"
+
+ return facts
+
+ def _run_on_device(self, cmd: str, remote_cwd: str, timeout: int = 60) -> str:
+ """内部 SSH 执行(可被测试 monkeypatch)。"""
+ # 不在这里 import DeviceAgent,避免循环依赖
+ raise NotImplementedError("需在运行时传入 DeviceAgent 或 override")
+
+ def save(self, facts: dict[str, str], memory: ProjectMemory) -> None:
+ """将 facts 写入 ProjectMemory __env__ 命名空间。"""
+ for key, val in facts.items():
+ memory.set_fact("__env__", key, val, role="env-collector")
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
+ memory.set_fact("__env__", "collected_at", ts, role="env-collector")
+```
+
+> **注意:** `collect_device` 不直接依赖 `DeviceAgent`——由上层 `run_collection()` 函数组合,避免耦合:
+
+```python
+def run_collection(cfg, memory: ProjectMemory) -> dict[str, str]:
+ """顶层编排:收集本机 + 所有 SSH 设备,保存到 memory。"""
+ from rockchip_agents.tools.device_agent import DeviceAgent
+
+ col = EnvCollector()
+ all_facts: dict[str, str] = {}
+
+ # 本机收集
+ local = col.collect_local()
+ all_facts.update(local)
+
+ # SSH 设备收集
+ for name, dev in (cfg.devices or {}).items():
+ if dev.connect != "ssh":
+ continue
+ agent = DeviceAgent(dev)
+
+ def _run(cmd, remote_cwd="", timeout=60, _agent=agent):
+ return _agent.run_command(cmd, remote_cwd or dev.workspace, timeout)
+
+ col._run_on_device = _run
+ device_facts = col.collect_device(name, dev)
+ all_facts.update(device_facts)
+
+ col.save(all_facts, memory)
+ return all_facts
+```
+
+将 `run_collection` 也放入 `env_collector.py`。
+
+**Step 5: 运行测试**
+
+```bash
+pytest tests/test_env_collector.py -v
+# Expected: 6/6 PASSED
+```
+
+**Step 6: Commit**
+
+```bash
+git add src/rockchip_agents/tools/env_collector.py \
+ src/rockchip_agents/tools/project_memory.py \
+ tests/test_env_collector.py
+git commit -m "feat: add EnvCollector core class with local+SSH collection"
+```
+
+---
+
+### Task 2: scripts/run.py — collect-env 子命令
+
+**Files:**
+- Modify: `scripts/run.py`
+- Test: `tests/test_env_collector.py`(扩展 CLI 测试)
+
+**Step 1: 写失败测试**
+
+```python
+# 追加到 tests/test_env_collector.py
+
+def test_run_collection_prints_summary(tmp_path, capsys):
+ """run_collection 完整运行后应返回包含 local/device 信息的 facts dict。"""
+ from rockchip_agents.tools.env_collector import run_collection
+ from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+
+ cfg = AgentsConfig(
+ projects={},
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ with patch.dict(os.environ, {"RKNN_MODEL_PATH": "/data/m"}, clear=False):
+ facts = run_collection(cfg, mem)
+ assert "local.envvar.RKNN_MODEL_PATH" in facts
+ assert "collected_at" in mem.get_facts("__env__")
+```
+
+**Step 2: 运行确认失败**
+
+```bash
+pytest tests/test_env_collector.py::test_run_collection_prints_summary -v
+# Expected: FAIL — run_collection 函数未集成完整
+```
+
+**Step 3: 在 scripts/run.py 添加 collect-env 子命令**
+
+在 `sub.add_parser("projects", ...)` 之后添加:
+
+```python
+sub.add_parser("collect-env", help="收集本机+SSH设备环境信息(模型路径/磁盘/包)")
+```
+
+在 `elif args.cmd == "projects":` 之后添加:
+
+```python
+elif args.cmd == "collect-env":
+ from rockchip_agents.config import load_config
+ from rockchip_agents.tools.env_collector import run_collection
+ from rockchip_agents.tools.project_memory import ProjectMemory
+ cfg = load_config()
+ mem = ProjectMemory()
+ facts = run_collection(cfg, mem)
+
+ # 打印摘要
+ env_vars = {k: v for k, v in facts.items() if k.startswith("local.envvar.")}
+ print(f"\n[本机]")
+ if env_vars:
+ print(f" ENV: {len(env_vars)} 条含模型关键词变量")
+ for k, v in list(env_vars.items())[:3]:
+ print(f" {k.split('.')[-1]}={v}")
+ for ext in ("rknn", "onnx", "pt", "pb"):
+ val = facts.get(f"local.models.{ext}", "")
+ if val:
+ cnt = len(val.split(","))
+ print(f" *.{ext}: {cnt} 个文件")
+
+ for key in sorted(facts):
+ if ".error" in key:
+ name = key.split(".")[0]
+ print(f"\n[{name}]")
+ print(f" ⚠ {facts[key]}")
+ elif ".disk" in key and not key.startswith("local"):
+ name = key.split(".")[0]
+ host = ""
+ from rockchip_agents.config import load_config as _lc
+ _cfg = _lc()
+ dev = _cfg.devices.get(name)
+ if dev:
+ host = f" @ {dev.host}"
+ print(f"\n[{name}{host}]")
+ rknn_val = facts.get(f"{name}.models.rknn", "")
+ if rknn_val:
+ print(f" *.rknn: {len(rknn_val.split(','))} 个文件")
+ print(f" 磁盘: {facts[key][:60]}")
+ pkgs = facts.get(f"{name}.packages", "")
+ if pkgs:
+ print(f" 关键包: {pkgs[:80]}")
+
+ ts = facts.get("collected_at", "")
+ print(f"\n已保存到 ProjectMemory (__env__) [{ts}]")
+```
+
+**Step 4: 运行测试**
+
+```bash
+pytest tests/test_env_collector.py -v
+# Expected: 7/7 PASSED
+```
+
+**Step 5: 手动验证 CLI**
+
+```bash
+python scripts/run.py collect-env
+# Expected: 输出 [本机] ENV: N 条含模型关键词变量 + 文件统计
+```
+
+**Step 6: Commit**
+
+```bash
+git add scripts/run.py tests/test_env_collector.py
+git commit -m "feat: add collect-env CLI subcommand"
+```
+
+---
+
+### Task 3: scheduler.py — 24h 定时 job + 启动后台线程
+
+**Files:**
+- Modify: `src/rockchip_agents/core/scheduler.py`
+- Test: `tests/test_scheduler.py`(已有,追加测试)
+
+**Step 1: 写失败测试**
+
+```python
+# 追加到 tests/test_scheduler.py(或新建 tests/test_env_scheduler.py)
+
+def test_scheduler_has_env_collect_job(monkeypatch):
+ """start() 添加的 APScheduler jobs 应包含 env_collect(id='env_collect')。"""
+ import types
+ added_jobs = []
+
+ class FakeScheduler:
+ def add_job(self, fn, trigger, **kwargs):
+ added_jobs.append(kwargs.get("id"))
+ def start(self):
+ raise KeyboardInterrupt # 立即退出
+
+ monkeypatch.setattr(
+ "rockchip_agents.core.scheduler.BlockingScheduler",
+ lambda: FakeScheduler()
+ )
+ # stub 其他副作用
+ monkeypatch.setattr("rockchip_agents.core.scheduler._scan_and_enqueue", lambda: None)
+ monkeypatch.setattr("rockchip_agents.core.scheduler.TaskQueue", MagicMock())
+ monkeypatch.setattr("rockchip_agents.core.scheduler.Watchdog", MagicMock())
+ monkeypatch.setattr("rockchip_agents.core.scheduler.load_config", MagicMock(
+ return_value=MagicMock(scheduler=MagicMock(interval_hours=1), devices={}, queues={})
+ ))
+
+ from rockchip_agents.core.scheduler import start
+ try:
+ start()
+ except (KeyboardInterrupt, Exception):
+ pass
+
+ assert "env_collect" in added_jobs, f"未找到 env_collect job,jobs={added_jobs}"
+```
+
+**Step 2: 运行确认失败**
+
+```bash
+pytest tests/test_scheduler.py::test_scheduler_has_env_collect_job -v
+# Expected: AssertionError — env_collect not in added_jobs
+```
+
+**Step 3: 修改 scheduler.py**
+
+在 `start()` 函数中,导入并添加 `_collect_env` 包装函数(在函数顶部附近添加):
+
+```python
+def _collect_env() -> None:
+ """后台静默收集环境信息,失败不影响主流程。"""
+ try:
+ from rockchip_agents.config import load_config
+ from rockchip_agents.tools.env_collector import run_collection
+ from rockchip_agents.tools.project_memory import ProjectMemory
+ cfg = load_config()
+ run_collection(cfg, ProjectMemory())
+ logger.info("EnvCollector 完成收集")
+ except Exception:
+ logger.exception("EnvCollector 收集失败(静默跳过)")
+```
+
+在 `start()` 函数内 `scheduler.add_job(_enqueue_market_intel_task, ...)` 之后添加:
+
+```python
+scheduler.add_job(
+ _collect_env, "interval", hours=24, id="env_collect",
+ next_run_time=datetime.now() + timedelta(minutes=30),
+)
+```
+
+在 `_scan_and_enqueue()` 调用(启动时立即扫描一次)之后,启动一个 daemon 后台线程触发首次收集(不阻塞调度器):
+
+```python
+# 启动时后台触发一次环境收集(daemon,不阻塞)
+import threading
+t = threading.Thread(target=_collect_env, daemon=True, name="env-collector-init")
+t.start()
+```
+
+**Step 4: 运行测试**
+
+```bash
+pytest tests/test_scheduler.py -v
+# Expected: 原有测试 + 新增测试均 PASSED
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/rockchip_agents/core/scheduler.py tests/test_scheduler.py
+git commit -m "feat: add EnvCollector 24h scheduler job + startup daemon thread"
+```
+
+---
+
+### Task 4: developer.py — _build_device_context 注入已知模型摘要
+
+**Files:**
+- Modify: `src/rockchip_agents/agents/developer.py`
+- Test: `tests/test_developer.py`(已有,追加测试)
+
+**Step 1: 写失败测试**
+
+```python
+# 追加到 tests/test_developer.py
+
+def test_build_device_context_injects_env_models(tmp_path):
+ """_build_device_context 在 __env__ 有数据时应注入已知模型摘要。"""
+ from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+ from rockchip_agents.agents.developer import DeveloperAgent
+ from rockchip_agents.tools.project_memory import ProjectMemory
+
+ cfg = AgentsConfig(
+ projects={},
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ mem.set_fact("__env__", "local.models.rknn", "/data/a.rknn,/data/b.rknn", role="env-collector")
+ mem.set_fact("__env__", "rk3588.models.rknn", "/home/pi/model.rknn", role="env-collector")
+ mem.set_fact("__env__", "rk3588.disk", "/dev/root 29G 8G 19G", role="env-collector")
+ mem.set_fact("__env__", "collected_at", "2026-03-09T10:00:00", role="env-collector")
+
+ agent = DeveloperAgent(cfg, memory=mem)
+ ctx = agent._build_device_context()
+
+ assert "已知模型" in ctx
+ assert "/data/a.rknn" in ctx or "2 个" in ctx
+ assert "rk3588" in ctx
+ assert "19G" in ctx or "29G" in ctx
+
+
+def test_build_device_context_no_env_data(tmp_path):
+ """__env__ 无数据时,_build_device_context 不注入模型摘要(不崩溃)。"""
+ from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+ from rockchip_agents.agents.developer import DeveloperAgent
+ from rockchip_agents.tools.project_memory import ProjectMemory
+
+ cfg = AgentsConfig(
+ projects={}, scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(api_key="fake"), feishu=FeishuConfig(), devices={},
+ )
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = DeveloperAgent(cfg, memory=mem)
+ ctx = agent._build_device_context() # 不应抛异常
+ assert "已知模型" not in ctx
+```
+
+**Step 2: 运行确认失败**
+
+```bash
+pytest tests/test_developer.py::test_build_device_context_injects_env_models \
+ tests/test_developer.py::test_build_device_context_no_env_data -v
+# Expected: FAIL — AssertionError: "已知模型" not in ctx
+```
+
+**Step 3: 修改 developer.py _build_device_context()**
+
+在 `_build_device_context()` 方法末尾 `return "\n".join(lines)` 之前,追加以下代码:
+
+```python
+# 注入已知模型摘要(从 ProjectMemory __env__ 读取)
+if self._memory is not None:
+ env_facts = self._memory.get_facts("__env__")
+ if env_facts:
+ lines.append("【已知模型文件(避免重复下载)】")
+
+ # 本机
+ local_rknn = env_facts.get("local.models.rknn", "")
+ local_onnx = env_facts.get("local.models.onnx", "")
+ local_env_vars = {
+ k.split(".")[-1]: v
+ for k, v in env_facts.items()
+ if k.startswith("local.envvar.")
+ }
+ lines.append("本机:")
+ if local_rknn:
+ lines.append(f" *.rknn: {local_rknn}")
+ if local_onnx:
+ lines.append(f" *.onnx: {local_onnx}")
+ for var_name, var_val in local_env_vars.items():
+ lines.append(f" {var_name}={var_val}(来自环境变量)")
+
+ # 各 SSH 设备
+ device_names = set()
+ for key in env_facts:
+ parts = key.split(".")
+ if len(parts) >= 2 and parts[0] not in ("local", "collected"):
+ device_names.add(parts[0])
+
+ for dev_name in sorted(device_names):
+ dev_cfg = self._config.devices.get(dev_name)
+ host_info = f" ({dev_cfg.host})" if dev_cfg else ""
+ lines.append(f"\n{dev_name}{host_info}:")
+ error = env_facts.get(f"{dev_name}.error", "")
+ if error:
+ lines.append(f" ⚠ {error}")
+ continue
+ rknn = env_facts.get(f"{dev_name}.models.rknn", "")
+ if rknn:
+ lines.append(f" *.rknn: {rknn}")
+ disk = env_facts.get(f"{dev_name}.disk", "")
+ if disk:
+ lines.append(f" 磁盘: {disk[:60]}")
+ pkgs = env_facts.get(f"{dev_name}.packages", "")
+ if pkgs:
+ lines.append(f" 已装: {pkgs[:80]}")
+
+ collected_at = env_facts.get("collected_at", "")
+ if collected_at:
+ lines.append(f"\n(更新于 {collected_at},执行 `python scripts/run.py collect-env` 可刷新)")
+ lines.append("")
+```
+
+**Step 4: 运行测试**
+
+```bash
+pytest tests/test_developer.py -v
+# Expected: 全部 PASSED(含两个新测试)
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/rockchip_agents/agents/developer.py tests/test_developer.py
+git commit -m "feat: inject known model summary into DeveloperAgent device context"
+```
+
+---
+
+### Task 5: 全量验证
+
+**Step 1: 运行全量测试**
+
+```bash
+pytest tests/ -q
+# Expected: 全部通过(含 test_env_collector.py 的 8+ 个测试)
+```
+
+**Step 2: 检查测试覆盖关键路径**
+
+确认以下场景有测试:
+- [ ] `collect_local` 过滤环境变量关键词
+- [ ] `collect_local` 递归扫描 + 跳过 venv
+- [ ] `collect_device` SSH 成功(mock)
+- [ ] `collect_device` SSH 离线 → error key
+- [ ] `save()` 写入 ProjectMemory
+- [ ] `env-collector` WRITE_ROLES 权限
+- [ ] scheduler 有 `env_collect` job
+- [ ] `_build_device_context` 注入模型摘要
+- [ ] `_build_device_context` 无 __env__ 数据时静默
+
+**Step 3: 手动冒烟测试 CLI**
+
+```bash
+# 应能正常运行并打印摘要
+python scripts/run.py collect-env
+
+# 验证数据写入
+python scripts/run.py facts
+# Expected: 显示 __env__ 下的 local.models.* / local.envvar.* 等条目
+```
+
+**Step 4: Commit**
+
+```bash
+git add .
+git commit -m "test: verify EnvCollector full integration"
+```
+
+---
+
+## 实施注意事项
+
+1. **`_run_on_device` 占位符**:`EnvCollector.collect_device()` 内部调用 `self._run_on_device()`,这个方法在 `EnvCollector` 类中 raise `NotImplementedError`。`run_collection()` 函数在调用前通过 monkeypatching(`col._run_on_device = _run`)注入真实的 `DeviceAgent.run_command`。测试同样用此机制 mock。
+
+2. **WRITE_ROLES 扩展**:`project_memory.py` 中 `WRITE_ROLES` 需添加 `"env-collector"`,否则 `set_fact()` 会抛 `PermissionError`。
+
+3. **`_memory` 字段检查**:`DeveloperAgent` 的 `__init__` 接收 `memory: ProjectMemory | None = None`,注入代码需先检查 `if self._memory is not None`。
+
+4. **scheduler import**:`_collect_env()` 函数内部延迟 import(`from rockchip_agents.tools.env_collector import run_collection`),避免在模块加载时引入不必要依赖。
+
+5. **设备 workspace 容错**:`find` 命令的 `remote_cwd` 用 `dev.workspace`,若设备 workspace 不存在,`find` 会返回错误但不影响其他命令执行。
diff --git a/docs/plans/2026-03-09-visual-tester-design.md b/docs/plans/2026-03-09-visual-tester-design.md
new file mode 100644
index 0000000..6f96111
--- /dev/null
+++ b/docs/plans/2026-03-09-visual-tester-design.md
@@ -0,0 +1,170 @@
+# Visual Tester 设计文档
+
+## 概述
+
+基于 Playwright 截图对比的 UI 视觉回归测试系统,支持多级断点恢复,集成至 Dashboard 看板,自动发现 `/data/company` 下所有 Web UI。
+
+---
+
+## 1. 目录结构 & /data/company 迁移
+
+### /data/company 目录布局
+
+```
+/data/company/
+├── visual-tester/ # 新建独立服务
+│ ├── src/visual_tester/
+│ │ ├── runner.py # Playwright 执行引擎 + checkpoint
+│ │ ├── differ.py # PIL/pixelmatch 差异计算
+│ │ ├── discovery.py # 自动发现 Web UI
+│ │ └── db.py # SQLite 数据层
+│ ├── baselines/ # {project}/{name}.png 基线截图
+│ ├── diffs/ # {run_id}/{case_id}_diff.png 差异图
+│ ├── checkpoints/ # {run_id}.json 会话恢复文件
+│ ├── data/visual.db
+│ ├── requirements.txt
+│ └── README.md
+├── agents/ → symlink /data/rockchip/agents
+├── yolo/ → symlink /data/rockchip/yolo
+└── ...(其他项目符号链接)
+```
+
+### configs/projects.yaml 迁移
+
+- `scan_dirs` 从 `/data/rockchip` 更改为 `/data/company`
+- 删除旧的显式 `projects:` 条目(由 scan_dirs 自动发现替代)
+- `data/tasks.db` 历史数据保留(不清除),仅新扫描基于新路径
+
+---
+
+## 2. 数据模型 & 断点恢复机制
+
+### SQLite 表结构(visual.db)
+
+```sql
+CREATE TABLE test_runs (
+ id TEXT PRIMARY KEY,
+ project TEXT NOT NULL,
+ started_at TEXT NOT NULL,
+ completed_at TEXT,
+ status TEXT NOT NULL, -- running | done | interrupted
+ total INTEGER DEFAULT 0,
+ passed INTEGER DEFAULT 0,
+ failed INTEGER DEFAULT 0,
+ pending_review INTEGER DEFAULT 0
+);
+
+CREATE TABLE test_cases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ run_id TEXT NOT NULL,
+ project TEXT NOT NULL,
+ url TEXT NOT NULL,
+ name TEXT NOT NULL,
+ status TEXT NOT NULL, -- pending | running | passed | failed | needs_review | skipped
+ screenshot_path TEXT,
+ baseline_path TEXT,
+ diff_path TEXT,
+ diff_pct REAL,
+ FOREIGN KEY (run_id) REFERENCES test_runs(id)
+);
+
+CREATE TABLE baselines (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ project TEXT NOT NULL,
+ url TEXT NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ commit_hash TEXT DEFAULT '',
+ created_at TEXT NOT NULL,
+ is_current INTEGER DEFAULT 1
+);
+
+CREATE TABLE approvals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ case_id INTEGER NOT NULL,
+ action TEXT NOT NULL, -- approve | reject
+ reason TEXT DEFAULT '',
+ approved_by TEXT DEFAULT 'human',
+ approved_at TEXT NOT NULL,
+ FOREIGN KEY (case_id) REFERENCES test_cases(id)
+);
+```
+
+### 四级断点恢复
+
+| 级别 | 触发场景 | 恢复策略 |
+|------|---------|---------|
+| **测试套件恢复** | 进程崩溃 / 手动中断 | 跳过 status≠pending 的 case,重置 running→pending |
+| **基线版本管理** | 误操作覆盖基线 | `baselines.is_current=0` 软删除,可随时切回 |
+| **差异审批流** | 截图差异需人工确认 | status=needs_review 暂停队列,Dashboard 审批后继续 |
+| **Claude 会话恢复** | 断网 / 关机 / token 耗尽 | `checkpoints/{run_id}.json` 记录 last_case_id + progress |
+
+Checkpoint JSON 格式:
+```json
+{
+ "run_id": "abc123",
+ "last_case_id": 42,
+ "progress": "15/30",
+ "status": "interrupted",
+ "claude_session_id": "sess_xxx",
+ "paused_at": "2026-03-09T10:30:00"
+}
+```
+
+---
+
+## 3. 自动发现 & Runner 工作流 & Dashboard 集成
+
+### 自动发现优先级(discovery.py)
+
+1. `CLAUDE.md` 中 `## 服务端口` / `## Web UI` 段落提取 URL
+2. `README.md` 中 `http://localhost:XXXX` 正则扫描
+3. `docker-compose.yml` 中 `ports: ["XXXX:XXXX"]` 提取宿主机端口
+
+发现结果缓存至 `visual.db`,支持手动覆盖。
+
+### Runner 工作流(runner.py)
+
+```
+runner.run(project, resume=run_id)
+ → 加载/创建 test_run
+ → 筛选 pending cases(断点恢复跳过已完成)
+ → for each case:
+ screenshot via Playwright → differ.compare()
+ diff_pct < threshold → passed
+ diff_pct > threshold AND has baseline → needs_review(暂停等待审批)
+ no baseline → auto-set as baseline (passed)
+ timeout/error → failed
+ 写 checkpoint
+ → 更新 run status → done / interrupted
+```
+
+差异阈值:默认 `diff_threshold=0.5%`,可按项目配置。
+
+### Dashboard 新增 Visual 标签页
+
+**新增 API 端点:**
+
+```
+GET /api/visual/runs # 最近 20 条运行记录
+GET /api/visual/runs/{run_id}/cases # 该次运行的所有 case
+POST /api/visual/runs # 触发新一轮测试(异步)
+POST /api/visual/cases/{id}/approve # 批准差异 → 更新基线,status=passed
+POST /api/visual/cases/{id}/reject # 拒绝差异 → status=failed,保留旧基线
+GET /api/visual/baselines/{project} # 列出该项目所有基线(含历史)
+POST /api/visual/runs/{run_id}/resume # 从断点继续
+```
+
+**前端视图:**
+- 运行列表:项目 / 时间 / 进度条 / 状态徽章
+- Case 列表:三栏对比(基线 | 当前 | 差异)+ 审批按钮
+- 需审批的 case 在顶部 "⚠ N 个截图待审批" 聚合
+
+---
+
+## 实施原则
+
+- **TDD**:先写失败测试,再实现
+- **YAGNI**:不提前实现 AI 差异分析、多分辨率适配等
+- **断点即一等公民**:每个 case 完成后立即 flush checkpoint
+- **轻依赖**:PIL(pillow)+ Playwright,无额外服务依赖
diff --git a/docs/plans/2026-03-09-visual-tester-impl.md b/docs/plans/2026-03-09-visual-tester-impl.md
new file mode 100644
index 0000000..a5c6c76
--- /dev/null
+++ b/docs/plans/2026-03-09-visual-tester-impl.md
@@ -0,0 +1,1630 @@
+# Visual Tester 实施计划
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 构建基于 Playwright 截图的 UI 视觉回归测试系统,支持四级断点恢复,集成至 Dashboard 看板。
+
+**Architecture:** 独立 Python 包 `visual_tester`(位于 `/data/company/visual-tester/`),通过 SQLite 存储运行状态和基线;Dashboard 扩展 `/api/visual/*` 端点和前端 Visual 标签页;`/data/company` 作为公司级项目目录,通过 symlinks 复用现有项目。
+
+**Tech Stack:** Python 3.10, Playwright (pytest-playwright), Pillow (PIL), FastAPI, React + TypeScript + Tailwind, SQLite, venv
+
+---
+
+## Task 1: /data/company 目录初始化 & projects.yaml 迁移
+
+**Files:**
+- Create: `/data/company/` (目录)
+- Create: `/data/company/agents` (symlink → `/data/rockchip/agents`)
+- Modify: `/data/rockchip/agents/configs/projects.yaml`
+
+**Step 1: 创建 /data/company 目录并建立 symlinks**
+
+```bash
+sudo mkdir -p /data/company
+sudo ln -s /data/rockchip/agents /data/company/agents
+sudo ln -s /data/rockchip/yolo /data/company/yolo
+sudo ln -s /data/rockchip/mediapipe /data/company/mediapipe
+sudo ln -s /data/rockchip/embedding /data/company/embedding
+sudo ln -s /data/test-platform/claude-wx /data/company/claude-wx
+sudo ln -s /data/test-platform/memory-analyze /data/company/memory-analyze
+# 设置宽松权限以便 agents 读写
+sudo chmod 755 /data/company
+```
+
+**Step 2: 验证 symlinks 正常**
+
+```bash
+ls -la /data/company/
+# Expected: 每个 symlink 都指向正确目录
+```
+
+**Step 3: 更新 projects.yaml**
+
+将 `configs/projects.yaml` 改为:
+
+```yaml
+scan_dirs:
+- /data/company
+exclude_projects:
+- paddle_ocr
+- rknn-toolkit2
+- rknn_model_zoo
+- rsdk
+- rk3588
+- rv1106
+- qt
+- info-privacy-rs
+- RV1103B_RV1106B_SDK_V1.2.2_20250228
+- visual-tester # 避免扫描 visual-tester 自身
+projects:
+ yolo:
+ mode: confirm
+ description: RKNN YOLO 目标检测,yolo12n 板端零检出待修复
+ mediapipe:
+ mode: auto
+ description: MediaPipe 关键点检测,Pose FPS 偏低
+ embedding:
+ mode: report
+ description: 向量数据库,基础功能完整
+ info-privacy:
+ mode: confirm
+ description: 隐私信息遮罩
+ logodetect-x86:
+ path: /data/company/agents/../rockchip/logodetect-x86
+ mode: auto
+ description: ''
+ memory-analyze:
+ mode: auto
+ description: 内存分析平台,含 PSI/eBPF/进程监控,持续优化架构与性能
+ claude-wx:
+ path: /data/company/claude-wx
+ mode: auto
+ description: Claude Code 飞书监控控制系统,Hook 审批+双向控制,自主优化中
+ ui-tars:
+ path: /data/rockchip/ui-tars
+ mode: auto
+ description: ''
+```
+
+> 注:`scan_dirs` 改为 `/data/company`;历史 `data/tasks.db` 保留不清除。
+
+**Step 4: 运行现有测试确保无破坏**
+
+```bash
+cd /data/rockchip/agents && source venv/bin/activate
+pytest tests/ -q --ignore=tests/test_edge_validator.py
+```
+
+Expected: 与现有通过数一致(≥200 passed)
+
+**Step 5: Commit**
+
+```bash
+git add configs/projects.yaml
+git commit -m "feat: migrate scan_dirs to /data/company, add visual-tester to exclude list"
+```
+
+---
+
+## Task 2: visual-tester 包结构 & SQLite 数据层(db.py)
+
+**Files:**
+- Create: `/data/company/visual-tester/` (目录结构)
+- Create: `/data/company/visual-tester/src/visual_tester/__init__.py`
+- Create: `/data/company/visual-tester/src/visual_tester/db.py`
+- Create: `/data/company/visual-tester/requirements.txt`
+- Create: `/data/company/visual-tester/pyproject.toml`
+- Test: `/data/company/visual-tester/tests/test_db.py`
+
+**Step 1: 创建目录结构**
+
+```bash
+mkdir -p /data/company/visual-tester/{src/visual_tester,tests,baselines,diffs,checkpoints,data}
+touch /data/company/visual-tester/src/visual_tester/__init__.py
+```
+
+**Step 2: 创建 pyproject.toml**
+
+```toml
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.backends.legacy:build"
+
+[project]
+name = "visual-tester"
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "pillow>=10.0",
+ "playwright>=1.40",
+]
+
+[tool.setuptools.packages.find]
+where = ["src"]
+```
+
+**Step 3: 创建 requirements.txt**
+
+```
+pillow>=10.0
+playwright>=1.40
+pytest>=8.0
+pytest-asyncio>=0.23
+fastapi>=0.110.0
+httpx>=0.27
+```
+
+**Step 4: 在 /data/company/visual-tester 创建 venv**
+
+```bash
+cd /data/company/visual-tester
+python3.10 -m venv venv
+source venv/bin/activate
+pip install -r requirements.txt
+pip install -e .
+```
+
+**Step 5: 写失败测试 tests/test_db.py**
+
+```python
+from __future__ import annotations
+import pytest
+from pathlib import Path
+from visual_tester.db import VisualDB
+
+
+def test_create_run_and_retrieve(tmp_path):
+ db = VisualDB(tmp_path / "v.db")
+ run_id = db.create_run("agents")
+ run = db.get_run(run_id)
+ assert run["project"] == "agents"
+ assert run["status"] == "running"
+
+
+def test_create_case_and_update_status(tmp_path):
+ db = VisualDB(tmp_path / "v.db")
+ run_id = db.create_run("agents")
+ case_id = db.create_case(run_id, "agents", "http://localhost:9080", "overview")
+ db.update_case_status(case_id, "passed", diff_pct=0.0)
+ case = db.get_case(case_id)
+ assert case["status"] == "passed"
+ assert case["diff_pct"] == 0.0
+
+
+def test_resume_resets_running_to_pending(tmp_path):
+ db = VisualDB(tmp_path / "v.db")
+ run_id = db.create_run("agents")
+ c1 = db.create_case(run_id, "agents", "http://localhost:9080", "page1")
+ c2 = db.create_case(run_id, "agents", "http://localhost:9080", "page2")
+ db.update_case_status(c1, "running")
+ db.update_case_status(c2, "passed")
+ db.reset_interrupted_run(run_id)
+ assert db.get_case(c1)["status"] == "pending" # running → pending
+ assert db.get_case(c2)["status"] == "passed" # passed 不变
+
+
+def test_baseline_set_and_get_current(tmp_path):
+ db = VisualDB(tmp_path / "v.db")
+ db.set_baseline("agents", "http://localhost:9080", "overview", "/path/a.png")
+ bl = db.get_current_baseline("agents", "http://localhost:9080", "overview")
+ assert bl["path"] == "/path/a.png"
+ assert bl["is_current"] == 1
+
+
+def test_baseline_version_switch(tmp_path):
+ db = VisualDB(tmp_path / "v.db")
+ db.set_baseline("agents", "http://localhost:9080", "overview", "/path/v1.png")
+ db.set_baseline("agents", "http://localhost:9080", "overview", "/path/v2.png")
+ # v2 是 current,v1 变 inactive
+ bl = db.get_current_baseline("agents", "http://localhost:9080", "overview")
+ assert bl["path"] == "/path/v2.png"
+ history = db.list_baselines("agents")
+ assert len(history) == 2
+```
+
+**Step 6: 运行测试确认失败**
+
+```bash
+cd /data/company/visual-tester && source venv/bin/activate
+pytest tests/test_db.py -v
+```
+
+Expected: ImportError / 5 errors(db.py 不存在)
+
+**Step 7: 实现 src/visual_tester/db.py**
+
+```python
+from __future__ import annotations
+
+import sqlite3
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+
+class VisualDB:
+ def __init__(self, db_path: str | Path) -> None:
+ self._path = Path(db_path)
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ self._init_db()
+
+ def _connect(self) -> sqlite3.Connection:
+ conn = sqlite3.connect(str(self._path))
+ conn.row_factory = sqlite3.Row
+ return conn
+
+ def _init_db(self) -> None:
+ with self._connect() as conn:
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS test_runs (
+ id TEXT PRIMARY KEY,
+ project TEXT NOT NULL,
+ started_at TEXT NOT NULL,
+ completed_at TEXT,
+ status TEXT NOT NULL DEFAULT 'running',
+ total INTEGER DEFAULT 0,
+ passed INTEGER DEFAULT 0,
+ failed INTEGER DEFAULT 0,
+ pending_review INTEGER DEFAULT 0
+ );
+ CREATE TABLE IF NOT EXISTS test_cases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ run_id TEXT NOT NULL,
+ project TEXT NOT NULL,
+ url TEXT NOT NULL,
+ name TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ screenshot_path TEXT,
+ baseline_path TEXT,
+ diff_path TEXT,
+ diff_pct REAL,
+ FOREIGN KEY (run_id) REFERENCES test_runs(id)
+ );
+ CREATE TABLE IF NOT EXISTS baselines (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ project TEXT NOT NULL,
+ url TEXT NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ commit_hash TEXT DEFAULT '',
+ created_at TEXT NOT NULL,
+ is_current INTEGER DEFAULT 1
+ );
+ CREATE TABLE IF NOT EXISTS approvals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ case_id INTEGER NOT NULL,
+ action TEXT NOT NULL,
+ reason TEXT DEFAULT '',
+ approved_by TEXT DEFAULT 'human',
+ approved_at TEXT NOT NULL,
+ FOREIGN KEY (case_id) REFERENCES test_cases(id)
+ );
+ """)
+
+ def create_run(self, project: str) -> str:
+ run_id = str(uuid.uuid4())[:8]
+ with self._connect() as conn:
+ conn.execute(
+ "INSERT INTO test_runs (id, project, started_at, status) VALUES (?,?,?,?)",
+ (run_id, project, datetime.now().isoformat(), "running"),
+ )
+ return run_id
+
+ def get_run(self, run_id: str) -> dict[str, Any] | None:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM test_runs WHERE id=?", (run_id,)).fetchone()
+ return dict(row) if row else None
+
+ def update_run_status(self, run_id: str, status: str) -> None:
+ completed = datetime.now().isoformat() if status in ("done", "interrupted") else None
+ with self._connect() as conn:
+ conn.execute(
+ "UPDATE test_runs SET status=?, completed_at=? WHERE id=?",
+ (status, completed, run_id),
+ )
+
+ def create_case(self, run_id: str, project: str, url: str, name: str) -> int:
+ with self._connect() as conn:
+ cur = conn.execute(
+ "INSERT INTO test_cases (run_id, project, url, name, status) VALUES (?,?,?,?,?)",
+ (run_id, project, url, name, "pending"),
+ )
+ conn.execute("UPDATE test_runs SET total=total+1 WHERE id=?", (run_id,))
+ return cur.lastrowid # type: ignore[return-value]
+
+ def get_case(self, case_id: int) -> dict[str, Any] | None:
+ with self._connect() as conn:
+ row = conn.execute("SELECT * FROM test_cases WHERE id=?", (case_id,)).fetchone()
+ return dict(row) if row else None
+
+ def update_case_status(
+ self, case_id: int, status: str,
+ diff_pct: float | None = None,
+ screenshot_path: str | None = None,
+ baseline_path: str | None = None,
+ diff_path: str | None = None,
+ ) -> None:
+ with self._connect() as conn:
+ conn.execute(
+ """UPDATE test_cases SET status=?, diff_pct=?,
+ screenshot_path=COALESCE(?, screenshot_path),
+ baseline_path=COALESCE(?, baseline_path),
+ diff_path=COALESCE(?, diff_path)
+ WHERE id=?""",
+ (status, diff_pct, screenshot_path, baseline_path, diff_path, case_id),
+ )
+ row = conn.execute("SELECT run_id FROM test_cases WHERE id=?", (case_id,)).fetchone()
+ if row and status in ("passed", "failed", "needs_review"):
+ col = {"passed": "passed", "failed": "failed", "needs_review": "pending_review"}[status]
+ conn.execute(f"UPDATE test_runs SET {col}={col}+1 WHERE id=?", (row["run_id"],))
+
+ def reset_interrupted_run(self, run_id: str) -> None:
+ """将 running 状态的 case 重置为 pending(断点恢复)。"""
+ with self._connect() as conn:
+ conn.execute(
+ "UPDATE test_cases SET status='pending' WHERE run_id=? AND status='running'",
+ (run_id,),
+ )
+
+ def get_pending_cases(self, run_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ "SELECT * FROM test_cases WHERE run_id=? AND status='pending' ORDER BY id",
+ (run_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def set_baseline(self, project: str, url: str, name: str, path: str, commit_hash: str = "") -> int:
+ with self._connect() as conn:
+ conn.execute(
+ "UPDATE baselines SET is_current=0 WHERE project=? AND url=? AND name=?",
+ (project, url, name),
+ )
+ cur = conn.execute(
+ "INSERT INTO baselines (project, url, name, path, commit_hash, created_at, is_current) VALUES (?,?,?,?,?,?,1)",
+ (project, url, name, path, commit_hash, datetime.now().isoformat()),
+ )
+ return cur.lastrowid # type: ignore[return-value]
+
+ def get_current_baseline(self, project: str, url: str, name: str) -> dict[str, Any] | None:
+ with self._connect() as conn:
+ row = conn.execute(
+ "SELECT * FROM baselines WHERE project=? AND url=? AND name=? AND is_current=1",
+ (project, url, name),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def list_baselines(self, project: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ "SELECT * FROM baselines WHERE project=? ORDER BY created_at DESC",
+ (project,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_runs(self, project: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ if project:
+ rows = conn.execute(
+ "SELECT * FROM test_runs WHERE project=? ORDER BY started_at DESC LIMIT ?",
+ (project, limit),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT * FROM test_runs ORDER BY started_at DESC LIMIT ?", (limit,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_cases(self, run_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ "SELECT * FROM test_cases WHERE run_id=? ORDER BY id", (run_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def add_approval(self, case_id: int, action: str, reason: str = "", approved_by: str = "human") -> None:
+ with self._connect() as conn:
+ conn.execute(
+ "INSERT INTO approvals (case_id, action, reason, approved_by, approved_at) VALUES (?,?,?,?,?)",
+ (case_id, action, reason, approved_by, datetime.now().isoformat()),
+ )
+```
+
+**Step 8: 运行测试确认通过**
+
+```bash
+pytest tests/test_db.py -v
+```
+
+Expected: 5 passed
+
+**Step 9: Commit**
+
+```bash
+cd /data/company/visual-tester
+git init && git add .
+git commit -m "feat: init visual-tester package with SQLite data layer"
+```
+
+> 注:`/data/company/visual-tester` 是独立 git 仓库,不提交到 `/data/rockchip/agents`。
+
+---
+
+## Task 3: 自动发现模块(discovery.py)
+
+**Files:**
+- Create: `/data/company/visual-tester/src/visual_tester/discovery.py`
+- Test: `/data/company/visual-tester/tests/test_discovery.py`
+
+**Step 1: 写失败测试**
+
+```python
+from __future__ import annotations
+import pytest
+from pathlib import Path
+from visual_tester.discovery import discover_urls
+
+
+def test_discover_from_readme(tmp_path):
+ (tmp_path / "README.md").write_text("访问 http://localhost:9080 查看 Dashboard\n")
+ urls = discover_urls(tmp_path)
+ assert "http://localhost:9080" in urls
+
+
+def test_discover_from_claude_md(tmp_path):
+ (tmp_path / "CLAUDE.md").write_text("## 服务端口\n- Dashboard: http://localhost:8080\n")
+ urls = discover_urls(tmp_path)
+ assert "http://localhost:8080" in urls
+
+
+def test_discover_from_docker_compose(tmp_path):
+ (tmp_path / "docker-compose.yml").write_text(
+ 'services:\n dashboard:\n ports:\n - "9080:8080"\n'
+ )
+ urls = discover_urls(tmp_path)
+ assert "http://localhost:9080" in urls
+
+
+def test_no_duplicates(tmp_path):
+ (tmp_path / "README.md").write_text("http://localhost:9080 and http://localhost:9080")
+ urls = discover_urls(tmp_path)
+ assert urls.count("http://localhost:9080") == 1
+
+
+def test_no_urls_returns_empty(tmp_path):
+ urls = discover_urls(tmp_path)
+ assert urls == []
+```
+
+**Step 2: 运行测试确认失败**
+
+```bash
+pytest tests/test_discovery.py -v
+```
+
+Expected: ImportError(discovery.py 不存在)
+
+**Step 3: 实现 src/visual_tester/discovery.py**
+
+```python
+from __future__ import annotations
+
+import re
+import yaml
+from pathlib import Path
+
+
+_URL_RE = re.compile(r"https?://localhost:\d+")
+_DOCKER_PORT_RE = re.compile(r'"(\d+):\d+"')
+
+
+def discover_urls(project_path: str | Path) -> list[str]:
+ """从项目目录中自动发现 Web UI URL,优先级:CLAUDE.md > README.md > docker-compose.yml。"""
+ path = Path(project_path)
+ urls: list[str] = []
+
+ for filename in ("CLAUDE.md", "README.md"):
+ f = path / filename
+ if f.exists():
+ found = _URL_RE.findall(f.read_text(encoding="utf-8", errors="ignore"))
+ urls.extend(found)
+
+ compose = path / "docker-compose.yml"
+ if compose.exists():
+ text = compose.read_text(encoding="utf-8", errors="ignore")
+ for port in _DOCKER_PORT_RE.findall(text):
+ urls.append(f"http://localhost:{port}")
+
+ # 去重,保持顺序
+ seen: set[str] = set()
+ result: list[str] = []
+ for u in urls:
+ if u not in seen:
+ seen.add(u)
+ result.append(u)
+ return result
+```
+
+**Step 4: 运行测试确认通过**
+
+```bash
+pytest tests/test_discovery.py -v
+```
+
+Expected: 5 passed
+
+**Step 5: Commit**
+
+```bash
+git add src/visual_tester/discovery.py tests/test_discovery.py
+git commit -m "feat: add URL auto-discovery from CLAUDE.md/README/docker-compose"
+```
+
+---
+
+## Task 4: 差异计算模块(differ.py)
+
+**Files:**
+- Create: `/data/company/visual-tester/src/visual_tester/differ.py`
+- Test: `/data/company/visual-tester/tests/test_differ.py`
+
+**Step 1: 写失败测试**
+
+```python
+from __future__ import annotations
+import pytest
+from pathlib import Path
+from PIL import Image
+from visual_tester.differ import ImageDiffer
+
+
+def _make_image(path: Path, color: tuple) -> Path:
+ img = Image.new("RGB", (100, 100), color)
+ img.save(path)
+ return path
+
+
+def test_identical_images_zero_diff(tmp_path):
+ a = _make_image(tmp_path / "a.png", (255, 0, 0))
+ b = _make_image(tmp_path / "b.png", (255, 0, 0))
+ diff = ImageDiffer()
+ pct, _ = diff.compare(a, b, tmp_path / "diff.png")
+ assert pct == 0.0
+
+
+def test_different_images_nonzero_diff(tmp_path):
+ a = _make_image(tmp_path / "a.png", (255, 0, 0))
+ b = _make_image(tmp_path / "b.png", (0, 255, 0))
+ diff = ImageDiffer()
+ pct, diff_path = diff.compare(a, b, tmp_path / "diff.png")
+ assert pct > 50.0
+ assert diff_path.exists()
+
+
+def test_diff_image_saved(tmp_path):
+ a = _make_image(tmp_path / "a.png", (100, 100, 100))
+ b = _make_image(tmp_path / "b.png", (200, 200, 200))
+ diff = ImageDiffer()
+ _, diff_path = diff.compare(a, b, tmp_path / "diff.png")
+ assert diff_path.exists()
+ result_img = Image.open(diff_path)
+ assert result_img.size == (100, 100)
+
+
+def test_size_mismatch_raises(tmp_path):
+ a = Image.new("RGB", (100, 100), (255, 0, 0))
+ a.save(tmp_path / "a.png")
+ b = Image.new("RGB", (200, 100), (0, 255, 0))
+ b.save(tmp_path / "b.png")
+ diff = ImageDiffer()
+ with pytest.raises(ValueError, match="size mismatch"):
+ diff.compare(tmp_path / "a.png", tmp_path / "b.png", tmp_path / "diff.png")
+```
+
+**Step 2: 运行测试确认失败**
+
+```bash
+pytest tests/test_differ.py -v
+```
+
+Expected: ImportError
+
+**Step 3: 实现 src/visual_tester/differ.py**
+
+```python
+from __future__ import annotations
+
+from pathlib import Path
+import numpy as np
+from PIL import Image, ImageChops
+
+
+class ImageDiffer:
+ """基于像素级别的图像差异计算器。"""
+
+ def compare(
+ self,
+ baseline: str | Path,
+ current: str | Path,
+ diff_out: str | Path,
+ ) -> tuple[float, Path]:
+ """
+ Returns:
+ (diff_pct, diff_image_path)
+ diff_pct: 不同像素占总像素的百分比 (0.0 ~ 100.0)
+ """
+ img_a = Image.open(baseline).convert("RGB")
+ img_b = Image.open(current).convert("RGB")
+
+ if img_a.size != img_b.size:
+ raise ValueError(
+ f"size mismatch: baseline {img_a.size} vs current {img_b.size}"
+ )
+
+ arr_a = np.array(img_a, dtype=np.int16)
+ arr_b = np.array(img_b, dtype=np.int16)
+ diff_arr = np.abs(arr_a - arr_b)
+
+ # 像素差异掩码:任一通道差异 > 10 视为不同
+ mask = diff_arr.max(axis=2) > 10
+ diff_pct = float(mask.sum()) / mask.size * 100.0
+
+ # 生成差异可视化图(红色标记差异像素)
+ diff_img = Image.fromarray(
+ np.where(mask[:, :, None], [255, 0, 0], np.array(img_a)).astype(np.uint8)
+ )
+ diff_out_path = Path(diff_out)
+ diff_out_path.parent.mkdir(parents=True, exist_ok=True)
+ diff_img.save(diff_out_path)
+
+ return diff_pct, diff_out_path
+```
+
+**Step 4: 安装 numpy 并运行测试**
+
+```bash
+pip install numpy
+pytest tests/test_differ.py -v
+```
+
+Expected: 4 passed
+
+**Step 5: 更新 requirements.txt 加 numpy**
+
+```
+pillow>=10.0
+numpy>=1.24
+playwright>=1.40
+pytest>=8.0
+pytest-asyncio>=0.23
+fastapi>=0.110.0
+httpx>=0.27
+```
+
+**Step 6: Commit**
+
+```bash
+git add src/visual_tester/differ.py tests/test_differ.py requirements.txt
+git commit -m "feat: add pixel-level image differ with diff visualization"
+```
+
+---
+
+## Task 5: 测试运行器(runner.py)+ checkpoint
+
+**Files:**
+- Create: `/data/company/visual-tester/src/visual_tester/runner.py`
+- Test: `/data/company/visual-tester/tests/test_runner.py`
+
+**Step 1: 写失败测试(不依赖真实 Playwright,使用 mock)**
+
+```python
+from __future__ import annotations
+import json
+import pytest
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+from visual_tester.db import VisualDB
+from visual_tester.runner import VisualRunner
+
+
+def _make_runner(tmp_path: Path) -> VisualRunner:
+ db = VisualDB(tmp_path / "v.db")
+ runner = VisualRunner(
+ db=db,
+ baselines_dir=tmp_path / "baselines",
+ diffs_dir=tmp_path / "diffs",
+ checkpoints_dir=tmp_path / "checkpoints",
+ diff_threshold=0.5,
+ )
+ return runner
+
+
+def test_new_run_creates_run_record(tmp_path):
+ runner = _make_runner(tmp_path)
+ cases = [{"url": "http://localhost:9080", "name": "overview"}]
+ run_id = runner.start_run("agents", cases)
+ run = runner.db.get_run(run_id)
+ assert run["project"] == "agents"
+ assert run["status"] == "running"
+ assert run["total"] == 1
+
+
+def test_resume_run_resets_running_cases(tmp_path):
+ runner = _make_runner(tmp_path)
+ cases = [{"url": "http://localhost:9080", "name": "overview"}]
+ run_id = runner.start_run("agents", cases)
+ # 模拟 case 卡在 running 状态
+ case_id = runner.db.get_pending_cases(run_id)[0]["id"]
+ runner.db.update_case_status(case_id, "running")
+ runner.resume_run(run_id)
+ assert runner.db.get_case(case_id)["status"] == "pending"
+
+
+def test_checkpoint_written_after_case(tmp_path):
+ runner = _make_runner(tmp_path)
+ cases = [{"url": "http://localhost:9080", "name": "overview"}]
+ run_id = runner.start_run("agents", cases)
+
+ # mock Playwright screenshot + differ
+ with patch.object(runner, "_take_screenshot", return_value=tmp_path / "ss.png"):
+ with patch.object(runner, "_compare", return_value=(0.0, None)):
+ runner._process_case(
+ run_id,
+ runner.db.get_pending_cases(run_id)[0],
+ )
+
+ cp_file = tmp_path / "checkpoints" / f"{run_id}.json"
+ assert cp_file.exists()
+ cp = json.loads(cp_file.read_text())
+ assert cp["run_id"] == run_id
+
+
+def test_no_baseline_sets_as_new_baseline(tmp_path):
+ runner = _make_runner(tmp_path)
+ ss_path = tmp_path / "ss.png"
+ # 创建假截图文件
+ from PIL import Image
+ Image.new("RGB", (10, 10), (0, 0, 0)).save(ss_path)
+
+ with patch.object(runner, "_take_screenshot", return_value=ss_path):
+ run_id = runner.start_run("agents", [{"url": "http://localhost:9080", "name": "home"}])
+ case = runner.db.get_pending_cases(run_id)[0]
+ runner._process_case(run_id, case)
+
+ bl = runner.db.get_current_baseline("agents", "http://localhost:9080", "home")
+ assert bl is not None
+ updated = runner.db.get_case(case["id"])
+ assert updated["status"] == "passed"
+
+
+def test_diff_above_threshold_sets_needs_review(tmp_path):
+ runner = _make_runner(tmp_path)
+ # 预设基线
+ bl_path = tmp_path / "baselines" / "bl.png"
+ bl_path.parent.mkdir(parents=True)
+ from PIL import Image
+ Image.new("RGB", (10, 10), (0, 0, 0)).save(bl_path)
+ runner.db.set_baseline("agents", "http://localhost:9080", "home", str(bl_path))
+
+ ss_path = tmp_path / "ss.png"
+ Image.new("RGB", (10, 10), (255, 255, 255)).save(ss_path)
+
+ with patch.object(runner, "_take_screenshot", return_value=ss_path):
+ # diff 超过阈值
+ with patch.object(runner, "_compare", return_value=(5.0, tmp_path / "diff.png")):
+ run_id = runner.start_run("agents", [{"url": "http://localhost:9080", "name": "home"}])
+ case = runner.db.get_pending_cases(run_id)[0]
+ runner._process_case(run_id, case)
+
+ updated = runner.db.get_case(case["id"])
+ assert updated["status"] == "needs_review"
+```
+
+**Step 2: 运行测试确认失败**
+
+```bash
+pytest tests/test_runner.py -v
+```
+
+Expected: ImportError(runner.py 不存在)
+
+**Step 3: 实现 src/visual_tester/runner.py**
+
+```python
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from visual_tester.db import VisualDB
+from visual_tester.differ import ImageDiffer
+
+logger = logging.getLogger(__name__)
+
+
+class VisualRunner:
+ """Playwright 截图驱动的视觉回归测试运行器,支持四级断点恢复。"""
+
+ def __init__(
+ self,
+ db: VisualDB,
+ baselines_dir: str | Path,
+ diffs_dir: str | Path,
+ checkpoints_dir: str | Path,
+ diff_threshold: float = 0.5,
+ ) -> None:
+ self.db = db
+ self.baselines_dir = Path(baselines_dir)
+ self.diffs_dir = Path(diffs_dir)
+ self.checkpoints_dir = Path(checkpoints_dir)
+ self.diff_threshold = diff_threshold
+ self._differ = ImageDiffer()
+ for d in (self.baselines_dir, self.diffs_dir, self.checkpoints_dir):
+ d.mkdir(parents=True, exist_ok=True)
+
+ def start_run(self, project: str, cases: list[dict[str, Any]]) -> str:
+ run_id = self.db.create_run(project)
+ for case in cases:
+ self.db.create_case(run_id, project, case["url"], case["name"])
+ return run_id
+
+ def resume_run(self, run_id: str) -> None:
+ """将该 run 中 status=running 的 case 重置为 pending(断点恢复)。"""
+ self.db.reset_interrupted_run(run_id)
+
+ def run(self, run_id: str) -> None:
+ """执行所有 pending cases;断点恢复时跳过已完成的 case。"""
+ self.resume_run(run_id)
+ run = self.db.get_run(run_id)
+ if not run:
+ raise ValueError(f"run {run_id} not found")
+
+ try:
+ for case in self.db.get_pending_cases(run_id):
+ self._process_case(run_id, case)
+ self.db.update_run_status(run_id, "done")
+ except Exception:
+ self.db.update_run_status(run_id, "interrupted")
+ raise
+
+ def _process_case(self, run_id: str, case: dict[str, Any]) -> None:
+ case_id = case["id"]
+ self.db.update_case_status(case_id, "running")
+
+ try:
+ ss_path = self._take_screenshot(case["url"], run_id, case_id)
+ bl = self.db.get_current_baseline(case["project"], case["url"], case["name"])
+
+ if bl is None:
+ # 无基线 → 自动设为基线,通过
+ new_bl = self.baselines_dir / case["project"] / f"{case['name']}.png"
+ new_bl.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(ss_path, new_bl)
+ self.db.set_baseline(case["project"], case["url"], case["name"], str(new_bl))
+ self.db.update_case_status(
+ case_id, "passed", diff_pct=0.0,
+ screenshot_path=str(ss_path), baseline_path=str(new_bl),
+ )
+ else:
+ diff_out = self.diffs_dir / run_id / f"{case_id}_diff.png"
+ diff_pct, diff_path = self._compare(bl["path"], ss_path, diff_out)
+ status = "passed" if diff_pct < self.diff_threshold else "needs_review"
+ self.db.update_case_status(
+ case_id, status, diff_pct=diff_pct,
+ screenshot_path=str(ss_path),
+ baseline_path=bl["path"],
+ diff_path=str(diff_path) if diff_path else None,
+ )
+ except Exception as e:
+ logger.error("case %s failed: %s", case_id, e)
+ self.db.update_case_status(case_id, "failed")
+ finally:
+ self._write_checkpoint(run_id, case_id)
+
+ def _take_screenshot(self, url: str, run_id: str, case_id: int) -> Path:
+ """使用 Playwright 截图(懒加载,避免 import 污染测试)。"""
+ from playwright.sync_api import sync_playwright # type: ignore[import]
+
+ out = self.diffs_dir / run_id / f"{case_id}_current.png"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ page = browser.new_page(viewport={"width": 1280, "height": 720})
+ page.goto(url, timeout=15000)
+ page.wait_for_load_state("networkidle", timeout=10000)
+ page.screenshot(path=str(out), full_page=False)
+ browser.close()
+ return out
+
+ def _compare(
+ self, baseline_path: str, current_path: Path, diff_out: Path
+ ) -> tuple[float, Path | None]:
+ try:
+ return self._differ.compare(baseline_path, current_path, diff_out)
+ except ValueError:
+ # 尺寸不匹配时,更新基线
+ return 100.0, None
+
+ def _write_checkpoint(self, run_id: str, last_case_id: int) -> None:
+ cp = {
+ "run_id": run_id,
+ "last_case_id": last_case_id,
+ "paused_at": datetime.now().isoformat(),
+ }
+ cp_file = self.checkpoints_dir / f"{run_id}.json"
+ cp_file.write_text(json.dumps(cp, ensure_ascii=False, indent=2))
+```
+
+**Step 4: 运行测试确认通过**
+
+```bash
+pytest tests/test_runner.py -v
+```
+
+Expected: 5 passed
+
+**Step 5: Commit**
+
+```bash
+git add src/visual_tester/runner.py tests/test_runner.py
+git commit -m "feat: add VisualRunner with checkpoint-based resume support"
+```
+
+---
+
+## Task 6: Dashboard API 扩展(/api/visual/* 端点)
+
+**Files:**
+- Modify: `/data/rockchip/agents/src/rockchip_agents/dashboard/api.py`
+- Test: `/data/rockchip/agents/tests/test_dashboard_visual.py`
+
+**Context:** `api.py` 是 753 行的 FastAPI 应用。在 `make_app()` 函数末尾添加 visual 路由。
+visual-tester 包已安装在 `/data/company/visual-tester/venv`,Dashboard 运行时需能 import。
+暂时**不要求**真实 Playwright 执行——API 层只负责读写 visual.db,runner 由后台线程或 CLI 触发。
+
+**Step 1: 写失败测试 tests/test_dashboard_visual.py**
+
+```python
+from __future__ import annotations
+import pytest
+from unittest.mock import patch, MagicMock
+from fastapi.testclient import TestClient
+from rockchip_agents.dashboard.api import make_app
+from rockchip_agents.core.queue import TaskQueue
+
+
+@pytest.fixture
+def client(tmp_path):
+ queue = TaskQueue(tmp_path / "tasks.db")
+ # visual_db_path 指向 tmp_path 下的测试数据库
+ app = make_app(queue=queue, visual_db_path=str(tmp_path / "visual.db"))
+ return TestClient(app)
+
+
+def test_visual_runs_empty(client):
+ resp = client.get("/api/visual/runs")
+ assert resp.status_code == 200
+ assert resp.json() == []
+
+
+def test_visual_runs_with_data(client, tmp_path):
+ from visual_tester.db import VisualDB
+ db = VisualDB(tmp_path / "visual.db")
+ run_id = db.create_run("agents")
+ resp = client.get("/api/visual/runs")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) == 1
+ assert data[0]["project"] == "agents"
+
+
+def test_visual_cases_for_run(client, tmp_path):
+ from visual_tester.db import VisualDB
+ db = VisualDB(tmp_path / "visual.db")
+ run_id = db.create_run("agents")
+ db.create_case(run_id, "agents", "http://localhost:9080", "home")
+ resp = client.get(f"/api/visual/runs/{run_id}/cases")
+ assert resp.status_code == 200
+ assert len(resp.json()) == 1
+
+
+def test_approve_case_updates_baseline(client, tmp_path):
+ from visual_tester.db import VisualDB
+ from PIL import Image
+ db = VisualDB(tmp_path / "visual.db")
+ run_id = db.create_run("agents")
+ case_id = db.create_case(run_id, "agents", "http://localhost:9080", "home")
+ # 准备假截图文件
+ ss_path = tmp_path / "ss.png"
+ Image.new("RGB", (10, 10)).save(ss_path)
+ db.update_case_status(case_id, "needs_review",
+ diff_pct=5.0, screenshot_path=str(ss_path))
+ resp = client.post(f"/api/visual/cases/{case_id}/approve", json={"reason": "looks good"})
+ assert resp.status_code == 200
+ updated = db.get_case(case_id)
+ assert updated["status"] == "passed"
+
+
+def test_reject_case_marks_failed(client, tmp_path):
+ from visual_tester.db import VisualDB
+ db = VisualDB(tmp_path / "visual.db")
+ run_id = db.create_run("agents")
+ case_id = db.create_case(run_id, "agents", "http://localhost:9080", "home")
+ db.update_case_status(case_id, "needs_review", diff_pct=5.0)
+ resp = client.post(f"/api/visual/cases/{case_id}/reject", json={"reason": "regression"})
+ assert resp.status_code == 200
+ updated = db.get_case(case_id)
+ assert updated["status"] == "failed"
+```
+
+**Step 2: 运行测试确认失败**
+
+```bash
+cd /data/rockchip/agents && source venv/bin/activate
+# 先把 visual_tester 安装到 agents venv
+pip install -e /data/company/visual-tester/
+pytest tests/test_dashboard_visual.py -v
+```
+
+Expected: ImportError 或 TypeError(make_app 不接受 visual_db_path 参数)
+
+**Step 3: 修改 api.py 中的 make_app 签名和路由**
+
+找到 `make_app()` 函数定义(约在第 200 行),添加 `visual_db_path` 参数:
+
+```python
+# 在文件顶部 imports 后添加(若 visual_tester 未安装则跳过)
+try:
+ from visual_tester.db import VisualDB as _VisualDB
+ _HAS_VISUAL = True
+except ImportError:
+ _HAS_VISUAL = False
+
+
+# make_app 签名修改(在现有参数后追加):
+def make_app(
+ db_path: str | Path = "data/tasks.db",
+ config_dir: str | Path = "configs",
+ queue: TaskQueue | None = None,
+ visual_db_path: str | None = None,
+) -> FastAPI:
+```
+
+在 `make_app()` 末尾(return app 之前)添加:
+
+```python
+ # ── Visual Tester 路由 ───────────────────────────────────────────────
+ if _HAS_VISUAL:
+ _vdb_path = visual_db_path or "/data/company/visual-tester/data/visual.db"
+ _visual_db = _VisualDB(_vdb_path)
+
+ class _ApproveBody(BaseModel):
+ reason: str = ""
+
+ @app.get("/api/visual/runs")
+ def visual_runs(project: Optional[str] = None):
+ return _visual_db.get_runs(project=project, limit=20)
+
+ @app.get("/api/visual/runs/{run_id}/cases")
+ def visual_cases(run_id: str):
+ return _visual_db.get_cases(run_id)
+
+ @app.post("/api/visual/cases/{case_id}/approve")
+ def visual_approve(case_id: int, body: _ApproveBody):
+ case = _visual_db.get_case(case_id)
+ if not case:
+ raise HTTPException(404, "case not found")
+ # 审批通过:当前截图升级为新基线
+ if case.get("screenshot_path"):
+ _visual_db.set_baseline(
+ case["project"], case["url"], case["name"], case["screenshot_path"]
+ )
+ _visual_db.update_case_status(case_id, "passed")
+ _visual_db.add_approval(case_id, "approve", body.reason)
+ return {"ok": True}
+
+ @app.post("/api/visual/cases/{case_id}/reject")
+ def visual_reject(case_id: int, body: _ApproveBody):
+ case = _visual_db.get_case(case_id)
+ if not case:
+ raise HTTPException(404, "case not found")
+ _visual_db.update_case_status(case_id, "failed")
+ _visual_db.add_approval(case_id, "reject", body.reason)
+ return {"ok": True}
+
+ @app.get("/api/visual/baselines/{project}")
+ def visual_baselines(project: str):
+ return _visual_db.list_baselines(project)
+
+ @app.post("/api/visual/runs/{run_id}/resume")
+ def visual_resume(run_id: str):
+ _visual_db.reset_interrupted_run(run_id)
+ return {"ok": True, "run_id": run_id}
+
+ return app
+```
+
+**Step 4: 运行测试确认通过**
+
+```bash
+pytest tests/test_dashboard_visual.py -v
+```
+
+Expected: 5 passed
+
+**Step 5: 运行全量回归测试**
+
+```bash
+pytest tests/ -q --ignore=tests/test_edge_validator.py
+```
+
+Expected: ≥205 passed(新增 5 个)
+
+**Step 6: Commit**
+
+```bash
+git add src/rockchip_agents/dashboard/api.py tests/test_dashboard_visual.py
+git commit -m "feat: add /api/visual/* endpoints to Dashboard for visual regression review"
+```
+
+---
+
+## Task 7: Dashboard 前端 Visual 标签页
+
+**Files:**
+- Create: `/data/rockchip/agents/dashboard/src/components/VisualView.tsx`
+- Modify: `/data/rockchip/agents/dashboard/src/App.tsx`
+- Modify: `/data/rockchip/agents/dashboard/src/components/TopBar.tsx`
+- Modify: `/data/rockchip/agents/dashboard/src/lib/api.ts`(新增 visual API 函数)
+
+**Step 1: 在 api.ts 添加 visual 相关 API 函数**
+
+首先查看现有 api.ts:
+
+```bash
+cat /data/rockchip/agents/dashboard/src/lib/api.ts
+```
+
+在文件末尾追加:
+
+```typescript
+// ── Visual Tester API ──────────────────────────────────────────────
+
+export interface VisualRun {
+ id: string
+ project: string
+ started_at: string
+ completed_at: string | null
+ status: 'running' | 'done' | 'interrupted'
+ total: number
+ passed: number
+ failed: number
+ pending_review: number
+}
+
+export interface VisualCase {
+ id: number
+ run_id: string
+ project: string
+ url: string
+ name: string
+ status: 'pending' | 'running' | 'passed' | 'failed' | 'needs_review' | 'skipped'
+ screenshot_path: string | null
+ baseline_path: string | null
+ diff_path: string | null
+ diff_pct: number | null
+}
+
+export async function fetchVisualRuns(project?: string): Promise {
+ const url = project ? `/api/visual/runs?project=${project}` : '/api/visual/runs'
+ const r = await fetch(url)
+ return r.json()
+}
+
+export async function fetchVisualCases(runId: string): Promise {
+ const r = await fetch(`/api/visual/runs/${runId}/cases`)
+ return r.json()
+}
+
+export async function approveVisualCase(caseId: number, reason = ''): Promise {
+ await fetch(`/api/visual/cases/${caseId}/approve`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reason }),
+ })
+}
+
+export async function rejectVisualCase(caseId: number, reason = ''): Promise {
+ await fetch(`/api/visual/cases/${caseId}/reject`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reason }),
+ })
+}
+```
+
+**Step 2: 创建 VisualView.tsx**
+
+```tsx
+import { useEffect, useState } from 'react'
+import {
+ fetchVisualRuns, fetchVisualCases, approveVisualCase, rejectVisualCase,
+ type VisualRun, type VisualCase,
+} from '../lib/api'
+
+const STATUS_BADGE: Record = {
+ passed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
+ failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
+ needs_review: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
+ running: 'bg-blue-100 text-blue-800',
+ pending: 'bg-slate-100 text-slate-600',
+ skipped: 'bg-gray-100 text-gray-500',
+ done: 'bg-green-100 text-green-800',
+ interrupted: 'bg-red-100 text-red-800',
+}
+
+function RunRow({ run, selected, onClick }: { run: VisualRun; selected: boolean; onClick: () => void }) {
+ const pct = run.total > 0 ? Math.round(run.passed / run.total * 100) : 0
+ return (
+
+
+ {run.project}
+
+ {run.status}
+
+
+
+ {run.started_at.slice(0, 16)}
+ {run.passed}/{run.total} passed
+ {run.pending_review > 0 && (
+ ⚠ {run.pending_review} 待审批
+ )}
+
+ {run.total > 0 && (
+
+ )}
+
+ )
+}
+
+function CaseRow({ c, onApprove, onReject }: {
+ c: VisualCase
+ onApprove: (id: number) => void
+ onReject: (id: number) => void
+}) {
+ return (
+
+
+
{c.name}
+
+
+ {c.status}
+
+ {c.diff_pct != null && (
+ diff {c.diff_pct.toFixed(2)}%
+ )}
+
+
+
+ {['baseline_path', 'screenshot_path', 'diff_path'].map((key, i) => {
+ const labels = ['基线', '当前', '差异']
+ const path = c[key as keyof VisualCase] as string | null
+ return (
+
+
{labels[i]}
+ {path ? (
+
![{labels[i]}]({`/api/visual/image?path=${encodeURIComponent(path)}`})
{ (e.target as HTMLImageElement).style.display = 'none' }}
+ />
+ ) : (
+
+ 无
+
+ )}
+
+ )
+ })}
+
+ {c.status === 'needs_review' && (
+
+
+
+
+ )}
+
+ )
+}
+
+export function VisualView() {
+ const [runs, setRuns] = useState([])
+ const [selectedRun, setSelectedRun] = useState(null)
+ const [cases, setCases] = useState([])
+
+ const pendingReviewTotal = runs.reduce((s, r) => s + r.pending_review, 0)
+
+ useEffect(() => {
+ fetchVisualRuns().then(setRuns).catch(console.warn)
+ }, [])
+
+ async function loadCases(runId: string) {
+ setSelectedRun(runId)
+ const cs = await fetchVisualCases(runId)
+ setCases(cs)
+ }
+
+ async function handleApprove(caseId: number) {
+ await approveVisualCase(caseId)
+ if (selectedRun) loadCases(selectedRun)
+ fetchVisualRuns().then(setRuns)
+ }
+
+ async function handleReject(caseId: number) {
+ await rejectVisualCase(caseId)
+ if (selectedRun) loadCases(selectedRun)
+ fetchVisualRuns().then(setRuns)
+ }
+
+ return (
+
+ {/* 左侧:运行列表 */}
+
+
+
视觉测试运行记录
+ {pendingReviewTotal > 0 && (
+
⚠ {pendingReviewTotal} 个截图待审批
+ )}
+
+ {runs.length === 0 ? (
+
暂无运行记录
+ ) : (
+ runs.map(r => (
+
loadCases(r.id)}
+ />
+ ))
+ )}
+
+
+ {/* 右侧:case 列表 */}
+
+ {!selectedRun ? (
+
+ 选择左侧运行记录查看详情
+
+ ) : cases.length === 0 ? (
+
暂无测试用例
+ ) : (
+
+ {cases.map(c => (
+
+ ))}
+
+ )}
+
+
+ )
+}
+```
+
+**Step 3: 修改 TopBar.tsx 添加 visual tab**
+
+找到 TopBar 中的 tab 列表,添加 visual 选项:
+
+```tsx
+// 在 TopBar 中,找到现有的 tab 切换按钮数组,追加 visual tab
+// 现有 tabs 类似:['overview', 'kanban', 'config']
+// 改为:
+const TABS = [
+ { key: 'overview', label: '概览' },
+ { key: 'kanban', label: '看板' },
+ { key: 'visual', label: '视觉测试' },
+ { key: 'config', label: '配置' },
+] as const
+```
+
+具体修改时读取 TopBar.tsx 现有代码,找到 tab 按钮渲染处,按格式追加 visual tab 即可。
+
+**Step 4: 修改 App.tsx 添加 visual tab 路由**
+
+```tsx
+// 在 tab state 类型中添加 'visual'
+const [tab, setTab] = useState<'overview' | 'kanban' | 'config' | 'visual'>('overview')
+
+// 在 TopBar 的 onTabChange 处,类型已自动匹配
+
+// 在 main 中添加:
+{tab === 'visual' && }
+```
+
+同时在文件顶部添加 import:
+```tsx
+import { VisualView } from './components/VisualView'
+```
+
+**Step 5: 在 api.py 添加图片服务端点(VisualView 用来显示截图)**
+
+在 Task 6 的 visual 路由块中追加:
+
+```python
+from fastapi.responses import FileResponse
+
+@app.get("/api/visual/image")
+def visual_image(path: str):
+ p = Path(path)
+ if not p.exists():
+ raise HTTPException(404, "image not found")
+ return FileResponse(str(p), media_type="image/png")
+```
+
+**Step 6: 构建前端确认无 TypeScript 错误**
+
+```bash
+cd /data/rockchip/agents/dashboard
+npm run build 2>&1 | tail -20
+```
+
+Expected: 无 TypeScript 错误,Build 成功
+
+**Step 7: Commit**
+
+```bash
+cd /data/rockchip/agents
+git add dashboard/src/components/VisualView.tsx
+git add dashboard/src/App.tsx
+git add dashboard/src/components/TopBar.tsx
+git add dashboard/src/lib/api.ts
+git add src/rockchip_agents/dashboard/api.py
+git commit -m "feat: add Visual tab to Dashboard with three-column diff review UI"
+```
+
+---
+
+## Task 8: 完整测试验证 & Docker 重建
+
+**Step 1: 运行 visual-tester 全量测试**
+
+```bash
+cd /data/company/visual-tester && source venv/bin/activate
+pytest tests/ -v
+```
+
+Expected: ≥14 passed(db: 5 + discovery: 5 + differ: 4)
+
+**Step 2: 安装 visual_tester 到 agents venv**
+
+```bash
+cd /data/rockchip/agents && source venv/bin/activate
+pip install -e /data/company/visual-tester/
+```
+
+**Step 3: 运行 agents 全量测试**
+
+```bash
+pytest tests/ -q --ignore=tests/test_edge_validator.py
+```
+
+Expected: ≥205 passed(含新增 dashboard_visual 5 个)
+
+**Step 4: 前端构建验证**
+
+```bash
+cd /data/rockchip/agents/dashboard
+npm run build
+```
+
+Expected: dist/ 更新,无报错
+
+**Step 5: 更新 requirements-dashboard.txt 添加 visual_tester 依赖**
+
+在 `requirements-dashboard.txt` 末尾追加:
+
+```
+pillow>=10.0
+numpy>=1.24
+# visual-tester 通过 pip install -e /data/company/visual-tester/ 安装
+```
+
+**Step 6: 更新 Dockerfile.dashboard 安装 visual-tester**
+
+在 `COPY src/ ./src/` 之后添加:
+
+```dockerfile
+# 安装 visual-tester(若目录存在)
+COPY --from=host /data/company/visual-tester /app/visual-tester 2>/dev/null || true
+RUN test -f /app/visual-tester/pyproject.toml && pip install --no-cache-dir -e /app/visual-tester/ || true
+```
+
+> 注:Docker 构建时宿主机目录无法直接 COPY,实际方案是在 docker-compose.yml 中挂载:
+> `- /data/company/visual-tester:/app/visual-tester`
+> 然后在 entrypoint 中 `pip install -e /app/visual-tester/ --quiet`
+
+修改 `docker-compose.yml` 的 dashboard service volumes:
+
+```yaml
+volumes:
+ - ./data:/app/data
+ - ./configs:/app/configs
+ - /data:/data
+ - /data/company/visual-tester:/app/visual-tester # 新增
+ - /home/qiurui/.claude:/home/qiurui/.claude
+ - /home/qiurui/.claude.json:/home/qiurui/.claude.json
+ - /home/qiurui/.local:/home/qiurui/.local
+```
+
+创建 `scripts/dashboard.py` 入口处添加安装逻辑(若尚未安装):
+
+```python
+import subprocess, sys
+try:
+ import visual_tester # noqa: F401
+except ImportError:
+ subprocess.run([sys.executable, "-m", "pip", "install", "-e",
+ "/app/visual-tester", "-q"], check=False)
+```
+
+**Step 7: 重建 Dashboard Docker 镜像**
+
+```bash
+cd /data/rockchip/agents
+sudo docker compose build dashboard --build-arg http_proxy=http://172.17.0.1:8889/
+sudo docker compose up -d dashboard
+```
+
+**Step 8: 验证 Dashboard 接口正常**
+
+```bash
+curl -s http://localhost:9080/api/visual/runs | python3 -m json.tool
+# Expected: [](空列表,说明端点正常)
+```
+
+**Step 9: Final Commit**
+
+```bash
+git add requirements-dashboard.txt docker-compose.yml Dockerfile.dashboard scripts/dashboard.py
+git commit -m "feat: integrate visual-tester into Dashboard Docker deployment"
+```
+
+---
+
+## 总结
+
+| Task | 内容 | 新增测试数 |
+|------|------|-----------|
+| 1 | /data/company + projects.yaml 迁移 | 0(利用现有) |
+| 2 | visual-tester db.py | 5 |
+| 3 | discovery.py | 5 |
+| 4 | differ.py | 4 |
+| 5 | runner.py + checkpoint | 5 |
+| 6 | Dashboard API /api/visual/* | 5 |
+| 7 | Dashboard 前端 Visual 标签页 | - |
+| 8 | 全量验证 + Docker 重建 | - |
+
+完成后:visual-tester 独立 repo ≥19 tests,agents repo ≥210 tests。
diff --git a/requirements-dashboard.txt b/requirements-dashboard.txt
new file mode 100644
index 0000000..4773e0f
--- /dev/null
+++ b/requirements-dashboard.txt
@@ -0,0 +1,7 @@
+fastapi>=0.110.0
+uvicorn[standard]>=0.27.0
+websockets>=12.0
+paramiko>=3.0.0
+pyyaml>=6.0
+pillow>=10.0
+numpy>=1.24
diff --git a/scripts/dashboard.py b/scripts/dashboard.py
index 907a087..6f0de80 100644
--- a/scripts/dashboard.py
+++ b/scripts/dashboard.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Rockchip Agents Dashboard 独立服务"""
+"""NMFS Agents Dashboard 独立服务"""
from __future__ import annotations
import subprocess, sys as _sys
@@ -70,7 +70,7 @@ def _watcher(app_state, loop: asyncio.AbstractEventLoop) -> None:
def main() -> None:
- from rockchip_agents.dashboard.api import make_app
+ from nmfs_agents.dashboard.api import make_app
app = make_app(db_path=DB_PATH, config_dir=CONFIG_DIR,
global_claude_dir=GLOBAL_CLAUDE_DIR)
diff --git a/scripts/run.py b/scripts/run.py
index b8c66d7..87c59e6 100755
--- a/scripts/run.py
+++ b/scripts/run.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Rockchip Agents 启动入口"""
+"""NMFS Agents 启动入口"""
from __future__ import annotations
import argparse
@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
def main() -> None:
- parser = argparse.ArgumentParser(description="Rockchip Agents")
+ parser = argparse.ArgumentParser(description="NMFS Agents")
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("scan", help="扫描所有项目,打印发现的任务(不执行)")
sub.add_parser("run", help="扫描 → 入队 → 执行所有任务(一键完成)")
@@ -33,8 +33,8 @@ def main() -> None:
args = parser.parse_args()
if args.cmd == "scan":
- from rockchip_agents.core.scanner import Scanner
- from rockchip_agents.config import load_config
+ from nmfs_agents.core.scanner import Scanner
+ from nmfs_agents.config import load_config
cfg = load_config()
tasks = Scanner(cfg).scan_all()
if not tasks:
@@ -44,23 +44,23 @@ def main() -> None:
elif args.cmd == "run":
import asyncio
- from rockchip_agents.agents.manager import ManagerAgent
- from rockchip_agents.config import load_config
+ from nmfs_agents.agents.manager import ManagerAgent
+ from nmfs_agents.config import load_config
cfg = load_config()
stats = asyncio.run(ManagerAgent(cfg).run_cycle())
print(f"扫描入队: {stats['enqueued']} 个,执行完成: {stats['executed']} 个")
elif args.cmd == "run-next":
import asyncio
- from rockchip_agents.core.executor import Executor
- from rockchip_agents.config import load_config
+ from nmfs_agents.core.executor import Executor
+ from nmfs_agents.config import load_config
cfg = load_config()
ran = asyncio.run(Executor(cfg).run_next())
print("已执行" if ran else "队列为空")
elif args.cmd == "status":
- from rockchip_agents.agents.manager import ManagerAgent
- from rockchip_agents.config import load_config
+ from nmfs_agents.agents.manager import ManagerAgent
+ from nmfs_agents.config import load_config
cfg = load_config()
status = ManagerAgent(cfg).get_status()
if not status:
@@ -70,12 +70,12 @@ def main() -> None:
print(f" {state}: {count}")
elif args.cmd == "start":
- from rockchip_agents.core.scheduler import start
+ from nmfs_agents.core.scheduler import start
start()
elif args.cmd == "arch-versions":
- from rockchip_agents.config import load_config
- from rockchip_agents.tools.arch_version import ArchVersion
+ from nmfs_agents.config import load_config
+ from nmfs_agents.tools.arch_version import ArchVersion
cfg = load_config()
proj_cfg = cfg.projects.get(args.project)
if not proj_cfg:
@@ -93,8 +93,8 @@ def main() -> None:
print(f" {v.version_id}{mark} [{v.status:12s}] [{passed}] {v.created_at[:16]} {v.description}")
elif args.cmd == "arch-restore":
- from rockchip_agents.config import load_config
- from rockchip_agents.tools.arch_version import ArchVersion
+ from nmfs_agents.config import load_config
+ from nmfs_agents.tools.arch_version import ArchVersion
cfg = load_config()
proj_cfg = cfg.projects.get(args.project)
if not proj_cfg:
@@ -105,7 +105,7 @@ def main() -> None:
print(f"已将 {args.project} 恢复到 {args.version_id}")
elif args.cmd == "goals":
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.tools.project_memory import ProjectMemory
goals = ProjectMemory().list_goals()
if not goals:
print("暂无项目目标记录(运行 productizer 或 architect 任务后自动写入)")
@@ -115,8 +115,8 @@ def main() -> None:
print(f" {g.goal_text}")
elif args.cmd == "facts":
- from rockchip_agents.tools.project_memory import ProjectMemory
- from rockchip_agents.config import load_config
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.config import load_config
mem = ProjectMemory()
cfg = load_config()
any_found = False
@@ -131,13 +131,13 @@ def main() -> None:
print("暂无项目事实记录(运行 architect 任务后自动写入)")
elif args.cmd == "set-fact":
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.tools.project_memory import ProjectMemory
mem = ProjectMemory()
mem.set_fact(args.project, args.key, args.value, role="developer")
print(f"已写入: [{args.project}] {args.key} = {args.value}")
elif args.cmd == "projects":
- from rockchip_agents.config import load_config
+ from nmfs_agents.config import load_config
cfg = load_config()
print(f"{'项目名':20s} {'类型':8s} {'模式':8s} 路径")
print("-" * 70)
@@ -146,9 +146,9 @@ def main() -> None:
print(f"{name:20s} {p.kind:8s} {p.mode:8s} {p.path}{dw}")
elif args.cmd == "collect-env":
- from rockchip_agents.config import load_config
- from rockchip_agents.tools.env_collector import run_collection
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.config import load_config
+ from nmfs_agents.tools.env_collector import run_collection
+ from nmfs_agents.tools.project_memory import ProjectMemory
cfg = load_config()
mem = ProjectMemory()
facts = run_collection(cfg, mem)
@@ -196,8 +196,8 @@ def main() -> None:
print(f"\n已保存到 ProjectMemory (__env__) [{ts}]")
elif args.cmd == "research":
- from rockchip_agents.config import load_config
- from rockchip_agents.core.queue import TaskQueue, Task
+ from nmfs_agents.config import load_config
+ from nmfs_agents.core.queue import TaskQueue, Task
cfg = load_config()
q = TaskQueue()
research_tasks = [
diff --git a/scripts/seed_edge_platform.py b/scripts/seed_edge_platform.py
new file mode 100644
index 0000000..1d0767b
--- /dev/null
+++ b/scripts/seed_edge_platform.py
@@ -0,0 +1,359 @@
+"""
+edge-platform 全链路任务种子脚本。
+
+向三个队列注入初始任务,覆盖所有 Agent 角色,建立完整的前后依赖上下文。
+
+使用方法:
+ source venv/bin/activate
+ python scripts/seed_edge_platform.py [--dry-run]
+"""
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+PROJECT = "edge-platform"
+PLATFORM_DESC = "边端 AI 推理服务平台(RK3588 NPU + RTP 流媒体),目标:FPS 60+,延迟 <80ms"
+
+# ── 初始项目目标(productizer 写入,其他 agent 读取)────────────────────────
+INITIAL_GOALS = {
+ "productizer": "边端 AI 推理服务平台达到生产可用:内存稳定、NPU三核并行、RTP延迟<80ms",
+ "architect": "整合 YOLO/MPP/RTP 技术栈,实现从摄像头到客户端的完整推理链路",
+}
+
+# ── 初始知识库(agent 可直接读取)───────────────────────────────────────────
+INITIAL_FACTS = {
+ "fps_baseline": "28",
+ "rtp_latency_baseline_ms": "180",
+ "npu_core_mode": "NPU_CORE_0(单核,需改为 NPU_CORE_ALL)",
+ "known_bugs": "内存泄漏,错误处理缺失,模型热加载缺失,RTP延迟高",
+ "test_cmd": "pytest tests/ -v",
+ "model_path": "/data/models/yolo11n.rknn",
+}
+
+
+def seed_memory(mem: ProjectMemory) -> None:
+ """写入初始项目目标和知识库,为 agent 提供起点上下文。"""
+ for role, goal in INITIAL_GOALS.items():
+ try:
+ mem.set_goal(PROJECT, goal, role=role)
+ except Exception:
+ pass # 已存在则跳过
+ for key, val in INITIAL_FACTS.items():
+ try:
+ mem.set_fact(PROJECT, key, val, role="productizer")
+ except Exception:
+ pass
+
+
+def build_tasks() -> list[tuple[str, list[Task]]]:
+ """构建所有队列的初始任务列表。
+
+ 返回 [(queue_name, [Task, ...]), ...]
+ """
+ # ── project_delivery 队列任务 ──────────────────────────────────────────
+ delivery_tasks = [
+ # 1. Productizer 先做成熟度评估,设定长期目标
+ Task(
+ project=PROJECT,
+ type="productize",
+ title="初始成熟度评估:建立产品基线",
+ context=(
+ f"{PLATFORM_DESC}\n\n"
+ "请评估当前项目成熟度(0-100分),重点关注:\n"
+ "- 功能完整性:推理 API/RTP 推流/错误处理\n"
+ "- 性能基线:FPS 28(目标 60),延迟 180ms(目标 80ms)\n"
+ "- 测试覆盖:test_api.py / test_inference.py / test_streamer.py\n"
+ "输出 [记忆] edge-platform: maturity_score=N,设定产品目标。"
+ ),
+ priority=1,
+ mode="report",
+ agent_role="productizer",
+ initiator="seed-script",
+ ),
+
+ # 2. Architect 做跨项目分析,派发开发任务
+ Task(
+ project="ALL",
+ type="architect",
+ title="全局架构分析:edge-platform 与现有项目整合",
+ context=(
+ "重点分析新加入的 edge-platform 项目与现有项目的整合机会:\n"
+ "- edge-platform(推理服务)+ yolo(检测模型)的集成路径\n"
+ "- edge-platform + embedding(向量检索)的语义搜索扩展\n"
+ "- edge-platform + claude-wx(飞书通知)的告警接入\n"
+ "- RTP 延迟问题与 os-base 调研的关联\n\n"
+ "必须输出以下任务:\n"
+ "[开发] edge-platform: 修复推理引擎内存泄漏——将模型加载移至 __init__,添加 release()\n"
+ "[开发] edge-platform: 修复 NPU 单核问题——改 NPU_CORE_ALL 并测试 FPS\n"
+ "[开发] edge-platform: 添加 /infer 错误处理——try/except base64/reshape 异常返回 400\n"
+ "[测试] edge-platform: 运行全量测试套件并报告失败的 test_api.py 断言\n"
+ "[记忆] edge-platform: arch_integration=yolo+embedding+claude-wx 三路整合方案已分析"
+ ),
+ priority=2,
+ mode="report",
+ agent_role="architect",
+ initiator="seed-script",
+ ),
+
+ # 3. Developer 修复 P0 内存泄漏(最高优先级)
+ Task(
+ project=PROJECT,
+ type="fix_bug",
+ title="[修复] P0 内存泄漏:推理引擎模型重复加载",
+ context=(
+ "文件:src/inference.py\n\n"
+ "问题:RknnInferenceEngine.infer() 每次调用都执行 _load_model(),"
+ "导致 RKNN 对象未释放,长时运行内存持续增长(500次请求 +200MB)。\n\n"
+ "修复方案:\n"
+ "1. 在 __init__ 中调用 _load_model() 并保存到 self._rknn\n"
+ "2. infer() 直接使用 self._rknn\n"
+ "3. 添加 __del__ 或 close() 方法释放 rknn 对象\n"
+ "4. 运行 tests/test_inference.py::test_memory_no_growth_over_requests 验证\n\n"
+ "完成后输出 [SPAWN:tester] 验证: 内存泄漏修复验证,运行 test_memory_no_growth"
+ ),
+ priority=1,
+ mode="auto",
+ agent_role="developer",
+ initiator="seed-script",
+ discussion=f"来自 seed-script 初始化:{PLATFORM_DESC},P0 内存泄漏需优先修复",
+ ),
+
+ # 4. Developer 修复 NPU 单核问题
+ Task(
+ project=PROJECT,
+ type="fix_bug",
+ title="[修复] P1 NPU 单核 → 三核并行,目标 FPS 60+",
+ context=(
+ "文件:src/inference.py\n\n"
+ "问题:init_runtime(core_mask=RKNNLite.NPU_CORE_0) 只使用单核,"
+ "三核空闲,FPS 仅 28(目标 60+)。\n\n"
+ "修复方案:\n"
+ "1. 改为 core_mask=RKNNLite.NPU_CORE_ALL\n"
+ "2. 在 infer() 末尾记录 fps 并更新 [记忆] edge-platform: fps_actual=N\n"
+ "3. 更新 FPS_TARGET=60 断言\n\n"
+ "完成后输出 [SPAWN:system-tester] 验证: 板端 FPS 回归测试,验证 NPU_CORE_ALL 效果"
+ ),
+ priority=2,
+ mode="auto",
+ agent_role="developer",
+ initiator="seed-script",
+ ),
+
+ # 5. Developer 修复错误处理
+ Task(
+ project=PROJECT,
+ type="fix_bug",
+ title="[修复] P1 /infer 端点错误处理——base64/reshape 异常返回 400",
+ context=(
+ "文件:src/server.py\n\n"
+ "问题:/infer 端点无 try/except,无效 base64 或尺寸不符直接 500。\n\n"
+ "修复方案:\n"
+ "1. 用 try/except 包装 base64.b64decode,抛出 HTTPException(400)\n"
+ "2. 用 try/except 包装 np.reshape,抛出 HTTPException(400)\n"
+ "3. 在 infer() 调用处捕获推理异常,返回 HTTPException(422)\n"
+ "4. 运行 tests/test_api.py 所有用例(含 test_infer_invalid_base64_returns_error)\n\n"
+ "完成后输出 [SPAWN:tester] 验证: 错误处理修复验证,运行 test_api.py"
+ ),
+ priority=2,
+ mode="auto",
+ agent_role="developer",
+ initiator="seed-script",
+ ),
+
+ # 6. System Tester 全系统测试(含视觉推理验证)
+ Task(
+ project=PROJECT,
+ type="test",
+ title="全系统测试:功能+性能+板端验证",
+ context=(
+ f"{PLATFORM_DESC}\n\n"
+ "测试清单:\n"
+ "1. 功能测试:pytest tests/ -v(本机),重点关注失败用例\n"
+ "2. 性能测试:连续调用 /infer 100 次,统计 avg_fps 和 p95 延迟\n"
+ "3. 板端测试(可选):ssh pi@192.168.123.181 运行 pytest tests/\n"
+ "4. 内存稳定性:验证 500 次请求后内存无增长\n\n"
+ "发现问题请输出 [需求] edge-platform: <具体问题描述>\n"
+ "性能基线请输出 [记忆] edge-platform: fps_system_test=N,latency_p95_ms=N"
+ ),
+ priority=3,
+ mode="auto",
+ agent_role="system-tester",
+ initiator="seed-script",
+ ),
+ ]
+
+ # ── insight 队列任务 ───────────────────────────────────────────────────
+ insight_tasks = [
+ # Planner 制定市场洞察计划
+ Task(
+ project=PROJECT,
+ type="plan",
+ title="边端 AI 推理服务市场洞察规划",
+ context=(
+ f"{PLATFORM_DESC}\n\n"
+ "请制定市场洞察调研计划:\n"
+ "- 边端 AI 推理服务(Edge Inference as a Service)市场规模\n"
+ "- RK3588 平台在工业/安防/运动分析场景的竞争格局\n"
+ "- 实时视频推理服务的付费意愿调研\n"
+ "输出结构化调研任务给 market-pm 执行。"
+ ),
+ priority=3,
+ mode="auto",
+ agent_role="planner",
+ initiator="seed-script",
+ ),
+
+ # Market PM 分析市场机会
+ Task(
+ project=PROJECT,
+ type="market_research",
+ title="边端 AI 推理服务市场分析:运动/工业/安防三方向",
+ context=(
+ f"{PLATFORM_DESC}\n\n"
+ "请分析以下三个目标市场的机会:\n\n"
+ "1. 【运动】实时动作识别(跑步/球类)+ 教练反馈,B 端体育机构\n"
+ " - 竞品:Camera IQ、Hudl,RK3588 本地化的差异点\n"
+ " - 付费场景:赛事直播增强、训练质量评估\n\n"
+ "2. 【工业/安防】边端目标检测,私有部署无需云端\n"
+ " - 客户痛点:数据隐私、网络依赖、云计算费用\n"
+ " - 定价逻辑:一次性硬件+软件授权 vs 订阅\n\n"
+ "3. 【AI 安全/隐私】端侧推理保证数据不出设备\n"
+ " - 与 info-privacy 项目的结合点\n\n"
+ "输出格式:\n"
+ "[调研报告] __market__.运动.opportunity=<市场机会摘要>\n"
+ "[调研报告] __market__.工业.opportunity=<市场机会摘要>\n"
+ "[调研报告] __market__.AI安全.opportunity=<市场机会摘要>"
+ ),
+ priority=3,
+ mode="auto",
+ agent_role="market-pm",
+ initiator="seed-script",
+ ),
+ ]
+
+ # ── base_opt 队列任务 ──────────────────────────────────────────────────
+ base_opt_tasks = [
+ # RTP 调研(直接关联 edge-platform 的 RTP 延迟问题)
+ Task(
+ project="rtp-research",
+ type="research",
+ title="RTP 延迟优化调研:针对 edge-platform MPP+RTP 瓶颈",
+ context=(
+ "关联项目:edge-platform(src/streamer.py)\n"
+ "当前瓶颈:MPP 编码器每帧重新初始化,端到端延迟 ~180ms,目标 <80ms。\n\n"
+ "请重点调研:\n"
+ "1. MPP 编码器会话保持(mpp_enc_cfg_init/mpp_enc_ref_cfg_init 复用)\n"
+ "2. RTP 包分片策略(MTU=1500 下 H264 NAL 单元分割)\n"
+ "3. SO_PRIORITY + SO_SNDBUF 对 UDP 延迟的影响\n"
+ "4. 零拷贝 DMA buffer → RTP payload 路径\n\n"
+ "输出 [优化任务] os-engineer: <具体 sysctl/MPP API 优化步骤>"
+ ),
+ priority=2,
+ mode="auto",
+ agent_role="rtp-researcher",
+ initiator="seed-script",
+ discussion=f"edge-platform RTP 延迟 180ms,超过目标 80ms",
+ ),
+
+ # Net 调研(API 服务网络调优)
+ Task(
+ project="net-research",
+ type="research",
+ title="网络栈调优调研:edge-platform 推理 API 响应时间优化",
+ context=(
+ "关联项目:edge-platform(src/server.py,FastAPI on uvicorn)\n"
+ "当前问题:高并发下 API 响应时间不稳定(p99 > 200ms)。\n\n"
+ "请调研:\n"
+ "1. uvicorn worker 数量与 RK3588 CPU 核心绑定策略\n"
+ "2. TCP_NODELAY + SO_REUSEPORT 对 FastAPI 延迟的影响\n"
+ "3. 推理服务与 RTP 推流共用网卡时的 QoS 优先级划分\n"
+ "4. 内核 net.core.somaxconn / net.ipv4.tcp_tw_reuse 参数建议\n\n"
+ "输出具体 sysctl 命令和 uvicorn 启动参数建议\n"
+ "输出 [优化任务] os-engineer: <网络参数配置步骤>"
+ ),
+ priority=2,
+ mode="auto",
+ agent_role="net-researcher",
+ initiator="seed-script",
+ ),
+
+ # Kernel 调研(NPU + API 并发调度)
+ Task(
+ project="kernel-analysis",
+ type="research",
+ title="内核调度调研:NPU 推理与 RTP 推流的资源竞争",
+ context=(
+ "关联项目:edge-platform(推理 API + RTP 推流并发运行)\n"
+ "当前问题:NPU 推理(大核 A76)与 RTP 编码(MPP/VPU)争抢内存带宽,"
+ "高负载下 FPS 从 28 降至 15。\n\n"
+ "请调研:\n"
+ "1. RK3588 NPU/VPU/ISP 内存带宽分配(IOMMU/CMA 配置)\n"
+ "2. isolcpus=4-7(A76 大核)专用于推理进程\n"
+ "3. SCHED_FIFO 实时优先级给 RTP 推流线程\n"
+ "4. CPUFreq governor=performance 对 NPU 推理 FPS 的影响\n\n"
+ "输出 [优化任务] dev-kernel: <内核参数/Kconfig 建议>"
+ ),
+ priority=2,
+ mode="auto",
+ agent_role="kernel-analyzer",
+ initiator="seed-script",
+ ),
+ ]
+
+ return [
+ ("project_delivery", delivery_tasks),
+ ("insight", insight_tasks),
+ ("base_opt", base_opt_tasks),
+ ]
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="seed edge-platform tasks")
+ parser.add_argument("--dry-run", action="store_true", help="只显示,不写入")
+ args = parser.parse_args()
+
+ # 初始化 memory
+ mem = ProjectMemory()
+
+ # 构建并注入初始队列任务
+ queue_configs = [
+ ("project_delivery", "data/tasks.db"),
+ ("insight", "data/insight_tasks.db"),
+ ("base_opt", "data/base_opt_tasks.db"),
+ ]
+ task_map = dict(build_tasks())
+
+ total = 0
+ for queue_name, db_path in queue_configs:
+ tasks = task_map.get(queue_name, [])
+ if not tasks:
+ continue
+ print(f"\n[{queue_name}] ({db_path}) — {len(tasks)} 个任务:")
+ for t in tasks:
+ print(f" [{t.agent_role:16s}] {t.title[:60]}")
+ if not args.dry_run:
+ q = TaskQueue(db_path=Path(db_path))
+ for t in tasks:
+ q.enqueue(t)
+ total += len(tasks)
+
+ if not args.dry_run:
+ # 写入初始 memory
+ seed_memory(mem)
+ print(f"\n✓ 已写入 {total} 个初始任务")
+ print("✓ 已写入 edge-platform 项目目标和知识库")
+ print("\n启动调度器执行:bash scripts/start_daemon.sh")
+ print("监控进度: bash scripts/watch_daemon.sh")
+ else:
+ print(f"\n[dry-run] 共 {sum(len(v) for v in task_map.values())} 个任务,未写入")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/start_daemon.sh b/scripts/start_daemon.sh
new file mode 100755
index 0000000..15699bf
--- /dev/null
+++ b/scripts/start_daemon.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# NMFS Agents 后台守护启动脚本
+# 用法:bash scripts/start_daemon.sh [--hours 24]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+LOG_DIR="$PROJECT_ROOT/logs"
+PID_FILE="$PROJECT_ROOT/logs/daemon.pid"
+LOG_FILE="$LOG_DIR/daemon_$(date '+%Y%m%d_%H%M%S').log"
+
+mkdir -p "$LOG_DIR"
+
+# ── 检查是否已在运行 ────────────────────────────────────────────
+if [[ -f "$PID_FILE" ]]; then
+ OLD_PID=$(cat "$PID_FILE")
+ if kill -0 "$OLD_PID" 2>/dev/null; then
+ echo "⚠️ 守护进程已在运行(PID=$OLD_PID),如需重启请先执行:"
+ echo " kill $OLD_PID && rm $PID_FILE"
+ exit 1
+ else
+ echo "旧 PID 文件残留,已清理"
+ rm -f "$PID_FILE"
+ fi
+fi
+
+# ── 激活虚拟环境 ────────────────────────────────────────────────
+VENV="$PROJECT_ROOT/venv"
+if [[ ! -f "$VENV/bin/activate" ]]; then
+ echo "❌ 未找到虚拟环境: $VENV/bin/activate"
+ echo "请先执行: python3.10 -m venv venv && pip install -r requirements.txt"
+ exit 1
+fi
+source "$VENV/bin/activate"
+
+# ── 启动调度器(后台 nohup)────────────────────────────────────
+echo "🚀 启动 NMFS Agents 守护进程"
+echo " 日志文件: $LOG_FILE"
+echo " PID 文件: $PID_FILE"
+
+nohup python "$PROJECT_ROOT/scripts/run.py" start \
+ >> "$LOG_FILE" 2>&1 &
+
+DAEMON_PID=$!
+echo "$DAEMON_PID" > "$PID_FILE"
+echo "✅ 守护进程已启动(PID=$DAEMON_PID)"
+echo ""
+echo "查看日志: tail -f $LOG_FILE"
+echo "查看状态: $VENV/bin/python $PROJECT_ROOT/scripts/run.py status"
+echo "查看目标: $VENV/bin/python $PROJECT_ROOT/scripts/run.py goals"
+echo "停止进程: kill $DAEMON_PID"
diff --git a/scripts/test_24h.py b/scripts/test_24h.py
new file mode 100644
index 0000000..268efdc
--- /dev/null
+++ b/scripts/test_24h.py
@@ -0,0 +1,400 @@
+#!/usr/bin/env python3
+"""
+24小时项目自动化运行测试 - 基于 Playwright 浏览器自动化
+
+用法:
+ python scripts/test_24h.py --hours 24 --project /data/rockchip/logodetect-x86
+ python scripts/test_24h.py --hours 1 --project /data/rockchip/logodetect-x86 --dry-run
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import time
+import urllib.request
+from datetime import datetime, timedelta
+from pathlib import Path
+
+
+DASHBOARD_URL = "http://localhost:9080"
+REPORT_DIR = Path("tmp/test_24h_report")
+CHECK_INTERVAL = 900 # 每 15 分钟一次
+
+
+# ── 辅助函数 ─────────────────────────────────────────────────────────
+
+def _api(method: str, path: str, body: dict | None = None) -> dict:
+ url = DASHBOARD_URL + path
+ data = json.dumps(body).encode() if body else None
+ headers = {"Content-Type": "application/json"} if data else {}
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
+ with urllib.request.urlopen(req, timeout=10) as r:
+ return json.loads(r.read())
+
+
+def _get_project_tasks(project_name: str) -> list[dict]:
+ return _api("GET", f"/api/tasks?project={project_name}")
+
+
+def _task_stats(tasks: list[dict]) -> dict:
+ statuses = [t["status"] for t in tasks]
+ return {
+ "total": len(tasks),
+ "pending": statuses.count("pending"),
+ "running": statuses.count("running"),
+ "done": statuses.count("done"),
+ "failed": statuses.count("failed"),
+ "vetoed": statuses.count("vetoed"),
+ "waiting": statuses.count("waiting_approval"),
+ }
+
+
+# ── Phase 1: 浏览器 UI 操作(Playwright)─────────────────────────────
+
+def setup_via_browser(
+ page, # playwright Page
+ project_path: str,
+ linked_project: str,
+ project_name: str,
+) -> dict:
+ """通过浏览器 UI 完整设置项目(导入 + 生成任务 + 启动执行器)"""
+ from playwright.sync_api import expect
+
+ print(f"[setup] 打开 Dashboard: {DASHBOARD_URL}")
+ page.goto(DASHBOARD_URL)
+ page.wait_for_timeout(2000)
+
+ # 截图:初始状态
+ _screenshot(page, "00_initial")
+
+ # 点击「导入项目」
+ print("[setup] 点击「导入项目」")
+ page.click("button:has-text('导入项目')")
+ page.wait_for_timeout(500)
+
+ # 填写路径(触发项目名自动推断)
+ print(f"[setup] 填写路径: {project_path}")
+ # 第 1 个 input = 目录路径
+ page.locator("input").nth(0).fill(project_path)
+ page.wait_for_timeout(500)
+
+ # 填写关联项目(第 3 个 input,placeholder 含 logo-collect)
+ if linked_project:
+ print(f"[setup] 填写关联项目: {linked_project}")
+ page.locator("input").nth(2).fill(linked_project)
+
+ # 确认 mode = auto
+ mode_select = page.locator("select")
+ mode_select.select_option("auto")
+
+ # 点击「预览任务」展开模板
+ try:
+ page.click("button:has-text('预览任务')")
+ page.wait_for_timeout(300)
+ except Exception:
+ pass
+
+ _screenshot(page, "01_import_modal_filled")
+
+ # 点击「导入并生成任务」
+ print("[setup] 提交导入表单")
+ page.click("button:has-text('导入并生成任务')")
+ page.wait_for_timeout(3000)
+
+ _screenshot(page, "02_import_success")
+
+ # 点击「完成」关闭成功弹窗
+ try:
+ page.click("button:has-text('完成')")
+ page.wait_for_timeout(1000)
+ except Exception:
+ pass
+
+ # 切换到目标项目 Tab
+ print(f"[setup] 切换到项目: {project_name}")
+ try:
+ page.click(f"button:has-text('{project_name}')")
+ page.wait_for_timeout(500)
+ except Exception:
+ pass
+
+ _screenshot(page, "03_project_kanban")
+
+ # 启动执行器(点击「开始执行」)
+ print("[setup] 启动执行器")
+ try:
+ page.click("button:has-text('开始执行')")
+ page.wait_for_timeout(2000)
+ except Exception as e:
+ print(f"[WARN] 启动执行器按钮未找到: {e}")
+
+ _screenshot(page, "04_executor_started")
+
+ # 收集初始任务统计
+ tasks = _get_project_tasks(project_name)
+ stats = _task_stats(tasks)
+ print(f"[setup] 初始任务统计: {stats}")
+ return stats
+
+
+# ── Phase 2: 24 小时监控循环 ─────────────────────────────────────────
+
+def monitoring_loop(
+ page,
+ project_name: str,
+ total_seconds: int,
+ dry_run: bool = False,
+) -> list[dict]:
+ """每 CHECK_INTERVAL 秒检查一次,记录任务进展"""
+ metrics: list[dict] = []
+ start_time = time.time()
+ check_num = 0
+ interval = 60 if dry_run else CHECK_INTERVAL # dry-run 用 1 分钟
+
+ print(f"\n[monitor] 开始监控,总时长 {total_seconds/3600:.1f}h,间隔 {interval}s")
+
+ while time.time() - start_time < total_seconds:
+ check_num += 1
+ elapsed_s = time.time() - start_time
+ elapsed_h = elapsed_s / 3600
+
+ print(f"\n[monitor] 第 {check_num} 次检查 ({elapsed_h:.2f}h / {total_seconds/3600:.1f}h)")
+
+ # 刷新页面,截图
+ try:
+ page.goto(DASHBOARD_URL)
+ try:
+ page.click(f"button:has-text('{project_name}')")
+ except Exception:
+ pass
+ page.wait_for_timeout(2000)
+ _screenshot(page, f"monitor_{check_num:04d}_{elapsed_h:.1f}h")
+ except Exception as e:
+ print(f"[WARN] 截图失败: {e}")
+
+ # 收集任务统计
+ try:
+ tasks = _get_project_tasks(project_name)
+ stats = _task_stats(tasks)
+ exec_status = _api("GET", "/api/executor/status")
+
+ record = {
+ "check": check_num,
+ "elapsed_h": round(elapsed_h, 2),
+ "timestamp": datetime.utcnow().isoformat(),
+ "tasks": stats,
+ "executor": exec_status,
+ "done_titles": [t["title"] for t in tasks if t["status"] == "done"],
+ "failed_titles": [t["title"] for t in tasks if t["status"] == "failed"],
+ }
+ metrics.append(record)
+ print(f" 任务: {stats}")
+ print(f" 执行器: running={exec_status.get('running')}, "
+ f"active={exec_status.get('active')}")
+
+ except Exception as e:
+ print(f"[WARN] 指标收集失败: {e}")
+
+ # 检查执行器是否停止,若停止则重启
+ try:
+ exec_st = _api("GET", "/api/executor/status")
+ if not exec_st.get("running") and exec_st.get("pending", 0) > 0:
+ print("[monitor] 执行器已停止,重新启动...")
+ _api("POST", "/api/executor/start")
+ except Exception:
+ pass
+
+ # 等待下次检查
+ next_check = start_time + check_num * interval
+ wait_s = max(0, next_check - time.time())
+ if wait_s > 0 and time.time() - start_time < total_seconds:
+ print(f" 下次检查:{wait_s:.0f}s 后")
+ time.sleep(wait_s)
+
+ return metrics
+
+
+# ── Phase 3: 评估报告生成 ─────────────────────────────────────────────
+
+def generate_report(
+ project_name: str,
+ project_path: str,
+ linked_project: str,
+ metrics: list[dict],
+ total_hours: float,
+ page,
+) -> Path:
+ """生成 Markdown 评估报告"""
+ if not metrics:
+ print("[report] 无指标数据")
+ return REPORT_DIR / "report.md"
+
+ first = metrics[0]["tasks"]
+ last = metrics[-1]["tasks"]
+
+ tasks_done = last["done"]
+ tasks_total = last["total"]
+ tasks_failed = last["failed"]
+ completion_pct = round(tasks_done / max(tasks_total, 1) * 100, 1)
+
+ # 收集所有 done 任务标题
+ all_done = []
+ for m in metrics:
+ for title in m.get("done_titles", []):
+ if title not in all_done:
+ all_done.append(title)
+
+ all_failed = []
+ for m in metrics:
+ for title in m.get("failed_titles", []):
+ if title not in all_failed:
+ all_failed.append(title)
+
+ # 执行器在线率
+ online_checks = sum(1 for m in metrics if m.get("executor", {}).get("running"))
+ online_pct = round(online_checks / max(len(metrics), 1) * 100, 1)
+
+ report_lines = [
+ f"# {total_hours:.0f}h 自动化运行评估报告",
+ f"",
+ f"**项目**: `{project_name}` ({project_path})",
+ f"**关联项目**: `{linked_project or '无'}`",
+ f"**报告时间**: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
+ f"**运行时长**: {total_hours:.1f} 小时",
+ f"",
+ f"---",
+ f"",
+ f"## 执行摘要",
+ f"",
+ f"| 指标 | 数值 |",
+ f"|------|------|",
+ f"| 总任务数 | {tasks_total} |",
+ f"| 完成任务 | {tasks_done} ({completion_pct}%) |",
+ f"| 失败任务 | {tasks_failed} |",
+ f"| 待执行 | {last['pending']} |",
+ f"| 执行器在线率 | {online_pct}% |",
+ f"",
+ f"## 已完成任务",
+ f"",
+ ]
+ for t in all_done:
+ report_lines.append(f"- ✅ {t}")
+
+ if all_failed:
+ report_lines += ["", "## 失败任务", ""]
+ for t in all_failed:
+ report_lines.append(f"- ❌ {t}")
+
+ report_lines += [
+ "",
+ "## 进度时间线",
+ "",
+ "| 时间点 | 完成 | 失败 | 待执行 |",
+ "|--------|------|------|--------|",
+ ]
+ for m in metrics[::4] + ([metrics[-1]] if metrics else []): # 每4条取1条
+ s = m["tasks"]
+ report_lines.append(
+ f"| {m['elapsed_h']:.1f}h | {s['done']} | {s['failed']} | {s['pending']} |"
+ )
+
+ report_lines += [
+ "",
+ "## 截图目录",
+ f"",
+ f"见 `{REPORT_DIR}/` 目录,共 {len(list(REPORT_DIR.glob('*.png')))} 张截图。",
+ "",
+ "---",
+ f"*由 `scripts/test_24h.py` 自动生成*",
+ ]
+
+ report_path = REPORT_DIR / "report.md"
+ report_path.write_text("\n".join(report_lines), encoding="utf-8")
+ print(f"\n[report] 报告已生成: {report_path}")
+ return report_path
+
+
+# ── 主入口 ────────────────────────────────────────────────────────────
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="24h 项目自动化运行测试")
+ parser.add_argument("--project", default="/data/rockchip/logodetect-x86",
+ help="项目目录路径")
+ parser.add_argument("--linked", default="/data/test-platform/logo-collect",
+ help="关联项目路径(用于集成任务)")
+ parser.add_argument("--hours", type=float, default=24.0,
+ help="运行时长(小时),默认 24")
+ parser.add_argument("--dry-run", action="store_true",
+ help="快速测试模式(1 分钟间隔,5 分钟总时长)")
+ parser.add_argument("--headless", action="store_true", default=True,
+ help="无头模式运行浏览器(默认开启)")
+ parser.add_argument("--no-headless", dest="headless", action="store_false",
+ help="显示浏览器窗口(需要显示器)")
+ args = parser.parse_args()
+
+ project_path = args.project
+ project_name = Path(project_path).name
+ linked_project = args.linked
+ total_hours = 5 / 60 if args.dry_run else args.hours # dry-run = 5 分钟
+ total_seconds = int(total_hours * 3600)
+
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
+ metrics_path = REPORT_DIR / "metrics.jsonl"
+
+ print("=" * 60)
+ print(f"NMFS Agents 自动化测试")
+ print(f" 项目: {project_name} ({project_path})")
+ print(f" 关联: {linked_project}")
+ print(f" 时长: {total_hours:.2f}h ({'dry-run' if args.dry_run else '正式'})")
+ print(f" 报告: {REPORT_DIR}/")
+ print("=" * 60)
+
+ try:
+ from playwright.sync_api import sync_playwright
+ except ImportError:
+ print("[ERROR] playwright 未安装,运行: pip install playwright && playwright install chromium")
+ raise SystemExit(1)
+
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(headless=args.headless)
+ ctx = browser.new_context(viewport={"width": 1440, "height": 900})
+ page = ctx.new_page()
+
+ try:
+ # Phase 1: 设置
+ print("\n=== Phase 1: 浏览器 UI 设置 ===")
+ setup_via_browser(page, project_path, linked_project, project_name)
+
+ # Phase 2: 监控
+ print("\n=== Phase 2: 监控循环 ===")
+ metrics = monitoring_loop(page, project_name, total_seconds, args.dry_run)
+
+ # 保存原始指标
+ with open(metrics_path, "w") as f:
+ for m in metrics:
+ f.write(json.dumps(m, ensure_ascii=False) + "\n")
+
+ # Phase 3: 生成报告
+ print("\n=== Phase 3: 生成评估报告 ===")
+ _screenshot(page, "final_state")
+ report_path = generate_report(
+ project_name, project_path, linked_project,
+ metrics, total_hours, page,
+ )
+ print(f"\n完成!评估报告: {report_path}")
+
+ finally:
+ browser.close()
+
+
+def _screenshot(page, name: str) -> None:
+ try:
+ path = REPORT_DIR / f"{name}.png"
+ page.screenshot(path=str(path), full_page=False)
+ print(f" [screenshot] {path.name}")
+ except Exception as e:
+ print(f" [WARN] 截图失败 {name}: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/watch_daemon.sh b/scripts/watch_daemon.sh
new file mode 100755
index 0000000..0029899
--- /dev/null
+++ b/scripts/watch_daemon.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# 实时监控 NMFS Agents 守护进程
+# 用法:bash scripts/watch_daemon.sh
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+PID_FILE="$PROJECT_ROOT/logs/daemon.pid"
+VENV="$PROJECT_ROOT/venv"
+
+if [[ ! -f "$PID_FILE" ]]; then
+ echo "守护进程未运行(PID 文件不存在)"
+ echo "启动方法: bash scripts/start_daemon.sh"
+ exit 0
+fi
+
+PID=$(cat "$PID_FILE")
+if ! kill -0 "$PID" 2>/dev/null; then
+ echo "守护进程已停止(PID=$PID 不存在)"
+ rm -f "$PID_FILE"
+ exit 0
+fi
+
+echo "═══════════════════════════════════════════"
+echo " NMFS Agents 监控 PID=$PID"
+echo " $(date '+%Y-%m-%d %H:%M:%S')"
+echo "═══════════════════════════════════════════"
+
+# 任务队列状态
+echo ""
+echo "── 任务队列状态 ─────────────────────────────"
+source "$VENV/bin/activate"
+python "$PROJECT_ROOT/scripts/run.py" status 2>/dev/null || echo "(获取失败)"
+
+# 项目目标
+echo ""
+echo "── 项目目标记忆 ─────────────────────────────"
+python "$PROJECT_ROOT/scripts/run.py" goals 2>/dev/null || echo "(获取失败)"
+
+# memory-analyze 版本历史
+echo ""
+echo "── memory-analyze 架构版本 ──────────────────"
+python "$PROJECT_ROOT/scripts/run.py" arch-versions memory-analyze 2>/dev/null || echo "(获取失败)"
+
+# 最新日志
+echo ""
+echo "── 最新日志(最近 30 行)────────────────────"
+LATEST_LOG=$(ls -t "$PROJECT_ROOT/logs"/daemon_*.log 2>/dev/null | head -1)
+if [[ -n "$LATEST_LOG" ]]; then
+ echo " 文件: $LATEST_LOG"
+ tail -30 "$LATEST_LOG"
+else
+ echo "(暂无日志文件)"
+fi
diff --git a/services/edge_validator/edge-validator.service b/services/edge_validator/edge-validator.service
new file mode 100644
index 0000000..7c73834
--- /dev/null
+++ b/services/edge_validator/edge-validator.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=EdgeValidatorService for RK3588
+After=network.target
+
+[Service]
+Type=simple
+User=pi
+WorkingDirectory=/home/pi/Desktop/edge_validator
+ExecStart=/home/pi/Desktop/edge_validator/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8899
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/services/edge_validator/main.py b/services/edge_validator/main.py
new file mode 100644
index 0000000..d083323
--- /dev/null
+++ b/services/edge_validator/main.py
@@ -0,0 +1,207 @@
+"""EdgeValidatorService: 部署在 RK3588 上,为开发机 agents 提供 HTTP 接口。
+
+启动:uvicorn main:app --host 0.0.0.0 --port 8899
+"""
+from __future__ import annotations
+
+import base64
+import glob
+import re
+import subprocess
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
+
+app = FastAPI(title="EdgeValidatorService", version="1.0.0")
+app.add_middleware(CORSMiddleware, allow_origins=["*"],
+ allow_methods=["*"], allow_headers=["*"])
+
+# 设备配置(本机 + 接入的 MCU)
+SERIAL_DEVICES: dict[str, dict] = {}
+
+# 推理日志路径
+LOG_PATHS = {
+ "default": "/tmp/infer.log",
+ "yolo": "/tmp/yolo_infer.log",
+}
+
+
+class RunTestRequest(BaseModel):
+ cmd: str
+ cwd: str = "/home/pi/Desktop"
+ timeout: int = 120
+
+
+class FlashRequest(BaseModel):
+ firmware_b64: str
+ offset: str = "0x0"
+ port: str = "/dev/ttyUSB0"
+
+
+# ── 端点 ──────────────────────────────────────────────────────────
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
+
+
+@app.get("/devices")
+def list_devices():
+ """列出识别到的串口设备"""
+ serial_ports = glob.glob("/dev/ttyUSB*") + glob.glob("/dev/ttyACM*")
+ return {"rk3588": "linux/ssh", "serial_ports": serial_ports,
+ "configured_mcus": list(SERIAL_DEVICES.keys())}
+
+
+@app.get("/metrics/{device_name}")
+def get_metrics(device_name: str):
+ if device_name == "rk3588":
+ return _linux_metrics()
+ if device_name in SERIAL_DEVICES:
+ return _mcu_metrics(SERIAL_DEVICES[device_name])
+ raise HTTPException(404, f"未知设备: {device_name}")
+
+
+@app.post("/run-test/{device_name}")
+def run_test(device_name: str, req: RunTestRequest):
+ if device_name == "rk3588":
+ return _run_local(req)
+ if device_name in SERIAL_DEVICES:
+ return _run_serial(SERIAL_DEVICES[device_name], req)
+ raise HTTPException(404, f"未知设备: {device_name}")
+
+
+@app.post("/screenshot")
+def screenshot():
+ """截取 RK3588 屏幕(需要 scrot 或 ffmpeg)"""
+ import tempfile
+ import os
+ tmp = tempfile.mktemp(suffix=".png")
+ r = subprocess.run(["scrot", tmp], capture_output=True, timeout=10)
+ if r.returncode != 0:
+ r = subprocess.run(
+ f"cat /dev/fb0 | ffmpeg -vcodec rawvideo -f rawvideo "
+ f"-pix_fmt rgb32 -s 1920x1080 -i - -f image2 -vcodec png {tmp} -y",
+ shell=True, capture_output=True, timeout=15,
+ )
+ if r.returncode != 0:
+ raise HTTPException(500, "截图失败,请确认 scrot 或 ffmpeg 已安装")
+ with open(tmp, "rb") as f:
+ img_b64 = base64.b64encode(f.read()).decode()
+ os.unlink(tmp)
+ return {"image_b64": img_b64, "format": "png"}
+
+
+@app.get("/logs/{device_name}/{n}")
+def get_logs(device_name: str, n: int = 50):
+ log_path = LOG_PATHS.get(device_name, LOG_PATHS["default"])
+ try:
+ r = subprocess.run(["tail", f"-{n}", log_path],
+ capture_output=True, text=True, timeout=5)
+ return {"lines": r.stdout.splitlines(), "file": log_path}
+ except Exception as e:
+ return {"lines": [], "error": str(e), "file": log_path}
+
+
+# ── 内部函数 ──────────────────────────────────────────────────────
+
+def _run_local(req: RunTestRequest) -> dict:
+ t0 = time.monotonic()
+ r = subprocess.run(
+ req.cmd, shell=True, capture_output=True, text=True,
+ cwd=req.cwd, timeout=req.timeout,
+ )
+ duration = time.monotonic() - t0
+ stdout = r.stdout
+ pass_ = r.returncode == 0 and not re.search(r"\b(FAILED|ERROR)\b", stdout + r.stderr)
+ return {"rc": r.returncode, "stdout": stdout, "stderr": r.stderr,
+ "duration_s": round(duration, 2), "pass_": pass_}
+
+
+def _run_serial(dev: dict, req: RunTestRequest) -> dict:
+ try:
+ import serial # type: ignore
+ except ImportError:
+ return {"rc": 1, "stdout": "", "stderr": "pyserial 未安装",
+ "duration_s": 0, "pass_": False}
+ t0 = time.monotonic()
+ lines: list[str] = []
+ try:
+ ser = serial.Serial(dev["port"], dev.get("baud", 115200), timeout=req.timeout)
+ ser.write((req.cmd + "\r\n").encode())
+ deadline = time.monotonic() + req.timeout
+ while time.monotonic() < deadline:
+ line = ser.readline().decode(errors="replace").strip()
+ if line:
+ lines.append(line)
+ if line.startswith(("PASS", "FAIL", "ERROR")):
+ break
+ ser.close()
+ except Exception as e:
+ return {"rc": 1, "stdout": "\n".join(lines), "stderr": str(e),
+ "duration_s": time.monotonic() - t0, "pass_": False}
+ stdout = "\n".join(lines)
+ rc = 0 if any(l.startswith("PASS") for l in lines) else 1
+ return {"rc": rc, "stdout": stdout, "stderr": "",
+ "duration_s": round(time.monotonic() - t0, 2), "pass_": rc == 0}
+
+
+def _linux_metrics() -> dict:
+ m: dict = {"device": "rk3588", "timestamp": datetime.utcnow().isoformat()}
+ try:
+ with open("/proc/stat") as f:
+ parts = f.readline().split()
+ user, nice, sys_, idle = int(parts[1]), int(parts[2]), int(parts[3]), int(parts[4])
+ total = user + nice + sys_ + idle
+ m["cpu_pct"] = round(100 * (user + sys_) / total, 1) if total else 0
+ except Exception:
+ m["cpu_pct"] = None
+ try:
+ r = subprocess.run(["free", "-m"], capture_output=True, text=True, timeout=3)
+ for line in r.stdout.splitlines():
+ if line.startswith("Mem:"):
+ parts = line.split()
+ m["mem_used_mb"] = float(parts[2])
+ break
+ except Exception:
+ m["mem_used_mb"] = None
+ try:
+ r = subprocess.run(
+ "tail -10 /tmp/infer.log 2>/dev/null | grep -oP 'fps=\\K[0-9.]+' | tail -1",
+ shell=True, capture_output=True, text=True, timeout=3,
+ )
+ raw = r.stdout.strip()
+ m["fps"] = float(raw) if raw else None
+ except Exception:
+ m["fps"] = None
+ return m
+
+
+def _mcu_metrics(dev: dict) -> dict:
+ m: dict = {"device": dev.get("name", "mcu"),
+ "timestamp": datetime.utcnow().isoformat()}
+ try:
+ import serial # type: ignore
+ ser = serial.Serial(dev["port"], dev.get("baud", 115200), timeout=3)
+ ser.write(b"GET_METRICS\r\n")
+ for _ in range(20):
+ line = ser.readline().decode(errors="replace").strip()
+ if line.startswith("METRICS:"):
+ for kv in line[8:].split():
+ k, _, v = kv.partition("=")
+ if k == "fps":
+ m["fps"] = float(v)
+ elif k == "latency_ms":
+ m["inference_latency_ms"] = float(v)
+ elif k == "ram_kb":
+ m["ram_used_kb"] = int(v)
+ break
+ ser.close()
+ except Exception as e:
+ m["error"] = str(e)
+ return m
diff --git a/services/edge_validator/requirements.txt b/services/edge_validator/requirements.txt
new file mode 100644
index 0000000..3c75757
--- /dev/null
+++ b/services/edge_validator/requirements.txt
@@ -0,0 +1,3 @@
+fastapi>=0.110.0
+uvicorn[standard]>=0.29.0
+pyserial>=3.5
diff --git a/src/nmfs_agents/__init__.py b/src/nmfs_agents/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nmfs_agents/agents/__init__.py b/src/nmfs_agents/agents/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nmfs_agents/agents/architect.py b/src/nmfs_agents/agents/architect.py
new file mode 100644
index 0000000..28632bc
--- /dev/null
+++ b/src/nmfs_agents/agents/architect.py
@@ -0,0 +1,418 @@
+from __future__ import annotations
+
+import logging
+import os
+import re
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+from nmfs_agents.agents.developer import (
+ AgentResult, _parse_cli_result, _make_drop_root_preexec,
+ _run_with_log, _task_log_path,
+)
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.arch_version import ArchVersion
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+TIMEOUT = 3600
+TEST_TIMEOUT = 120
+
+ARCHITECT_SYSTEM = """你是一位技术架构师,负责分析多个软件项目,识别整合机会,提出架构改进建议。
+
+分析维度:
+- 功能互补性:哪些项目的能力可以组合,产生新价值
+- 技术共性:是否有可复用的基础设施或数据管道
+- 产品可能性:整合后能解决什么实际问题
+
+请用中文输出结构化分析报告,末尾按格式列出任务(每行一条):
+- [预研] 任务名称 ← 跨项目探索、新功能研究
+- [优化] <项目名>: 优化描述 ← 对特定项目的架构改进(项目名须在上面列出的项目中)
+- [开发] <项目名>: 任务描述 ← 派发具体开发任务给 developer(可多条,多开发者并行执行)
+- [测试] <项目名>: 验证要点 ← 派发集成测试任务给 tester
+- [重构] <项目名>: 重构内容 ← 派发代码重构任务给 dev-refactor
+- [内核开发] <项目名>: 任务 ← 派发内核驱动开发任务给 dev-kernel
+- [目标] <项目名>: 目标描述 ← 更新项目长期目标(仅当需要修订时)
+- [记忆] <项目名>: key=value, key2=value2 ← 记录项目关键技术事实
+- [后续] 自动续链: 描述 ← 基于本次分析发现,列出1-3条应立即跟进的技术调研或验证任务
+
+## 技能配置能力(Skills)
+如分析中发现 Agent 工具/能力需要调整,可直接修改配置文件:
+- MCP 工具配置:`/data/company/agents/configs/mcp_tools.yaml`(角色工具授权)
+- Agent 配置:`/data/company/agents/configs/agents.yaml`(队列、并发、角色列表)
+- 每次修改配置文件后,在 [记忆] 中记录变更原因:key=config_update, value=<变更摘要>
+"""
+
+
+class ArchitectAgent:
+ def __init__(self, config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None) -> None:
+ self._config = config
+ self._queue = queue or TaskQueue()
+ self._memory = memory or ProjectMemory()
+
+ # ── entry point ───────────────────────────────────────────────────────────
+
+ def run(self, task: Task) -> AgentResult:
+ """Route to optimize flow or cross-project analysis based on task type."""
+ if not self._verify_claude_binary():
+ return AgentResult(status="failed", summary="claude CLI 不可达,bootstrap 验证失败")
+ if task.type == "arch_optimize":
+ return self._optimize(task)
+ return self._analyze(task)
+
+ @staticmethod
+ def _verify_claude_binary() -> bool:
+ """验证 claude CLI 可达性(bootstrap 检查)。"""
+ try:
+ result = subprocess.run(
+ ["claude", "--version"],
+ capture_output=True, text=True, timeout=10,
+ )
+ if result.returncode == 0:
+ logger.info("claude CLI 可达: %s", result.stdout.strip())
+ return True
+ logger.warning("claude --version 返回非零码 %d: %s", result.returncode, result.stderr.strip())
+ return False
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
+ logger.error("claude CLI 不可达: %s", e)
+ return False
+
+ # ── cross-project analysis ────────────────────────────────────────────────
+
+ def _analyze(self, task: Task) -> AgentResult:
+ """Generate architecture report and enqueue [预研]/[优化] tasks."""
+ summaries = self._collect_summaries()
+ cmd = [
+ "claude", "--print",
+ "--output-format", "json",
+ "--no-session-persistence",
+ "--model", self._config.claude.model,
+ "--allowedTools", "Read,Glob,Grep",
+ "--append-system-prompt", ARCHITECT_SYSTEM,
+ "--", self._build_prompt(summaries),
+ ]
+ try:
+ _env = self._build_env()
+ result = _run_with_log(
+ cmd, None, _env, TIMEOUT, _task_log_path(task.id),
+ preexec_fn=_make_drop_root_preexec(_env),
+ )
+ if result.status == "done":
+ self._extract_and_enqueue(result.summary, task.project)
+ logger.info("架构分析完成,已生成 %d 字报告", len(result.summary))
+ return result
+ except Exception as e:
+ logger.error("ArchitectAgent 分析失败: %s", e)
+ return AgentResult(status="failed", summary=str(e))
+
+ # ── iterative optimization with version control ───────────────────────────
+
+ def _optimize(self, task: Task) -> AgentResult:
+ """Apply an architectural optimization with snapshot/test/rollback cycle."""
+ proj_cfg = self._config.projects.get(task.project)
+ if not proj_cfg:
+ return AgentResult(status="failed",
+ summary=f"项目 {task.project!r} 未在配置中找到")
+
+ arch_v = ArchVersion(proj_cfg.path)
+ version_id = arch_v.snapshot(task.title)
+
+ # Apply optimization
+ cmd = [
+ "claude", "--print",
+ "--output-format", "json",
+ "--no-session-persistence",
+ "--model", self._config.claude.model,
+ "--dangerously-skip-permissions",
+ "--append-system-prompt", ARCHITECT_SYSTEM,
+ "--", self._build_optimize_prompt(task),
+ ]
+ try:
+ _env = self._build_env()
+ apply_result = _run_with_log(
+ cmd, str(proj_cfg.path), _env, TIMEOUT, _task_log_path(task.id),
+ preexec_fn=_make_drop_root_preexec(_env),
+ )
+ except Exception as e:
+ logger.error("arch_optimize 执行失败: %s", e)
+ try:
+ arch_v.rollback(version_id, str(e))
+ except Exception:
+ pass
+ return AgentResult(status="failed", summary=str(e))
+
+ if apply_result.status == "failed":
+ arch_v.rollback(version_id, apply_result.summary)
+ return AgentResult(
+ status="failed",
+ summary=f"[{version_id}] 优化执行失败,已回滚:\n{apply_result.summary}",
+ )
+
+ # Validate with tests
+ test_res = self._run_tests(proj_cfg.path)
+ if test_res["passed"]:
+ arch_v.promote(version_id, test_res["output"])
+ return AgentResult(
+ status="done",
+ summary=(
+ f"[{version_id}] 优化成功并晋升\n"
+ f"{apply_result.summary}\n\n"
+ f"测试: {test_res['output'][:300]}"
+ ),
+ )
+ else:
+ arch_v.rollback(version_id, test_res["output"])
+ return AgentResult(
+ status="failed",
+ summary=(
+ f"[{version_id}] 优化后测试失败,已回滚\n"
+ f"{test_res['output'][:400]}"
+ ),
+ )
+
+ # ── helpers ───────────────────────────────────────────────────────────────
+
+ def _collect_summaries(self) -> dict[str, str]:
+ return {
+ name: self._read_project_summary(proj_cfg.path)
+ for name, proj_cfg in self._config.projects.items()
+ }
+
+ def _read_project_summary(self, path: Path) -> str:
+ for fname in ("README.md", "CLAUDE.md"):
+ f = path / fname
+ if not f.exists():
+ continue
+ lines = [l.strip() for l in f.read_text(errors="ignore").splitlines()
+ if l.strip() and not l.startswith("#")]
+ if lines:
+ return " ".join(lines[:3])[:300]
+ return f"({path.name},无说明文档)"
+
+ def _build_prompt(self, summaries: dict[str, str]) -> str:
+ proj_names = "、".join(summaries.keys())
+ lines = [f"以下是当前所有项目的功能描述(可用项目名:{proj_names}):\n"]
+ for name, desc in summaries.items():
+ goal_ctx = self._memory.get_context_for_prompt(name, role="architect")
+ lines.append(f"**{name}**: {desc}")
+ if goal_ctx.strip():
+ lines.append(goal_ctx.strip())
+ lines.append(
+ "\n请分析这些项目的能力,识别整合机会,给出产品架构建议。\n"
+ "在报告末尾按以下格式列出任务(每行一条):\n"
+ "- [预研] 任务名称\n"
+ f"- [优化] <项目名>: 优化描述 (项目名须为:{proj_names} 之一)\n"
+ f"- [开发] <项目名>: 具体开发任务描述 (可多条,并行派发给 developer)\n"
+ f"- [测试] <项目名>: 集成测试验证要点 (派发给 tester)\n"
+ f"- [重构] <项目名>: 重构目标和范围 (派发给 dev-refactor)\n"
+ f"- [内核开发] <项目名>: 内核/驱动任务 (派发给 dev-kernel)\n"
+ f"- [目标] <项目名>: 更新后的项目长期目标描述 (可选,仅当目标需要修订时)"
+ )
+ return "\n".join(lines)
+
+ def _build_optimize_prompt(self, task: Task) -> str:
+ proj_cfg = self._config.projects.get(task.project)
+ proj_desc = proj_cfg.description if proj_cfg else ""
+ return (
+ f"你是 {task.project} 项目的架构优化 Agent。\n"
+ f"项目: {task.project},描述: {proj_desc}\n"
+ f"优化任务: {task.title}\n"
+ f"背景: {task.context}\n\n"
+ f"请对该项目进行架构层面的优化:\n"
+ f"1. 分析现有代码结构和性能瓶颈\n"
+ f"2. 实施具体的架构改进(直接修改代码文件)\n"
+ f"3. 确保改动向后兼容,不破坏现有接口\n"
+ f"4. 输出改动摘要\n"
+ f"请直接修改代码文件,不要只输出报告。"
+ )
+
+ def _build_env(self) -> dict:
+ env = os.environ.copy()
+ if self._config.claude.api_key:
+ env["ANTHROPIC_API_KEY"] = self._config.claude.api_key
+ env.pop("CLAUDECODE", None)
+ env.pop("CLAUDE_CODE_ENTRYPOINT", None)
+ return env
+
+ def _run_tests(self, project_path: Path) -> dict:
+ """Run pytest in the project directory. Returns {passed, output}."""
+ tests_dir = project_path / "tests"
+ if not tests_dir.exists():
+ return {"passed": True, "output": "(无测试目录,跳过验证)"}
+
+ for pytest_bin in [str(project_path / "venv" / "bin" / "pytest"), "pytest"]:
+ try:
+ r = subprocess.run(
+ [pytest_bin, "tests/", "-q", "--tb=short"],
+ capture_output=True, text=True,
+ timeout=TEST_TIMEOUT, cwd=str(project_path),
+ )
+ return {
+ "passed": r.returncode == 0,
+ "output": (r.stdout + r.stderr)[:1000],
+ }
+ except FileNotFoundError:
+ continue
+ except subprocess.TimeoutExpired:
+ return {"passed": False, "output": f"测试超时({TEST_TIMEOUT}s)"}
+ return {"passed": False, "output": "未找到 pytest 可执行文件"}
+
+ def _extract_and_enqueue(self, report_text: str, requester: str) -> None:
+ """Parse [预研], [优化], [目标], and [记忆] tags from report."""
+ # [预研] → cross-project research task (report mode)
+ for m in re.finditer(r"\[预研\]\s+(.+)", report_text):
+ title = m.group(1).strip()[:100]
+ self._queue.enqueue(Task(
+ project="research", type="architect", title=title,
+ context=f"由 Architect Agent 从架构分析中提取(来源: {requester})",
+ priority=3, mode="report", agent_role="architect",
+ initiator="architect",
+ discussion=f"架构分析报告中的预研项(来源项目: {requester})",
+ ))
+ logger.info("预研任务入队: %s", title)
+
+ # [优化] : → arch_optimize task
+ for m in re.finditer(r"\[优化\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ description = m.group(2).strip()[:100]
+ if project not in self._config.projects:
+ logger.warning("优化任务项目 %r 不在配置中,跳过", project)
+ continue
+ proj_cfg = self._config.projects[project]
+ self._queue.enqueue(Task(
+ project=project,
+ type="arch_optimize",
+ title=f"[架构优化] {description}",
+ context=f"由 Architect Agent 提出(来源: {requester})\n{description}",
+ priority=2,
+ mode=proj_cfg.mode,
+ agent_role="architect",
+ initiator="architect",
+ discussion=f"Architect Agent 在分析 {requester} 时提出的优化建议",
+ ))
+ logger.info("架构优化任务入队: [%s] %s", project, description)
+
+ # [开发] : → developer task (multiple allowed for parallel dispatch)
+ for m in re.finditer(r"\[开发\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ description = m.group(2).strip()[:100]
+ if project not in self._config.projects:
+ logger.warning("开发任务项目 %r 不在配置中,跳过", project)
+ continue
+ proj_cfg = self._config.projects[project]
+ self._queue.enqueue(Task(
+ project=project, type="fix_bug",
+ title=f"[开发] {description}",
+ context=f"由 Architect 指派(来源分析: {requester})\n{description}",
+ priority=2, mode=proj_cfg.mode, agent_role="developer",
+ initiator="architect",
+ discussion=f"Architect 分析 {requester} 时指派的开发任务:\n{description}",
+ ))
+ logger.info("开发任务入队: [%s] %s", project, description)
+
+ # [测试] : → tester task
+ for m in re.finditer(r"\[测试\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ description = m.group(2).strip()[:100]
+ if project not in self._config.projects:
+ logger.warning("测试任务项目 %r 不在配置中,跳过", project)
+ continue
+ proj_cfg = self._config.projects[project]
+ self._queue.enqueue(Task(
+ project=project, type="test_improvement",
+ title=f"[测试] {description}",
+ context=f"由 Architect 指派(来源分析: {requester})\n{description}",
+ priority=2, mode=proj_cfg.mode, agent_role="tester",
+ initiator="architect",
+ discussion=f"Architect 分析 {requester} 时指派的测试任务:\n{description}",
+ ))
+ logger.info("测试任务入队: [%s] %s", project, description)
+
+ # [重构] : → dev-refactor task
+ for m in re.finditer(r"\[重构\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ description = m.group(2).strip()[:100]
+ if project not in self._config.projects:
+ logger.warning("重构任务项目 %r 不在配置中,跳过", project)
+ continue
+ proj_cfg = self._config.projects[project]
+ self._queue.enqueue(Task(
+ project=project, type="refactor",
+ title=f"[重构] {description}",
+ context=f"由 Architect 指派(来源分析: {requester})\n{description}",
+ priority=3, mode=proj_cfg.mode, agent_role="dev-refactor",
+ initiator="architect",
+ discussion=f"Architect 分析 {requester} 时指派的重构任务:\n{description}",
+ ))
+ logger.info("重构任务入队: [%s] %s", project, description)
+
+ # [内核开发] : → dev-kernel task
+ for m in re.finditer(r"\[内核开发\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ description = m.group(2).strip()[:100]
+ if project not in self._config.projects:
+ logger.warning("内核开发任务项目 %r 不在配置中,跳过", project)
+ continue
+ proj_cfg = self._config.projects[project]
+ self._queue.enqueue(Task(
+ project=project, type="fix_bug",
+ title=f"[内核开发] {description}",
+ context=f"由 Architect 指派(来源分析: {requester})\n{description}",
+ priority=2, mode=proj_cfg.mode, agent_role="dev-kernel",
+ initiator="architect",
+ discussion=f"Architect 分析 {requester} 时指派的内核开发任务:\n{description}",
+ ))
+ logger.info("内核开发任务入队: [%s] %s", project, description)
+
+ # [目标] : → update project memory (architect can write)
+ for m in re.finditer(r"\[目标\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ goal_text = m.group(2).strip()[:300]
+ if project not in self._config.projects:
+ logger.warning("目标更新项目 %r 不在配置中,跳过", project)
+ continue
+ try:
+ self._memory.set_goal(project, goal_text, role="architect")
+ logger.info("项目目标已更新 by architect: [%s]", project)
+ except Exception as e:
+ logger.warning("[%s] 写入项目目标失败: %s", project, e)
+
+ # [记忆] : key=value, key2=value2 → update project facts
+ for m in re.finditer(r"\[记忆\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ kv_text = m.group(2).strip()
+ if project not in self._config.projects:
+ logger.warning("记忆更新项目 %r 不在配置中,跳过", project)
+ continue
+ for kv in kv_text.split(","):
+ kv = kv.strip()
+ if "=" in kv:
+ key, _, value = kv.partition("=")
+ key = key.strip()[:50]
+ value = value.strip()[:200]
+ if key and value:
+ try:
+ self._memory.set_fact(project, key, value, "architect")
+ logger.info("项目事实已记录 [%s] %s=%s", project, key, value)
+ except Exception as e:
+ logger.warning("[%s] 写入项目事实失败: %s", project, e)
+
+ # [后续] 自动续链: → 立即跟进的调研/验证任务(入 project_delivery,低优先级)
+ for m in re.finditer(r"\[后续\][^\[::]*[::]\s*(.+)", report_text):
+ desc = m.group(1).strip()[:120]
+ self._queue.enqueue(Task(
+ project=requester if requester in self._config.projects else "research",
+ type="tech_support",
+ title=f"[后续] {desc}",
+ context=f"由 Architect 在分析中自动续链(来源: {requester})\n{desc}",
+ priority=4,
+ mode="auto",
+ agent_role="architect",
+ initiator="architect",
+ ))
+ logger.info("后续任务入队: %s", desc)
diff --git a/src/nmfs_agents/agents/base_architect.py b/src/nmfs_agents/agents/base_architect.py
new file mode 100644
index 0000000..fdbb810
--- /dev/null
+++ b/src/nmfs_agents/agents/base_architect.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from nmfs_agents.agents.architect import ArchitectAgent
+
+BASE_ARCHITECT_SYSTEM = """你是基础技术研发组的架构师,专注平台级技术栈整体设计。
+
+职责(区别于项目组 architect):
+- 分析 OS/算法/硬件 三个方向的技术债和瓶颈
+- 识别跨方向整合机会(如防抖算法与 GPS 惯导融合)
+- 评估新硬件对现有基础能力的影响
+- 为 os-engineer / algo-researcher 提出具体 [优化] 任务
+
+输出格式:
+- [优化] os-engineer: 具体优化建议
+- [优化] algo-researcher: 具体优化建议
+- [记忆] __platform__: key=value(记录架构决策)
+"""
+
+
+class BaseArchitectAgent(ArchitectAgent):
+ """基础技术研发组:OS/算法/硬件跨方向架构分析"""
+
+ def _build_prompt(self, summaries: dict[str, str]) -> str:
+ base = super()._build_prompt(summaries)
+ return BASE_ARCHITECT_SYSTEM + "\n\n" + base
diff --git a/src/nmfs_agents/agents/base_validator.py b/src/nmfs_agents/agents/base_validator.py
new file mode 100644
index 0000000..94ef72b
--- /dev/null
+++ b/src/nmfs_agents/agents/base_validator.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from nmfs_agents.agents.developer import DeveloperAgent
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+BASE_VALIDATOR_SYSTEM = """你是基础技术研发组的验证工程师,负责算法和OS的基线验证。
+
+验证维度:
+1. 精度测试:检出率、误检率、漏检率,与 __hardware__ 基线对比
+2. 性能测试:FPS、推理延迟(ms)、NPU利用率、CPU/内存占用
+3. 稳定性测试:长时间运行(≥1h)、内存泄漏检测、异常恢复
+4. 硬件约束验证:读取 __hardware__ facts,确认算法满足硬件限制
+
+边端验证方式:
+- 通过 EdgeValidatorService HTTP API 执行测试(见上方设备信息)
+- 或通过 SSH 直接执行:sshpass -p '...' ssh pi@ "cd && pytest ..."
+
+输出格式:
+- [验证结果] project= fps= precision=<0.xx> pass=true/false
+- [验证退化] project= metric=fps current= baseline= drop_pct=
+- [记忆] __hardware__: .fps_baseline= (首次验证或基线更新时)
+- [需求] : <具体优化需求> (当 fps drop >10% 或 precision drop >5% 时)
+
+退化阈值(自动生成 [需求]):
+- FPS 下降超过 10%
+- 精度(precision/recall)下降超过 5 个百分点
+- 内存使用增长超过 20%
+
+请用中文输出,数值精确,结论明确。
+"""
+
+
+class BaseValidatorAgent(DeveloperAgent):
+ """基础技术组:算法精度 + 性能 + 稳定性 + 硬件约束验证"""
+
+ def __init__(
+ self,
+ config: AgentsConfig,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ super().__init__(config, memory=memory)
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ hw_facts = self._memory.get_facts("__hardware__")
+ if hw_facts:
+ hw_lines = "\n".join(f" {k}: {v}" for k, v in hw_facts.items())
+ hw_section = f"\n【硬件约束(来自 hw-engineer)】\n{hw_lines}\n"
+ else:
+ hw_section = "\n【硬件约束】暂无,请查阅 __hardware__ facts\n"
+ return base + hw_section + self._build_device_context() + "\n" + BASE_VALIDATOR_SYSTEM
diff --git a/src/nmfs_agents/agents/boss.py b/src/nmfs_agents/agents/boss.py
new file mode 100644
index 0000000..f1bb4e7
--- /dev/null
+++ b/src/nmfs_agents/agents/boss.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+"""boss.py — L0 Boss Agent(CEO/战略决策者)。
+
+职责:
+- 每周接收各部门长汇报,制定战略方向
+- 读取四域知识库(tech/market/management/delivery)做决策参考
+- 不介入日常开发细节,聚焦跨部门协作和长期规划
+- 接收 [越级汇报] 标签的重要信息(重大技术风险/市场机会)
+- 输出 [决策] [方向] [协调] 标签驱动后续行动
+"""
+
+import logging
+import re
+from typing import Optional
+
+from nmfs_agents.agents.developer import AgentResult, DeveloperAgent
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.knowledge_base import KnowledgeBase
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+BOSS_SYSTEM = """你是 NMFS AI Company 的 CEO/Boss(L0 战略决策层)。
+
+## 职责定位
+- 接收基础技术部、项目交付部、市场洞察部三个部门长的周报
+- 制定公司技术战略和市场方向
+- 协调跨部门资源分配和优先级
+- 处理 [越级汇报] 重要信息(技术风险/市场机会/跨组织问题)
+- **不介入**具体代码、测试、日常开发细节
+
+## 决策维度
+1. **技术战略**:基础技术积累是否与市场需求对齐?
+2. **市场机会**:当前研发方向是否抓住了最重要的市场窗口?
+3. **资源效率**:三个部门的投入产出比是否合理?
+4. **风险管控**:是否存在技术债务、人员短板、外部威胁?
+5. **长期护城河**:我们的核心壁垒是什么,如何持续加固?
+
+## 输出格式(必须包含)
+- [决策] <具体决策或批准事项>
+- [方向] <调整优先级或战略方向>
+- [协调] <需要跨部门协作的事项,说明哪两个部门>
+- [记忆] kb.tech: 关键技术决策 或 kb.market: 市场判断 或 kb.management: 管理决策
+- [后续] <下周需要重点跟进的1-3件事>
+
+## 工作模式
+1. 读取四域知识库最新动态(上方已注入)
+2. 综合分析三部门现状
+3. 识别最重要的2-3个战略问题
+4. 给出明确决策,不模糊,不推诿
+5. 记录关键决策到知识库
+
+## 沟通原则
+- 与部门长通过任务队列异步沟通
+- 接受 [越级汇报] 但不鼓励绕过中间层(除非紧急)
+- 决策有依据,不独断专行
+"""
+
+
+class BossAgent(DeveloperAgent):
+ """L0 战略决策 Boss Agent。"""
+
+ def __init__(
+ self,
+ config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None,
+ knowledge_base: Optional[KnowledgeBase] = None,
+ ) -> None:
+ super().__init__(config, memory=memory)
+ self._queue = queue or TaskQueue()
+ self._kb = knowledge_base or KnowledgeBase()
+
+ def run(self, task: Task) -> AgentResult:
+ result = super().run(task)
+ if result.status == "done":
+ self._extract_kb_facts(result.summary)
+ self._dispatch_coordination(result.summary, task)
+ return result
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ # 注入四域知识库摘要
+ kb_sections = []
+ for domain in ("tech", "market", "management", "delivery"):
+ snippet = self._kb.context_for_agent(domain, limit=5)
+ kb_sections.append(snippet)
+ kb_ctx = "\n\n".join(kb_sections)
+ stats = self._kb.summary_by_domain()
+ stats_str = " | ".join(f"{d}: {stats.get(d, 0)}条" for d in ("tech", "market", "management", "delivery"))
+ return (
+ base
+ + f"\n\n【知识库统计】{stats_str}\n\n"
+ + kb_ctx
+ + "\n\n"
+ + BOSS_SYSTEM
+ )
+
+ def _extract_kb_facts(self, report_text: str) -> None:
+ """解析 [记忆] kb.{domain}: 内容,写入知识库。"""
+ for m in re.finditer(r"\[记忆\]\s+kb\.(\w+):\s*(.+)", report_text):
+ domain, content = m.group(1), m.group(2).strip()
+ if domain in ("tech", "market", "management", "delivery"):
+ self._kb.store(
+ domain=domain,
+ category="boss-decision",
+ title=f"Boss决策 {__import__('datetime').datetime.now().strftime('%Y-%m-%d')}",
+ content=content,
+ source="boss",
+ )
+ logger.info("Boss 写入知识库 [%s]: %s", domain, content[:80])
+
+ def _dispatch_coordination(self, report_text: str, task: Task) -> None:
+ """解析 [协调] 标签,创建跨部门协调任务。"""
+ for m in re.finditer(r"\[协调\]\s*(.+)", report_text):
+ desc = m.group(1).strip()[:200]
+ logger.info("Boss 协调任务: %s", desc)
+ # 协调任务写入 project_delivery 队列,交部门长处理
+ self._queue.enqueue(Task(
+ project="company",
+ type="coordination",
+ title=f"[Boss协调] {desc[:60]}",
+ context=f"Boss 战略协调指令:{desc}",
+ priority=2,
+ mode=task.mode,
+ agent_role="tech-dept-leader",
+ parent_task_id=task.id,
+ initiator="boss",
+ discussion=f"来自 Boss 周报决策",
+ ))
diff --git a/src/rockchip_agents/agents/developer.py b/src/nmfs_agents/agents/developer.py
similarity index 94%
rename from src/rockchip_agents/agents/developer.py
rename to src/nmfs_agents/agents/developer.py
index c46dcad..d3d77b1 100644
--- a/src/rockchip_agents/agents/developer.py
+++ b/src/nmfs_agents/agents/developer.py
@@ -11,9 +11,9 @@ from typing import Optional
from pathlib import Path
-from rockchip_agents.config import AgentsConfig
-from rockchip_agents.core.queue import Task
-from rockchip_agents.tools.project_memory import ProjectMemory
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task
+from nmfs_agents.tools.project_memory import ProjectMemory
logger = logging.getLogger(__name__)
@@ -185,7 +185,7 @@ class DeveloperAgent:
def _build_cmd(self, task: Task) -> tuple[list[str], list[str]]:
"""返回 (命令列表, 需要清理的临时文件列表)"""
- from rockchip_agents.config import load_mcp_tools
+ from nmfs_agents.config import load_mcp_tools
mcp_cfg = load_mcp_tools()
registry: dict[str, str] = mcp_cfg.get("registry", {})
role_tools: list[str] = mcp_cfg.get("roles", {}).get(task.agent_role, [])
@@ -200,7 +200,7 @@ class DeveloperAgent:
tmp_files: list[str] = []
if task.mode == "report":
- cmd += ["--allowedTools", "Read,Glob,Grep"]
+ cmd += ["--allowedTools", "Read,Glob,Grep,WebFetch,WebSearch"]
else:
# auto/confirm 模式:跳过 claude 内部权限对话框
cmd += ["--dangerously-skip-permissions"]
@@ -258,13 +258,26 @@ class DeveloperAgent:
"只有 sequentialthinking 调用完成后才能调用 Write/Edit/Bash。\n\n"
)
+ spawn_hint = "" if task.mode == "report" else (
+ "【工作流协作】完成代码修改并确认有效变更后,在输出末尾追加:\n"
+ "[SPAWN:tester] 验证: <简述改动要点和测试重点>\n"
+ "(若无实质代码改动,则不输出此标签)\n\n"
+ )
+
+ discussion_section = (
+ f"【任务背景/沟通纪要】\n{task.discussion.strip()}\n\n"
+ if task.discussion and task.discussion.strip() else ""
+ )
+
return (
f"{plan_first}"
+ f"{spawn_hint}"
f"你是 {task.project} 项目的 {task.agent_role} Agent。\n"
f"项目路径: {proj_path}。\n"
f"项目描述: {proj_desc}。\n"
f"当前模式: {task.mode} — {mode_desc}\n"
f"{mem_section}"
+ f"{discussion_section}"
f"请用中文回复,保持简洁,聚焦问题。"
)
diff --git a/src/nmfs_agents/agents/group_leader.py b/src/nmfs_agents/agents/group_leader.py
new file mode 100644
index 0000000..2163c5b
--- /dev/null
+++ b/src/nmfs_agents/agents/group_leader.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+"""group_leader.py — L2/L3 组长基类。
+
+职责:
+- 协调组内成员工作,不直接写业务代码
+- 解析成员输出的 [越级汇报] 标签并转发
+- 周期性生成组内状态摘要,向 L1 汇报
+- 根据进展动态调整成员任务优先级
+- 组长自身通过 SPAWN 派发任务给具体成员
+"""
+
+import logging
+import re
+from typing import Optional
+
+from nmfs_agents.agents.developer import AgentResult, DeveloperAgent
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+GROUP_LEADER_SYSTEM = """你是一个技术组的组长(L2 级别)。
+
+## 职责范围
+- 协调组内成员(L3)工作,解决跨成员依赖和阻塞
+- 向上向 L1 部门长汇报组内进展
+- 识别并上报重大技术风险或机会(用 [越级汇报] 标签)
+- **不直接写业务代码**,但可以做技术方案评审
+
+## 组内管理原则
+- 管理而非控制:给方向和支持,不微管理每行代码
+- 识别瓶颈:哪个成员被卡住了?原因是什么?
+- 资源调配:某个任务比其他任务更紧急时,调整优先级
+- 知识流通:成员 A 的发现对成员 B 有价值,主动传递
+
+## 输出格式
+- [状态] 组内整体进展(1-3 句话)
+- [阻塞] <成员/任务>: <阻塞原因>(如有)
+- [调度] <成员角色>: <新的优先级任务>(通过 SPAWN 派发)
+- [向上汇报] <向 L1 部门长的关键信息>
+- [越级汇报] <重大风险或机会>(直达 Boss,谨慎使用)
+- [后续] <下轮组内重点>
+
+## 工作模式
+1. 读取组内各成员最近记忆和任务结果
+2. 识别进展、阻塞、风险
+3. 调整优先级,派发协调任务
+4. 生成向上汇报摘要
+"""
+
+
+class GroupLeaderAgent(DeveloperAgent):
+ """L2/L3 组长 Agent 基类。子类设置 GROUP_NAME / MEMBER_ROLES / DEPT_LEADER_ROLE。"""
+
+ GROUP_NAME: str = "通用组"
+ MEMBER_ROLES: list[str] = []
+ DEPT_LEADER_ROLE: str = "tech-dept-leader"
+
+ def __init__(
+ self,
+ config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ super().__init__(config, memory=memory)
+ self._queue = queue or TaskQueue()
+
+ def run(self, task: Task) -> AgentResult:
+ result = super().run(task)
+ if result.status == "done":
+ self._handle_escalation(result.summary, task)
+ self._dispatch_spawned_tasks(result.summary, task)
+ return result
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ # 注入组内成员最近记忆
+ member_section = self._build_member_context()
+ return base + member_section + "\n\n" + GROUP_LEADER_SYSTEM
+
+ def _build_member_context(self) -> str:
+ """汇总组内各成员的 ProjectMemory facts,供组长参考。"""
+ lines = [f"\n【{self.GROUP_NAME} 成员状态汇总】"]
+ for role in self.MEMBER_ROLES:
+ facts = self._memory.get_facts(role) or {}
+ if facts:
+ fact_strs = [f"{k}={v}" for k, v in list(facts.items())[:5]]
+ lines.append(f" {role}: {', '.join(fact_strs)}")
+ else:
+ lines.append(f" {role}: (暂无记忆记录)")
+ return "\n".join(lines) + "\n"
+
+ def _handle_escalation(self, report_text: str, task: Task) -> None:
+ """将 [越级汇报] 内容转发至 Boss 队列。"""
+ for m in re.finditer(r"\[越级汇报\]\s*(.+)", report_text):
+ content = m.group(1).strip()[:300]
+ logger.warning("[%s] 越级汇报触发: %s", self.GROUP_NAME, content[:80])
+ self._queue.enqueue(Task(
+ project="company",
+ type="escalation",
+ title=f"[越级汇报][{self.GROUP_NAME}] {content[:60]}",
+ context=content,
+ priority=1,
+ mode=task.mode,
+ agent_role="boss",
+ parent_task_id=task.id,
+ initiator=task.agent_role,
+ discussion=f"来自 {self.GROUP_NAME} 组长越级汇报",
+ ))
+
+ def _dispatch_spawned_tasks(self, report_text: str, task: Task) -> None:
+ """解析 [调度] 标签,为指定成员入队任务。"""
+ for m in re.finditer(r"\[调度\]\s*([a-zA-Z0-9_-]+):\s*(.+)", report_text):
+ role, desc = m.group(1).strip(), m.group(2).strip()[:200]
+ if role not in self.MEMBER_ROLES:
+ logger.debug("[%s] 调度目标 %s 不在组内,跳过", self.GROUP_NAME, role)
+ continue
+ self._queue.enqueue(Task(
+ project=task.project,
+ type="code_improve",
+ title=f"[{self.GROUP_NAME}组长调度] {desc[:60]}",
+ context=desc,
+ priority=task.priority,
+ mode=task.mode,
+ agent_role=role,
+ parent_task_id=task.id,
+ initiator=task.agent_role,
+ discussion=f"{self.GROUP_NAME} 组长协调",
+ ))
+ logger.info("[%s] 组长调度 %s: %s", self.GROUP_NAME, role, desc[:60])
+
+
+# ── 具体组长实现 ────────────────────────────────────────────────────
+
+
+class OsBaseLeaderAgent(GroupLeaderAgent):
+ """OS-Base 组组长(短距通信/内核底层/视频子系统/网络优化)。"""
+
+ GROUP_NAME = "OS-Base"
+ MEMBER_ROLES = ["rtp-researcher", "net-researcher", "kernel-analyzer",
+ "dev-kernel", "os-engineer"]
+ DEPT_LEADER_ROLE = "tech-dept-leader"
+
+
+class VisionAlgoLeaderAgent(GroupLeaderAgent):
+ """视觉算法组组长(EIS/光流/RKNN 推理优化)。"""
+
+ GROUP_NAME = "视觉算法"
+ MEMBER_ROLES = ["algo-antishake", "algo-vision"]
+ DEPT_LEADER_ROLE = "tech-dept-leader"
diff --git a/src/nmfs_agents/agents/hw_engineer.py b/src/nmfs_agents/agents/hw_engineer.py
new file mode 100644
index 0000000..5e38c0c
--- /dev/null
+++ b/src/nmfs_agents/agents/hw_engineer.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from nmfs_agents.agents.developer import DeveloperAgent
+from nmfs_agents.core.queue import Task
+
+
+class HwEngineerAgent(DeveloperAgent):
+ """芯片选型、外设接口设计、BOM 评审、功耗分析、Datasheet 查阅专职 Agent"""
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ hw_guidance = (
+ "\n【硬件工程规范】\n"
+ "- 芯片选型:从算力(TOPS)、功耗(TDP)、接口(PCIe/USB/MIPI)、成本、供货稳定性五维评估\n"
+ "- Rockchip 芯片系列:RK3588(8TOPS)、RV1106(0.5TOPS)、RK3562(1TOPS)\n"
+ "- BOM 评审:关注关键器件替代方案,避免单一货源风险\n"
+ "- 功耗分析:区分静态功耗与动态功耗,评估散热需求\n"
+ "- Datasheet 查阅:优先使用 fetch 工具获取最新官方文档\n"
+ "- 接口设计:注意信号完整性、阻抗匹配、ESD 保护\n"
+ )
+ return base + hw_guidance
diff --git a/src/nmfs_agents/agents/kernel_dev.py b/src/nmfs_agents/agents/kernel_dev.py
new file mode 100644
index 0000000..399f47d
--- /dev/null
+++ b/src/nmfs_agents/agents/kernel_dev.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from nmfs_agents.agents.developer import DeveloperAgent
+from nmfs_agents.core.queue import Task
+
+
+class KernelDevAgent(DeveloperAgent):
+ """Linux 内核模块、驱动开发、Kconfig/Device Tree 专职 Agent"""
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ kernel_guidance = (
+ "\n【内核开发规范】\n"
+ "- 修改 DTS/DTSI 前必须了解上下游节点关系\n"
+ "- 内核模块编译:make -C /lib/modules/$(uname -r)/build M=$(pwd) modules\n"
+ "- 驱动调试:dmesg -T | tail -50;insmod/rmmod 前确认依赖\n"
+ "- Kconfig 变更需同步更新 defconfig\n"
+ "- 内存安全:检查 NULL 指针、use-after-free、buffer overflow\n"
+ )
+ return base + self._build_device_context() + kernel_guidance
diff --git a/src/nmfs_agents/agents/lowlevel_dev.py b/src/nmfs_agents/agents/lowlevel_dev.py
new file mode 100644
index 0000000..88f369e
--- /dev/null
+++ b/src/nmfs_agents/agents/lowlevel_dev.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from nmfs_agents.agents.developer import DeveloperAgent
+from nmfs_agents.core.queue import Task
+
+
+class LowlevelDevAgent(DeveloperAgent):
+ """BSP、Bootloader(U-Boot)、裸机/RTOS、硬件 bring-up 专职 Agent"""
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ lowlevel_guidance = (
+ "\n【底层开发规范】\n"
+ "- U-Boot 编译:make CROSS_COMPILE=aarch64-linux-gnu- rockchip_rk3588_defconfig && make\n"
+ "- 裸机调试优先使用 UART 串口输出,不依赖 OS\n"
+ "- 地址空间:严格区分物理地址与虚拟地址,注意 MMU 开关时机\n"
+ "- 硬件 bring-up:先验证电源时序,再验证时钟,最后验证外设\n"
+ "- RTOS 任务优先级设计:中断 > 实时任务 > 普通任务\n"
+ )
+ return base + self._build_device_context() + lowlevel_guidance
diff --git a/src/nmfs_agents/agents/manager.py b/src/nmfs_agents/agents/manager.py
new file mode 100644
index 0000000..c74fe09
--- /dev/null
+++ b/src/nmfs_agents/agents/manager.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Optional
+
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.executor import Executor
+from nmfs_agents.core.queue import TaskQueue
+from nmfs_agents.core.scanner import Scanner
+from nmfs_agents.tools.callback_server import CallbackServer
+
+logger = logging.getLogger(__name__)
+
+
+class ManagerAgent:
+ """顶层协调器:扫描 → 入队 → 执行 → 汇报。"""
+
+ def __init__(self, config: AgentsConfig,
+ queue: Optional[TaskQueue] = None) -> None:
+ self._config = config
+ self._queue = queue or TaskQueue()
+ self._executor = Executor(config, queue=self._queue)
+ self._callback_server: Optional[CallbackServer] = None
+
+ def start_callback_server(self) -> None:
+ if not self._callback_server:
+ self._callback_server = CallbackServer(
+ port=self._config.feishu.callback_port,
+ queue=self._queue,
+ )
+ self._callback_server.start()
+
+ def stop_callback_server(self) -> None:
+ if self._callback_server:
+ self._callback_server.stop()
+ self._callback_server = None
+
+ def scan_and_enqueue(self) -> int:
+ """扫描所有项目,将发现的任务入队,返回新增任务数。"""
+ scanner = Scanner(self._config)
+ results = scanner.scan_all()
+ count = 0
+ for r in results:
+ from nmfs_agents.core.queue import Task
+ tid = self._queue.enqueue(Task(
+ project=r.project, type=r.type, title=r.title,
+ context=r.context, priority=r.priority,
+ mode=r.mode, agent_role=r.agent_role,
+ ))
+ if tid > 0:
+ count += 1
+ logger.info("本轮扫描新增任务: %d", count)
+ return count
+
+ async def run_cycle(self) -> dict:
+ """执行一次完整循环,返回统计信息。"""
+ enqueued = self.scan_and_enqueue()
+ executed = await self._executor.run_all_pending()
+ return {"enqueued": enqueued, "executed": executed}
+
+ def get_status(self) -> dict:
+ """返回任务队列各状态计数。"""
+ conn = self._queue._conn()
+ try:
+ rows = conn.execute(
+ "SELECT status, COUNT(*) as cnt FROM tasks GROUP BY status"
+ ).fetchall()
+ finally:
+ conn.close()
+ return {row["status"]: row["cnt"] for row in rows}
diff --git a/src/nmfs_agents/agents/media_producer.py b/src/nmfs_agents/agents/media_producer.py
new file mode 100644
index 0000000..8eee4cb
--- /dev/null
+++ b/src/nmfs_agents/agents/media_producer.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from nmfs_agents.agents.developer import DeveloperAgent
+from nmfs_agents.core.queue import Task
+
+MEDIA_PRODUCER_SYSTEM = """你是产品洞察与市场落地组的媒体制作师,随时可被触发,兼顾技术演示和市场展示。
+
+技术演示侧:
+- 使用 OpenCV(python3 + cv2)在推理结果图上绘制检测框、置信度、平台水印
+- 命令示例:python3 -c "import cv2; img=cv2.imread('input.jpg'); ..."
+- 输出至 assets/demo/
+
+市场展示侧:
+- 生成产品效果图:Pillow 合成硬件外观图 + 算法效果标注
+- Demo 视频剪辑:ffmpeg -i input.mp4 -vf "drawtext=text='RK3588':..." output.mp4
+- 输出至 assets/market/
+
+工作流:
+1. 确认输入素材路径(推理图/视频/产品图)
+2. 选择合适处理方式(cv2/ffmpeg/Pillow)
+3. 执行并保存输出文件
+4. 报告生成的文件路径和简要说明
+
+注意:随时可触发,不依赖测试结果,同时服务技术和市场两种用途。
+"""
+
+
+class MediaProducerAgent(DeveloperAgent):
+ """产品洞察与市场落地组:技术演示图 + 产品效果图 + Demo 视频"""
+
+ def _build_context(self, task: Task) -> str:
+ return super()._build_context(task) + "\n" + MEDIA_PRODUCER_SYSTEM
diff --git a/src/nmfs_agents/agents/os_engineer.py b/src/nmfs_agents/agents/os_engineer.py
new file mode 100644
index 0000000..8a22fd8
--- /dev/null
+++ b/src/nmfs_agents/agents/os_engineer.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from nmfs_agents.agents.developer import DeveloperAgent
+from nmfs_agents.core.queue import Task
+
+OS_ENGINEER_SYSTEM = """你是项目研发与交付组的系统性能剖析工程师,专注识别和消除系统瓶颈。
+
+职责:不做基础搭建,只做性能问题的深度剖析与优化建议。
+
+四大剖析领域:
+
+【内存分析】
+- 工具:smem -r(进程内存排行)、valgrind --leak-check=full、pmap -x
+- 目标:定位内存泄漏、OOM killer 触发链、大 allocation 热点
+- 输出:内存占用趋势图 + 泄漏函数调用链
+
+【网络分析】
+- 工具:iperf3 -s/-c(吞吐测试)、ss -tulpn(连接状态)、tc qdisc show(排队策略)
+- 目标:带宽瓶颈、延迟尖峰、包重传率
+- 输出:吞吐/延迟基准数据 + 建议调整的内核参数
+
+【视觉库分析】
+- 工具:OpenCV 帧处理计时(cv2.getTickCount)、NEON/SIMD 利用率(perf stat -e simd)
+- 目标:Mat 内存分配热点、colorspace 转换开销、resize/warp 算子效率
+- 输出:帧处理时序分解 + 高耗时算子替换建议
+
+【AI 算子分析】
+- 工具:RKNN profiler(rknn_server --profile)、rknpu-debug、perf record + flamegraph
+- 目标:NPU 算子耗时排行、内存搬运开销(DDR ↔ NPU SRAM)、算子融合机会
+- 输出:算子热点火焰图 + 量化/融合优化建议
+
+输出规范:
+- 报告:docs/perf_reports/YYYY-MM-DD-{project}.md(含测量命令 + 原始数据 + 建议)
+- 基线:[记忆] __hardware__: perf.{category}.bottleneck={desc}
+- 优化任务:[需求] {project}: 基于剖析结果的具体优化项
+
+请用中文,数据精确,建议可操作。
+"""
+
+
+class OsEngineerAgent(DeveloperAgent):
+ """基础技术研发组:RTOS + Linux OS 基础层"""
+
+ def _build_context(self, task: Task) -> str:
+ return super()._build_context(task) + self._build_device_context() + "\n" + OS_ENGINEER_SYSTEM
diff --git a/src/nmfs_agents/agents/planner.py b/src/nmfs_agents/agents/planner.py
new file mode 100644
index 0000000..1421100
--- /dev/null
+++ b/src/nmfs_agents/agents/planner.py
@@ -0,0 +1,232 @@
+from __future__ import annotations
+
+import logging
+import os
+import re
+from typing import Optional
+
+from nmfs_agents.agents.developer import (
+ AgentResult, _make_drop_root_preexec,
+ _run_with_log, _task_log_path,
+)
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+TIMEOUT = 600 # planner 是 report 模式,只读分析
+
+_DEFAULT_PLATFORM_GOAL = (
+ "构建 RK3588 NPU 平台上的模块化 AI 推理生态,"
+ "持续提升各推理模块成熟度,探索跨模块整合产品价值。"
+)
+
+PLANNER_SYSTEM = """你是 RK3588 AI 生态团队的产品经理。
+
+你的职责:
+1. 分析平台目标与当前项目状态的差距
+2. 识别跨模块整合机会,提出高价值的下一步需求
+3. 不重复已经在执行或等待中的任务
+
+输出格式(报告末尾,每行一条):
+- [需求] <项目名>: 具体需求描述(现有项目优化,直接入队执行)
+- [集成] 跨模块整合需求描述(直接入队执行)
+- [目标] <项目名>: 更新后的项目目标(仅当需要修订时)
+- [平台目标] 更新后的平台整体目标(仅当需要修订时)
+- [选题] 新方向描述:价值分析(≥30字)
+
+[选题] 仅用于:
+✓ 当前项目列表中完全不存在的全新技术领域
+✓ 需要新建项目的方向
+✓ 超出现有平台目标范围的探索性方向
+✗ 现有项目的优化/修复 → 用 [需求]
+✗ 现有能力的组合整合 → 用 [集成]
+
+去重原则:若相似需求已在队列中,请勿重复生成。
+请用中文输出,保持简洁。
+"""
+
+
+class PlannerAgent:
+ def __init__(
+ self,
+ config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ self._config = config
+ self._queue = queue or TaskQueue()
+ self._memory = memory or ProjectMemory()
+
+ def run(self, task: Task) -> AgentResult:
+ self._ensure_platform_goal()
+ prompt = self._build_prompt()
+ cmd = [
+ "claude", "--print",
+ "--output-format", "json",
+ "--no-session-persistence",
+ "--model", self._config.claude.model,
+ "--allowedTools", "Read,Glob,Grep",
+ "--append-system-prompt", PLANNER_SYSTEM,
+ "--", prompt,
+ ]
+ try:
+ env = self._build_env()
+ result = _run_with_log(
+ cmd, None, env, TIMEOUT, _task_log_path(task.id),
+ preexec_fn=_make_drop_root_preexec(env),
+ )
+ if result.status == "done":
+ self._extract_and_enqueue(result.summary)
+ logger.info("PlannerAgent 分析完成,报告 %d 字", len(result.summary))
+ return result
+ except Exception as e:
+ logger.error("PlannerAgent 运行失败: %s", e)
+ return AgentResult(status="failed", summary=str(e))
+
+ # ── helpers ───────────────────────────────────────────────────────────────
+
+ def _ensure_platform_goal(self) -> None:
+ """首次运行时初始化平台目标。"""
+ if not self._memory.get_goal("__platform__"):
+ try:
+ self._memory.set_goal("__platform__", _DEFAULT_PLATFORM_GOAL, role="planner")
+ logger.info("平台目标已初始化")
+ except Exception as e:
+ logger.warning("平台目标初始化失败: %s", e)
+
+ def _build_prompt(self) -> str:
+ platform_ctx = self._memory.get_context_for_prompt("__platform__", role="planner")
+ proj_names = "、".join(self._config.projects.keys())
+ lines = [
+ f"以下是当前 RK3588 AI 生态团队的状态(可用项目:{proj_names}):\n",
+ platform_ctx.strip(),
+ "",
+ ]
+ for name, proj_cfg in self._config.projects.items():
+ proj_ctx = self._memory.get_context_for_prompt(name, role="planner")
+ desc = proj_cfg.description if hasattr(proj_cfg, "description") else ""
+ lines.append(f"**{name}**: {desc}")
+ if proj_ctx.strip():
+ lines.append(proj_ctx.strip())
+ # 附上当前队列摘要(防重复生成)
+ pending_titles = self._get_pending_titles()
+ if pending_titles:
+ lines.append(f"\n当前待处理任务({len(pending_titles)} 条,避免重复):")
+ for t in pending_titles[:15]:
+ lines.append(f" - {t}")
+ lines.append(
+ "\n请分析平台目标与当前项目状态的差距,提出 3~5 条最高价值的需求或整合机会。"
+ )
+ return "\n".join(lines)
+
+ def _get_pending_titles(self) -> list[str]:
+ try:
+ conn = self._queue._conn()
+ rows = conn.execute(
+ "SELECT title FROM tasks WHERE status IN ('pending','running') ORDER BY id DESC LIMIT 30"
+ ).fetchall()
+ conn.close()
+ return [r["title"] for r in rows]
+ except Exception:
+ return []
+
+ def _is_duplicate(self, title: str) -> bool:
+ """模糊去重:与任何 pending/running 任务 title 共同前缀超 40 字或完整标题相互包含则视为重复。"""
+ key = title[:40]
+ for existing in self._get_pending_titles():
+ # 两个标题取各自前 40 字,若较短一方与另一方的前缀匹配则视为重复
+ existing_key = existing[:40]
+ min_len = min(len(key), len(existing_key))
+ if min_len > 0 and key[:min_len] == existing_key[:min_len]:
+ return True
+ return False
+
+ def _extract_and_enqueue(self, report_text: str) -> None:
+ # [需求] :
+ for m in re.finditer(r"\[需求\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ desc = m.group(2).strip()[:120]
+ if project not in self._config.projects:
+ logger.warning("需求任务项目 %r 不在配置中,跳过", project)
+ continue
+ proj_cfg = self._config.projects[project]
+ title = f"[需求] {desc}"
+ if self._is_duplicate(title):
+ logger.info("需求重复跳过: %s", title)
+ continue
+ self._queue.enqueue(Task(
+ project=project, type="feature", title=title,
+ context=f"由 PlannerAgent 从差距分析中提取\n{desc}",
+ priority=3, mode=proj_cfg.mode,
+ agent_role="developer", initiator="planner",
+ discussion="PlannerAgent 基于平台目标-项目现状差距分析生成",
+ ))
+ logger.info("需求任务入队: [%s] %s", project, title)
+
+ # [集成] (跨项目,无 project 前缀)
+ for m in re.finditer(r"\[集成\]\s+(.+)", report_text):
+ desc = m.group(1).strip()[:120]
+ title = f"[集成] {desc}"
+ if self._is_duplicate(title):
+ logger.info("集成需求重复跳过: %s", title)
+ continue
+ self._queue.enqueue(Task(
+ project="research", type="architect", title=title,
+ context=f"由 PlannerAgent 提出的跨模块整合机会\n{desc}",
+ priority=3, mode="report",
+ agent_role="architect", initiator="planner",
+ discussion="PlannerAgent 跨项目整合分析",
+ ))
+ logger.info("集成任务入队: %s", title)
+
+ # [目标] :
+ for m in re.finditer(r"\[目标\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ goal_text = m.group(2).strip()[:300]
+ if project not in self._config.projects:
+ logger.warning("目标更新项目 %r 不在配置中,跳过", project)
+ continue
+ try:
+ self._memory.set_goal(project, goal_text, role="planner")
+ logger.info("项目目标已更新 by planner: [%s]", project)
+ except Exception as e:
+ logger.warning("[%s] 写入项目目标失败: %s", project, e)
+
+ # [平台目标]
+ for m in re.finditer(r"\[平台目标\]\s+(.+)", report_text):
+ goal_text = m.group(1).strip()[:300]
+ try:
+ self._memory.set_goal("__platform__", goal_text, role="planner")
+ logger.info("平台目标已更新 by planner")
+ except Exception as e:
+ logger.warning("写入平台目标失败: %s", e)
+
+ # [选题] :新方向/新课题,写入 topic_pending 待人工审批
+ for m in re.finditer(r"\[选题\]\s+(.+?)(?:[::]\s*(.+))?$", report_text, re.MULTILINE):
+ desc = m.group(1).strip()[:120]
+ context = (m.group(2) or "").strip()[:300]
+ title = f"[选题] {desc}"
+ # 检查是否已有同标题的 topic_pending(去重)
+ existing = self._queue.get_topic_pending_tasks()
+ if any(t.title == title for t in existing):
+ logger.info("选题重复跳过: %s", title)
+ continue
+ tid = self._queue.enqueue_topic(Task(
+ project="research", type="topic", title=title,
+ context=context or f"由 PlannerAgent 提出的新课题方向\n{desc}",
+ priority=3, mode="report",
+ agent_role="planner", initiator="planner",
+ discussion="PlannerAgent 识别为新方向/新课题,需人工确认后执行",
+ ))
+ if tid > 0:
+ logger.info("选题入队待审批 #%d: %s", tid, title)
+
+ def _build_env(self) -> dict:
+ env = os.environ.copy()
+ if self._config.claude.api_key:
+ env["ANTHROPIC_API_KEY"] = self._config.claude.api_key
+ env.pop("CLAUDECODE", None)
+ env.pop("CLAUDE_CODE_ENTRYPOINT", None)
+ return env
diff --git a/src/nmfs_agents/agents/productizer.py b/src/nmfs_agents/agents/productizer.py
new file mode 100644
index 0000000..9cbd693
--- /dev/null
+++ b/src/nmfs_agents/agents/productizer.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+from nmfs_agents.agents.developer import AgentResult
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class MaturityItem:
+ name: str
+ score: int # 实际得分
+ max_score: int # 满分
+ present: bool
+ note: str = ""
+
+
+@dataclass
+class MaturityReport:
+ project: str
+ score: int # 总分 0-100
+ items: list[MaturityItem] = field(default_factory=list)
+ suggestions: list[str] = field(default_factory=list)
+
+ def summary_text(self) -> str:
+ lines = [f"# {self.project} 成熟度报告", f"总分: {self.score}/100", ""]
+ lines.append("## 各项评分")
+ for item in self.items:
+ icon = "✅" if item.present else "❌"
+ lines.append(f"{icon} {item.name}: {item.score}/{item.max_score}"
+ + (f" — {item.note}" if item.note else ""))
+ if self.suggestions:
+ lines.append("\n## 改进建议")
+ for s in self.suggestions:
+ lines.append(f"- {s}")
+ return "\n".join(lines)
+
+
+class ProductizerAgent:
+ """基于规则评估项目产品化成熟度,生成改进建议。"""
+
+ CHECKS: list[tuple[str, int]] = [
+ ("README.md", 20), # 项目说明文档
+ ("RELEASE.md", 15), # 发布记录
+ ("pyproject.toml", 10), # 打包配置
+ ("tests/", 20), # 测试目录
+ ("DEVELOP.md", 10), # 开发指南
+ ("src/", 10), # 标准源码结构
+ ("configs/", 5), # 配置文件
+ ("scripts/", 5), # 工具脚本
+ ("Makefile", 5), # 构建入口
+ ]
+
+ def __init__(self, config: AgentsConfig,
+ memory: Optional[ProjectMemory] = None) -> None:
+ self._config = config
+ self._memory = memory or ProjectMemory()
+ self.last_report: Optional[MaturityReport] = None
+
+ def run(self, task: Task) -> AgentResult:
+ proj_cfg = self._config.projects.get(task.project)
+ if not proj_cfg:
+ return AgentResult(status="failed", summary=f"项目不存在: {task.project}")
+
+ report = self._evaluate(task.project, proj_cfg.path)
+ self.last_report = report
+ summary = report.summary_text()
+ logger.info("[%s] 成熟度评分: %d/100", task.project, report.score)
+
+ # Write initial project goal if not already set (productizer can always update)
+ self._refresh_goal(task.project, proj_cfg.path, report)
+
+ return AgentResult(status="done", summary=summary)
+
+ def _evaluate(self, project: str, path: Path) -> MaturityReport:
+ items: list[MaturityItem] = []
+ total = 0
+
+ for name, max_score in self.CHECKS:
+ target = path / name
+ present = target.exists()
+ score = max_score if present else 0
+
+ # 额外细粒度检查
+ note = ""
+ if present and name == "README.md":
+ text = target.read_text(errors="ignore")
+ has_quickstart = any(kw in text for kw in ["快速开始", "Quick Start", "quickstart", "## 安装", "## Install"])
+ if not has_quickstart:
+ score = max_score // 2
+ note = "缺少快速开始章节"
+ if present and name == "tests/":
+ test_files = list(target.rglob("test_*.py")) + list(target.rglob("*_test.py"))
+ if not test_files:
+ score = max_score // 2
+ note = "tests/ 目录为空"
+ else:
+ note = f"{len(test_files)} 个测试文件"
+
+ items.append(MaturityItem(name=name, score=score,
+ max_score=max_score, present=present, note=note))
+ total += score
+
+ suggestions = self._generate_suggestions(items, path)
+ return MaturityReport(project=project, score=total,
+ items=items, suggestions=suggestions)
+
+ def _generate_suggestions(self, items: list[MaturityItem], path: Path) -> list[str]:
+ suggestions = []
+ for item in items:
+ if not item.present:
+ suggestions.append(f"添加 {item.name}(可提升 {item.max_score} 分)")
+ elif item.score < item.max_score:
+ suggestions.append(f"完善 {item.name}:{item.note}")
+ return suggestions
+
+ def _refresh_goal(self, project: str, path: Path,
+ report: MaturityReport) -> None:
+ """Write a structured project goal to memory.
+
+ The goal captures the project's current purpose and maturity status so
+ that future agents always work with an up-to-date objective.
+ """
+ desc = self._config.projects[project].description
+ score_line = f"当前成熟度评分 {report.score}/100"
+ top_suggestions = ";".join(report.suggestions[:3]) if report.suggestions else "暂无改进建议"
+
+ if desc:
+ goal_text = f"{desc}。{score_line}。近期改进方向:{top_suggestions}。"
+ else:
+ goal_text = f"{project} 项目。{score_line}。近期改进方向:{top_suggestions}。"
+
+ try:
+ self._memory.set_goal(project, goal_text, role="productizer")
+ except Exception as e:
+ logger.warning("[%s] 写入项目目标失败: %s", project, e)
diff --git a/src/nmfs_agents/agents/senior_dev.py b/src/nmfs_agents/agents/senior_dev.py
new file mode 100644
index 0000000..eafb289
--- /dev/null
+++ b/src/nmfs_agents/agents/senior_dev.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+"""senior_dev.py — 高级研发 Agent(L3 Senior Developer)。
+
+职责:
+- 平衡「高质量代码实现」与「架构视角」
+- 可主动提出架构改进建议([架构建议] 标签)
+- 技术决策有依据,关注先进性(通过 web search 跟踪技术前沿)
+- 普通任务以代码质量为主;发现架构问题时触发讨论
+- 可接受 senior-dev 任务,也可接受 developer 任务
+"""
+
+import logging
+from typing import Optional
+
+from nmfs_agents.agents.developer import DeveloperAgent
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+SENIOR_DEV_SYSTEM = """你是一名高级研发工程师(Senior Developer,L3)。
+
+## 与普通研发的区别
+- **架构视角**:不只写功能代码,还思考设计是否合理、可扩展
+- **技术先进性**:关注业界最新方案,通过 web search 验证是否有更好的实现
+- **代码质量**:类型注解、测试覆盖、可读性,高于普通标准
+- **主动发现**:遇到技术债务或架构隐患,主动提出而不忽视
+
+## 工作模式
+1. **理解需求**:明确本次任务边界和验收标准
+2. **技术调研**(如需要):用 web search 看业界最优实践
+3. **实现**:写高质量代码,完整类型注解
+4. **自测**:写/完善 pytest 测试,覆盖边界情况
+5. **架构反思**:此次修改是否引入了新的技术债务?是否有更好的设计?
+
+## 输出标签
+- [架构建议] <发现的架构问题或改进机会>(会触发讨论任务)
+- [技术讨论] <值得团队讨论的技术方案>
+- [越级汇报] <重大技术风险>(谨慎使用)
+- [记忆] <项目名>: key=value(记录关键技术事实)
+- [后续] <下一步建议>
+
+## 编码规范(严格执行)
+- `from __future__ import annotations`
+- 完整类型注解;路径用 `pathlib.Path`
+- 日志用 `logging`,禁止 `print`
+- 新功能必须有对应 pytest 测试
+- 不引入未经评估的外部依赖
+"""
+
+
+class SeniorDevAgent(DeveloperAgent):
+ """高级研发 Agent:平衡代码实现与架构视角。"""
+
+ def __init__(
+ self,
+ config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ super().__init__(config, memory=memory)
+ self._queue = queue or TaskQueue()
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ return base + "\n\n" + SENIOR_DEV_SYSTEM
diff --git a/src/nmfs_agents/agents/system_tester.py b/src/nmfs_agents/agents/system_tester.py
new file mode 100644
index 0000000..4265014
--- /dev/null
+++ b/src/nmfs_agents/agents/system_tester.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import logging
+import re
+from typing import Optional
+
+from nmfs_agents.agents.developer import AgentResult, DeveloperAgent
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_TESTER_SYSTEM = """你是项目研发与交付组的系统测试工程师。
+
+测试五层能力:
+1. 功能测试:运行 pytest,SSH 板端自动执行,检查通过/失败
+2. 视觉测试:使用 Playwright 截图推理界面,分析检测框/FPS/误检,截图保存至 docs/test_reports/
+3. 性能测试:测量 FPS、推理延迟、内存占用,与目标值对比,写入 [记忆]
+4. 跨平台测试:RK3588/全志/ESP32 分平台调度执行,记录差异
+5. 测试报告:生成结构化 Markdown 报告至 docs/test_reports/YYYY-MM-DD-{project}.md
+
+工具使用:
+- Playwright:browser_navigate → 推理演示页 → browser_take_screenshot → 分析截图
+- SSH 板端:通过 Bash 执行 ssh user@host "pytest tests/ -q"
+- 性能测量:time/perf_counter 或板端 top/free 命令
+
+失败处理:
+- 测试失败 → 输出 [需求] <项目>: 具体问题描述,触发 developer 修复
+- 性能退化 → [记忆] <项目>: fps_baseline=N,记录当前基线
+
+请用中文,数值精确,测试结论明确。
+"""
+
+
+class SystemTesterAgent(DeveloperAgent):
+ """项目交付组:多层系统测试(功能+视觉+性能+跨平台+报告)"""
+
+ def __init__(
+ self,
+ config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ super().__init__(config, memory=memory)
+ self._queue = queue or TaskQueue()
+
+ def run(self, task: Task) -> AgentResult:
+ result = super().run(task)
+ if result.status == "done":
+ self._dispatch_require_tasks(result.summary, task)
+ return result
+
+ def _build_context(self, task: Task) -> str:
+ base = super()._build_context(task)
+ return base + self._build_device_context() + "\n" + SYSTEM_TESTER_SYSTEM
+
+ def _dispatch_require_tasks(self, report_text: str, task: Task) -> None:
+ """解析 [需求] 标签,将测试发现的问题派发给 developer 修复。"""
+ for m in re.finditer(r"\[需求\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ desc = m.group(2).strip()[:120]
+ child = Task(
+ project=project,
+ type="fix_bug",
+ title=f"[修复] {desc}",
+ agent_role="developer",
+ priority=task.priority,
+ mode=task.mode,
+ parent_task_id=task.id,
+ initiator="system-tester",
+ discussion=f"系统测试发现问题(任务#{task.id} {task.title}):\n{desc}",
+ )
+ self._queue.enqueue(child)
+ logger.info("系统测试派发修复任务: project=%s, title=%s", project, child.title)
diff --git a/src/nmfs_agents/agents/tester.py b/src/nmfs_agents/agents/tester.py
new file mode 100644
index 0000000..37051e9
--- /dev/null
+++ b/src/nmfs_agents/agents/tester.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+import logging
+
+from nmfs_agents.agents.developer import AgentResult
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task
+from nmfs_agents.tools.device_agent import DeviceAgent
+from nmfs_agents.tools.file_tools import run_command
+
+logger = logging.getLogger(__name__)
+
+
+class TesterAgent:
+ def __init__(self, config: AgentsConfig) -> None:
+ self._config = config
+
+ def run(self, task: Task) -> AgentResult:
+ proj_cfg = self._config.projects.get(task.project)
+ if not proj_cfg:
+ return AgentResult(status="failed", summary=f"项目不存在: {task.project}")
+
+ lines = []
+ # 1. 本地测试
+ local_result = run_command(
+ "source venv/bin/activate && pytest tests/ -v --tb=short -q",
+ cwd=str(proj_cfg.path),
+ )
+ lines.append(f"=== 本地测试 ===\n{local_result}")
+
+ # 2. 设备端测试(如有配置)
+ if proj_cfg.device_workspace and self._config.devices:
+ dev_name = next(iter(self._config.devices))
+ dev_cfg = self._config.devices[dev_name]
+ device = DeviceAgent(dev_cfg)
+ # 先同步
+ sync_result = device.sync(str(proj_cfg.path), proj_cfg.device_workspace)
+ lines.append(f"=== 设备同步 ===\n{sync_result}")
+ # 然后运行板端测试
+ board_result = device.run_command(
+ "pytest tests/ -v --tb=short -q",
+ proj_cfg.device_workspace,
+ )
+ lines.append(f"=== 板端测试 ({dev_name}) ===\n{board_result}")
+
+ summary = "\n\n".join(lines)
+ failed = "FAILED" in summary or "ERROR" in summary
+ return AgentResult(
+ status="failed" if failed else "done",
+ summary=summary,
+ )
diff --git a/src/nmfs_agents/agents/vision_analyst.py b/src/nmfs_agents/agents/vision_analyst.py
new file mode 100644
index 0000000..15172b0
--- /dev/null
+++ b/src/nmfs_agents/agents/vision_analyst.py
@@ -0,0 +1,141 @@
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import shutil
+import tempfile
+from typing import Optional
+
+from nmfs_agents.agents.developer import (
+ AgentResult, _make_drop_root_preexec, _run_with_log, _task_log_path,
+)
+from nmfs_agents.config import AgentsConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+TIMEOUT = 600
+
+VISION_ANALYST_SYSTEM = """你是产品洞察与市场落地组的视觉分析师,基于 Claude Vision 进行多帧视觉质量分析。
+
+工作流程(5帧分析):
+1. 调用 EdgeValidatorService 截图(优先)或 Playwright 截图
+ - EdgeValidatorService: fetch POST http://:8899/screenshot(见上方设备信息)
+ - Playwright 备用: browser_navigate → browser_take_screenshot
+2. 连续截取 5 帧,间隔 2 秒
+3. 逐帧作为多模态 LLM 分析(你直接看截图):
+ - 检测框位置合理性(有无明显偏移/错位)
+ - FPS 数值读取(从 OSD/界面文字)
+ - 漏检:应检测目标未出现检测框
+ - 误检:背景被误识别
+4. 跨帧分析:检测稳定性(同一目标的框是否抖动/消失)
+5. 存档:docs/visual_tests/YYYY-MM-DD-{project}-{seq:02d}.png
+
+输出格式:
+- [视觉结果] fps= stability=HIGH/MED/LOW detected= false_pos= pass=true/false
+- [视觉退化] fps_trend=升/降 bbox_jitter=高/中/低
+- [需求] <项目名>: 具体视觉问题描述和改进方向
+
+结合推理日志定量验证:
+- fetch GET http://:8899/logs/yolo/20
+
+请用中文,视觉描述精确,问题定位清晰。
+"""
+
+
+class VisionAnalystAgent:
+ def __init__(
+ self,
+ config: AgentsConfig,
+ queue: Optional[TaskQueue] = None,
+ memory: Optional[ProjectMemory] = None,
+ ) -> None:
+ self._config = config
+ self._queue = queue or TaskQueue()
+ self._memory = memory or ProjectMemory()
+
+ def run(self, task: Task) -> AgentResult:
+ from nmfs_agents.config import load_mcp_tools
+ mcp_cfg = load_mcp_tools()
+ registry = mcp_cfg.get("registry", {})
+
+ mcp_servers: dict = {}
+ for tool in ["playwright", "sequential-thinking"]:
+ if tool in registry:
+ parts = registry[tool].split()
+ if shutil.which(parts[0]):
+ mcp_servers[tool] = {"command": parts[0], "args": parts[1:]}
+
+ tmp_files: list[str] = []
+ cmd = [
+ "claude", "--print",
+ "--output-format", "json",
+ "--no-session-persistence",
+ "--model", self._config.claude.model,
+ "--dangerously-skip-permissions",
+ "--append-system-prompt", VISION_ANALYST_SYSTEM,
+ ]
+ if mcp_servers:
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".json", delete=False, prefix="mcp_vision_"
+ ) as f:
+ json.dump({"mcpServers": mcp_servers}, f)
+ tmp_files.append(f.name)
+ cmd += ["--mcp-config", tmp_files[-1]]
+ cmd += ["--", self._build_prompt(task)]
+
+ try:
+ env = self._build_env()
+ result = _run_with_log(
+ cmd, None, env, TIMEOUT, _task_log_path(task.id),
+ preexec_fn=_make_drop_root_preexec(env),
+ )
+ if result.status == "done":
+ self._extract_needs(result.summary)
+ return result
+ except Exception as e:
+ logger.error("VisionAnalystAgent 失败: %s", e)
+ return AgentResult(status="failed", summary=str(e))
+ finally:
+ for f in tmp_files:
+ try:
+ os.unlink(f)
+ except OSError:
+ pass
+
+ def _build_prompt(self, task: Task) -> str:
+ proj_cfg = self._config.projects.get(task.project)
+ desc = proj_cfg.description if proj_cfg else task.project
+ return (
+ f"项目: {task.project},描述: {desc}\n"
+ f"任务: {task.title}\n"
+ f"背景: {task.context}\n\n"
+ f"请使用 Playwright 截图并分析推理效果,输出视觉分析报告。"
+ )
+
+ def _extract_needs(self, report_text: str) -> None:
+ for m in re.finditer(r"\[需求\]\s+([\w-]+)\s*[::]\s*(.+)", report_text):
+ project = m.group(1).strip()
+ desc = m.group(2).strip()[:120]
+ if project not in self._config.projects:
+ logger.warning("视觉分析需求项目 %r 不在配置中,跳过", project)
+ continue
+ proj_cfg = self._config.projects[project]
+ self._queue.enqueue(Task(
+ project=project, type="fix_bug",
+ title=f"[视觉分析] {desc}",
+ context=f"由 VisionAnalystAgent 发现\n{desc}",
+ priority=2, mode=proj_cfg.mode,
+ agent_role="developer", initiator="vision-analyst",
+ discussion="VisionAnalystAgent 视觉分析发现的问题",
+ ))
+
+ def _build_env(self) -> dict:
+ env = os.environ.copy()
+ if self._config.claude.api_key:
+ env["ANTHROPIC_API_KEY"] = self._config.claude.api_key
+ env.pop("CLAUDECODE", None)
+ env.pop("CLAUDE_CODE_ENTRYPOINT", None)
+ return env
diff --git a/src/nmfs_agents/config.py b/src/nmfs_agents/config.py
new file mode 100644
index 0000000..992e25c
--- /dev/null
+++ b/src/nmfs_agents/config.py
@@ -0,0 +1,213 @@
+from __future__ import annotations
+
+import logging
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+import yaml
+
+logger = logging.getLogger(__name__)
+
+CONFIG_DIR = Path(__file__).parent.parent.parent / "configs"
+
+
+@dataclass
+class ProjectConfig:
+ path: Path
+ mode: str = "report" # auto | confirm | report
+ kind: str = "host" # embedded | host
+ device_workspace: Optional[str] = None
+ description: str = ""
+
+
+@dataclass
+class QueueConfig:
+ db: str # 相对于项目根目录的 DB 路径
+ max_concurrent: int = 3
+ scan_interval_hours: int = 1
+ roles: frozenset[str] = field(default_factory=frozenset)
+
+
+@dataclass
+class SchedulerConfig:
+ interval_hours: int = 1
+ max_concurrent: int = 3
+
+
+@dataclass
+class ClaudeConfig:
+ model: str = "claude-sonnet-4-6"
+ api_key: str = field(default_factory=lambda: os.environ.get("ANTHROPIC_API_KEY", ""))
+
+
+@dataclass
+class FeishuConfig:
+ webhook_url: str = field(default_factory=lambda: os.environ.get("FEISHU_WEBHOOK_URL", ""))
+ bot_token: str = field(default_factory=lambda: os.environ.get("FEISHU_BOT_TOKEN", ""))
+ callback_port: int = 8765
+
+
+@dataclass
+class DeviceConfig:
+ host: str = ""
+ user: str = ""
+ password: str = ""
+ workspace: str = ""
+ pre_cmd: str = ""
+ type: str = "linux" # linux | esp32 | nordic
+ connect: str = "ssh" # ssh | serial | gateway
+ via: str = "" # gateway 时的中转设备名(对应 devices 中的 key)
+ port: str = "" # 串口路径,如 /dev/ttyUSB0
+ baud: int = 115200
+
+
+@dataclass
+class AgentsConfig:
+ projects: dict[str, ProjectConfig]
+ scheduler: SchedulerConfig
+ claude: ClaudeConfig
+ feishu: FeishuConfig
+ devices: dict[str, DeviceConfig]
+ claude_wx_url: str = "" # 可选:claude-wx 服务端 URL,用于 agent dashboard 通知
+ queues: dict[str, "QueueConfig"] = field(default_factory=dict)
+
+
+def load_config(config_dir: Path | None = None) -> AgentsConfig:
+ """Load configuration, merging auto-discovered projects with explicit overrides.
+
+ projects.yaml may contain:
+ - ``scan_dirs``: list of paths to scan for Claude-managed projects
+ - ``projects``: explicit per-project config (overrides auto-discovered values;
+ ``path`` is optional if the project is found via scan_dirs)
+ """
+ d = config_dir or CONFIG_DIR
+ with open(d / "projects.yaml") as f:
+ proj_data = yaml.safe_load(f) or {}
+ with open(d / "agents.yaml") as f:
+ agent_data = yaml.safe_load(f) or {}
+ with open(d / "devices.yaml") as f:
+ dev_data = yaml.safe_load(f) or {}
+
+ # Parse devices first (needed for device_workspace inference)
+ devices: dict[str, DeviceConfig] = {
+ name: DeviceConfig(
+ host=v.get("host", ""),
+ user=v.get("user", ""),
+ password=str(v.get("password", "")),
+ workspace=v.get("workspace", ""),
+ pre_cmd=v.get("pre_cmd", ""),
+ type=v.get("type", "linux"),
+ connect=v.get("connect", "ssh"),
+ via=v.get("via", ""),
+ port=v.get("port", ""),
+ baud=int(v.get("baud", 115200)),
+ )
+ for name, v in (dev_data.get("devices") or {}).items()
+ }
+
+ # ── step 1: auto-discover from scan_dirs ──────────────────────────────────
+ discovered: dict[str, ProjectConfig] = {}
+ scan_dirs = proj_data.get("scan_dirs") or []
+ if scan_dirs:
+ from nmfs_agents.tools.project_discovery import ProjectDiscovery
+ discovery = ProjectDiscovery(devices=devices)
+ for raw_path in scan_dirs:
+ target = Path(raw_path)
+ for disc in discovery.scan(target):
+ discovered[disc.name] = ProjectConfig(
+ path=disc.path,
+ mode=disc.mode,
+ kind=disc.kind,
+ device_workspace=disc.device_workspace,
+ description=disc.description,
+ )
+ logger.debug("自动发现项目: %s (%s) at %s",
+ disc.name, disc.kind, disc.path)
+
+ # ── step 1b: remove excluded projects ────────────────────────────────────
+ exclude_set = set(proj_data.get("exclude_projects") or [])
+ for name in list(discovered.keys()):
+ if name in exclude_set:
+ del discovered[name]
+ logger.debug("已排除项目: %s", name)
+
+ # ── step 2: apply explicit overrides ─────────────────────────────────────
+ explicit: dict[str, ProjectConfig] = {}
+ for name, v in (proj_data.get("projects") or {}).items():
+ base = discovered.get(name)
+ # path is optional when project was already discovered
+ raw_path = v.get("path")
+ if raw_path:
+ path = Path(raw_path)
+ elif base:
+ path = base.path
+ else:
+ logger.warning("项目 %r 无法确定路径(未在 scan_dirs 中发现,且未指定 path),跳过", name)
+ continue
+ explicit[name] = ProjectConfig(
+ path=path,
+ mode=v.get("mode", base.mode if base else "report"),
+ kind=v.get("kind", base.kind if base else "host"),
+ device_workspace=v.get("device_workspace",
+ base.device_workspace if base else None),
+ description=v.get("description", base.description if base else ""),
+ )
+
+ # 只启用显式配置的项目;scan_dirs 仅用于路径解析,不自动引入未列出的项目
+ projects = explicit
+
+ sched = agent_data.get("scheduler", {})
+ claude_d = agent_data.get("claude", {})
+ feishu_d = agent_data.get("feishu", {})
+
+ # 解析多队列配置
+ queues: dict[str, QueueConfig] = {}
+ for q_name, q_data in agent_data.get("queues", {}).items():
+ queues[q_name] = QueueConfig(
+ db=q_data.get("db", f"data/{q_name}_tasks.db"),
+ max_concurrent=q_data.get("max_concurrent", 3),
+ scan_interval_hours=q_data.get("scan_interval_hours", 1),
+ roles=frozenset(q_data.get("roles", [])),
+ )
+
+ return AgentsConfig(
+ projects=projects,
+ scheduler=SchedulerConfig(**sched),
+ claude=ClaudeConfig(model=claude_d.get("model", "claude-sonnet-4-6")),
+ feishu=FeishuConfig(callback_port=feishu_d.get("callback_port", 8765)),
+ devices=devices,
+ claude_wx_url=agent_data.get("claude_wx_url", ""),
+ queues=queues,
+ )
+
+
+def load_mcp_tools(config_dir: Path | None = None) -> dict:
+ """加载 MCP 工具注册表和角色工具映射(支持热更新)"""
+ d = config_dir or CONFIG_DIR
+ path = d / "mcp_tools.yaml"
+ if not path.exists():
+ return {"registry": {}, "roles": {}}
+ with open(path) as f:
+ return yaml.safe_load(f) or {"registry": {}, "roles": {}}
+
+
+def load_role_routing(config_dir: Path | None = None) -> dict[str, list[str]]:
+ """加载角色关键词路由表(返回 role → [keywords] 映射)"""
+ d = config_dir or CONFIG_DIR
+ path = d / "role_routing.yaml"
+ if not path.exists():
+ return {}
+ with open(path) as f:
+ data = yaml.safe_load(f) or {}
+ return data.get("role_keywords", {})
+
+
+def route_role(text: str, routing: dict[str, list[str]]) -> str:
+ """按关键词路由任务到合适的 agent_role,无匹配返回 dev-embedded"""
+ text_lower = text.lower()
+ for role, keywords in routing.items():
+ if any(kw.lower() in text_lower for kw in keywords):
+ return role
+ return "dev-embedded"
diff --git a/src/nmfs_agents/core/__init__.py b/src/nmfs_agents/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nmfs_agents/core/queue.py b/src/nmfs_agents/core/queue.py
new file mode 100644
index 0000000..0145445
--- /dev/null
+++ b/src/nmfs_agents/core/queue.py
@@ -0,0 +1,356 @@
+from __future__ import annotations
+
+import sqlite3
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Optional
+
+DEFAULT_DB = Path(__file__).parent.parent.parent.parent / "data" / "tasks.db"
+
+
+@dataclass
+class Task:
+ project: str
+ type: str # fix_bug | improve_perf | add_feature | code_review | productize | architect
+ title: str
+ priority: int # 1(严重)→ 5(低)
+ mode: str # auto | confirm | report
+ agent_role: str # developer | tester | productizer | architect
+ context: str = ""
+ id: int = 0
+ status: str = "pending"
+ result_summary: str = ""
+ # 制约工作流扩展字段
+ retry_count: int = 0
+ parent_task_id: int = 0
+ constraint_type: str = "" # "veto_pending"|"approval_pending"|"adversarial"
+ awaiting_role: str = "" # 串行链中等待哪个角色
+ created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
+ # 任务溯源字段
+ initiator: str = "system" # 发起人:scanner / architect / manager / user / system
+ discussion: str = "" # 沟通纪要:任务背景、决策依据、相关讨论
+ execution_host: str = "dev" # dev | edge-api
+
+
+class TaskQueue:
+ def __init__(self, db_path: Path | None = None) -> None:
+ self._db = db_path or DEFAULT_DB
+ self._db.parent.mkdir(parents=True, exist_ok=True)
+ self._init_db()
+
+ def _conn(self) -> sqlite3.Connection:
+ conn = sqlite3.connect(self._db, isolation_level=None) # autocommit
+ conn.row_factory = sqlite3.Row
+ return conn
+
+ def _init_db(self) -> None:
+ with self._conn() as c:
+ c.execute("PRAGMA journal_mode=WAL")
+ c.execute("""
+ CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ project TEXT NOT NULL,
+ type TEXT NOT NULL,
+ title TEXT NOT NULL,
+ context TEXT DEFAULT '',
+ priority INTEGER DEFAULT 3,
+ mode TEXT DEFAULT 'report',
+ agent_role TEXT DEFAULT 'developer',
+ status TEXT DEFAULT 'pending',
+ created_at TEXT,
+ started_at TEXT,
+ completed_at TEXT,
+ result_summary TEXT DEFAULT '',
+ retry_count INTEGER DEFAULT 0,
+ parent_task_id INTEGER DEFAULT 0,
+ constraint_type TEXT DEFAULT '',
+ awaiting_role TEXT DEFAULT '',
+ initiator TEXT DEFAULT 'system',
+ discussion TEXT DEFAULT ''
+ )
+ """)
+ # 迁移:为已存在的旧表添加新列(幂等)
+ with self._conn() as c:
+ for col, col_type, defval in [
+ ("retry_count", "INTEGER", "0"),
+ ("parent_task_id", "INTEGER", "0"),
+ ("constraint_type", "TEXT", "''"),
+ ("awaiting_role", "TEXT", "''"),
+ ("initiator", "TEXT", "'system'"),
+ ("discussion", "TEXT", "''"),
+ ("execution_host", "TEXT", "'dev'"),
+ ]:
+ try:
+ c.execute(f"ALTER TABLE tasks ADD COLUMN {col} {col_type} DEFAULT {defval}")
+ except sqlite3.OperationalError:
+ pass # 列已存在
+
+ def _row_to_task(self, row) -> Task:
+ return Task(
+ id=row["id"], project=row["project"], type=row["type"],
+ title=row["title"], context=row["context"], priority=row["priority"],
+ mode=row["mode"], agent_role=row["agent_role"], status=row["status"],
+ retry_count=int(row["retry_count"] or 0),
+ parent_task_id=row["parent_task_id"] or 0,
+ constraint_type=row["constraint_type"] or "",
+ awaiting_role=row["awaiting_role"] or "",
+ initiator=row["initiator"] or "system",
+ discussion=row["discussion"] or "",
+ execution_host=row["execution_host"] if "execution_host" in row.keys() else "dev",
+ )
+
+ def enqueue(self, task: Task) -> int:
+ """返回 task id;若已存在同 project+type+title 的 pending/running 任务则返回 -1。"""
+ with self._conn() as c:
+ exists = c.execute(
+ "SELECT id FROM tasks WHERE project=? AND type=? AND title=? AND status IN ('pending','running')",
+ (task.project, task.type, task.title),
+ ).fetchone()
+ if exists:
+ return -1
+ cur = c.execute(
+ "INSERT INTO tasks "
+ "(project,type,title,context,priority,mode,agent_role,status,created_at,"
+ "retry_count,parent_task_id,constraint_type,awaiting_role,initiator,discussion,"
+ "execution_host) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ (task.project, task.type, task.title, task.context,
+ task.priority, task.mode, task.agent_role, "pending", task.created_at,
+ task.retry_count, task.parent_task_id, task.constraint_type, task.awaiting_role,
+ task.initiator, task.discussion, task.execution_host),
+ )
+ return cur.lastrowid
+
+ def dequeue(
+ self,
+ exclude_projects: frozenset[str] | None = None,
+ role_filter: frozenset[str] | None = None,
+ ) -> Optional[Task]:
+ """原子化取出优先级最高的 pending 任务并标记为 running。
+ exclude_projects: 跳过这些项目的任务(用于项目级暂停)。
+ role_filter: 只取这些角色的任务(用于多队列隔离)。
+ """
+ conn = self._conn()
+ try:
+ conn.execute("BEGIN EXCLUSIVE")
+ # 动态构建 WHERE 子句
+ conditions = ["status='pending'"]
+ params: list = []
+ if exclude_projects:
+ placeholders = ",".join("?" * len(exclude_projects))
+ conditions.append(f"project NOT IN ({placeholders})")
+ params.extend(tuple(exclude_projects))
+ if role_filter:
+ placeholders = ",".join("?" * len(role_filter))
+ conditions.append(f"agent_role IN ({placeholders})")
+ params.extend(tuple(role_filter))
+ where = " AND ".join(conditions)
+ row = conn.execute(
+ f"SELECT * FROM tasks WHERE {where} ORDER BY priority ASC, id ASC LIMIT 1",
+ params,
+ ).fetchone()
+ if not row:
+ conn.execute("COMMIT")
+ return None
+ conn.execute(
+ "UPDATE tasks SET status='running', started_at=? WHERE id=?",
+ (datetime.utcnow().isoformat(), row["id"]),
+ )
+ conn.execute("COMMIT")
+ t = self._row_to_task(row)
+ t.status = "running"
+ return t
+ except Exception:
+ conn.execute("ROLLBACK")
+ raise
+ finally:
+ conn.close()
+
+ def mark_done(self, task_id: int, summary: str = "") -> None:
+ with self._conn() as c:
+ c.execute(
+ "UPDATE tasks SET status='done', completed_at=?, result_summary=? WHERE id=?",
+ (datetime.utcnow().isoformat(), summary, task_id),
+ )
+
+ def mark_failed(self, task_id: int, reason: str = "") -> None:
+ with self._conn() as c:
+ c.execute(
+ "UPDATE tasks SET status='failed', completed_at=?, result_summary=? WHERE id=?",
+ (datetime.utcnow().isoformat(), reason, task_id),
+ )
+
+ def mark_waiting_confirm(self, task_id: int, diff: str = "") -> None:
+ with self._conn() as c:
+ c.execute(
+ "UPDATE tasks SET status='waiting_confirm', result_summary=? WHERE id=?",
+ (diff, task_id),
+ )
+
+ def mark_waiting_approval(self, task_id: int, awaiting_role: str = "") -> None:
+ """进入串行审批链等待状态(不占 Semaphore)"""
+ with self._conn() as c:
+ c.execute(
+ "UPDATE tasks SET status='waiting_approval', awaiting_role=? WHERE id=?",
+ (awaiting_role, task_id),
+ )
+
+ def mark_vetoed(self, task_id: int, reason: str = "") -> None:
+ """被否决:retry_count+1;第 3 次否决时标记为 failed(最多允许 2 次重试,retry_count 上限为 2)"""
+ with self._conn() as c:
+ row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ if not row:
+ return
+ retry = int(row["retry_count"] or 0) + 1
+ if retry > 2:
+ c.execute(
+ "UPDATE tasks SET status='failed', result_summary=? WHERE id=?",
+ (f"否决次数超限: {reason}", task_id),
+ )
+ return
+ new_context = (
+ f"\u26a0\ufe0f 上次执行被否决(第 {retry} 次重试)\n"
+ f"原因:{reason}\n\n"
+ f"--- 原始任务上下文 ---\n"
+ f"{row['context']}"
+ )
+ c.execute(
+ "UPDATE tasks SET status='pending', retry_count=?, context=?, "
+ "awaiting_role='', constraint_type='veto_pending' WHERE id=?",
+ (retry, new_context, task_id),
+ )
+
+ def mark_adversarial_pending(self, task_id: int) -> None:
+ """对抗式提案等待 SELECT"""
+ with self._conn() as c:
+ c.execute(
+ "UPDATE tasks SET status='adversarial_pending', "
+ "constraint_type='adversarial' WHERE id=?",
+ (task_id,),
+ )
+
+ def approve_waiting(self, task_id: int) -> None:
+ """审批通过:waiting_approval → done"""
+ with self._conn() as c:
+ c.execute(
+ "UPDATE tasks SET status='done', awaiting_role='' WHERE id=?",
+ (task_id,),
+ )
+
+ def reset_stale_running(self, timeout_minutes: int = 120) -> int:
+ """将超时的 running 任务标记为 failed,返回处理数量。
+
+ timeout_minutes=0 表示重置所有 running 任务(用于进程重启后的恢复)。
+ """
+ with self._conn() as c:
+ if timeout_minutes == 0:
+ rows = c.execute(
+ "SELECT id FROM tasks WHERE status='running'"
+ ).fetchall()
+ else:
+ cutoff = (datetime.utcnow() - timedelta(minutes=timeout_minutes)).isoformat()
+ rows = c.execute(
+ "SELECT id FROM tasks WHERE status='running' AND started_at < ?",
+ (cutoff,),
+ ).fetchall()
+ if not rows:
+ return 0
+ ids = [r["id"] for r in rows]
+ placeholders = ",".join("?" * len(ids))
+ reason = "执行超时(Watchdog 检测)" if timeout_minutes > 0 else "进程重启恢复"
+ c.execute(
+ f"UPDATE tasks SET status='failed', completed_at=?, result_summary=? "
+ f"WHERE id IN ({placeholders})",
+ [datetime.utcnow().isoformat(), reason, *ids],
+ )
+ return len(ids)
+
+ def bump_stale_priorities(self, threshold_minutes: int = 120) -> int:
+ """将长时间等待的 pending 任务优先级提升(priority-1,最低为 1),返回处理数量。"""
+ cutoff = (datetime.utcnow() - timedelta(minutes=threshold_minutes)).isoformat()
+ with self._conn() as c:
+ rows = c.execute(
+ "SELECT id, priority FROM tasks "
+ "WHERE status='pending' AND priority > 1 AND created_at < ?",
+ (cutoff,),
+ ).fetchall()
+ for r in rows:
+ c.execute(
+ "UPDATE tasks SET priority=? WHERE id=?",
+ (r["priority"] - 1, r["id"]),
+ )
+ return len(rows)
+
+ def get_waiting_approval_tasks(self) -> list[Task]:
+ """获取所有 waiting_approval 状态的任务"""
+ with self._conn() as c:
+ rows = c.execute(
+ "SELECT * FROM tasks WHERE status='waiting_approval'"
+ ).fetchall()
+ return [self._row_to_task(r) for r in rows]
+
+
+ def enqueue_topic(self, task: Task) -> int:
+ """将选题任务写入 topic_pending 状态(不走普通 pending 去重检查)。"""
+ with self._conn() as c:
+ exists = c.execute(
+ "SELECT id FROM tasks WHERE project=? AND title=? AND status='topic_pending'",
+ (task.project, task.title),
+ ).fetchone()
+ if exists:
+ return -1
+ cur = c.execute(
+ "INSERT INTO tasks "
+ "(project,type,title,context,priority,mode,agent_role,status,created_at,"
+ "initiator,discussion,execution_host) "
+ "VALUES (?,?,?,?,?,?,?,'topic_pending',?,?,?,?)",
+ (task.project, task.type, task.title, task.context,
+ task.priority, task.mode, task.agent_role, task.created_at,
+ task.initiator, task.discussion, task.execution_host),
+ )
+ return cur.lastrowid
+
+ def approve_topic(self, task_id: int) -> None:
+ """用户确认选题:topic_pending → pending"""
+ with self._conn() as c:
+ c.execute(
+ "UPDATE tasks SET status='pending' WHERE id=? AND status='topic_pending'",
+ (task_id,),
+ )
+
+ def reject_topic(self, task_id: int, reason: str = "") -> None:
+ """用户否决选题:topic_pending → vetoed"""
+ with self._conn() as c:
+ c.execute(
+ "UPDATE tasks SET status='vetoed', result_summary=? WHERE id=? AND status='topic_pending'",
+ (reason, task_id),
+ )
+
+ def get_topic_pending_tasks(self) -> list[Task]:
+ """返回所有待审批选题。"""
+ with self._conn() as c:
+ rows = c.execute(
+ "SELECT * FROM tasks WHERE status='topic_pending' ORDER BY created_at DESC"
+ ).fetchall()
+ return [self._row_to_task(r) for r in rows]
+
+ def clear_project_tasks(self, project: str, statuses: list[str] | None = None) -> int:
+ """删除指定项目的任务记录,默认删除全部状态。返回删除数量。"""
+ with self._conn() as c:
+ if statuses:
+ placeholders = ",".join("?" * len(statuses))
+ rows = c.execute(
+ f"SELECT id FROM tasks WHERE project=? AND status IN ({placeholders})",
+ [project, *statuses],
+ ).fetchall()
+ else:
+ rows = c.execute(
+ "SELECT id FROM tasks WHERE project=?", (project,)
+ ).fetchall()
+ if not rows:
+ return 0
+ ids = [r["id"] for r in rows]
+ placeholders = ",".join("?" * len(ids))
+ c.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", ids)
+ return len(ids)
diff --git a/src/nmfs_agents/core/scanner.py b/src/nmfs_agents/core/scanner.py
new file mode 100644
index 0000000..d187bfc
--- /dev/null
+++ b/src/nmfs_agents/core/scanner.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+from nmfs_agents.config import AgentsConfig, ProjectConfig
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ScanResult:
+ project: str
+ type: str # fix_bug | improve_perf | code_review
+ title: str
+ context: str
+ priority: int
+ mode: str
+ agent_role: str = "developer"
+ initiator: str = "scanner"
+ discussion: str = ""
+
+
+class Scanner:
+ def __init__(self, config: AgentsConfig,
+ memory: ProjectMemory | None = None) -> None:
+ self._config = config
+ self._memory = memory or ProjectMemory()
+ from nmfs_agents.config import load_role_routing
+ self._role_routing = load_role_routing()
+
+ def scan_all(self) -> list[ScanResult]:
+ results: list[ScanResult] = []
+ for name, proj_cfg in self._config.projects.items():
+ if not proj_cfg.path.exists():
+ logger.warning("项目路径不存在: %s", proj_cfg.path)
+ continue
+ results.extend(self.scan_project(name, proj_cfg))
+ return results
+
+ def scan_project(self, name: str, cfg: ProjectConfig) -> list[ScanResult]:
+ results: list[ScanResult] = []
+ results.extend(self._scan_readme(name, cfg))
+ results.extend(self._scan_todos(name, cfg))
+ results.extend(self._scan_claude_md(name, cfg))
+ results.extend(self._scan_description(name, cfg))
+ results.extend(self._scan_planned_features(name, cfg))
+ results.extend(self._scan_periodic(name, cfg))
+ # Append long-term goal to every task context so agents stay aligned
+ goal_ctx = self._memory.get_context_for_prompt(name)
+ if goal_ctx:
+ for r in results:
+ r.context = r.context + goal_ctx
+ return results
+
+ def _make(self, project: str, cfg: ProjectConfig, type_: str,
+ title: str, context: str, priority: int,
+ agent_role: str | None = None) -> ScanResult:
+ if agent_role is None:
+ from nmfs_agents.config import route_role
+ agent_role = route_role(f"{title} {context}", self._role_routing)
+ return ScanResult(
+ project=project, type=type_, title=title,
+ context=context, priority=priority, mode=cfg.mode,
+ agent_role=agent_role,
+ )
+
+ def _scan_readme(self, project: str, cfg: ProjectConfig) -> list[ScanResult]:
+ results = []
+ readme = cfg.path / "README.md"
+ if not readme.exists():
+ return results
+ text = readme.read_text(encoding="utf-8", errors="ignore")
+ # 查找"已知问题"章节
+ section = re.search(r"##\s*(已知问题|Known Issues?|known issues?)(.*?)(?=\n##|\Z)",
+ text, re.DOTALL | re.IGNORECASE)
+ if not section:
+ return results
+ for line in section.group(2).splitlines():
+ line = line.strip("- *\t ")
+ if len(line) > 5:
+ results.append(self._make(project, cfg, "fix_bug",
+ f"[README] {line[:80]}", line, priority=2))
+ return results
+
+ _SKIP_DIRS = frozenset({
+ "venv", ".venv", ".git", "__pycache__", "node_modules",
+ ".tox", "build", "dist", "*.egg-info",
+ })
+
+ def _scan_todos(self, project: str, cfg: ProjectConfig) -> list[ScanResult]:
+ results = []
+ src_dirs = [cfg.path / "src", cfg.path / "tools", cfg.path]
+ pattern = re.compile(r"#\s*(TODO|FIXME|HACK)[:\s]+(.*)", re.IGNORECASE)
+ seen: set[str] = set()
+ for src_dir in src_dirs:
+ if not src_dir.is_dir():
+ continue
+ for py_file in self._iter_py_files(src_dir):
+ try:
+ rel = py_file.relative_to(cfg.path)
+ for i, line in enumerate(py_file.read_text(errors="ignore").splitlines(), 1):
+ m = pattern.search(line)
+ if m:
+ key = m.group(2).strip()[:60]
+ if key not in seen:
+ seen.add(key)
+ results.append(self._make(
+ project, cfg, "code_review",
+ f"TODO: {key}",
+ f"{rel}:{i} — {line.strip()}",
+ priority=4,
+ ))
+ except OSError:
+ pass
+ return results
+
+ def _iter_py_files(self, root: Path):
+ """递归遍历 .py 文件,跳过 venv/__pycache__ 等无关目录。"""
+ for entry in root.iterdir():
+ if entry.is_dir():
+ if entry.name in self._SKIP_DIRS or entry.name.endswith(".egg-info"):
+ continue
+ yield from self._iter_py_files(entry)
+ elif entry.suffix == ".py":
+ yield entry
+
+ def _scan_claude_md(self, project: str, cfg: ProjectConfig) -> list[ScanResult]:
+ """从 CLAUDE.md 的关键技术点/关键约束中提取潜在优化任务。"""
+ results = []
+ cfile = cfg.path / "CLAUDE.md"
+ if not cfile.exists():
+ return results
+ text = cfile.read_text(encoding="utf-8", errors="ignore")
+ # 查找包含 ❌ 或 ⚠ 的行(表示有问题的项目)
+ for line in text.splitlines():
+ if ("❌" in line or "⚠" in line) and len(line.strip()) > 5:
+ title = re.sub(r"[❌⚠️\|\s]+", " ", line).strip()[:80]
+ if title:
+ results.append(self._make(project, cfg, "fix_bug",
+ f"[CLAUDE.md] {title}", line, priority=2))
+ return results
+
+ _BUG_KEYWORDS = frozenset(["待修复", "bug", "问题", "错误", "失败", "零检出", "报错"])
+ _PERF_KEYWORDS = frozenset(["fps偏低", "fps 偏低", "性能偏低", "偏低", "延迟高", "速度慢"])
+
+ def _scan_description(self, project: str, cfg: ProjectConfig) -> list[ScanResult]:
+ """从 projects.yaml description 字段提取问题任务。"""
+ desc = cfg.description.strip()
+ if not desc:
+ return []
+ desc_lower = desc.lower()
+ if any(kw in desc_lower for kw in self._BUG_KEYWORDS):
+ return [self._make(project, cfg, "fix_bug",
+ f"[描述] {desc[:80]}", desc, priority=2)]
+ if any(kw in desc_lower for kw in self._PERF_KEYWORDS):
+ return [self._make(project, cfg, "improve_perf",
+ f"[描述] {desc[:80]}", desc, priority=3)]
+ return []
+
+ def _scan_periodic(self, project: str, cfg: ProjectConfig) -> list[ScanResult]:
+ """为每个项目生成兜底定期代码巡检任务(优先级最低)。"""
+ return [self._make(
+ project, cfg, "code_review",
+ "定期代码巡检",
+ f"对 {project} 项目进行整体代码质量检查,识别潜在改进点。",
+ priority=5,
+ )]
+
+ def _scan_planned_features(self, project: str, cfg: ProjectConfig) -> list[ScanResult]:
+ """从 CLAUDE.md 的 ## 规划功能 章节提取 new_feature 任务"""
+ results = []
+ cfile = cfg.path / "CLAUDE.md"
+ if not cfile.exists():
+ return results
+ text = cfile.read_text(encoding="utf-8", errors="ignore")
+ section = re.search(
+ r"##\s*规划功能(.*?)(?=\n##|\Z)", text, re.DOTALL
+ )
+ if not section:
+ return results
+ for line in section.group(1).splitlines():
+ line = line.strip("- *\t ")
+ if len(line) > 5:
+ results.append(self._make(
+ project, cfg, "new_feature",
+ f"[规划] {line[:80]}", line, priority=3,
+ ))
+ return results
diff --git a/src/nmfs_agents/core/watchdog.py b/src/nmfs_agents/core/watchdog.py
new file mode 100644
index 0000000..9340f78
--- /dev/null
+++ b/src/nmfs_agents/core/watchdog.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import logging
+import threading
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from nmfs_agents.core.queue import TaskQueue
+
+logger = logging.getLogger(__name__)
+
+WATCHDOG_INTERVAL = 60 # 检查间隔(秒)
+TASK_TIMEOUT_MINUTES = 150 # running 任务超过此时长视为卡死(任务最大超时 120min + 30min 缓冲)
+PRIORITY_AGING_MINUTES = 120 # pending 任务等待超过此时长触发优先级提升
+
+
+class Watchdog(threading.Thread):
+ """后台看门狗线程:定期检测并修复卡死任务,防止队列长期阻塞。
+
+ 功能:
+ - 超时任务检测:running > TASK_TIMEOUT_MINUTES → failed(释放 semaphore 名额)
+ - 优先级老化:pending > PRIORITY_AGING_MINUTES → priority -1(防止任务永久饥饿)
+ - 心跳日志:每次 tick 写 DEBUG 日志,便于确认 watchdog 存活
+ """
+
+ def __init__(
+ self,
+ queue: TaskQueue,
+ interval: int = WATCHDOG_INTERVAL,
+ task_timeout_minutes: int = TASK_TIMEOUT_MINUTES,
+ ) -> None:
+ super().__init__(name="Watchdog", daemon=True)
+ self._queue = queue
+ self._interval = interval
+ self._task_timeout = task_timeout_minutes
+ self._stop_event = threading.Event()
+
+ def run(self) -> None:
+ logger.info(
+ "Watchdog 已启动(检查间隔=%ds,任务超时阈值=%dm)",
+ self._interval, self._task_timeout,
+ )
+ # Event.wait(timeout) 在 set() 时立即返回 True,否则等满 timeout 秒返回 False
+ while not self._stop_event.wait(self._interval):
+ try:
+ self._tick()
+ except Exception as e:
+ logger.error("Watchdog tick 异常: %s", e)
+ logger.info("Watchdog 已停止")
+
+ def stop(self) -> None:
+ """通知 Watchdog 退出,调用后线程将在当前 sleep 结束时退出。"""
+ self._stop_event.set()
+
+ def _tick(self) -> None:
+ stale = self._queue.reset_stale_running(self._task_timeout)
+ if stale:
+ logger.warning("Watchdog: %d 个超时任务已重置为 failed", stale)
+
+ aged = self._queue.bump_stale_priorities(PRIORITY_AGING_MINUTES)
+ if aged:
+ logger.info("Watchdog: %d 个长期 pending 任务优先级已提升", aged)
+
+ logger.debug("Watchdog tick OK (stale=%d, aged=%d)", stale, aged)
diff --git a/src/nmfs_agents/dashboard/__init__.py b/src/nmfs_agents/dashboard/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nmfs_agents/tools/__init__.py b/src/nmfs_agents/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nmfs_agents/tools/arch_version.py b/src/nmfs_agents/tools/arch_version.py
new file mode 100644
index 0000000..c21066d
--- /dev/null
+++ b/src/nmfs_agents/tools/arch_version.py
@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+from dataclasses import asdict, dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+_SKIP_DIRS = frozenset({
+ "venv", ".venv", ".git", "__pycache__", "node_modules",
+ ".tox", "build", "dist", ".arch_versions",
+})
+
+
+@dataclass
+class VersionMeta:
+ version_id: str
+ description: str
+ created_at: str
+ status: str # candidate | promoted | rolled_back
+ test_passed: Optional[bool]
+ test_output: str
+ files: list[str] # relative paths that were snapshotted
+
+
+class ArchVersion:
+ """Manages architecture optimization snapshots for a project.
+
+ Each snapshot stores a copy of all Python source files under
+ {project}/.arch_versions/{version_id}/snapshot/. Metadata is written
+ to {version_id}/meta.json.
+
+ Typical lifecycle:
+ vid = av.snapshot("add module-level cache") # before change
+ # ... apply change (e.g. via subprocess) ...
+ if tests_pass:
+ av.promote(vid, test_output) # keep change
+ else:
+ av.rollback(vid, test_output) # revert files
+ """
+
+ def __init__(self, project_path: Path) -> None:
+ self._project = project_path
+ self._root = project_path / ".arch_versions"
+ self._root.mkdir(parents=True, exist_ok=True)
+
+ # ── public API ───────────────────────────────────────────────────────────
+
+ def snapshot(self, description: str) -> str:
+ """Copy current Python sources to a new version directory.
+
+ Returns the new version_id (e.g. 'v001').
+ """
+ version_id = self._next_version_id()
+ snapshot_dir = self._root / version_id / "snapshot"
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
+
+ files_saved: list[str] = []
+ for py_file in self._iter_py_files(self._project):
+ try:
+ rel = py_file.relative_to(self._project)
+ dest = snapshot_dir / rel
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(py_file, dest)
+ files_saved.append(str(rel))
+ except OSError as e:
+ logger.warning("快照跳过 %s: %s", py_file, e)
+
+ self._write_meta(version_id, VersionMeta(
+ version_id=version_id,
+ description=description,
+ created_at=datetime.utcnow().isoformat(),
+ status="candidate",
+ test_passed=None,
+ test_output="",
+ files=files_saved,
+ ))
+ logger.info("已创建快照 %s(%d 文件): %s", version_id, len(files_saved), description)
+ return version_id
+
+ def restore(self, version_id: str) -> None:
+ """Restore project files from the named snapshot."""
+ meta = self._read_meta(version_id)
+ snapshot_dir = self._root / version_id / "snapshot"
+ restored = 0
+ for rel_path in meta.files:
+ src = snapshot_dir / rel_path
+ dest = self._project / rel_path
+ if src.exists():
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(src, dest)
+ restored += 1
+ logger.info("已从 %s 恢复 %d 个文件", version_id, restored)
+
+ def promote(self, version_id: str, test_output: str = "") -> None:
+ """Mark a version as promoted (tests passed, changes are kept)."""
+ meta = self._read_meta(version_id)
+ meta.status = "promoted"
+ meta.test_passed = True
+ meta.test_output = test_output
+ self._write_meta(version_id, meta)
+ logger.info("版本 %s 已晋升(测试通过)", version_id)
+
+ def rollback(self, version_id: str, test_output: str = "") -> None:
+ """Restore files and mark the version as rolled_back."""
+ self.restore(version_id)
+ meta = self._read_meta(version_id)
+ meta.status = "rolled_back"
+ meta.test_passed = False
+ meta.test_output = test_output
+ self._write_meta(version_id, meta)
+ logger.info("版本 %s 已回滚(测试失败)", version_id)
+
+ def list_versions(self) -> list[VersionMeta]:
+ """Return all versions sorted by creation time (oldest first)."""
+ versions: list[VersionMeta] = []
+ for meta_file in sorted(self._root.glob("v*/meta.json")):
+ try:
+ versions.append(self._read_meta_from_file(meta_file))
+ except Exception as e:
+ logger.warning("读取版本元数据失败 %s: %s", meta_file, e)
+ return versions
+
+ def current_best(self) -> Optional[str]:
+ """Return the version_id of the most recently promoted version."""
+ promoted = [v for v in self.list_versions() if v.status == "promoted"]
+ return promoted[-1].version_id if promoted else None
+
+ # ── private helpers ───────────────────────────────────────────────────────
+
+ def _next_version_id(self) -> str:
+ existing = list(self._root.glob("v*/meta.json"))
+ return f"v{len(existing) + 1:03d}"
+
+ def _iter_py_files(self, root: Path):
+ for entry in root.iterdir():
+ if entry.is_dir():
+ if entry.name in _SKIP_DIRS or entry.name.endswith(".egg-info"):
+ continue
+ yield from self._iter_py_files(entry)
+ elif entry.suffix == ".py":
+ yield entry
+
+ def _write_meta(self, version_id: str, meta: VersionMeta) -> None:
+ path = self._root / version_id / "meta.json"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(asdict(meta), ensure_ascii=False, indent=2))
+
+ def _read_meta(self, version_id: str) -> VersionMeta:
+ return self._read_meta_from_file(self._root / version_id / "meta.json")
+
+ def _read_meta_from_file(self, path: Path) -> VersionMeta:
+ data = json.loads(path.read_text())
+ return VersionMeta(**data)
diff --git a/src/nmfs_agents/tools/callback_server.py b/src/nmfs_agents/tools/callback_server.py
new file mode 100644
index 0000000..0db4a13
--- /dev/null
+++ b/src/nmfs_agents/tools/callback_server.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import logging
+import threading
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from typing import Optional
+from urllib.parse import urlparse
+
+from nmfs_agents.core.queue import TaskQueue
+
+logger = logging.getLogger(__name__)
+
+
+class _CallbackHandler(BaseHTTPRequestHandler):
+ queue: TaskQueue # 由 CallbackServer 注入
+
+ def do_GET(self) -> None:
+ parsed = urlparse(self.path)
+ parts = parsed.path.strip("/").split("/")
+ # 期望路径:/approve/ 或 /reject/
+ if len(parts) == 2 and parts[0] in ("approve", "reject"):
+ action, task_id_str = parts
+ try:
+ task_id = int(task_id_str)
+ except ValueError:
+ self._respond(400, "invalid task_id")
+ return
+ if action == "approve":
+ self.queue.mark_done(task_id, summary="飞书审批通过")
+ logger.info("任务已批准: %d", task_id)
+ self._respond(200, f"Task {task_id} approved")
+ else:
+ self.queue.mark_failed(task_id, reason="飞书审批拒绝")
+ logger.info("任务已拒绝: %d", task_id)
+ self._respond(200, f"Task {task_id} rejected")
+ else:
+ self._respond(404, "not found")
+
+ def _respond(self, code: int, body: str) -> None:
+ self.send_response(code)
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(body.encode())
+
+ def log_message(self, fmt: str, *args) -> None: # 静默 access log
+ pass
+
+
+class CallbackServer:
+ def __init__(self, port: int, queue: TaskQueue) -> None:
+ self._port = port
+ self._queue = queue
+ self._server: Optional[HTTPServer] = None
+ self._thread: Optional[threading.Thread] = None
+
+ def start(self) -> None:
+ handler = type("Handler", (_CallbackHandler,), {"queue": self._queue})
+ self._server = HTTPServer(("", self._port), handler)
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
+ self._thread.start()
+ logger.info("Callback server 已启动,端口: %d", self._port)
+
+ def stop(self) -> None:
+ if self._server:
+ self._server.shutdown()
+ logger.info("Callback server 已停止")
diff --git a/src/nmfs_agents/tools/device_agent.py b/src/nmfs_agents/tools/device_agent.py
new file mode 100644
index 0000000..105ee76
--- /dev/null
+++ b/src/nmfs_agents/tools/device_agent.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import logging
+import shlex
+import subprocess
+from contextlib import contextmanager
+
+import paramiko
+
+from nmfs_agents.config import DeviceConfig
+
+logger = logging.getLogger(__name__)
+
+
+class DeviceAgent:
+ def __init__(self, device: DeviceConfig) -> None:
+ self._dev = device
+
+ @contextmanager
+ def _ssh(self):
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(self._dev.host, username=self._dev.user,
+ password=self._dev.password, timeout=10,
+ allow_agent=False, look_for_keys=False)
+ try:
+ yield client
+ finally:
+ client.close()
+
+ def run_command(self, cmd: str, remote_cwd: str, timeout: int = 120) -> str:
+ full_cmd = self._build_cmd(cmd, remote_cwd)
+ try:
+ with self._ssh() as client:
+ _, stdout, stderr = client.exec_command(full_cmd, timeout=timeout)
+ out = stdout.read().decode(errors="replace")
+ err = stderr.read().decode(errors="replace")
+ rc = stdout.channel.recv_exit_status()
+ return f"[rc={rc}]\n{out}\n{err}".strip()
+ except Exception as e:
+ logger.error("SSH 命令失败: %s", e)
+ return f"[ERROR] {e}"
+
+ def sync(self, local_path: str, remote_workspace: str,
+ excludes: list[str] | None = None) -> str:
+ """用 rsync 将本地项目同步到设备。"""
+ cmd = self._build_sync_cmd(local_path, remote_workspace, excludes)
+ try:
+ result = subprocess.run(cmd, shell=True, capture_output=True,
+ text=True, timeout=120)
+ return f"[rc={result.returncode}]\n{result.stdout}\n{result.stderr}".strip()
+ except Exception as e:
+ return f"[ERROR] rsync 失败: {e}"
+
+ def _build_cmd(self, cmd: str, remote_cwd: str) -> str:
+ pre = self._dev.pre_cmd
+ parts = []
+ if pre:
+ parts.append(pre)
+ parts.append(f"cd {remote_cwd}")
+ parts.append(cmd)
+ return " && ".join(parts)
+
+ def _build_sync_cmd(self, local_path: str, remote_path: str,
+ excludes: list[str] | None = None) -> str:
+ exc = " ".join(f"--exclude={e}" for e in (excludes or ["venv", "__pycache__", "*.pyc"]))
+ ssh_opt = "-e 'ssh -o StrictHostKeyChecking=no'"
+ prefix = f"sshpass -p {shlex.quote(self._dev.password)} " if self._dev.password else ""
+ return (
+ f"{prefix}rsync -avz --delete {exc} {ssh_opt} "
+ f"{local_path}/ "
+ f"{self._dev.user}@{self._dev.host}:{remote_path}/"
+ )
diff --git a/src/nmfs_agents/tools/device_manager.py b/src/nmfs_agents/tools/device_manager.py
new file mode 100644
index 0000000..b9fe13a
--- /dev/null
+++ b/src/nmfs_agents/tools/device_manager.py
@@ -0,0 +1,275 @@
+from __future__ import annotations
+
+import logging
+import re
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional
+
+import paramiko
+
+from nmfs_agents.config import DeviceConfig
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class TestResult:
+ rc: int
+ stdout: str
+ stderr: str
+ duration_s: float
+
+ @property
+ def pass_(self) -> bool:
+ if self.rc != 0:
+ return False
+ text = self.stdout + self.stderr
+ if re.search(r"\b(FAILED|ERROR|error:)\b", text):
+ return False
+ return True
+
+
+@dataclass
+class DeviceMetrics:
+ device: str
+ timestamp: str
+ cpu_pct: Optional[float] = None
+ mem_used_mb: Optional[float] = None
+ npu_util_pct: Optional[float] = None
+ fps: Optional[float] = None
+ inference_latency_ms: Optional[float] = None
+ ram_used_kb: Optional[int] = None
+ flash_used_kb: Optional[int] = None
+
+
+class SSHAdapter:
+ """SSH 适配器,对应 type=linux connect=ssh 设备。"""
+
+ def __init__(self, cfg: DeviceConfig) -> None:
+ self._cfg = cfg
+
+ def _connect(self) -> paramiko.SSHClient:
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(self._cfg.host, username=self._cfg.user,
+ password=self._cfg.password, timeout=10)
+ return client
+
+ def run_test(self, cmd: str, cwd: str, timeout: int = 120) -> TestResult:
+ pre = self._cfg.pre_cmd
+ full_cmd = " && ".join(filter(None, [pre, f"cd {cwd}", cmd]))
+ t0 = time.monotonic()
+ try:
+ client = self._connect()
+ _, stdout, stderr = client.exec_command(full_cmd, timeout=timeout)
+ out = stdout.read().decode(errors="replace")
+ err = stderr.read().decode(errors="replace")
+ rc = stdout.channel.recv_exit_status()
+ client.close()
+ return TestResult(rc=rc, stdout=out, stderr=err,
+ duration_s=time.monotonic() - t0)
+ except Exception as e:
+ return TestResult(rc=1, stdout="", stderr=str(e),
+ duration_s=time.monotonic() - t0)
+
+ def get_metrics(self) -> DeviceMetrics:
+ import datetime
+ metrics = DeviceMetrics(device="", timestamp=datetime.datetime.utcnow().isoformat())
+ try:
+ client = self._connect()
+ _, out, _ = client.exec_command(
+ "grep 'cpu ' /proc/stat | awk '{u=$2+$4; t=$2+$3+$4+$5; print 100*u/t}'"
+ )
+ metrics.cpu_pct = float(out.read().decode().strip() or "0")
+ _, out, _ = client.exec_command("free -m | awk 'NR==2{print $3}'")
+ metrics.mem_used_mb = float(out.read().decode().strip() or "0")
+ _, out, _ = client.exec_command(
+ "tail -5 /tmp/infer.log 2>/dev/null | grep -oP 'fps=\\K[0-9.]+' | tail -1"
+ )
+ raw = out.read().decode().strip()
+ if raw:
+ metrics.fps = float(raw)
+ client.close()
+ except Exception as e:
+ logger.debug("metrics 采集失败: %s", e)
+ return metrics
+
+ def send_file(self, local: Path, remote: str) -> bool:
+ import subprocess
+ cmd = (f"rsync -avz --exclude='.git' --exclude='venv' "
+ f"{local}/ {self._cfg.user}@{self._cfg.host}:{remote}/")
+ r = subprocess.run(cmd, shell=True, capture_output=True, timeout=120)
+ return r.returncode == 0
+
+ def read_logs(self, n: int = 50) -> list[str]:
+ try:
+ client = self._connect()
+ _, out, _ = client.exec_command(f"tail -{n} /tmp/infer.log 2>/dev/null")
+ lines = out.read().decode(errors="replace").splitlines()
+ client.close()
+ return lines
+ except Exception:
+ return []
+
+
+class SerialAdapter:
+ """串口适配器,对应 connect=serial 设备(ESP32/Nordic 直连开发机)。"""
+
+ def __init__(self, cfg: DeviceConfig) -> None:
+ self._cfg = cfg
+
+ def run_test(self, cmd: str, cwd: str, timeout: int = 120) -> TestResult:
+ """通过串口发送命令并读取响应。
+ 约定:设备收到命令后输出 PASS 或 FAIL 结束行。
+ """
+ try:
+ import serial # type: ignore
+ except ImportError:
+ return TestResult(rc=1, stdout="", stderr="pyserial 未安装",
+ duration_s=0)
+ t0 = time.monotonic()
+ try:
+ ser = serial.Serial(self._cfg.port, self._cfg.baud, timeout=timeout)
+ ser.write((cmd + "\r\n").encode())
+ output_lines: list[str] = []
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ line = ser.readline().decode(errors="replace").strip()
+ if line:
+ output_lines.append(line)
+ if line.startswith(("PASS", "FAIL", "ERROR")):
+ break
+ ser.close()
+ stdout = "\n".join(output_lines)
+ rc = 0 if any(l.startswith("PASS") for l in output_lines) else 1
+ return TestResult(rc=rc, stdout=stdout, stderr="",
+ duration_s=time.monotonic() - t0)
+ except Exception as e:
+ return TestResult(rc=1, stdout="", stderr=str(e),
+ duration_s=time.monotonic() - t0)
+
+ def get_metrics(self) -> DeviceMetrics:
+ """从 UART 输出解析指标(格式:METRICS:fps=N latency_ms=N ram_kb=N)"""
+ import datetime
+ m = DeviceMetrics(device="", timestamp=datetime.datetime.utcnow().isoformat())
+ try:
+ import serial # type: ignore
+ ser = serial.Serial(self._cfg.port, self._cfg.baud, timeout=3)
+ ser.write(b"GET_METRICS\r\n")
+ for _ in range(20):
+ line = ser.readline().decode(errors="replace").strip()
+ if line.startswith("METRICS:"):
+ for kv in line[8:].split():
+ k, _, v = kv.partition("=")
+ if k == "fps":
+ m.fps = float(v)
+ elif k == "latency_ms":
+ m.inference_latency_ms = float(v)
+ elif k == "ram_kb":
+ m.ram_used_kb = int(v)
+ break
+ ser.close()
+ except Exception as e:
+ logger.debug("SerialAdapter metrics 失败: %s", e)
+ return m
+
+ def send_file(self, local: Path, remote: str) -> bool:
+ """ESP32:通过 esptool 烧录(将固件视为 remote 路径地址)"""
+ import subprocess
+ cmd = f"esptool.py --port {self._cfg.port} write_flash 0x0 {local}"
+ r = subprocess.run(cmd, shell=True, capture_output=True, timeout=120)
+ return r.returncode == 0
+
+ def read_logs(self, n: int = 50) -> list[str]:
+ lines: list[str] = []
+ try:
+ import serial # type: ignore
+ ser = serial.Serial(self._cfg.port, self._cfg.baud, timeout=2)
+ for _ in range(n):
+ line = ser.readline().decode(errors="replace").strip()
+ if line:
+ lines.append(line)
+ ser.close()
+ except Exception:
+ pass
+ return lines
+
+ def flash(self, firmware: Path) -> bool:
+ return self.send_file(firmware, "0x0")
+
+
+class GatewayAdapter:
+ """网关适配器:SSH 到中转设备,再通过其串口与目标 MCU 通信。"""
+
+ def __init__(self, cfg: DeviceConfig, gateway_cfg: DeviceConfig) -> None:
+ self._cfg = cfg
+ self._gw = SSHAdapter(gateway_cfg)
+
+ def run_test(self, cmd: str, cwd: str, timeout: int = 120) -> TestResult:
+ serial_cmd = (
+ f"python3 -c \""
+ f"import serial, time; "
+ f"s=serial.Serial('{self._cfg.port}', {self._cfg.baud}, timeout={timeout}); "
+ f"s.write(b'{cmd}\\r\\n'); "
+ f"out=[]; deadline=time.monotonic()+{timeout}; "
+ f"[out.append(s.readline().decode().strip()) or (out[-1].startswith(('PASS','FAIL')) and True) "
+ f"for _ in range(200) if time.monotonic() DeviceMetrics:
+ import datetime
+ result = self._gw.run_test(
+ f"python3 -c \"import serial; s=serial.Serial('{self._cfg.port}',{self._cfg.baud},timeout=3); "
+ f"s.write(b'GET_METRICS\\r\\n'); "
+ f"[print(s.readline().decode().strip()) for _ in range(20)]; s.close()\"",
+ "/tmp",
+ )
+ m = DeviceMetrics(device="", timestamp=datetime.datetime.utcnow().isoformat())
+ for line in result.stdout.splitlines():
+ if line.startswith("METRICS:"):
+ for kv in line[8:].split():
+ k, _, v = kv.partition("=")
+ if k == "fps":
+ m.fps = float(v)
+ elif k == "latency_ms":
+ m.inference_latency_ms = float(v)
+ return m
+
+ def send_file(self, local: Path, remote: str) -> bool:
+ return self._gw.send_file(local, remote)
+
+ def read_logs(self, n: int = 50) -> list[str]:
+ return []
+
+ def flash(self, firmware: Path) -> bool:
+ return self.send_file(firmware, "0x0")
+
+
+class DeviceManager:
+ """根据 DeviceConfig 实例化对应适配器的工厂。"""
+
+ def __init__(self, devices: dict[str, DeviceConfig]) -> None:
+ self._devices = devices
+ self._adapters: dict[str, SSHAdapter | SerialAdapter | GatewayAdapter] = {}
+
+ def get(self, name: str) -> SSHAdapter | SerialAdapter | GatewayAdapter:
+ if name not in self._adapters:
+ cfg = self._devices[name]
+ if cfg.connect == "ssh":
+ self._adapters[name] = SSHAdapter(cfg)
+ elif cfg.connect == "serial":
+ self._adapters[name] = SerialAdapter(cfg)
+ elif cfg.connect == "gateway":
+ gw_name = cfg.via
+ gw_cfg = self._devices[gw_name]
+ self._adapters[name] = GatewayAdapter(cfg, gw_cfg)
+ else:
+ raise ValueError(f"未知 connect 类型: {cfg.connect}")
+ return self._adapters[name]
+
+ def list_devices(self) -> list[str]:
+ return list(self._devices.keys())
diff --git a/src/rockchip_agents/tools/env_collector.py b/src/nmfs_agents/tools/env_collector.py
similarity index 96%
rename from src/rockchip_agents/tools/env_collector.py
rename to src/nmfs_agents/tools/env_collector.py
index c15784b..f095e85 100644
--- a/src/rockchip_agents/tools/env_collector.py
+++ b/src/nmfs_agents/tools/env_collector.py
@@ -6,8 +6,8 @@ from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
-from rockchip_agents.config import AgentsConfig, DeviceConfig
-from rockchip_agents.tools.project_memory import ProjectMemory
+from nmfs_agents.config import AgentsConfig, DeviceConfig
+from nmfs_agents.tools.project_memory import ProjectMemory
logger = logging.getLogger(__name__)
@@ -117,7 +117,7 @@ class EnvCollector:
def run_collection(cfg: "AgentsConfig", memory: ProjectMemory) -> dict[str, str]:
"""顶层编排:收集本机 + 所有 SSH 设备,保存到 memory。"""
- from rockchip_agents.tools.device_agent import DeviceAgent
+ from nmfs_agents.tools.device_agent import DeviceAgent
col = EnvCollector()
all_facts: dict[str, str] = {}
diff --git a/src/nmfs_agents/tools/feishu.py b/src/nmfs_agents/tools/feishu.py
new file mode 100644
index 0000000..83955b1
--- /dev/null
+++ b/src/nmfs_agents/tools/feishu.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import json
+import logging
+import urllib.request
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+class FeishuNotifier:
+ def __init__(self, webhook_url: str, bot_token: str = "") -> None:
+ self._webhook = webhook_url
+ self._bot_token = bot_token
+
+ def send_text(self, content: str, title: str = "NMFS Agents") -> bool:
+ """发送富文本通知到飞书群。"""
+ if not self._webhook:
+ logger.debug("飞书 Webhook 未配置,跳过通知")
+ return False
+ payload = {
+ "msg_type": "post",
+ "content": {
+ "post": {
+ "zh_cn": {
+ "title": title,
+ "content": [[{"tag": "text", "text": content}]],
+ }
+ }
+ },
+ }
+ return self._post(payload)
+
+ def send_task_result(self, project: str, task_title: str,
+ status: str, summary: str) -> bool:
+ status_icon = {"done": "✅", "failed": "❌", "report": "📋"}.get(status, "ℹ️")
+ title = f"{status_icon} [{project}] {task_title}"
+ content = f"状态: {status}\n\n{summary}"
+ return self.send_text(content, title=title)
+
+ def send_confirm_card(self, task_id: int, project: str,
+ task_title: str, diff_summary: str,
+ callback_url: str) -> bool:
+ """发送需要审批的交互卡片(confirm 模式)。"""
+ if not self._webhook:
+ return False
+ payload = {
+ "msg_type": "interactive",
+ "card": {
+ "header": {"title": {"tag": "plain_text", "content": f"[{project}] {task_title}"}},
+ "elements": [
+ {"tag": "div", "text": {"tag": "lark_md", "content": diff_summary}},
+ {
+ "tag": "action",
+ "actions": [
+ {"tag": "button", "text": {"tag": "plain_text", "content": "批准执行"},
+ "type": "primary",
+ "url": f"{callback_url}/approve/{task_id}"},
+ {"tag": "button", "text": {"tag": "plain_text", "content": "拒绝"},
+ "type": "danger",
+ "url": f"{callback_url}/reject/{task_id}"},
+ ],
+ },
+ ],
+ },
+ }
+ return self._post(payload)
+
+ def _post(self, payload: dict) -> bool:
+ data = json.dumps(payload).encode()
+ req = urllib.request.Request(
+ self._webhook, data=data,
+ headers={"Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ result = json.loads(resp.read())
+ if result.get("code", 0) != 0:
+ logger.warning("飞书返回错误: %s", result)
+ return False
+ return True
+ except Exception as e:
+ logger.error("飞书通知失败: %s", e)
+ return False
diff --git a/src/nmfs_agents/tools/file_tools.py b/src/nmfs_agents/tools/file_tools.py
new file mode 100644
index 0000000..48e6429
--- /dev/null
+++ b/src/nmfs_agents/tools/file_tools.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+
+def read_file(path: str) -> str:
+ p = Path(path)
+ if not p.exists():
+ return f"[ERROR] 文件不存在: {path}"
+ try:
+ return p.read_text(encoding="utf-8", errors="replace")
+ except OSError as e:
+ return f"[ERROR] 读取失败: {e}"
+
+
+def write_file(path: str, content: str) -> str:
+ p = Path(path)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(content, encoding="utf-8")
+ return f"[OK] 已写入: {path} ({len(content)} bytes)"
+
+
+def list_files(directory: str, pattern: str = "**/*.py") -> str:
+ d = Path(directory)
+ if not d.is_dir():
+ return f"[ERROR] 目录不存在: {directory}"
+ files = sorted(d.glob(pattern))
+ return "\n".join(str(f.relative_to(d)) for f in files[:50])
+
+
+def run_command(cmd: str, cwd: Optional[str] = None,
+ timeout: int = 60) -> str:
+ """在指定目录运行 shell 命令,返回 stdout+stderr。"""
+ try:
+ result = subprocess.run(
+ cmd, shell=True, capture_output=True, text=True,
+ cwd=cwd, timeout=timeout, executable="/bin/bash",
+ )
+ output = result.stdout + result.stderr
+ return f"[returncode={result.returncode}]\n{output[:4000]}"
+ except subprocess.TimeoutExpired:
+ return f"[ERROR] 命令超时({timeout}s): {cmd}"
+ except Exception as e:
+ return f"[ERROR] 命令执行失败: {e}"
diff --git a/src/nmfs_agents/tools/knowledge_base.py b/src/nmfs_agents/tools/knowledge_base.py
new file mode 100644
index 0000000..8ea111a
--- /dev/null
+++ b/src/nmfs_agents/tools/knowledge_base.py
@@ -0,0 +1,185 @@
+from __future__ import annotations
+
+"""knowledge_base.py — 长期知识记忆库(技术/市场/管理/交付四域)。
+
+本地实现:SQLite + 简单关键词检索,接口与 Pinecone 对齐,
+当 API key 就绪后可平滑迁移至向量检索。
+
+使用示例:
+ kb = KnowledgeBase()
+ kb.store("tech", "RTP", "RTP延迟优化", "...详细内容...", source="rtp-researcher")
+ records = kb.search("光流 延迟", domain="tech", limit=5)
+"""
+
+import logging
+import sqlite3
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_DB = Path("data/knowledge.db")
+
+DOMAINS = ("tech", "market", "management", "delivery")
+
+
+@dataclass
+class KBRecord:
+ id: int
+ domain: str # tech | market | management | delivery
+ category: str # 子类别(如 RTP、竞品分析、团队管理)
+ title: str
+ content: str
+ source: str # 来源(agent_role / URL)
+ tags: str # 逗号分隔标签
+ created_at: str
+ updated_at: str
+
+
+class KnowledgeBase:
+ """四域长期知识库:技术 / 市场 / 管理 / 交付。
+
+ search() 接口预留 embedding 参数,Pinecone 迁移时只需替换实现。
+ """
+
+ def __init__(self, db_path: Path = _DEFAULT_DB) -> None:
+ self._db_path = db_path
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._init_db()
+
+ def _connect(self) -> sqlite3.Connection:
+ conn = sqlite3.connect(str(self._db_path))
+ conn.row_factory = sqlite3.Row
+ return conn
+
+ def _now(self) -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M")
+
+ def _init_db(self) -> None:
+ with self._connect() as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS knowledge (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain TEXT NOT NULL,
+ category TEXT NOT NULL DEFAULT '',
+ title TEXT NOT NULL,
+ content TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_kb_domain ON knowledge(domain)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_kb_updated ON knowledge(updated_at DESC)")
+
+ # ─── 写入 ──────────────────────────────────────────────────────
+
+ def store(
+ self,
+ domain: str,
+ category: str,
+ title: str,
+ content: str,
+ source: str = "",
+ tags: str = "",
+ ) -> int:
+ """写入一条知识记录,返回 id。
+
+ 同 (domain, title) 存在则 upsert(更新内容+时间)。
+ """
+ if domain not in DOMAINS:
+ logger.warning("知识库 domain 无效: %s,应为 %s", domain, DOMAINS)
+ now = self._now()
+ with self._connect() as conn:
+ row = conn.execute(
+ "SELECT id FROM knowledge WHERE domain=? AND title=?",
+ (domain, title),
+ ).fetchone()
+ if row:
+ conn.execute(
+ "UPDATE knowledge SET category=?, content=?, source=?, tags=?, updated_at=? "
+ "WHERE id=?",
+ (category, content, source, tags, now, row["id"]),
+ )
+ logger.info("知识库更新 [%s/%s] %s", domain, category, title)
+ return row["id"]
+ cur = conn.execute(
+ "INSERT INTO knowledge(domain,category,title,content,source,tags,created_at,updated_at) "
+ "VALUES(?,?,?,?,?,?,?,?)",
+ (domain, category, title, content, source, tags, now, now),
+ )
+ logger.info("知识库写入 [%s/%s] %s", domain, category, title)
+ return cur.lastrowid
+
+ # ─── 检索 ──────────────────────────────────────────────────────
+
+ def search(
+ self,
+ query: str,
+ domain: Optional[str] = None,
+ limit: int = 5,
+ # embedding: Optional[list[float]] = None, # Pinecone 迁移时启用
+ ) -> list[KBRecord]:
+ """关键词搜索,支持按 domain 过滤。
+
+ 多词查询:词之间 AND 关系,每个词在 title/content/tags 中任意匹配即可。
+ """
+ keywords = [w.strip() for w in query.split() if w.strip()]
+ if not keywords:
+ return []
+ params: list[str] = []
+ clauses = []
+ for kw in keywords:
+ like = f"%{kw}%"
+ clauses.append("(title LIKE ? OR content LIKE ? OR tags LIKE ?)")
+ params.extend([like, like, like])
+ where = " AND ".join(clauses)
+ if domain:
+ where = f"domain=? AND ({where})"
+ params = [domain] + params
+ sql = (
+ f"SELECT * FROM knowledge WHERE {where} "
+ "ORDER BY updated_at DESC LIMIT ?"
+ )
+ params.append(limit)
+ with self._connect() as conn:
+ rows = conn.execute(sql, params).fetchall()
+ return [KBRecord(**dict(r)) for r in rows]
+
+ def get_recent(
+ self,
+ domain: Optional[str] = None,
+ limit: int = 10,
+ ) -> list[KBRecord]:
+ """按更新时间倒序返回最新记录。"""
+ if domain:
+ sql = "SELECT * FROM knowledge WHERE domain=? ORDER BY updated_at DESC LIMIT ?"
+ params = [domain, limit]
+ else:
+ sql = "SELECT * FROM knowledge ORDER BY updated_at DESC LIMIT ?"
+ params = [limit]
+ with self._connect() as conn:
+ rows = conn.execute(sql, params).fetchall()
+ return [KBRecord(**dict(r)) for r in rows]
+
+ def summary_by_domain(self) -> dict[str, int]:
+ """返回各域记录数量统计。"""
+ with self._connect() as conn:
+ rows = conn.execute(
+ "SELECT domain, COUNT(*) as cnt FROM knowledge GROUP BY domain"
+ ).fetchall()
+ return {r["domain"]: r["cnt"] for r in rows}
+
+ def context_for_agent(self, domain: str, limit: int = 8) -> str:
+ """生成适合注入 agent context 的知识摘要(最近 N 条)。"""
+ records = self.get_recent(domain=domain, limit=limit)
+ if not records:
+ return f"[{domain}知识库] 暂无记录。"
+ lines = [f"[{domain}知识库(最近{len(records)}条)]"]
+ for r in records:
+ snippet = r.content[:120].replace("\n", " ")
+ lines.append(f" [{r.category}] {r.title}:{snippet}…")
+ return "\n".join(lines)
diff --git a/src/nmfs_agents/tools/project_discovery.py b/src/nmfs_agents/tools/project_discovery.py
new file mode 100644
index 0000000..ea2e6fa
--- /dev/null
+++ b/src/nmfs_agents/tools/project_discovery.py
@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+# Keywords that indicate a project targets an embedded RK-series device
+_EMBEDDED_KEYWORDS = frozenset({
+ "rknn", "rk3588", "rk3399", "rk3568", "rknnlite", "rknn_lite",
+ "npu_core", "npu core", "nanopc", "rockchip npu",
+ "板端", "推理板", "嵌入式",
+})
+
+# A directory qualifies as a Claude-managed project if it has at least one of these
+_CLAUDE_SIGNALS = [
+ "CLAUDE.md", "README.md", "pyproject.toml", "setup.py", "setup.cfg",
+]
+
+_SKIP_DIRS = frozenset({
+ "venv", ".venv", ".git", "__pycache__", "node_modules",
+ ".tox", "build", "dist", ".arch_versions",
+})
+
+
+@dataclass
+class DiscoveredProject:
+ name: str
+ path: Path
+ kind: str # "embedded" | "host"
+ description: str
+ device_workspace: Optional[str]
+ mode: str # inferred default mode
+
+
+class ProjectDiscovery:
+ """Discover Claude-managed projects under a directory tree.
+
+ Supports two usage patterns:
+ - Parent directory (e.g. /data/rockchip): scans subdirectories,
+ returns all subdirs that look like Claude projects.
+ - Single project dir (e.g. /data/rockchip/yolo): returns that project
+ directly, ignoring subdirectories.
+
+ Embedded vs host detection uses keyword heuristics on CLAUDE.md,
+ README.md, requirements.txt, and a sample of Python source files.
+ """
+
+ def __init__(self, devices: dict | None = None) -> None:
+ # devices: dict[name, DeviceConfig] — used to infer device_workspace
+ self._devices = devices or {}
+
+ # ── public API ────────────────────────────────────────────────────────────
+
+ def scan(self, target: Path) -> list[DiscoveredProject]:
+ """Return discovered projects under target.
+
+ If target itself looks like a Claude project, return it as a single
+ entry. Otherwise scan immediate subdirectories.
+ """
+ if not target.is_dir():
+ logger.warning("扫描路径不存在或不是目录: %s", target)
+ return []
+ if self._is_claude_project(target):
+ proj = self._make(target)
+ return [proj] if proj else []
+ # parent directory mode
+ results: list[DiscoveredProject] = []
+ for subdir in sorted(target.iterdir()):
+ if not subdir.is_dir():
+ continue
+ if subdir.name.startswith(".") or subdir.name in _SKIP_DIRS:
+ continue
+ if self._is_claude_project(subdir):
+ proj = self._make(subdir)
+ if proj:
+ results.append(proj)
+ return results
+
+ # ── internal helpers ──────────────────────────────────────────────────────
+
+ def _is_claude_project(self, path: Path) -> bool:
+ return any((path / s).exists() for s in _CLAUDE_SIGNALS)
+
+ def _make(self, path: Path) -> Optional[DiscoveredProject]:
+ name = path.name
+ kind = self._detect_kind(path)
+ return DiscoveredProject(
+ name=name,
+ path=path,
+ kind=kind,
+ description=self._extract_description(path),
+ device_workspace=self._infer_device_workspace(name, kind),
+ mode="confirm" if kind == "embedded" else "report",
+ )
+
+ def _detect_kind(self, path: Path) -> str:
+ """Return 'embedded' if project targets RK-series NPU, else 'host'."""
+ # Fast check: text documents
+ for fname in ("CLAUDE.md", "README.md", "requirements.txt"):
+ f = path / fname
+ if not f.exists():
+ continue
+ try:
+ if self._has_embedded_keyword(f.read_text(errors="ignore")):
+ return "embedded"
+ except OSError:
+ pass
+ # Deeper check: sample up to 20 Python source files
+ for i, py_file in enumerate(self._iter_py_files(path)):
+ if i >= 20:
+ break
+ try:
+ if self._has_embedded_keyword(py_file.read_text(errors="ignore")):
+ return "embedded"
+ except OSError:
+ continue
+ return "host"
+
+ @staticmethod
+ def _has_embedded_keyword(text: str) -> bool:
+ lowered = text.lower()
+ return any(kw in lowered for kw in _EMBEDDED_KEYWORDS)
+
+ def _extract_description(self, path: Path) -> str:
+ """Read the first meaningful line from README or CLAUDE.md."""
+ for fname in ("README.md", "CLAUDE.md"):
+ f = path / fname
+ if not f.exists():
+ continue
+ for line in f.read_text(errors="ignore").splitlines():
+ line = line.strip()
+ if line and not line.startswith("#") and len(line) > 10:
+ return line[:120]
+ return ""
+
+ def _infer_device_workspace(self, name: str, kind: str) -> Optional[str]:
+ """For embedded projects, derive remote path from first device config."""
+ if kind != "embedded":
+ return None
+ for dev_cfg in self._devices.values():
+ return f"{dev_cfg.workspace}/{name}"
+ return None
+
+ def _iter_py_files(self, root: Path):
+ for entry in root.iterdir():
+ if entry.is_dir():
+ if entry.name in _SKIP_DIRS or entry.name.endswith(".egg-info"):
+ continue
+ yield from self._iter_py_files(entry)
+ elif entry.suffix == ".py":
+ yield entry
diff --git a/src/rockchip_agents/tools/project_memory.py b/src/nmfs_agents/tools/project_memory.py
similarity index 100%
rename from src/rockchip_agents/tools/project_memory.py
rename to src/nmfs_agents/tools/project_memory.py
diff --git a/src/rockchip_agents/agents/kernel_analyzer.py b/src/rockchip_agents/agents/kernel_analyzer.py
deleted file mode 100644
index f97f4c5..0000000
--- a/src/rockchip_agents/agents/kernel_analyzer.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from __future__ import annotations
-
-import logging
-import re
-
-from rockchip_agents.agents.developer import DeveloperAgent
-from rockchip_agents.core.queue import Task
-
-logger = logging.getLogger(__name__)
-
-KERNEL_ANALYZER_SYSTEM = """你是内核资源分析与调度优化专项调研员,专注于 RK3588 平台的内核级性能分析。
-
-调研重点:
-- RK3588 多核架构:A76 大核(4)× A55 小核(4)的 CFS 调度策略
-- NPU/GPU/ISP 与 CPU 的资源竞争分析(中断、内存带宽、PCIe)
-- CPUFreq Governor 选择(schedutil vs performance)对 AI 推理的影响
-- 实时性优化:PREEMPT_RT 补丁、IRQ 线程化、CPU isolation(isolcpus)
-- 内存子系统:CMA 区域划分、DMA 连续内存分配策略
-- 性能分析工具:perf、ftrace、eBPF on RK3588
-
-输出标签(写入系统记忆):
-- [调研报告] __research__.kernel.summary=综合调研结论
-- [调研报告] __research__.kernel.bottlenecks=调度瓶颈分析
-- [调研报告] __research__.kernel.next_steps=建议优化步骤
-- [调研报告] __research__.kernel.updated_at=调研时间
-
-调研完成后:
-- [优化任务] dev-kernel: <内核补丁/配置优化任务>
-- [优化任务] os-engineer: <系统级调度参数任务>
-
-要求:
-- 查阅 RK3588 SDK 内核配置、Linaro/ARM 调度优化文档
-- 给出 /proc/sys/ 或 Kconfig 级别的具体参数建议
-"""
-
-
-class KernelAnalyzerAgent(DeveloperAgent):
- """内核资源分析与调度优化专项调研 Agent。"""
-
- def _build_context(self, task: Task) -> str:
- return super()._build_context(task) + "\n" + KERNEL_ANALYZER_SYSTEM
-
- def _extract_research_facts(self, report_text: str) -> None:
- for m in re.finditer(r"\[调研报告\]\s+(__research__\.[\w.]+)=(.+)", report_text):
- key_full = m.group(1).strip()
- value = m.group(2).strip()[:500]
- _, _, sub_key = key_full.partition(".")
- try:
- self._memory.set_fact("__research__", sub_key, value, role="kernel-analyzer")
- except Exception as e:
- logger.warning("调研报告写入失败 %s: %s", key_full, e)
diff --git a/src/rockchip_agents/agents/market_pm.py b/src/rockchip_agents/agents/market_pm.py
deleted file mode 100644
index 133d93f..0000000
--- a/src/rockchip_agents/agents/market_pm.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from __future__ import annotations
-
-import logging
-import re
-
-from rockchip_agents.agents.developer import DeveloperAgent
-from rockchip_agents.core.queue import Task
-
-_logger = logging.getLogger(__name__)
-
-MARKET_PM_SYSTEM = """你是产品洞察与市场落地组的市场 PM,负责面向客户的交付文档和长期市场情报积累。
-
-公司三大目标领域:运动、适老、AI安全
-
-输出类型:
-- 方案书:技术方案 + 架构图说明 + 成本估算,保存至 docs/market/{project}-proposal.md
-- 技术白皮书:平台能力 + 算法性能 + 竞品对比,保存至 docs/market/{project}-whitepaper.md
-- BOM 估算:核心器件 + 单价区间 + 替代方案,保存至 docs/market/{project}-bom.md
-- 市场情报:竞品动态、行业趋势,用 [市场情报] 标签写入系统
-
-输出标签(**必须使用目标领域前缀**,目标领域只能是:运动、适老、AI安全):
-- [市场情报] __market__.运动.summary=<综合摘要>
-- [市场情报] __market__.运动.competitors=<竞品列表>
-- [市场情报] __market__.运动.trends=<趋势分析>
-- [选题] 新方向:价值分析(≥30字)(仅当市场数据支持新课题时)
-
-示例(严格遵循格式):
-[市场情报] __market__.运动.summary=跑步市场年增速25%,AI辅助训练需求旺盛
-[市场情报] __market__.适老.competitors=小米、华为手环、Fitbit
-[市场情报] __market__.AI安全.trends=联邦学习隐私保护需求上升
-
-内容要求:
-- 基于 ProjectMemory 中的能力事实(FPS、精度、平台规格)填写实际数值
-- 避免夸大性描述,数值来自测试报告
-- 格式:Markdown,结构清晰,可直接发给客户
-
-工作流:
-1. 用 fetch 工具搜集竞品信息、行业报告、技术趋势
-2. 读取 ProjectMemory 获取本平台能力数值
-3. 积累 [市场情报] 事实,达到 5 条以上时评估是否生成 [选题]
-4. 生成或更新对应文档
-"""
-
-
-class MarketPmAgent(DeveloperAgent):
- """产品洞察与市场落地组:面向客户的技术与市场文档"""
-
- def _build_context(self, task: Task) -> str:
- return super()._build_context(task) + "\n" + MARKET_PM_SYSTEM
-
- def _extract_market_intelligence(self, report_text: str) -> None:
- """解析 [市场情报] 标签,写入 ProjectMemory __market__ facts。"""
- for m in re.finditer(r"\[市场情报\]\s+(__market__\.[\w.]+)=(.+)", report_text):
- key_full = m.group(1).strip() # __market__.xxx.yyy
- value = m.group(2).strip()[:200]
- # key_full = "__market__.xxx.yyy",分割取 sub_key = "xxx.yyy"
- _, _, sub_key = key_full.partition(".")
- try:
- self._memory.set_fact("__market__", sub_key, value, role="market-pm")
- except Exception as e:
- _logger.warning("市场情报写入失败 %s: %s", key_full, e)
diff --git a/src/rockchip_agents/agents/net_researcher.py b/src/rockchip_agents/agents/net_researcher.py
deleted file mode 100644
index 13f7776..0000000
--- a/src/rockchip_agents/agents/net_researcher.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from __future__ import annotations
-
-import logging
-import re
-
-from rockchip_agents.agents.developer import DeveloperAgent
-from rockchip_agents.core.queue import Task
-
-logger = logging.getLogger(__name__)
-
-NET_RESEARCHER_SYSTEM = """你是网络优化专项调研员,专注于 RK3588 平台的网络栈调优。
-
-调研重点:
-- TCP/UDP socket buffer 调优(rmem/wmem、tcp_mem)
-- 拥塞控制算法选择(BBR、CUBIC)对嵌入式场景的适配
-- RK3588 以太网驱动(stmmac)与千兆网卡中断合并(ethtool -C)
-- 多队列网卡(MQ)+ CPU 亲和性绑定策略
-- iperf3 基准测试方法与 RK3588 实测参考数据
-
-输出标签(写入系统记忆):
-- [调研报告] __research__.net.summary=综合调研结论
-- [调研报告] __research__.net.bottlenecks=当前瓶颈分析
-- [调研报告] __research__.net.next_steps=建议优化步骤
-- [调研报告] __research__.net.updated_at=调研时间
-
-调研完成后:
-- [优化任务] os-engineer: <网络栈优化任务>
-- [优化任务] dev-kernel: <驱动层优化任务>
-
-要求:
-- 查阅 Linux 网络调优文档、RK3588 SDK 网络相关补丁
-- 给出可直接使用的 sysctl 参数或 ethtool 命令
-"""
-
-
-class NetResearcherAgent(DeveloperAgent):
- """网络栈优化专项调研 Agent。"""
-
- def _build_context(self, task: Task) -> str:
- return super()._build_context(task) + "\n" + NET_RESEARCHER_SYSTEM
-
- def _extract_research_facts(self, report_text: str) -> None:
- """解析 [调研报告] 标签,写入 ProjectMemory __research__ facts。"""
- for m in re.finditer(r"\[调研报告\]\s+(__research__\.[\w.]+)=(.+)", report_text):
- key_full = m.group(1).strip()
- value = m.group(2).strip()[:500]
- _, _, sub_key = key_full.partition(".")
- try:
- self._memory.set_fact("__research__", sub_key, value, role="net-researcher")
- except Exception as e:
- logger.warning("调研报告写入失败 %s: %s", key_full, e)
diff --git a/src/rockchip_agents/agents/rtp_researcher.py b/src/rockchip_agents/agents/rtp_researcher.py
deleted file mode 100644
index 97f6137..0000000
--- a/src/rockchip_agents/agents/rtp_researcher.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from __future__ import annotations
-
-import logging
-import re
-
-from rockchip_agents.agents.developer import DeveloperAgent
-from rockchip_agents.core.queue import Task
-
-logger = logging.getLogger(__name__)
-
-RTP_RESEARCHER_SYSTEM = """你是 RTP 实时传输协议专项调研员,专注于 RK3588 平台的媒体实时传输优化。
-
-调研重点:
-- RTP/RTSP 协议栈延迟分析(端到端 < 100ms 目标)
-- RK3588 媒体通路:ISP → 编码器 → 网络发送的延迟分解
-- 内核网络栈优化:SO_PRIORITY、TX queue、中断亲和性
-- 硬件编码器(MPP)与软件 RTP 封装的同步机制
-
-输出标签(写入系统记忆):
-- [调研报告] __research__.rtp.summary=综合调研结论
-- [调研报告] __research__.rtp.bottlenecks=当前瓶颈分析
-- [调研报告] __research__.rtp.next_steps=建议优化步骤(逗号分隔)
-- [调研报告] __research__.rtp.updated_at=调研时间
-
-调研完成后,如有明确优化方向,输出:
-- [优化任务] os-engineer: <具体优化任务描述>
-- [优化任务] dev-kernel: <具体内核优化任务描述>
-
-要求:
-- 先用 fetch 工具查阅最新 RTP/RTCP 规范、RK3588 BSP 文档
-- 输出结构化 Markdown 报告,含实测数据或参考基准
-"""
-
-
-class RtpResearcherAgent(DeveloperAgent):
- """RTP 实时传输协议专项调研 Agent。"""
-
- def _build_context(self, task: Task) -> str:
- return super()._build_context(task) + "\n" + RTP_RESEARCHER_SYSTEM
-
- def _extract_research_facts(self, report_text: str) -> None:
- """解析 [调研报告] 标签,写入 ProjectMemory __research__ facts。"""
- for m in re.finditer(r"\[调研报告\]\s+(__research__\.[\w.]+)=(.+)", report_text):
- key_full = m.group(1).strip()
- value = m.group(2).strip()[:500]
- _, _, sub_key = key_full.partition(".")
- try:
- self._memory.set_fact("__research__", sub_key, value, role="rtp-researcher")
- except Exception as e:
- logger.warning("调研报告写入失败 %s: %s", key_full, e)
diff --git a/src/rockchip_agents/core/executor.py b/src/rockchip_agents/core/executor.py
deleted file mode 100644
index be9b988..0000000
--- a/src/rockchip_agents/core/executor.py
+++ /dev/null
@@ -1,291 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import json
-import logging
-import re
-import urllib.request
-from typing import Any, Optional
-
-from rockchip_agents.agents.developer import DeveloperAgent
-from rockchip_agents.config import AgentsConfig
-from rockchip_agents.core.queue import TaskQueue, Task
-from rockchip_agents.tools.feishu import FeishuNotifier
-from rockchip_agents.tools.project_memory import ProjectMemory
-
-logger = logging.getLogger(__name__)
-
-_TAG_PATTERN = re.compile(
- r"\[(?PSPAWN|AWAIT|APPROVE|REJECT|VETO|PROPOSAL|SELECT)"
- r"(?::(?P[a-zA-Z0-9_-]+))?\]"
- r"(?P[^\[]*)",
- re.MULTILINE,
-)
-
-
-def _parse_agent_tags(text: str) -> list[dict]:
- """从 agent 输出中解析结构化控制标签"""
- results = []
- for m in _TAG_PATTERN.finditer(text):
- tag_type = m.group("type")
- role = (m.group("role") or "").strip()
- desc = (m.group("desc") or "").strip()
- entry: dict = {"type": tag_type, "role": role}
- if tag_type in ("VETO", "REJECT"):
- entry["reason"] = desc
- elif tag_type in ("SPAWN", "PROPOSAL", "AWAIT"):
- entry["desc"] = desc
- results.append(entry)
- return results
-
-
-def _make_agent(config: AgentsConfig, task: Task, memory: ProjectMemory) -> Any:
- """根据 agent_role 路由到对应 Agent,传入共享 memory 实例。"""
- role = task.agent_role
- if role == "tester":
- from rockchip_agents.agents.tester import TesterAgent
- return TesterAgent(config)
- if role == "productizer":
- from rockchip_agents.agents.productizer import ProductizerAgent
- return ProductizerAgent(config, memory=memory)
- if role == "architect":
- from rockchip_agents.agents.architect import ArchitectAgent
- return ArchitectAgent(config, memory=memory)
- if role == "planner":
- from rockchip_agents.agents.planner import PlannerAgent
- from rockchip_agents.core.queue import TaskQueue as _TQ
- return PlannerAgent(config, queue=_TQ(), memory=memory)
- if role == "dev-kernel":
- from rockchip_agents.agents.kernel_dev import KernelDevAgent
- return KernelDevAgent(config, memory=memory)
- if role == "dev-lowlevel":
- from rockchip_agents.agents.lowlevel_dev import LowlevelDevAgent
- return LowlevelDevAgent(config, memory=memory)
- if role == "hw-engineer":
- from rockchip_agents.agents.hw_engineer import HwEngineerAgent
- return HwEngineerAgent(config, memory=memory)
- if role == "os-engineer":
- from rockchip_agents.agents.os_engineer import OsEngineerAgent
- return OsEngineerAgent(config, memory=memory)
- if role == "algo-researcher":
- from rockchip_agents.agents.algo_researcher import AlgoResearcherAgent
- return AlgoResearcherAgent(config, memory=memory)
- if role == "algo-antishake":
- from rockchip_agents.agents.algo_antishake import AlgoAntishakeAgent
- return AlgoAntishakeAgent(config, memory=memory)
- if role == "algo-position":
- from rockchip_agents.agents.algo_position import AlgoPositionAgent
- return AlgoPositionAgent(config, memory=memory)
- if role == "algo-nav":
- from rockchip_agents.agents.algo_nav import AlgoNavAgent
- return AlgoNavAgent(config, memory=memory)
- if role == "base-validator":
- from rockchip_agents.agents.base_validator import BaseValidatorAgent
- return BaseValidatorAgent(config, memory=memory)
- if role == "base-architect":
- from rockchip_agents.agents.base_architect import BaseArchitectAgent
- from rockchip_agents.core.queue import TaskQueue as _TQ2
- return BaseArchitectAgent(config, queue=_TQ2(), memory=memory)
- if role == "system-tester":
- from rockchip_agents.agents.system_tester import SystemTesterAgent
- return SystemTesterAgent(config, memory=memory)
- if role == "vision-analyst":
- from rockchip_agents.agents.vision_analyst import VisionAnalystAgent
- from rockchip_agents.core.queue import TaskQueue as _TQ3
- return VisionAnalystAgent(config, queue=_TQ3(), memory=memory)
- if role == "market-pm":
- from rockchip_agents.agents.market_pm import MarketPmAgent
- return MarketPmAgent(config, memory=memory)
- if role == "media-producer":
- from rockchip_agents.agents.media_producer import MediaProducerAgent
- return MediaProducerAgent(config, memory=memory)
- if role == "rtp-researcher":
- from rockchip_agents.agents.rtp_researcher import RtpResearcherAgent
- return RtpResearcherAgent(config, memory=memory)
- if role == "net-researcher":
- from rockchip_agents.agents.net_researcher import NetResearcherAgent
- return NetResearcherAgent(config, memory=memory)
- if role == "kernel-analyzer":
- from rockchip_agents.agents.kernel_analyzer import KernelAnalyzerAgent
- return KernelAnalyzerAgent(config, memory=memory)
- return DeveloperAgent(config, memory=memory)
-
-
-class Executor:
- def __init__(self, config: AgentsConfig,
- queue: Optional[TaskQueue] = None,
- memory: Optional[ProjectMemory] = None) -> None:
- self._config = config
- self._queue = queue or TaskQueue()
- self._memory = memory or ProjectMemory()
- self._feishu = FeishuNotifier(
- webhook_url=config.feishu.webhook_url,
- bot_token=config.feishu.bot_token,
- )
- self._semaphore = asyncio.Semaphore(config.scheduler.max_concurrent)
-
- async def run_next(self) -> bool:
- """取出并执行下一个任务,返回是否有任务执行。"""
- task = self._queue.dequeue()
- if not task:
- logger.debug("任务队列为空")
- return False
- async with self._semaphore:
- await self._run_task(task)
- return True
-
- async def run_all_pending(
- self,
- skip_projects: frozenset[str] | None = None,
- role_filter: frozenset[str] | None = None,
- ) -> int:
- """并发执行所有 pending 任务(受 semaphore 限流),返回执行数量。
- skip_projects: 跳过这些项目的任务(用于项目级暂停)。
- role_filter: 仅执行指定 agent_role 的任务(用于多队列隔离)。
- """
- tasks_to_run: list[Task] = []
- while True:
- task = self._queue.dequeue(
- exclude_projects=skip_projects,
- role_filter=role_filter,
- )
- if not task:
- break
- tasks_to_run.append(task)
-
- async def _run_with_sem(t: Task) -> None:
- async with self._semaphore:
- await self._run_task(t)
-
- if tasks_to_run:
- await asyncio.gather(*[_run_with_sem(t) for t in tasks_to_run],
- return_exceptions=True)
- return len(tasks_to_run)
-
- def _notify_wx(self, path: str, data: dict) -> None:
- """非阻塞通知 claude-wx 服务(失败静默降级)"""
- if not self._config.claude_wx_url:
- return
- try:
- url = self._config.claude_wx_url.rstrip("/") + path
- body = json.dumps(data).encode()
- req = urllib.request.Request(
- url, data=body,
- headers={"Content-Type": "application/json"},
- )
- urllib.request.urlopen(req, timeout=3)
- except Exception as e:
- logger.debug("claude-wx 通知失败(非关键): %s", e)
-
- async def _run_task(self, task: Task) -> None:
- logger.info("开始执行任务 [%s] %s (role=%s, mode=%s)",
- task.project, task.title, task.agent_role, task.mode)
- try:
- agent = _make_agent(self._config, task, self._memory)
- self._notify_wx("/agent/task-start", {
- "task_id": task.id,
- "project": task.project,
- "title": task.title,
- "mode": task.mode,
- })
- result = await asyncio.get_running_loop().run_in_executor(
- None, agent.run, task
- )
- # 解析结构化输出标签
- tags = _parse_agent_tags(result.summary)
- handled = await self._handle_tags(task, tags)
- if handled:
- return # 标签处理接管状态,不走普通 mark_done
- if result.status == "failed":
- self._queue.mark_failed(task.id, result.summary)
- logger.error("任务失败 [%s] %s: %s", task.project, task.title,
- result.summary[:200])
- else:
- self._queue.mark_done(task.id, result.summary)
- logger.info("任务完成 [%s] %s", task.project, task.title)
- self._notify_wx("/agent/task-end", {
- "task_id": task.id,
- "project": task.project,
- "status": result.status,
- "summary": result.summary[:200],
- })
- self._feishu.send_task_result(
- project=task.project, task_title=task.title,
- status=result.status, summary=result.summary,
- )
- try:
- self._memory.add_history(
- project=task.project,
- task_title=task.title,
- task_type=task.type,
- status=result.status,
- summary=result.summary[:300],
- )
- except Exception as hist_err:
- logger.warning("写入任务历史失败: %s", hist_err)
- except Exception as e:
- logger.error("任务失败 [%s] %s: %s", task.project, task.title, e)
- self._queue.mark_failed(task.id, str(e))
- self._notify_wx("/agent/task-end", {
- "task_id": task.id,
- "project": task.project,
- "status": "failed",
- "summary": str(e)[:200],
- })
- try:
- self._memory.add_history(
- project=task.project,
- task_title=task.title,
- task_type=task.type,
- status="failed",
- summary=str(e)[:300],
- )
- except Exception as hist_err:
- logger.warning("写入任务历史失败: %s", hist_err)
-
- async def _handle_tags(self, task: Task, tags: list[dict]) -> bool:
- """处理结构化输出标签,返回 True 表示状态已由标签接管"""
- if not tags:
- return False
- handled = False
- for tag in tags:
- t = tag["type"]
- if t == "SPAWN":
- child = Task(
- project=task.project,
- type="code_review",
- title=f"[审查] {task.title}",
- context=tag.get("desc", ""),
- priority=task.priority,
- mode=task.mode,
- agent_role=tag["role"],
- parent_task_id=task.id,
- )
- self._queue.enqueue(child)
- logger.info("SPAWN 子任务: role=%s, title=%s", tag["role"], child.title)
- elif t == "AWAIT":
- self._queue.mark_waiting_approval(task.id, awaiting_role=tag["role"])
- review = Task(
- project=task.project,
- type="code_review",
- title=f"[审批] {task.title}",
- context=f"请审批任务 #{task.id}: {task.title}",
- priority=1,
- mode=task.mode,
- agent_role=tag["role"],
- parent_task_id=task.id,
- )
- self._queue.enqueue(review)
- logger.info("AWAIT: 任务 #%d 进入 waiting_approval, 等待 %s", task.id, tag["role"])
- handled = True
- elif t == "VETO" and task.parent_task_id:
- self._queue.mark_done(task.id, tag.get("reason", ""))
- self._queue.mark_vetoed(task.parent_task_id, reason=tag.get("reason", ""))
- logger.info("VETO: 父任务 #%d 被否决,重入队", task.parent_task_id)
- handled = True
- elif t == "APPROVE" and task.parent_task_id:
- self._queue.mark_done(task.id, "approved")
- self._queue.approve_waiting(task.parent_task_id)
- logger.info("APPROVE: 父任务 #%d 审批通过", task.parent_task_id)
- handled = True
- return handled
diff --git a/src/rockchip_agents/core/scheduler.py b/src/rockchip_agents/core/scheduler.py
deleted file mode 100644
index 1764c8e..0000000
--- a/src/rockchip_agents/core/scheduler.py
+++ /dev/null
@@ -1,180 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import logging
-import threading
-from datetime import datetime, timedelta
-
-from apscheduler.schedulers.blocking import BlockingScheduler
-
-from rockchip_agents.config import load_config
-from rockchip_agents.core.executor import Executor
-from rockchip_agents.core.queue import TaskQueue
-from rockchip_agents.core.scanner import Scanner
-from rockchip_agents.core.watchdog import Watchdog
-
-logger = logging.getLogger(__name__)
-
-
-def _scan_and_enqueue() -> None:
- cfg = load_config()
- q = TaskQueue()
- scanner = Scanner(cfg)
- results = scanner.scan_all()
- for r in results:
- from rockchip_agents.core.queue import Task
- tid = q.enqueue(Task(
- project=r.project, type=r.type, title=r.title,
- context=r.context, priority=r.priority,
- mode=r.mode, agent_role=r.agent_role,
- initiator=r.initiator, discussion=r.discussion,
- ))
- if tid > 0:
- logger.info("新任务入队 [%s] %s (priority=%d)", r.project, r.title, r.priority)
-
-
-def _get_queue() -> "TaskQueue":
- """可被测试 monkeypatch 替换的工厂函数。"""
- return TaskQueue()
-
-
-def _enqueue_planner_task() -> None:
- """将 planner 分析任务入队(同标题去重,不重复触发)。"""
- from rockchip_agents.core.queue import Task
- q = _get_queue()
- tid = q.enqueue(Task(
- project="research",
- type="planner",
- title="PlannerAgent 周期差距分析",
- context="由 Scheduler 定时触发,分析平台目标与项目现状差距",
- priority=5,
- mode="report",
- agent_role="planner",
- initiator="scheduler",
- discussion="6h 定时 planner 分析",
- ))
- if tid > 0:
- logger.info("PlannerAgent 任务入队 #%d", tid)
- else:
- logger.debug("PlannerAgent 任务已在队列中,跳过")
-
-
-def _enqueue_market_intel_task() -> None:
- """将 Market-PM 情报收集任务入队(同标题去重,不重复触发)。"""
- from rockchip_agents.core.queue import Task
- q = _get_queue()
- tid = q.enqueue(Task(
- project="research",
- type="market_intel",
- title="Market-PM 12h 情报收集",
- context="定期收集竞品动态、行业趋势,更新 __market__ facts",
- priority=5,
- mode="report",
- agent_role="market-pm",
- initiator="scheduler",
- ))
- if tid > 0:
- logger.info("Market-PM 情报任务入队 #%d", tid)
- else:
- logger.debug("Market-PM 情报任务已在队列中,跳过")
-
-
-def _collect_env() -> None:
- """后台静默收集环境信息,失败不影响主流程。"""
- try:
- from rockchip_agents.config import load_config
- from rockchip_agents.tools.env_collector import run_collection
- from rockchip_agents.tools.project_memory import ProjectMemory
- cfg = load_config()
- run_collection(cfg, ProjectMemory())
- logger.info("EnvCollector 完成收集")
- except Exception:
- logger.exception("EnvCollector 收集失败(静默跳过)")
-
-
-def _run_pending() -> None:
- cfg = load_config()
- executor = Executor(cfg)
- count = asyncio.run(executor.run_all_pending())
- logger.info("本轮执行任务数: %d", count)
- if count == 0:
- # 队列空时触发 planner 补充需求
- _enqueue_planner_task()
- logger.info("队列为空,已触发 PlannerAgent 差距分析")
-
-
-def _run_pending_queue(queue_name: str) -> None:
- """执行指定队列的待处理任务。"""
- from pathlib import Path
- from rockchip_agents.core.queue import TaskQueue
- cfg = load_config()
- q_cfg = cfg.queues.get(queue_name)
- if not q_cfg:
- logger.warning("队列配置不存在: %s", queue_name)
- return
-
- db_path = Path(q_cfg.db)
- queue = TaskQueue(db_path=db_path)
- executor = Executor(cfg, queue=queue)
- role_filter = q_cfg.roles if q_cfg.roles else None
- count = asyncio.run(executor.run_all_pending(role_filter=role_filter))
- logger.info("[%s] 本轮执行任务数: %d", queue_name, count)
-
- # project_delivery 队列空时触发 planner
- if count == 0 and queue_name == "project_delivery":
- _enqueue_planner_task()
- logger.info("project_delivery 队列为空,已触发 PlannerAgent")
-
-
-def start() -> None:
- cfg = load_config()
- interval = cfg.scheduler.interval_hours
-
- # 启动恢复:将上次运行遗留的 running 任务重置为 failed,避免阻塞 semaphore
- q = TaskQueue()
- recovered = q.reset_stale_running(timeout_minutes=0)
- if recovered:
- logger.warning("启动恢复:%d 个遗留 running 任务已重置为 failed", recovered)
-
- # 启动 Watchdog(daemon 线程,60s 一次超时检测 + 优先级老化)
- watchdog = Watchdog(q)
- watchdog.start()
-
- scheduler = BlockingScheduler()
- scheduler.add_job(_scan_and_enqueue, "interval", hours=interval, id="scan")
- scheduler.add_job(
- _enqueue_planner_task, "interval", hours=6, id="planner",
- next_run_time=datetime.now() + timedelta(minutes=10),
- )
- # 三队列独立调度,首次执行错开启动时间
- scheduler.add_job(
- lambda: _run_pending_queue("project_delivery"),
- "interval", hours=interval, id="run_project",
- next_run_time=datetime.now() + timedelta(minutes=5),
- )
- scheduler.add_job(
- lambda: _run_pending_queue("base_opt"),
- "interval", hours=4, id="run_base",
- next_run_time=datetime.now() + timedelta(minutes=15),
- )
- scheduler.add_job(
- lambda: _run_pending_queue("insight"),
- "interval", hours=6, id="run_insight",
- next_run_time=datetime.now() + timedelta(minutes=10),
- )
- scheduler.add_job(
- _enqueue_market_intel_task, "interval", hours=12, id="market_intel",
- next_run_time=datetime.now() + timedelta(hours=1),
- )
- scheduler.add_job(
- _collect_env, "interval", hours=24, id="env_collect",
- next_run_time=datetime.now() + timedelta(minutes=30),
- )
- logger.info("Scheduler 已启动,扫描间隔: %d 小时", interval)
- _scan_and_enqueue() # 启动时立即扫描一次
- # 启动时后台触发一次环境收集(daemon 线程,不阻塞调度器)
- threading.Thread(target=_collect_env, daemon=True, name="env-collector-init").start()
- try:
- scheduler.start()
- finally:
- watchdog.stop()
diff --git a/src/rockchip_agents/dashboard/api.py b/src/rockchip_agents/dashboard/api.py
deleted file mode 100644
index 0455ef3..0000000
--- a/src/rockchip_agents/dashboard/api.py
+++ /dev/null
@@ -1,869 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import sqlite3
-import threading
-import time
-from datetime import datetime
-from pathlib import Path
-from typing import Any, Optional
-
-from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.staticfiles import StaticFiles
-from pydantic import BaseModel
-
-from rockchip_agents.core.queue import TaskQueue, Task
-
-try:
- from visual_tester.db import VisualDB as _VisualDB
- _HAS_VISUAL = True
-except ImportError:
- _HAS_VISUAL = False
-
-# ── Pydantic 模型 ──────────────────────────────────────────────────────
-
-
-class TaskOut(BaseModel):
- id: int
- project: str
- type: str
- title: str
- context: str
- priority: int
- mode: str
- agent_role: str
- status: str
- created_at: str
- started_at: Optional[str] = None
- completed_at: Optional[str] = None
- result_summary: str
- retry_count: int
- parent_task_id: int
- constraint_type: str
- awaiting_role: str
- initiator: str = "system"
- discussion: str = ""
-
-
-class TaskCreate(BaseModel):
- project: str
- type: str
- title: str
- priority: int = 3
- mode: str = "auto"
- agent_role: str = "developer"
- context: str = ""
- initiator: str = "user"
- discussion: str = ""
-
-
-class TaskPatch(BaseModel):
- priority: Optional[int] = None
-
-
-class RejectBody(BaseModel):
- reason: str = ""
-
-
-class ProjectImport(BaseModel):
- path: str
- name: str = ""
- mode: str = "auto"
- description: str = ""
- scan_now: bool = True
- linked_project: str = "" # 关联项目路径(用于生成跨项目集成任务)
-
-
-class ConfigUpdate(BaseModel):
- content: str
-
-
-class StartBody(BaseModel):
- project: Optional[str] = None # None=全局,非 None=仅执行该项目
-
-
-class StopBody(BaseModel):
- project: Optional[str] = None # None=全局停止,非 None=仅停止该项目(暂停)
-
-
-class VisualApproveBody(BaseModel):
- reason: str = ""
-
-
-class MarketTriggerResponse(BaseModel):
- task_id: int
- goal: str
-
-
-# ── 项目特征检测 ────────────────────────────────────────────────────────
-
-def _detect_project_features(path: Path) -> dict:
- """检测项目特征,用于生成适配的任务模板。"""
- def _has_file(*parts: str) -> bool:
- return any((path / p).exists() for p in parts)
-
- def _has_pattern(glob: str) -> bool:
- return next(path.rglob(glob), None) is not None
-
- return {
- "has_tests": _has_file("tests", "test") and _has_pattern("test_*.py"),
- "has_rknn": (
- _has_file("requirements-device.txt") or
- _has_pattern("*.rknn") or
- any("rknn" in (path / f).read_text(encoding="utf-8", errors="ignore")
- for f in ["requirements.txt", "requirements-x86.txt",
- "README.md", "CLAUDE.md"]
- if (path / f).exists())
- ),
- "has_training": _has_pattern("train_*.py") or _has_pattern("*train*.py"),
- "has_docker": _has_file("Dockerfile", "docker-compose.yml"),
- "has_device_code": (
- _has_file("requirements-device.txt") or
- any("rk3588" in (path / f).read_text(encoding="utf-8", errors="ignore")
- for f in ["README.md", "CLAUDE.md"]
- if (path / f).exists())
- ),
- "has_frontend": _has_file("frontend", "web", "dashboard", "package.json"),
- }
-
-
-def _generate_template_tasks(
- name: str, path: Path, mode: str, linked_project: str = ""
-) -> list[Task]:
- """根据项目特征生成适合软硬件协同研发的默认任务集。"""
- feat = _detect_project_features(path)
- proj_desc = f"项目路径: {path}\n"
- tasks: list[Task] = []
-
- # P1: 全量代码审查(必须)
- tasks.append(Task(
- project=name, type="code_review", priority=1, mode=mode,
- agent_role="developer",
- title="全量代码审查:架构设计与技术债务识别",
- context=(
- proj_desc +
- "请对整个项目进行全面代码审查:\n"
- "1) src/ 下各模块的代码质量、接口设计、异常处理完整性、类型注解覆盖率\n"
- "2) 识别并修复所有技术债务和潜在 Bug\n"
- "3) 检查编码规范一致性(命名、注释、导入结构)\n"
- "4) 确保所有公共接口有完整文档字符串"
- ),
- ))
-
- # P1: 测试覆盖率分析
- if feat["has_tests"]:
- tasks.append(Task(
- project=name, type="test_improvement", priority=1, mode=mode,
- agent_role="tester",
- title="测试覆盖率分析:识别盲区并补全关键路径测试",
- context=(
- proj_desc +
- "运行现有测试套件,分析覆盖率报告(pytest --cov):\n"
- "1) 识别覆盖率低于 80% 的核心模块\n"
- "2) 为关键业务逻辑补全单元测试(使用 mock 替代外部依赖)\n"
- "3) 确保边界条件和异常路径有测试覆盖\n"
- "4) 被 ignore 的测试文件(如依赖模型权重的)改用 mock 使其可在 CI 运行"
- ),
- ))
-
- # P2: 重构与接口规范化
- tasks.append(Task(
- project=name, type="refactor", priority=2, mode=mode,
- agent_role="developer",
- title="重构:关键模块解耦与接口规范化",
- context=(
- proj_desc +
- "重点重构以下方面:\n"
- "1) 识别高耦合模块,提取公共抽象基类或接口\n"
- "2) 消除重复代码(DRY 原则)\n"
- "3) 统一错误处理策略(自定义异常类 vs 通用异常)\n"
- "4) 规范化配置管理(避免硬编码常量散落各处)"
- ),
- ))
-
- # P2: 性能分析(如有 RKNN/推理相关)
- if feat["has_rknn"] or feat["has_training"]:
- tasks.append(Task(
- project=name, type="performance", priority=2, mode=mode,
- agent_role="developer",
- title="性能分析:推理管道瓶颈识别与 numpy 向量化优化",
- context=(
- proj_desc +
- "分析推理管道性能:\n"
- "1) 使用 timeit 或 cProfile 识别热点函数\n"
- "2) 检查 numpy 操作是否充分向量化(消除 Python 级别循环)\n"
- "3) 评估批量推理的可行性(batch inference API)\n"
- "4) 若有相似度检索,评估大规模数据(1000+)时的查询性能"
- ),
- ))
-
- # P2: 板端集成测试(如有设备代码)
- if feat["has_device_code"] or feat["has_rknn"]:
- tasks.append(Task(
- project=name, type="device_test", priority=2, mode=mode,
- agent_role="tester",
- title="板端集成:RKNN 模型部署验证与 SSH 测试",
- context=(
- proj_desc +
- "验证设备端部署:\n"
- "1) 检查 RKNN 模型转换脚本的完整性(calibration dataset, quantization)\n"
- "2) 通过 SSH 到 RK3588 设备运行基准测试\n"
- "3) 对比 x86 ONNX 与板端 RKNN 的精度差异(允许 < 2% 误差)\n"
- "4) 记录板端推理延迟(目标 < 50ms/帧)"
- ),
- ))
-
- # P2: 跨项目数据流集成(如有关联项目)
- if linked_project:
- linked_name = Path(linked_project).name
- tasks.append(Task(
- project=name, type="integration", priority=2, mode=mode,
- agent_role="developer",
- title=f"跨项目集成:与 {linked_name} 数据管道对接",
- context=(
- proj_desc +
- f"关联项目路径: {linked_project}\n\n"
- f"分析并实现 {name} 与 {linked_name} 的数据流对接:\n"
- f"1) 评估 {linked_name} 的输出格式与 {name} 训练数据目录的兼容性\n"
- f"2) 设计或实现自动数据同步脚本(支持增量更新)\n"
- f"3) 验证数据质量:格式、分辨率、类别标签一致性\n"
- f"4) 制定持续训练流程(数据更新 → 重新训练 → 评估 → 部署)"
- ),
- ))
-
- # P3: 架构评估(architect 角色)
- tasks.append(Task(
- project=name, type="architecture", priority=3, mode=mode,
- agent_role="architect",
- title="架构评估:模块耦合度分析与可扩展性建议",
- context=(
- proj_desc +
- "从架构视角深度分析:\n"
- "1) 绘制模块依赖图,识别循环依赖和过度耦合\n"
- "2) 评估当前设计对新需求的扩展成本(如增加新模型类型)\n"
- "3) 识别与相关项目(yolo/embedding/mediapipe)的代码复用机会\n"
- "4) 给出下一步架构优化路线图"
- ),
- ))
-
- # P3: 文档完善
- tasks.append(Task(
- project=name, type="documentation", priority=3, mode=mode,
- agent_role="developer",
- title="文档完善:README/CLAUDE.md 更新与 API 使用示例",
- context=(
- proj_desc +
- "完善项目文档:\n"
- "1) 更新 README.md:确保快速开始步骤准确可用\n"
- "2) 更新 CLAUDE.md:补充最新架构说明和开发约定\n"
- "3) 为核心 API 添加使用示例代码(注册、查询、管道调用)\n"
- "4) 补充常见问题(FAQ)和已知限制说明"
- ),
- ))
-
- return tasks
-
-
-# ── 工厂函数 ───────────────────────────────────────────────────────────
-
-def make_app(db_path: Path | None = None, config_dir: Path | None = None,
- global_claude_dir: str | None = None,
- queue: TaskQueue | None = None,
- visual_db_path: str | None = None,
- memory: Any | None = None) -> FastAPI:
- from rockchip_agents.core.queue import DEFAULT_DB
-
- _db_path = db_path or DEFAULT_DB
- _queue = queue if queue is not None else TaskQueue(db_path=_db_path)
-
- # ── 内嵌执行器状态 ─────────────────────────────────────────────────
- _exec_state: dict = {
- "running": False, "thread": None, "started_at": None,
- "paused_projects": set(), # 项目级暂停集合
- "scope_project": None, # 非 None 时仅执行该项目的任务
- }
-
- def _executor_loop() -> None:
- """后台线程:循环执行队列中的 pending 任务。"""
- import logging as _log
- _lg = _log.getLogger("dashboard.executor")
- # 启动时清除所有遗留 running 任务(容器重启后遗留)
- _queue.reset_stale_running(timeout_minutes=0)
- while _exec_state["running"]:
- try:
- from rockchip_agents.config import load_config
- from rockchip_agents.core.executor import Executor
- cfg = load_config(Path(config_dir) if config_dir else None)
- executor = Executor(cfg, queue=_queue)
- scope = _exec_state["scope_project"]
- if scope:
- # 单项目模式:仅执行该项目,跳过所有其他已知项目
- all_known = frozenset(cfg.projects.keys())
- skip = all_known - {scope}
- else:
- skip = frozenset(_exec_state["paused_projects"])
- count = asyncio.run(executor.run_all_pending(skip_projects=skip or None))
- _lg.info("执行器本轮处理任务数: %d(范围: %s,跳过: %s)",
- count, scope or "全局", skip or "无")
- time.sleep(10 if count > 0 else 30)
- except Exception as e:
- _lg.error("执行器异常: %s", e)
- time.sleep(60)
-
- app = FastAPI(title="Rockchip Agents Dashboard")
- app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_methods=["*"],
- allow_headers=["*"],
- )
-
- def _row_to_dict(row: sqlite3.Row) -> dict:
- return {
- "id": row["id"],
- "project": row["project"],
- "type": row["type"],
- "title": row["title"],
- "context": row["context"] or "",
- "priority": row["priority"],
- "mode": row["mode"],
- "agent_role": row["agent_role"],
- "status": row["status"],
- "created_at": row["created_at"] or "",
- "started_at": row["started_at"],
- "completed_at": row["completed_at"],
- "result_summary": row["result_summary"] or "",
- "retry_count": int(row["retry_count"] or 0),
- "parent_task_id": int(row["parent_task_id"] or 0),
- "constraint_type": row["constraint_type"] or "",
- "awaiting_role": row["awaiting_role"] or "",
- "initiator": row["initiator"] or "system",
- "discussion": row["discussion"] or "",
- }
-
- def _task_to_out(t: Task) -> dict:
- """将 Task dataclass 转换为 API 响应 dict。"""
- return {
- "id": t.id,
- "project": t.project,
- "type": t.type,
- "title": t.title,
- "context": t.context or "",
- "priority": t.priority,
- "mode": t.mode,
- "agent_role": t.agent_role,
- "status": t.status,
- "created_at": t.created_at or "",
- "started_at": t.started_at,
- "completed_at": t.completed_at,
- "result_summary": t.result_summary or "",
- "retry_count": int(t.retry_count or 0),
- "parent_task_id": int(t.parent_task_id or 0),
- "constraint_type": t.constraint_type or "",
- "awaiting_role": t.awaiting_role or "",
- "initiator": t.initiator or "system",
- "discussion": t.discussion or "",
- }
-
- def _all_tasks() -> list[dict]:
- with _queue._conn() as c:
- rows = c.execute(
- "SELECT * FROM tasks ORDER BY id DESC LIMIT 500"
- ).fetchall()
- return [_row_to_dict(r) for r in rows]
-
- # ── REST 路由 ──────────────────────────────────────────────────────
-
- @app.get("/api/tasks")
- def get_tasks(project: Optional[str] = None) -> list[dict]:
- tasks = _all_tasks()
- if project:
- tasks = [t for t in tasks if t["project"] == project]
- return tasks
-
- @app.post("/api/tasks")
- def create_task(body: TaskCreate) -> dict:
- task = Task(
- project=body.project, type=body.type, title=body.title,
- priority=body.priority, mode=body.mode, agent_role=body.agent_role,
- context=body.context,
- initiator=body.initiator,
- discussion=body.discussion,
- )
- tid = _queue.enqueue(task)
- if tid == -1:
- raise HTTPException(400, "同名任务已在 pending/running 中")
- with _queue._conn() as c:
- row = c.execute("SELECT * FROM tasks WHERE id=?", (tid,)).fetchone()
- return _row_to_dict(row)
-
- @app.patch("/api/tasks/{task_id}")
- def patch_task(task_id: int, body: TaskPatch) -> dict:
- with _queue._conn() as c:
- row = c.execute("SELECT id FROM tasks WHERE id=?", (task_id,)).fetchone()
- if not row:
- raise HTTPException(404, "任务不存在")
- if body.priority is not None:
- c.execute("UPDATE tasks SET priority=? WHERE id=?",
- (body.priority, task_id))
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- return _row_to_dict(row)
-
- @app.post("/api/tasks/{task_id}/retry")
- def retry_task(task_id: int) -> dict:
- with _queue._conn() as c:
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- if not row:
- raise HTTPException(404, "任务不存在")
- if row["status"] not in ("failed", "vetoed"):
- raise HTTPException(400, f"只能重试 failed/vetoed 任务,当前状态: {row['status']}")
- c.execute(
- "UPDATE tasks SET status='pending', retry_count=retry_count+1,"
- "started_at=NULL, completed_at=NULL WHERE id=?",
- (task_id,),
- )
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- return _row_to_dict(row)
-
- @app.post("/api/tasks/{task_id}/cancel")
- def cancel_task(task_id: int) -> dict:
- with _queue._conn() as c:
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- if not row:
- raise HTTPException(404, "任务不存在")
- if row["status"] != "running":
- raise HTTPException(400, "只能取消 running 任务")
- c.execute(
- "UPDATE tasks SET status='failed', result_summary='手动取消',"
- "completed_at=? WHERE id=?",
- (datetime.utcnow().isoformat(), task_id),
- )
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- return _row_to_dict(row)
-
- @app.post("/api/tasks/{task_id}/approve")
- def approve_task(task_id: int) -> dict:
- with _queue._conn() as c:
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- if not row:
- raise HTTPException(404, "任务不存在")
- if row["status"] != "waiting_approval":
- raise HTTPException(400, "只能审批 waiting_approval 任务")
- _queue.approve_waiting(task_id)
- with _queue._conn() as c:
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- return _row_to_dict(row)
-
- @app.post("/api/tasks/{task_id}/reject")
- def reject_task(task_id: int, body: RejectBody) -> dict:
- with _queue._conn() as c:
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- if not row:
- raise HTTPException(404, "任务不存在")
- if row["status"] != "waiting_approval":
- raise HTTPException(400, "只能拒绝 waiting_approval 任务")
- _queue.mark_vetoed(task_id, reason=body.reason or "审批拒绝")
- with _queue._conn() as c:
- row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
- return _row_to_dict(row)
-
- @app.post("/api/projects/import")
- def import_project(body: ProjectImport) -> dict:
- import yaml as _yaml
- p = Path(body.path)
- name = body.name.strip() or p.name
-
- if config_dir:
- yaml_path = Path(config_dir) / "projects.yaml"
- else:
- from rockchip_agents.config import CONFIG_DIR
- yaml_path = CONFIG_DIR / "projects.yaml"
-
- with open(yaml_path) as f:
- proj_data = _yaml.safe_load(f) or {}
-
- proj_data.setdefault("projects", {})[name] = {
- "path": str(p),
- "mode": body.mode,
- "description": body.description,
- }
-
- with open(yaml_path, "w") as f:
- _yaml.dump(proj_data, f, allow_unicode=True,
- default_flow_style=False, sort_keys=False)
-
- if body.scan_now:
- template_tasks = _generate_template_tasks(
- name, p, body.mode, body.linked_project
- )
- enqueued = []
- for t in template_tasks:
- tid = _queue.enqueue(t)
- if tid > 0:
- enqueued.append({"title": t.title, "priority": t.priority,
- "agent_role": t.agent_role})
- else:
- enqueued = []
-
- return {"name": name, "path": body.path, "mode": body.mode,
- "tasks_created": len(enqueued), "tasks": enqueued}
-
- # ── 执行器控制 API ─────────────────────────────────────────────────
-
- @app.get("/api/executor/status")
- def get_executor_status() -> dict:
- running = (
- _exec_state["running"] and
- _exec_state["thread"] is not None and
- _exec_state["thread"].is_alive()
- )
- with _queue._conn() as c:
- pending = c.execute(
- "SELECT COUNT(*) FROM tasks WHERE status='pending'"
- ).fetchone()[0]
- active = c.execute(
- "SELECT COUNT(*) FROM tasks WHERE status='running'"
- ).fetchone()[0]
- return {
- "running": running,
- "pending": int(pending),
- "active": int(active),
- "started_at": _exec_state.get("started_at"),
- "paused_projects": list(_exec_state["paused_projects"]),
- "scope_project": _exec_state["scope_project"],
- }
-
- @app.post("/api/executor/pause-project/{name}")
- def pause_project(name: str) -> dict:
- _exec_state["paused_projects"].add(name)
- return {"ok": True, "paused_projects": list(_exec_state["paused_projects"])}
-
- @app.post("/api/executor/resume-project/{name}")
- def resume_project(name: str) -> dict:
- _exec_state["paused_projects"].discard(name)
- return {"ok": True, "paused_projects": list(_exec_state["paused_projects"])}
-
- @app.post("/api/executor/start")
- def start_executor(body: StartBody = StartBody()) -> dict:
- scope = body.project or None
- already_running = (
- _exec_state["running"] and
- _exec_state["thread"] and
- _exec_state["thread"].is_alive()
- )
- # 更新 scope(即使已在运行也可切换范围)
- _exec_state["scope_project"] = scope
- if already_running:
- return {"ok": True, "message": f"范围已切换: {scope or '全局'}"}
- _exec_state["running"] = True
- _exec_state["started_at"] = datetime.utcnow().isoformat()
- t = threading.Thread(target=_executor_loop, daemon=True, name="web-executor")
- _exec_state["thread"] = t
- t.start()
- return {"ok": True, "message": f"执行器已启动(范围: {scope or '全局'})"}
-
- @app.post("/api/executor/stop")
- def stop_executor(body: StopBody = StopBody()) -> dict:
- if body.project:
- # 单项目停止:只是将该项目加入暂停列表,不停止整个执行器
- _exec_state["paused_projects"].add(body.project)
- if _exec_state["scope_project"] == body.project:
- _exec_state["scope_project"] = None
- _exec_state["running"] = False
- return {"ok": True, "message": f"{body.project} 已暂停"}
- # 全局停止
- _exec_state["running"] = False
- _exec_state["scope_project"] = None
- return {"ok": True, "message": "执行器停止信号已发送(当前任务完成后生效)"}
-
- # ── 配置文件 API ───────────────────────────────────────────────────
-
- def _get_project_path(name: str) -> Path | None:
- try:
- from rockchip_agents.config import load_config
- cfg = load_config(Path(config_dir) if config_dir else None)
- proj = cfg.projects.get(name)
- if proj:
- return Path(proj.path)
- except Exception:
- pass
- return None
-
- @app.get("/api/config/global")
- def get_global_config() -> dict:
- if not global_claude_dir:
- raise HTTPException(404, "未配置全局 CLAUDE.md 目录(CLAUDE_GLOBAL_DIR 未设置)")
- p = Path(global_claude_dir) / "CLAUDE.md"
- if not p.exists():
- return {"content": "", "path": str(p), "exists": False}
- return {"content": p.read_text(encoding="utf-8"), "path": str(p), "exists": True}
-
- @app.put("/api/config/global")
- def update_global_config(body: ConfigUpdate) -> dict:
- if not global_claude_dir:
- raise HTTPException(404, "未配置全局 CLAUDE.md 目录(CLAUDE_GLOBAL_DIR 未设置)")
- p = Path(global_claude_dir) / "CLAUDE.md"
- p.write_text(body.content, encoding="utf-8")
- return {"ok": True}
-
- @app.get("/api/projects/{name}/config")
- def get_project_config(name: str) -> dict:
- proj_path = _get_project_path(name)
- if not proj_path:
- raise HTTPException(404, f"项目 {name} 不存在或路径未配置")
- result: dict = {"project": name, "path": str(proj_path)}
- claude_p = proj_path / "CLAUDE.md"
- result["claude"] = {
- "content": claude_p.read_text(encoding="utf-8") if claude_p.exists() else "",
- "exists": claude_p.exists(),
- "path": str(claude_p),
- }
- readme_p = proj_path / "README.md"
- result["readme"] = {
- "content": readme_p.read_text(encoding="utf-8") if readme_p.exists() else "",
- "exists": readme_p.exists(),
- "path": str(readme_p),
- }
- return result
-
- @app.put("/api/projects/{name}/config/{file_type}")
- def update_project_config(name: str, file_type: str, body: ConfigUpdate) -> dict:
- if file_type not in ("claude", "readme"):
- raise HTTPException(400, "file_type 必须是 claude 或 readme")
- proj_path = _get_project_path(name)
- if not proj_path:
- raise HTTPException(404, f"项目 {name} 不存在或路径未配置")
- filename = "CLAUDE.md" if file_type == "claude" else "README.md"
- p = proj_path / filename
- p.write_text(body.content, encoding="utf-8")
- return {"ok": True, "path": str(p)}
-
- @app.get("/api/projects")
- def get_projects() -> list[dict]:
- try:
- from rockchip_agents.config import load_config
- from pathlib import Path as P
- cfg = load_config(P(config_dir) if config_dir else None)
- return [
- {"name": name, "mode": p.mode, "kind": p.kind,
- "description": p.description}
- for name, p in sorted(cfg.projects.items())
- ]
- except Exception:
- return []
-
- @app.delete("/api/projects/{name}/tasks")
- def clear_project_tasks(name: str) -> dict:
- """清空指定项目的全部任务记录(立即生效,不可恢复)。"""
- count = _queue.clear_project_tasks(name)
- return {"ok": True, "name": name, "deleted": count}
-
- @app.delete("/api/projects/{name}")
- def delete_project(name: str) -> dict:
- import yaml as _yaml
- if config_dir:
- yaml_path = Path(config_dir) / "projects.yaml"
- else:
- from rockchip_agents.config import CONFIG_DIR
- yaml_path = CONFIG_DIR / "projects.yaml"
- with open(yaml_path) as f:
- proj_data = _yaml.safe_load(f) or {}
- projects = proj_data.get("projects", {})
- if name not in projects:
- raise HTTPException(404, f"项目 {name} 不存在")
- del projects[name]
- proj_data["projects"] = projects
- with open(yaml_path, "w") as f:
- _yaml.dump(proj_data, f, allow_unicode=True,
- default_flow_style=False, sort_keys=False)
- # 同步清除该项目所有任务
- _queue.clear_project_tasks(name)
- return {"ok": True, "name": name}
-
- # ── 选题审批 API ───────────────────────────────────────────────────
-
- @app.get("/api/tasks/topics")
- def get_topic_pending() -> list[dict]:
- """返回所有待审批的选题任务。"""
- tasks = _queue.get_topic_pending_tasks()
- return [_task_to_out(t) for t in tasks]
-
- @app.post("/api/tasks/{task_id}/approve-topic")
- def approve_topic(task_id: int) -> dict:
- """确认选题:topic_pending → pending,进入正常执行队列。"""
- _queue.approve_topic(task_id)
- return {"ok": True, "task_id": task_id, "status": "pending"}
-
- @app.post("/api/tasks/{task_id}/reject-topic")
- def reject_topic(task_id: int, body: RejectBody) -> dict:
- """否决选题:topic_pending → vetoed。"""
- _queue.reject_topic(task_id, reason=body.reason)
- return {"ok": True, "task_id": task_id, "status": "vetoed"}
-
- # ── 飞书通知设置 API ───────────────────────────────────────────────
-
- # 内存配置,重启后重置
- _settings: dict = {"feishu_topic_notify": False}
-
- class SettingsPatch(BaseModel):
- feishu_topic_notify: bool | None = None
-
- @app.get("/api/settings")
- def get_settings() -> dict:
- """获取 Dashboard 配置项。"""
- return _settings
-
- @app.patch("/api/settings")
- def patch_settings(body: SettingsPatch) -> dict:
- """更新 Dashboard 配置项(内存中,重启后重置)。"""
- if body.feishu_topic_notify is not None:
- _settings["feishu_topic_notify"] = body.feishu_topic_notify
- return _settings
-
- # ── 任务日志 API ───────────────────────────────────────────────────
-
- @app.get("/api/tasks/{task_id}/log")
- def get_task_log(task_id: int, offset: int = 0) -> dict:
- """返回任务执行日志,支持增量拉取(offset=已读字节数)。"""
- from rockchip_agents.agents.developer import _task_log_path
- log_path = _task_log_path(task_id)
- if not log_path.exists():
- return {"lines": [], "size": 0, "exists": False}
- size = log_path.stat().st_size
- if offset >= size:
- return {"lines": [], "size": size, "exists": True}
- with open(log_path, "r", encoding="utf-8", errors="replace") as f:
- f.seek(offset)
- content = f.read(65536) # 最多 64KB 每次
- return {"lines": content.splitlines(), "size": log_path.stat().st_size, "exists": True}
-
- # ── WebSocket ──────────────────────────────────────────────────────
-
- _ws_connections: set[WebSocket] = set()
-
- @app.websocket("/ws")
- async def websocket_endpoint(ws: WebSocket) -> None:
- await ws.accept()
- _ws_connections.add(ws)
- import json
- try:
- snapshot = {"type": "snapshot", "tasks": _all_tasks()}
- await ws.send_text(json.dumps(snapshot, ensure_ascii=False))
- while True:
- await ws.receive_text() # keep-alive (ping)
- except WebSocketDisconnect:
- pass
- finally:
- _ws_connections.discard(ws)
-
- # ── Visual Tester 路由 ──────────────────────────────────────────────
- if _HAS_VISUAL:
- _vdb_path = visual_db_path or "/data/company/visual-tester/data/visual.db"
- _visual_db = _VisualDB(_vdb_path)
-
- @app.get("/api/visual/runs")
- def visual_runs(project: Optional[str] = None):
- return _visual_db.get_runs(project=project, limit=20)
-
- @app.get("/api/visual/runs/{run_id}/cases")
- def visual_cases(run_id: str):
- return _visual_db.get_cases(run_id)
-
- @app.post("/api/visual/cases/{case_id}/approve")
- def visual_approve(case_id: int, body: VisualApproveBody):
- case = _visual_db.get_case(case_id)
- if not case:
- raise HTTPException(404, "case not found")
- if case.get("screenshot_path"):
- _visual_db.set_baseline(
- case["project"], case["url"], case["name"], case["screenshot_path"]
- )
- _visual_db.update_case_status(case_id, "passed")
- _visual_db.add_approval(case_id, "approve", body.reason)
- return {"ok": True}
-
- @app.post("/api/visual/cases/{case_id}/reject")
- def visual_reject(case_id: int, body: VisualApproveBody):
- case = _visual_db.get_case(case_id)
- if not case:
- raise HTTPException(404, "case not found")
- _visual_db.update_case_status(case_id, "failed")
- _visual_db.add_approval(case_id, "reject", body.reason)
- return {"ok": True}
-
- @app.get("/api/visual/baselines/{project}")
- def visual_baselines(project: str):
- return _visual_db.list_baselines(project)
-
- @app.post("/api/visual/runs/{run_id}/resume")
- def visual_resume(run_id: str):
- _visual_db.reset_interrupted_run(run_id)
- return {"ok": True, "run_id": run_id}
-
- from fastapi.responses import FileResponse
-
- @app.get("/api/visual/image")
- def visual_image(path: str):
- p = Path(path)
- if not p.exists():
- raise HTTPException(404, "image not found")
- return FileResponse(str(p), media_type="image/png")
-
- # ── Market Insight API ─────────────────────────────────────────────
- from rockchip_agents.tools.project_memory import ProjectMemory as _PM
- _memory = memory if memory is not None else _PM()
- _GOALS = ["运动", "适老", "AI安全"]
-
- @app.get("/api/market/facts")
- def market_facts():
- raw = _memory.get_facts("__market__")
- grouped: dict[str, dict[str, str]] = {}
- for k, v in raw.items():
- parts = k.split(".", 1)
- if len(parts) == 2:
- goal, sub = parts
- grouped.setdefault(goal, {})[sub] = v
- return {"goals": _GOALS, "facts": grouped}
-
- @app.post("/api/market/trigger/{goal}")
- def market_trigger(goal: str):
- if goal not in _GOALS:
- raise HTTPException(status_code=400, detail=f"无效目标: {goal}")
- q = TaskQueue()
- from rockchip_agents.core.queue import Task as _Task
- tid = q.enqueue(_Task(
- project="research",
- type="market_intel",
- title=f"市场洞察:{goal}",
- context=f"目标领域:{goal}。请调研该领域竞品动态、行业趋势,输出 [市场情报] 标签写入 __market__.{goal}.* facts。",
- priority=3,
- mode="report",
- agent_role="market-pm",
- initiator="dashboard",
- ))
- return MarketTriggerResponse(task_id=tid, goal=goal)
-
- @app.get("/api/market/history")
- def market_history():
- conn = sqlite3.connect(_db_path)
- conn.row_factory = sqlite3.Row
- rows = conn.execute(
- "SELECT id, title, status, result_summary, completed_at FROM tasks "
- "WHERE agent_role='market-pm' ORDER BY id DESC LIMIT 10"
- ).fetchall()
- conn.close()
- return [dict(r) for r in rows]
-
- # ── 静态文件(React 构建产物)─────────────────────────────────────
- # 注意:必须在所有 API 路由注册完成后再挂载,否则 mount("/") 会拦截后续路由
-
- dist_path = Path(__file__).parent.parent.parent.parent / "dashboard" / "dist"
- if dist_path.exists():
- app.mount("/", StaticFiles(directory=dist_path, html=True), name="static")
-
- # 挂载 WS 连接集合供 watcher 访问
- app.state.ws_connections = _ws_connections
- app.state.all_tasks_fn = _all_tasks
-
- return app
diff --git a/tests/test_algo_baseline.py b/tests/test_algo_baseline.py
new file mode 100644
index 0000000..2c0fc88
--- /dev/null
+++ b/tests/test_algo_baseline.py
@@ -0,0 +1,426 @@
+"""
+算法基准测试汇总 - 建立防抖/定位/避障基线
+
+运行方式:
+ pytest tests/test_algo_baseline.py -v -s
+
+或针对 rk3566 通过 EdgeValidatorService 运行各子项目的 benchmark 测试。
+
+输出格式(供 [记忆] 标签提取):
+ [基线] antishake.latency_p95_ms= antishake.fps=
+ [基线] positioning.uwb_error_cm= positioning.hz_3anchors=
+ [基线] navigation.update_p95_ms= navigation.fps= navigation.estop_p95_ms=
+"""
+from __future__ import annotations
+
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+# algo-base 项目根目录
+_ALGO_BASE = Path("/data/company/algo-base")
+
+# 将三个项目的 src 加入路径(本机运行时)
+for _proj in ("antishake", "positioning", "navigation"):
+ _src = _ALGO_BASE / _proj / "src"
+ if _src.exists() and str(_src) not in sys.path:
+ sys.path.insert(0, str(_src))
+
+
+# ---------------------------------------------------------------------------
+# 辅助
+# ---------------------------------------------------------------------------
+
+def _run_project_benchmark(project: str, timeout: int = 120) -> dict:
+ """在子项目中运行 benchmark 测试,解析 print 输出中的基线数值。
+
+ Returns:
+ dict 键值对,如 {'latency_p95_ms': 14.5, 'fps': 70.9, ...}
+ """
+ test_dir = _ALGO_BASE / project / "tests"
+ bench_file = test_dir / "test_benchmark.py"
+ if not bench_file.exists():
+ return {}
+
+ result = subprocess.run(
+ [sys.executable, "-m", "pytest", str(bench_file), "-v", "-s", "-q",
+ "--tb=no", "--no-header"],
+ capture_output=True, text=True, timeout=timeout,
+ cwd=str(_ALGO_BASE / project),
+ )
+ output = result.stdout + result.stderr
+ return _parse_benchmark_output(output)
+
+
+def _parse_benchmark_output(text: str) -> dict:
+ """从 pytest -s 输出中提取数值。
+
+ 支持的格式(由各 benchmark 测试 print 输出):
+ [避障延迟] avg=14.1ms p50=14.0ms p95=14.5ms ...
+ [避障FPS] 70.9 fps
+ [紧急制动响应] p50=0.011ms p95=0.011ms
+ [延迟] avg=26.3ms, p95=36.2ms
+ [吞吐] 19.9 fps
+ """
+ import re
+ metrics: dict = {}
+
+ for line in text.splitlines():
+ # 避障延迟:[避障延迟] avg=Xms p50=Xms p95=Xms
+ m = re.search(r"\[避障延迟\].*p95=([0-9.]+)ms", line)
+ if m:
+ metrics["update_p95_ms"] = float(m.group(1))
+
+ # 避障FPS
+ m = re.search(r"\[避障FPS\]\s+([0-9.]+)\s+fps", line)
+ if m:
+ metrics["fps"] = float(m.group(1))
+
+ # 紧急制动
+ m = re.search(r"\[紧急制动响应\].*p95=([0-9.]+)ms", line)
+ if m:
+ metrics["estop_p95_ms"] = float(m.group(1))
+
+ # APF 规划 FPS
+ m = re.search(r"\[APF规划FPS\]\s+([0-9]+)\s+fps", line)
+ if m:
+ metrics["apf_fps"] = float(m.group(1))
+
+ # antishake 延迟 [延迟] avg=Xms, p95=Xms
+ m = re.search(r"\[延迟\].*avg=([0-9.]+)ms", line)
+ if m:
+ metrics["latency_avg_ms"] = float(m.group(1))
+ m = re.search(r"\[延迟\].*p95=([0-9.]+)ms", line)
+ if m:
+ metrics["latency_p95_ms"] = float(m.group(1))
+
+ # antishake 吞吐 [吞吐] X fps
+ m = re.search(r"\[吞吐\]\s+([0-9.]+)\s+fps", line)
+ if m:
+ metrics["fps"] = float(m.group(1))
+
+ return metrics
+
+
+# ---------------------------------------------------------------------------
+# 基线数据(上次测量值,用于退化检测)
+# rk3566 实测(2026-03-09)
+# ---------------------------------------------------------------------------
+
+_BASELINE_ANTISHAKE = {
+ "latency_avg_ms": 26.3,
+ "latency_p95_ms": 36.2,
+ "fps": 19.9,
+}
+
+_BASELINE_POSITIONING = {
+ "uwb_error_cm": 2.1,
+ "hz_3anchors": 617.0,
+ "hz_4anchors": 97.0,
+}
+
+_BASELINE_NAVIGATION_RK3566 = {
+ # 将由 rk3566 首次运行后更新
+ "update_p95_ms": None,
+ "fps": None,
+ "estop_p95_ms": None,
+}
+
+# x86 基线(本机)
+_BASELINE_NAVIGATION_X86 = {
+ "update_p95_ms": 14.5,
+ "fps": 70.9,
+ "estop_p95_ms": 0.011,
+}
+
+
+# ---------------------------------------------------------------------------
+# 防抖基准测试(调用 antishake 项目)
+# ---------------------------------------------------------------------------
+
+class TestAntishakeBaseline:
+ """防抖精度与延迟基线验证"""
+
+ def test_jitter_reduction_ratio(self):
+ """合成抖动序列防抖率 ≥ 5%(退化检测阈值)"""
+ import cv2
+ from eis import EISConfig, EISProcessor
+
+ rng = np.random.default_rng(42)
+ cfg = EISConfig(frame_width=1280, frame_height=720)
+ eis = EISProcessor(cfg)
+
+ h, w = 720, 1280
+ # 生成基础帧
+ base = rng.integers(60, 200, (h, w, 3), dtype=np.uint8)
+ for i in range(0, h, 40):
+ for j in range(0, w, 40):
+ if (i // 40 + j // 40) % 2 == 0:
+ base[i:i+40, j:j+40] = np.clip(
+ base[i:i+40, j:j+40].astype(int) + 60, 0, 255
+ ).astype(np.uint8)
+
+ raw_frames, stable_frames = [], []
+ for _ in range(30):
+ dx, dy = rng.normal(0, 8.0), rng.normal(0, 8.0)
+ M = np.float32([[1, 0, dx], [0, 1, dy]])
+ jittered = cv2.warpAffine(base, M, (w, h), borderMode=cv2.BORDER_REFLECT_101)
+ raw_frames.append(jittered)
+ result = eis.process_frame(jittered)
+ stable_frames.append(result.frame)
+
+ def _jitter(frames):
+ grays = [cv2.cvtColor(f, cv2.COLOR_BGR2GRAY) for f in frames]
+ diffs = []
+ for i in range(1, len(grays)):
+ diff = grays[i].astype(np.float32) - grays[i-1].astype(np.float32)
+ gx = np.gradient(diff, axis=1)
+ gy = np.gradient(diff, axis=0)
+ diffs.append(np.sqrt(gx**2 + gy**2).mean())
+ return float(np.mean(diffs))
+
+ raw_j = _jitter(raw_frames)
+ stable_j = _jitter(stable_frames)
+ reduction_pct = (raw_j - stable_j) / raw_j * 100
+
+ print(
+ f"\n[基线-防抖] raw_jitter={raw_j:.3f} stable_jitter={stable_j:.3f} "
+ f"reduction={reduction_pct:.1f}%"
+ )
+ assert reduction_pct >= 5.0, f"防抖率退化:{reduction_pct:.1f}% < 5%"
+
+ def test_latency_no_regression(self):
+ """防抖延迟不超过基线 120%"""
+ from eis import EISConfig, EISProcessor
+
+ cfg = EISConfig(frame_width=1280, frame_height=720, max_features=120)
+ eis = EISProcessor(cfg)
+ rng = np.random.default_rng(0)
+ frame = rng.integers(60, 200, (720, 1280, 3), dtype=np.uint8)
+
+ # 预热
+ for _ in range(5):
+ eis.process_frame(frame)
+
+ latencies = []
+ for _ in range(30):
+ result = eis.process_frame(frame)
+ latencies.append(result.latency_ms)
+
+ avg = float(np.mean(latencies))
+ p95 = float(np.percentile(latencies, 95))
+ baseline_p95 = _BASELINE_ANTISHAKE["latency_p95_ms"]
+
+ print(
+ f"\n[基线-防抖延迟] avg={avg:.1f}ms p95={p95:.1f}ms "
+ f"baseline_p95={baseline_p95:.1f}ms (rk3566)"
+ )
+ print(f"[基线] antishake.latency_avg_ms={avg:.1f} antishake.latency_p95_ms={p95:.1f}")
+ # x86 上通常比 rk3566 快,只检查合理上限
+ assert p95 < 200.0, f"防抖 p95 延迟:{p95:.1f}ms 超过上限"
+
+
+# ---------------------------------------------------------------------------
+# 定位基准测试
+# ---------------------------------------------------------------------------
+
+class TestPositioningBaseline:
+ """定位精度与刷新率基线验证"""
+
+ def test_uwb_accuracy_no_regression(self):
+ """UWB 3锚点静止精度不超过基线 +5cm"""
+ from positioning import (
+ AnchorMeasurement, EKF2D, PositionFusion,
+ WeightedTrilateration,
+ )
+
+ anchors = [
+ np.array([0.0, 0.0]),
+ np.array([3.0, 0.0]),
+ np.array([1.5, 3.0]),
+ ]
+ true_pos = np.array([1.5, 1.5])
+ rng = np.random.default_rng(100)
+
+ fusion = PositionFusion()
+ errors = []
+ for _ in range(100):
+ measurements = []
+ for i, apos in enumerate(anchors):
+ dist = float(np.linalg.norm(true_pos - apos))
+ noise = float(rng.normal(0, 0.03))
+ measurements.append(AnchorMeasurement(
+ anchor_id=i, anchor_pos=apos,
+ range_m=max(dist + noise, 0.01), quality=1.0,
+ ))
+ est = fusion.update(measurements)
+ if est is not None:
+ errors.append(float(np.linalg.norm(est - true_pos)) * 100)
+
+ mean_err = float(np.mean(errors))
+ max_err = float(np.max(errors))
+ baseline = _BASELINE_POSITIONING["uwb_error_cm"]
+
+ print(
+ f"\n[基线-UWB精度] mean_err={mean_err:.2f}cm max_err={max_err:.2f}cm "
+ f"baseline={baseline:.1f}cm"
+ )
+ print(f"[基线] positioning.uwb_error_mean_cm={mean_err:.2f}")
+ # 精度退化阈值:均值误差 > 基线 + 10cm 报警
+ assert mean_err < baseline + 10.0, f"UWB 精度退化:{mean_err:.2f}cm"
+
+ def test_throughput_no_regression(self):
+ """UWB 3锚点刷新率不低于基线 80%"""
+ from positioning import benchmark_throughput
+
+ hz = benchmark_throughput(n_anchors=3, n_rounds=1000)
+ baseline = _BASELINE_POSITIONING["hz_3anchors"]
+
+ print(
+ f"\n[基线-定位吞吐] {hz:.0f} Hz(3锚点) baseline={baseline:.0f} Hz (rk3566)"
+ )
+ print(f"[基线] positioning.hz_3anchors={hz:.0f}")
+ # x86 应远高于 rk3566 基线,只设最低保障
+ assert hz > 100.0, f"定位刷新率过低:{hz:.0f} Hz"
+
+
+# ---------------------------------------------------------------------------
+# 避障基准测试
+# ---------------------------------------------------------------------------
+
+class TestNavigationBaseline:
+ """避障响应时间基线验证"""
+
+ def _build_system(self):
+ from obstacle_avoidance import (
+ MockDepthModel, ObstacleAvoidanceConfig, ObstacleAvoidanceSystem,
+ )
+ cfg = ObstacleAvoidanceConfig()
+ cfg.img_width = 640
+ cfg.img_height = 480
+ cfg.fx = 500.0
+ cfg.fy = 500.0
+ cfg.cx = 320.0
+ cfg.cy = 240.0
+ cfg.depth_scale_factor = 5.0
+ cfg.enable_auto_scale = False
+ s = ObstacleAvoidanceSystem(cfg)
+ s.set_depth_model(MockDepthModel(obstacle_depth=0.8, bg_depth=0.05))
+ return s
+
+ def test_update_latency_baseline(self):
+ """update() p95 延迟记录(建立 x86 基线)"""
+ from obstacle_avoidance import DetectionBox
+ system = self._build_system()
+
+ frame = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
+ det = DetectionBox(0, "person", 0.85, 0.3, 0.3, 0.7, 0.7)
+
+ for _ in range(5):
+ system.update(frame, [det])
+
+ latencies = []
+ for _ in range(50):
+ t0 = time.perf_counter()
+ system.update(frame, [det])
+ latencies.append((time.perf_counter() - t0) * 1000)
+
+ arr = np.array(latencies)
+ p95 = float(np.percentile(arr, 95))
+ fps = 1000.0 / float(arr.mean())
+
+ print(
+ f"\n[基线-避障延迟] p95={p95:.1f}ms fps={fps:.1f} "
+ f"x86_baseline={_BASELINE_NAVIGATION_X86['update_p95_ms']:.1f}ms"
+ )
+ print(f"[基线] navigation.update_p95_ms={p95:.1f} navigation.fps={fps:.1f}")
+ assert p95 < 200.0, f"避障 update p95:{p95:.1f}ms"
+
+ def test_emergency_stop_latency_baseline(self):
+ """紧急制动 APF 响应时间基线"""
+ from obstacle_avoidance import Obstacle3D, LocalPlanner, ObstacleAvoidanceConfig
+
+ cfg = ObstacleAvoidanceConfig()
+ cfg.img_width = 640
+ cfg.img_height = 480
+ planner = LocalPlanner(cfg)
+
+ close_obs = [Obstacle3D(
+ id=0, position=np.array([0.0, 0.0, 0.15]),
+ velocity=np.zeros(3), size=np.ones(3),
+ confidence=0.9, class_name="person",
+ last_update=time.time(), depth_m=0.15,
+ )]
+
+ latencies = []
+ for _ in range(500):
+ t0 = time.perf_counter()
+ cmd = planner.plan(close_obs)
+ latencies.append((time.perf_counter() - t0) * 1000)
+ assert cmd.emergency_stop
+
+ arr = np.array(latencies)
+ p95 = float(np.percentile(arr, 95))
+ baseline = _BASELINE_NAVIGATION_X86["estop_p95_ms"]
+
+ print(
+ f"\n[基线-紧急制动] p95={p95:.4f}ms "
+ f"x86_baseline={baseline:.4f}ms"
+ )
+ print(f"[基线] navigation.estop_p95_ms={p95:.4f}")
+ assert p95 < 10.0, f"紧急制动响应 p95:{p95:.4f}ms 过慢"
+
+
+# ---------------------------------------------------------------------------
+# 退化检测汇总
+# ---------------------------------------------------------------------------
+
+class TestRegressionSummary:
+ """统一退化检测:对比 rk3566 基线,超阈值即报警"""
+
+ _THRESHOLDS = {
+ # 指标: (方向, 阈值倍数) 'max'=越小越好 'min'=越大越好
+ "antishake.latency_p95_ms": ("max", 1.20), # 不超过基线 120%
+ "positioning.hz_3anchors": ("min", 0.80), # 不低于基线 80%
+ "positioning.uwb_error_cm": ("max", 2.00), # 不超过基线 200%
+ }
+
+ def test_summary_print(self):
+ """打印三项算法的基线汇总表"""
+ print("\n" + "=" * 60)
+ print("算法基线汇总(x86 参考 vs rk3566 实测)")
+ print("=" * 60)
+
+ rows = [
+ ("防抖", "latency_avg_ms", f"{_BASELINE_ANTISHAKE['latency_avg_ms']:.1f} ms",
+ "N/A(x86)", "< rk3566 实测"),
+ ("防抖", "fps", f"{_BASELINE_ANTISHAKE['fps']:.1f}",
+ "N/A(x86)", "> rk3566 实测"),
+ ("定位", "uwb_error_cm", f"{_BASELINE_POSITIONING['uwb_error_cm']:.1f} cm",
+ "rk3566 实测", "< 30 cm"),
+ ("定位", "hz_3anchors", f"{_BASELINE_POSITIONING['hz_3anchors']:.0f} Hz",
+ "rk3566 实测", "> 10 Hz"),
+ ("避障", "update_p95_ms",
+ f"{_BASELINE_NAVIGATION_X86['update_p95_ms']:.1f} ms",
+ "x86 基线", "< 35ms (rk3588)"),
+ ("避障", "estop_p95_ms",
+ f"{_BASELINE_NAVIGATION_X86['estop_p95_ms']:.3f} ms",
+ "x86 基线", "< 5ms"),
+ ]
+
+ for algo, metric, value, source, target in rows:
+ print(f" [{algo:4s}] {metric:25s} = {value:12s} 来源={source} 目标={target}")
+
+ print("=" * 60)
+ print("\n[记忆] __hardware__: algo-base.antishake.latency_p95_ms_rk3566=36.2")
+ print("[记忆] __hardware__: algo-base.antishake.fps_rk3566=19.9")
+ print("[记忆] __hardware__: algo-base.positioning.uwb_error_cm_rk3566=2.1")
+ print("[记忆] __hardware__: algo-base.positioning.hz_3anchors_rk3566=617")
+ print("[记忆] __hardware__: algo-base.navigation.update_p95_ms_x86=14.5")
+ print("[记忆] __hardware__: algo-base.navigation.fps_x86=70.9")
+ print("[记忆] __hardware__: algo-base.navigation.estop_p95_ms_x86=0.011")
+ assert True
diff --git a/tests/test_arch_version.py b/tests/test_arch_version.py
new file mode 100644
index 0000000..0e6d6b9
--- /dev/null
+++ b/tests/test_arch_version.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from nmfs_agents.tools.arch_version import ArchVersion
+
+
+def _write(path: Path, text: str = "x = 1\n") -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+
+
+def test_snapshot_creates_version_directory(tmp_path):
+ _write(tmp_path / "src" / "main.py")
+ av = ArchVersion(tmp_path)
+ vid = av.snapshot("initial snapshot")
+ assert vid == "v001"
+ assert (tmp_path / ".arch_versions" / "v001" / "meta.json").exists()
+ assert (tmp_path / ".arch_versions" / "v001" / "snapshot" / "src" / "main.py").exists()
+
+
+def test_snapshot_version_ids_increment(tmp_path):
+ _write(tmp_path / "a.py")
+ av = ArchVersion(tmp_path)
+ v1 = av.snapshot("first")
+ v2 = av.snapshot("second")
+ v3 = av.snapshot("third")
+ assert v1 == "v001"
+ assert v2 == "v002"
+ assert v3 == "v003"
+
+
+def test_restore_reverts_modified_file(tmp_path):
+ src = tmp_path / "src" / "module.py"
+ _write(src, "ORIGINAL\n")
+ av = ArchVersion(tmp_path)
+ vid = av.snapshot("before change")
+
+ src.write_text("MODIFIED\n")
+ av.restore(vid)
+
+ assert src.read_text() == "ORIGINAL\n"
+
+
+def test_promote_marks_status_and_test_passed(tmp_path):
+ _write(tmp_path / "src" / "a.py")
+ av = ArchVersion(tmp_path)
+ vid = av.snapshot("test promote")
+ av.promote(vid, "3 passed")
+ meta = av.list_versions()[0]
+ assert meta.status == "promoted"
+ assert meta.test_passed is True
+ assert "3 passed" in meta.test_output
+
+
+def test_rollback_restores_files_and_marks_status(tmp_path):
+ src = tmp_path / "main.py"
+ _write(src, "GOOD\n")
+ av = ArchVersion(tmp_path)
+ vid = av.snapshot("before bad change")
+ src.write_text("BROKEN\n")
+
+ av.rollback(vid, "1 failed")
+
+ assert src.read_text() == "GOOD\n"
+ meta = av.list_versions()[0]
+ assert meta.status == "rolled_back"
+ assert meta.test_passed is False
+
+
+def test_current_best_returns_last_promoted(tmp_path):
+ _write(tmp_path / "a.py")
+ av = ArchVersion(tmp_path)
+ v1 = av.snapshot("v1")
+ av.promote(v1)
+ v2 = av.snapshot("v2")
+ av.rollback(v2)
+ v3 = av.snapshot("v3")
+ av.promote(v3)
+ assert av.current_best() == "v003"
+
+
+def test_current_best_none_when_no_promoted(tmp_path):
+ _write(tmp_path / "a.py")
+ av = ArchVersion(tmp_path)
+ av.snapshot("candidate only")
+ assert av.current_best() is None
+
+
+def test_snapshot_skips_venv_directory(tmp_path):
+ _write(tmp_path / "src" / "real.py", "REAL\n")
+ _write(tmp_path / "venv" / "lib" / "site.py", "VENV\n")
+ av = ArchVersion(tmp_path)
+ vid = av.snapshot("skip venv")
+ meta = av.list_versions()[0]
+ # venv files must not appear in the snapshot
+ assert not any("venv" in f for f in meta.files)
+ assert any("real.py" in f for f in meta.files)
+
+
+def test_list_versions_sorted_oldest_first(tmp_path):
+ _write(tmp_path / "a.py")
+ av = ArchVersion(tmp_path)
+ av.snapshot("alpha")
+ av.snapshot("beta")
+ av.snapshot("gamma")
+ versions = av.list_versions()
+ ids = [v.version_id for v in versions]
+ assert ids == ["v001", "v002", "v003"]
diff --git a/tests/test_architect.py b/tests/test_architect.py
new file mode 100644
index 0000000..4dca03e
--- /dev/null
+++ b/tests/test_architect.py
@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch, MagicMock, patch as mpatch
+
+from nmfs_agents.agents.architect import ArchitectAgent, ARCHITECT_SYSTEM
+from nmfs_agents.agents.developer import AgentResult
+from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+
+
+def _cfg(tmp_path: Path) -> AgentsConfig:
+ yolo = tmp_path / "yolo"
+ yolo.mkdir()
+ (yolo / "README.md").write_text("# RKNN YOLO\nYOLO 目标检测框架,支持 RK3588。\n")
+ embed = tmp_path / "embedding"
+ embed.mkdir()
+ (embed / "README.md").write_text("# embed-db\n本地向量数据库,语义搜索。\n")
+ return AgentsConfig(
+ projects={
+ "yolo": ProjectConfig(path=yolo, mode="confirm"),
+ "embedding": ProjectConfig(path=embed, mode="report"),
+ },
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+
+
+def _verify_ok():
+ """subprocess.run mock for _verify_claude_binary (returncode=0)."""
+ m = MagicMock()
+ m.returncode = 0
+ m.stdout = "claude 1.0.0"
+ m.stderr = ""
+ return m
+
+
+def test_architect_collects_project_summaries(tmp_path):
+ """_collect_summaries() 应读取各项目 README 并返回摘要。"""
+ cfg = _cfg(tmp_path)
+ agent = ArchitectAgent(cfg)
+ summaries = agent._collect_summaries()
+ assert "yolo" in summaries
+ assert "embedding" in summaries
+ assert len(summaries["yolo"]) > 0
+
+
+def test_architect_run_returns_done(tmp_path):
+ """run() 在 Mock CLI 下应返回 done。"""
+ cfg = _cfg(tmp_path)
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ agent = ArchitectAgent(cfg, queue=q)
+ task = Task(project="ALL", type="architect", title="跨项目架构分析",
+ priority=2, mode="report", agent_role="architect", id=1)
+
+ mock_summary = "整合建议:yolo+embedding 可构建视觉搜索引擎。"
+ with patch("subprocess.run", return_value=_verify_ok()), \
+ patch("nmfs_agents.agents.architect._run_with_log",
+ return_value=AgentResult(status="done", summary=mock_summary)):
+ result = agent.run(task)
+
+ assert result.status == "done"
+ assert len(result.summary) > 0
+
+
+def test_architect_enqueues_research_tasks(tmp_path):
+ """run() 应将 [预研] 标记的任务写入队列。"""
+ cfg = _cfg(tmp_path)
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ agent = ArchitectAgent(cfg, queue=q)
+ task = Task(project="ALL", type="architect", title="跨项目架构分析",
+ priority=2, mode="report", agent_role="architect", id=1)
+
+ report = (
+ "整合建议:yolo+embedding 可构建视觉搜索引擎。\n\n"
+ "预研任务:\n- [预研] 视觉语义检索原型\n- [预研] 隐私保护流水线"
+ )
+ with patch("subprocess.run", return_value=_verify_ok()), \
+ patch("nmfs_agents.agents.architect._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ agent.run(task)
+
+ with q._conn() as c:
+ count = c.execute(
+ "SELECT COUNT(*) FROM tasks WHERE type='architect'"
+ ).fetchone()[0]
+ assert count >= 1
+
+
+def test_architect_enqueues_optimize_tasks(tmp_path):
+ """run() 应将 [优化] 标记的任务以 arch_optimize 类型写入队列。"""
+ cfg = _cfg(tmp_path)
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ agent = ArchitectAgent(cfg, queue=q)
+ task = Task(project="ALL", type="architect", title="跨项目架构分析",
+ priority=2, mode="report", agent_role="architect", id=1)
+
+ report = (
+ "架构建议报告。\n\n"
+ "- [预研] 视觉语义检索原型\n"
+ "- [优化] yolo: 提取推理引擎为独立模块,支持热插拔\n"
+ "- [优化] embedding: 增加连接池避免重复初始化"
+ )
+ with patch("subprocess.run", return_value=_verify_ok()), \
+ patch("nmfs_agents.agents.architect._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ agent.run(task)
+
+ with q._conn() as c:
+ opt_count = c.execute(
+ "SELECT COUNT(*) FROM tasks WHERE type='arch_optimize'"
+ ).fetchone()[0]
+ research_count = c.execute(
+ "SELECT COUNT(*) FROM tasks WHERE type='architect'"
+ ).fetchone()[0]
+ assert opt_count == 2 # yolo + embedding
+ assert research_count >= 1
+
+
+def test_architect_enqueues_developer_tasks(tmp_path):
+ """run() 应将 [开发] 标记的任务以 fix_bug 类型路由给 developer。"""
+ cfg = _cfg(tmp_path)
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ agent = ArchitectAgent(cfg, queue=q)
+ task = Task(project="ALL", type="architect", title="跨项目分析",
+ priority=2, mode="report", agent_role="architect", id=1)
+
+ report = (
+ "分析结果。\n"
+ "- [开发] yolo: 修复推理管道空帧崩溃\n"
+ "- [开发] embedding: 添加批量查询接口\n"
+ "- [测试] yolo: 验证批量推理精度\n"
+ "- [重构] embedding: 拆解 god class\n"
+ )
+ with patch("subprocess.run", return_value=_verify_ok()), \
+ patch("nmfs_agents.agents.architect._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ agent.run(task)
+
+ with q._conn() as c:
+ dev_count = c.execute(
+ "SELECT COUNT(*) FROM tasks WHERE agent_role='developer'"
+ ).fetchone()[0]
+ tester_count = c.execute(
+ "SELECT COUNT(*) FROM tasks WHERE agent_role='tester'"
+ ).fetchone()[0]
+ refactor_count = c.execute(
+ "SELECT COUNT(*) FROM tasks WHERE agent_role='dev-refactor'"
+ ).fetchone()[0]
+ assert dev_count == 2
+ assert tester_count == 1
+ assert refactor_count == 1
+
+
+def test_architect_optimize_promotes_on_test_pass(tmp_path):
+ """arch_optimize 任务在测试通过时应晋升版本。"""
+ cfg = _cfg(tmp_path)
+ yolo_path = cfg.projects["yolo"].path
+ (yolo_path / "src").mkdir()
+ (yolo_path / "src" / "infer.py").write_text("# original\n")
+
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ agent = ArchitectAgent(cfg, queue=q)
+ task = Task(project="yolo", type="arch_optimize",
+ title="[架构优化] 提取推理引擎",
+ context="测试用优化任务",
+ priority=2, mode="confirm", agent_role="architect", id=2)
+
+ test_mock = MagicMock()
+ test_mock.returncode = 0
+ test_mock.stdout = "3 passed"
+ test_mock.stderr = ""
+
+ # subprocess.run: verify(ok) + pytest(pass)
+ with patch("subprocess.run", side_effect=[_verify_ok(), test_mock]), \
+ patch("nmfs_agents.agents.architect._run_with_log",
+ return_value=AgentResult(status="done", summary="已完成重构")):
+ result = agent.run(task)
+
+ assert result.status == "done"
+ assert "晋升" in result.summary
+
+ from nmfs_agents.tools.arch_version import ArchVersion
+ av = ArchVersion(yolo_path)
+ assert av.current_best() == "v001"
+
+
+def test_architect_optimize_rollback_on_test_fail(tmp_path):
+ """arch_optimize 任务在测试失败时应回滚文件并返回 failed。"""
+ cfg = _cfg(tmp_path)
+ yolo_path = cfg.projects["yolo"].path
+ src_file = yolo_path / "infer.py"
+ src_file.write_text("ORIGINAL\n")
+
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ agent = ArchitectAgent(cfg, queue=q)
+ task = Task(project="yolo", type="arch_optimize",
+ title="[架构优化] 破坏性改动",
+ context="测试用优化任务",
+ priority=2, mode="confirm", agent_role="architect", id=3)
+
+ def fake_run_with_log(cmd, cwd, env, timeout, log_path, **kwargs):
+ src_file.write_text("BROKEN\n")
+ return AgentResult(status="done", summary="改动完成")
+
+ with patch("subprocess.run", return_value=_verify_ok()), \
+ patch("nmfs_agents.agents.architect._run_with_log", side_effect=fake_run_with_log), \
+ patch.object(agent, "_run_tests", return_value={"passed": False, "output": "1 failed"}):
+ result = agent.run(task)
+
+ assert result.status == "failed"
+ assert "回滚" in result.summary
+ assert src_file.read_text() == "ORIGINAL\n"
+
+
+def test_architect_parses_memory_tag_and_writes_fact(tmp_path):
+ """[记忆] tag in report should call memory.set_fact for each key=value pair."""
+ from nmfs_agents.tools.project_memory import ProjectMemory
+
+ cfg = _cfg(tmp_path)
+ mem = ProjectMemory(db_path=tmp_path / "mem.db")
+ queue = TaskQueue(db_path=tmp_path / "q.db")
+ agent = ArchitectAgent(cfg, queue=queue, memory=mem)
+
+ report = (
+ "架构分析完成。\n"
+ "[记忆] yolo: test_cmd=pytest tests/, main_lang=Python\n"
+ "[优化] yolo: 优化推理管道\n"
+ )
+ agent._extract_and_enqueue(report, requester="test")
+
+ facts = mem.get_facts("yolo")
+ assert facts.get("test_cmd") == "pytest tests/"
+ assert facts.get("main_lang") == "Python"
+
+
+def test_architect_system_prompt_is_generic():
+ """ARCHITECT_SYSTEM must not contain hardcoded project names."""
+ for hardcoded in ["Rockchip", "RKNN", "YOLO", "MediaPipe", "embedding"]:
+ assert hardcoded not in ARCHITECT_SYSTEM, (
+ f"ARCHITECT_SYSTEM 不应包含硬编码词语: {hardcoded!r}"
+ )
diff --git a/tests/test_base_tech_agents.py b/tests/test_base_tech_agents.py
new file mode 100644
index 0000000..c2c62dd
--- /dev/null
+++ b/tests/test_base_tech_agents.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from nmfs_agents.config import AgentsConfig, ClaudeConfig
+from nmfs_agents.core.queue import Task
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ cfg.devices = {}
+ return cfg
+
+
+def _make_task(role):
+ return Task(project="base", type="research", title="test",
+ context="", priority=3, mode="auto", agent_role=role)
+
+
+def test_os_engineer_context_contains_perf_domains(tmp_path):
+ from nmfs_agents.agents.os_engineer import OsEngineerAgent
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = OsEngineerAgent(_make_config(), memory=mem)
+ ctx = agent._build_context(_make_task("os-engineer"))
+ assert "内存" in ctx
+ assert "NPU" in ctx or "valgrind" in ctx
+
+
+def test_algo_researcher_context_contains_algorithm_domains(tmp_path):
+ from nmfs_agents.agents.algo_researcher import AlgoResearcherAgent
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = AlgoResearcherAgent(_make_config(), memory=mem)
+ ctx = agent._build_context(_make_task("algo-researcher"))
+ # AlgoResearcher 已改为代码迭代模式,聚焦 rk3566 验证
+ assert "rk3566" in ctx or "算法" in ctx or "numpy" in ctx
+
+
+def test_algo_researcher_injects_hardware_facts(tmp_path):
+ from nmfs_agents.agents.algo_researcher import AlgoResearcherAgent
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ mem.set_fact("__hardware__", "rk3588.npu", "6TOPS", role="hw-engineer")
+ agent = AlgoResearcherAgent(_make_config(), memory=mem)
+ ctx = agent._build_context(_make_task("algo-researcher"))
+ assert "6TOPS" in ctx
+
+
+def test_base_architect_context_focuses_on_platform(tmp_path):
+ from nmfs_agents.agents.base_architect import BaseArchitectAgent
+ from nmfs_agents.core.queue import TaskQueue
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = BaseArchitectAgent(_make_config(), queue=q, memory=mem)
+ # base-architect 继承 ArchitectAgent,有 run() 方法
+ assert hasattr(agent, "run")
+ # system prompt 包含基础技术相关词汇
+ from nmfs_agents.agents.base_architect import BASE_ARCHITECT_SYSTEM
+ assert "平台" in BASE_ARCHITECT_SYSTEM or "基础技术" in BASE_ARCHITECT_SYSTEM
+ assert "os-engineer" in BASE_ARCHITECT_SYSTEM or "algo-researcher" in BASE_ARCHITECT_SYSTEM
diff --git a/tests/test_base_validator.py b/tests/test_base_validator.py
new file mode 100644
index 0000000..027430e
--- /dev/null
+++ b/tests/test_base_validator.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from nmfs_agents.agents.base_validator import BaseValidatorAgent, BASE_VALIDATOR_SYSTEM
+from nmfs_agents.config import AgentsConfig, ClaudeConfig
+from nmfs_agents.core.queue import Task
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="auto", path=MagicMock())}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ cfg.devices = {}
+ return cfg
+
+
+def test_base_validator_system_prompt_contains_keywords():
+ for kw in ["性能", "稳定性", "精度", "硬件约束"]:
+ assert kw in BASE_VALIDATOR_SYSTEM, f"缺少关键词: {kw}"
+
+
+def test_base_validator_context_contains_hardware_facts(tmp_path):
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ mem.set_fact("__hardware__", "rk3588.npu", "6TOPS", role="hw-engineer")
+ agent = BaseValidatorAgent(_make_config(), memory=mem)
+ task = Task(project="yolo", type="validate", title="test",
+ context="", priority=3, mode="auto", agent_role="base-validator")
+ ctx = agent._build_context(task)
+ assert "6TOPS" in ctx or "rk3588.npu" in ctx
+
+
+def test_base_validator_system_covers_regression_detection():
+ from nmfs_agents.agents.base_validator import BASE_VALIDATOR_SYSTEM
+ assert "[验证结果]" in BASE_VALIDATOR_SYSTEM
+ assert "[验证退化]" in BASE_VALIDATOR_SYSTEM
+ assert "基线" in BASE_VALIDATOR_SYSTEM
+
+
+def test_base_validator_context_has_ssh_and_edge_service(tmp_path):
+ from nmfs_agents.config import DeviceConfig
+ from nmfs_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+ cfg = AgentsConfig(
+ projects={},
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={"rk3588": DeviceConfig(
+ host="192.168.1.1", user="pi", password="pi",
+ workspace="/home/pi", type="linux", connect="ssh",
+ )},
+ )
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = BaseValidatorAgent(cfg, memory=mem)
+ task = Task(project="yolo", type="test", title="验证",
+ context="", priority=2, mode="auto", agent_role="base-validator")
+ ctx = agent._build_context(task)
+ assert "EdgeValidatorService" in ctx or "8899" in ctx
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..32f22fb
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import pytest
+from pathlib import Path
+from nmfs_agents.config import load_config, AgentsConfig
+
+
+def _write_base_yamls(tmp_path: Path, projects_extra: str = "") -> None:
+ (tmp_path / "projects.yaml").write_text(
+ "projects:\n yolo:\n path: /data/rockchip/yolo\n mode: confirm\n"
+ + projects_extra
+ )
+ (tmp_path / "agents.yaml").write_text(
+ "scheduler:\n interval_hours: 1\n max_concurrent: 3\n"
+ "claude:\n model: claude-sonnet-4-6\n"
+ "feishu:\n callback_port: 8765\n"
+ )
+ (tmp_path / "devices.yaml").write_text(
+ "devices:\n rk3588:\n host: 192.168.0.127\n user: pi\n password: '123123'\n"
+ " workspace: /home/pi/Desktop\n pre_cmd: 'echo'\n"
+ )
+
+
+def test_load_config_returns_typed_config(tmp_path):
+ _write_base_yamls(tmp_path)
+ cfg = load_config(tmp_path)
+ assert "yolo" in cfg.projects
+ assert cfg.projects["yolo"].mode == "confirm"
+ assert cfg.projects["yolo"].path == Path("/data/rockchip/yolo")
+ assert cfg.scheduler.interval_hours == 1
+ assert cfg.claude.model == "claude-sonnet-4-6"
+ assert "rk3588" in cfg.devices
+ assert cfg.devices["rk3588"].host == "192.168.0.127"
+
+
+def test_load_config_scan_dirs_resolves_path_for_explicit_project(tmp_path):
+ """scan_dirs 仅用于为显式列出的项目解析路径,不自动引入未列出的项目。"""
+ # Create a parent dir with two sub-projects
+ parent = tmp_path / "repos"
+ (parent / "proj_a").mkdir(parents=True)
+ (parent / "proj_a" / "README.md").write_text("# Proj A\nsome text\n")
+ (parent / "proj_b").mkdir()
+ (parent / "proj_b" / "CLAUDE.md").write_text("# Proj B\n")
+
+ (tmp_path / "projects.yaml").write_text(
+ f"scan_dirs:\n - {parent}\n"
+ "projects:\n proj_a:\n mode: auto\n" # 只显式列出 proj_a
+ )
+ (tmp_path / "agents.yaml").write_text(
+ "scheduler:\n interval_hours: 1\n max_concurrent: 3\n"
+ "claude:\n model: claude-sonnet-4-6\n"
+ "feishu:\n callback_port: 8765\n"
+ )
+ (tmp_path / "devices.yaml").write_text("devices: {}\n")
+
+ cfg = load_config(tmp_path)
+ # 只有显式列出的 proj_a 应该存在
+ assert "proj_a" in cfg.projects
+ # proj_b 未在 projects: 中列出,不应自动引入
+ assert "proj_b" not in cfg.projects
+ # scan_dirs 帮助解析了 proj_a 的路径
+ assert cfg.projects["proj_a"].path == parent / "proj_a"
+ assert cfg.projects["proj_a"].mode == "auto"
+
+
+def test_load_config_kind_field_present(tmp_path):
+ """ProjectConfig must have a 'kind' field after loading."""
+ _write_base_yamls(tmp_path)
+ cfg = load_config(tmp_path)
+ assert hasattr(cfg.projects["yolo"], "kind")
+ assert cfg.projects["yolo"].kind == "host" # default when not specified
+
+
+def test_claude_wx_project_in_config(tmp_path):
+ """claude-wx 应在 projects 配置中,mode=auto"""
+ (tmp_path / "projects.yaml").write_text(
+ "projects:\n"
+ " claude-wx:\n"
+ " path: /data/test-platform/claude-wx\n"
+ " mode: auto\n"
+ " description: \"Claude Code 飞书监控控制系统\"\n"
+ )
+ (tmp_path / "agents.yaml").write_text(
+ "scheduler:\n interval_hours: 1\n max_concurrent: 3\n"
+ "claude:\n model: claude-sonnet-4-6\n"
+ "feishu:\n callback_port: 8765\n"
+ )
+ (tmp_path / "devices.yaml").write_text("devices: {}\n")
+ cfg = load_config(tmp_path)
+ assert "claude-wx" in cfg.projects
+ assert cfg.projects["claude-wx"].mode == "auto"
+
+
+def test_load_mcp_tools_yaml():
+ """load_mcp_tools() 应返回 registry 和 roles 两个 dict"""
+ from nmfs_agents.config import load_mcp_tools
+ tools = load_mcp_tools()
+ assert "registry" in tools
+ assert "roles" in tools
+ assert "sequential-thinking" in tools["registry"]
+ assert "dev-embedded" in tools["roles"]
+
+def test_load_role_routing_yaml():
+ """load_role_routing() 返回 role → keywords 映射"""
+ from nmfs_agents.config import load_role_routing, route_role
+ routing = load_role_routing()
+ assert "dev-kernel" in routing
+ assert "kernel" in routing["dev-kernel"]
+ role = route_role("修复 RKNN NPU 推理崩溃", routing)
+ assert role == "dev-embedded"
+
+def test_route_role_fallback():
+ """无匹配时返回 dev-embedded(嵌入式项目默认)"""
+ from nmfs_agents.config import load_role_routing, route_role
+ routing = load_role_routing()
+ role = route_role("随机任务无关键词", routing)
+ assert role == "dev-embedded"
+
+
+def test_queue_configs_loaded():
+ """load_config 能解析三组队列配置"""
+ from nmfs_agents.config import load_config, QueueConfig
+ cfg = load_config()
+ assert hasattr(cfg, "queues"), "AgentsConfig 缺少 queues 字段"
+ assert "base_opt" in cfg.queues, "缺少 base_opt 队列"
+ assert "project_delivery" in cfg.queues, "缺少 project_delivery 队列"
+ assert "insight" in cfg.queues, "缺少 insight 队列"
+ base = cfg.queues["base_opt"]
+ assert base.max_concurrent == 5
+ assert base.scan_interval_hours == 4
+ assert "hw-engineer" in base.roles
+ assert "algo-antishake" in base.roles
+ assert "algo-position" in base.roles
+ assert "algo-nav" in base.roles
+
+
+def test_queue_config_dataclass():
+ """QueueConfig dataclass 字段正确"""
+ from nmfs_agents.config import QueueConfig
+ q = QueueConfig(
+ db="data/test.db",
+ max_concurrent=2,
+ scan_interval_hours=6,
+ roles=frozenset({"developer", "tester"}),
+ )
+ assert q.max_concurrent == 2
+ assert "developer" in q.roles
diff --git a/tests/test_config_device.py b/tests/test_config_device.py
new file mode 100644
index 0000000..71d6643
--- /dev/null
+++ b/tests/test_config_device.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+import tempfile
+from pathlib import Path
+import yaml
+import pytest
+from nmfs_agents.config import DeviceConfig, load_config
+
+def _write_yaml(path: Path, data: dict) -> None:
+ with open(path, "w") as f:
+ yaml.dump(data, f)
+
+def test_device_config_serial_fields():
+ dc = DeviceConfig(
+ host="", user="pi", password="pi", workspace="/tmp",
+ type="esp32", connect="serial", port="/dev/ttyUSB0", baud=115200,
+ )
+ assert dc.type == "esp32"
+ assert dc.connect == "serial"
+ assert dc.port == "/dev/ttyUSB0"
+ assert dc.baud == 115200
+
+def test_device_config_gateway_fields():
+ dc = DeviceConfig(
+ host="", user="", password="", workspace="",
+ type="esp32", connect="gateway", via="rk3588",
+ port="/dev/ttyUSB1", baud=115200,
+ )
+ assert dc.connect == "gateway"
+ assert dc.via == "rk3588"
+
+def test_load_config_parses_serial_device(tmp_path):
+ (tmp_path / "projects.yaml").write_text("projects: {}\nscan_dirs: []\n")
+ (tmp_path / "agents.yaml").write_text("scheduler:\n interval_hours: 1\n max_concurrent: 3\nclaude:\n model: claude-sonnet-4-6\nfeishu:\n callback_port: 8765\n")
+ (tmp_path / "devices.yaml").write_text("""
+devices:
+ rk3588:
+ type: linux
+ connect: ssh
+ host: 192.168.1.1
+ user: pi
+ password: pi
+ workspace: /home/pi
+ esp32-dev:
+ type: esp32
+ connect: serial
+ host: ""
+ user: ""
+ password: ""
+ workspace: /tmp/esp32
+ port: /dev/ttyUSB0
+ baud: 115200
+""")
+ cfg = load_config(tmp_path)
+ assert "esp32-dev" in cfg.devices
+ esp = cfg.devices["esp32-dev"]
+ assert esp.type == "esp32"
+ assert esp.connect == "serial"
+ assert esp.port == "/dev/ttyUSB0"
diff --git a/tests/test_dashboard_api.py b/tests/test_dashboard_api.py
new file mode 100644
index 0000000..39a0ecb
--- /dev/null
+++ b/tests/test_dashboard_api.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import pytest
+from pathlib import Path
+from fastapi.testclient import TestClient
+from nmfs_agents.core.queue import TaskQueue, Task
+
+
+def _make_app(db_path: Path):
+ from nmfs_agents.dashboard.api import make_app
+ return make_app(db_path=db_path, config_dir=None)
+
+
+def test_get_tasks_empty(tmp_path):
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.get("/api/tasks")
+ assert resp.status_code == 200
+ assert resp.json() == []
+
+
+def test_get_tasks_returns_all(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ q.enqueue(Task(project="yolo", type="fix_bug", title="修复",
+ priority=1, mode="auto", agent_role="developer"))
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.get("/api/tasks")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) == 1
+ assert data[0]["project"] == "yolo"
+ assert data[0]["status"] == "pending"
+
+
+def test_post_task_enqueues(tmp_path):
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.post("/api/tasks", json={
+ "project": "yolo", "type": "fix_bug", "title": "新任务",
+ "priority": 2, "mode": "auto", "agent_role": "developer",
+ "context": "测试上下文"
+ })
+ assert resp.status_code == 200
+ assert resp.json()["id"] > 0
+
+ # 验证入队成功
+ resp2 = client.get("/api/tasks")
+ assert len(resp2.json()) == 1
+
+
+def test_post_task_retry_failed(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="yolo", type="fix_bug", title="失败任务",
+ priority=1, mode="auto", agent_role="developer"))
+ q.mark_failed(tid, "执行出错")
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.post(f"/api/tasks/{tid}/retry")
+ assert resp.status_code == 200
+ # 验证重入队
+ resp2 = client.get("/api/tasks")
+ tasks = {t["id"]: t for t in resp2.json()}
+ assert tasks[tid]["status"] == "pending"
+
+
+def test_post_task_approve(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="yolo", type="fix_bug", title="等待审批",
+ priority=1, mode="auto", agent_role="developer"))
+ q.mark_waiting_approval(tid, awaiting_role="arch-security")
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.post(f"/api/tasks/{tid}/approve")
+ assert resp.status_code == 200
+ resp2 = client.get("/api/tasks")
+ tasks = {t["id"]: t for t in resp2.json()}
+ assert tasks[tid]["status"] == "done"
+
+
+def test_post_task_reject(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="yolo", type="fix_bug", title="等待审批",
+ priority=1, mode="auto", agent_role="developer"))
+ q.mark_waiting_approval(tid, awaiting_role="pm")
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.post(f"/api/tasks/{tid}/reject", json={"reason": "需求不明确"})
+ assert resp.status_code == 200
+ resp2 = client.get("/api/tasks")
+ tasks = {t["id"]: t for t in resp2.json()}
+ assert tasks[tid]["status"] == "pending"
+ assert tasks[tid]["retry_count"] == 1
+
+
+def test_post_task_cancel(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="yolo", type="fix_bug", title="运行中",
+ priority=1, mode="auto", agent_role="developer"))
+ q.dequeue() # → running
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.post(f"/api/tasks/{tid}/cancel")
+ assert resp.status_code == 200
+ resp2 = client.get("/api/tasks")
+ tasks = {t["id"]: t for t in resp2.json()}
+ assert tasks[tid]["status"] == "failed"
+
+
+def test_patch_task_priority(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="yolo", type="fix_bug", title="任务",
+ priority=3, mode="auto", agent_role="developer"))
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.patch(f"/api/tasks/{tid}", json={"priority": 1})
+ assert resp.status_code == 200
+ resp2 = client.get("/api/tasks")
+ tasks = {t["id"]: t for t in resp2.json()}
+ assert tasks[tid]["priority"] == 1
+
+
+def test_get_projects_returns_list(tmp_path):
+ app = _make_app(tmp_path / "t.db")
+ client = TestClient(app)
+ resp = client.get("/api/projects")
+ assert resp.status_code == 200
+ assert isinstance(resp.json(), list)
diff --git a/tests/test_dashboard_market_api.py b/tests/test_dashboard_market_api.py
index b93cec8..9c8c195 100644
--- a/tests/test_dashboard_market_api.py
+++ b/tests/test_dashboard_market_api.py
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
def _make_client(tmp_path: Path, mem=None):
- from rockchip_agents.dashboard.api import make_app
+ from nmfs_agents.dashboard.api import make_app
app = make_app(db_path=tmp_path / "t.db", memory=mem)
return TestClient(app)
@@ -45,7 +45,7 @@ def test_market_trigger_queues_task(tmp_path):
mem = MagicMock()
mem.get_facts.return_value = {}
- with patch("rockchip_agents.dashboard.api.TaskQueue") as MockQ:
+ with patch("nmfs_agents.dashboard.api.TaskQueue") as MockQ:
mock_q = MagicMock()
mock_q.enqueue.return_value = 42
MockQ.return_value = mock_q
diff --git a/tests/test_dashboard_topic.py b/tests/test_dashboard_topic.py
new file mode 100644
index 0000000..5f8e3e1
--- /dev/null
+++ b/tests/test_dashboard_topic.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+import pytest
+from fastapi.testclient import TestClient
+from pathlib import Path
+from nmfs_agents.core.queue import TaskQueue, Task
+from nmfs_agents.dashboard.api import make_app
+
+
+def _app(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ return make_app(queue=q), q
+
+
+def test_get_topics_empty(tmp_path):
+ app, _ = _app(tmp_path)
+ client = TestClient(app)
+ r = client.get("/api/tasks/topics")
+ assert r.status_code == 200
+ assert r.json() == []
+
+
+def test_approve_topic(tmp_path):
+ app, q = _app(tmp_path)
+ tid = q.enqueue_topic(Task(
+ project="research", type="topic", title="[选题] 工业质检",
+ priority=3, mode="report", agent_role="planner",
+ ))
+ client = TestClient(app)
+ r = client.post(f"/api/tasks/{tid}/approve-topic")
+ assert r.status_code == 200
+ # 确认状态变为 pending
+ conn = q._conn()
+ row = conn.execute("SELECT status FROM tasks WHERE id=?", (tid,)).fetchone()
+ conn.close()
+ assert row["status"] == "pending"
+
+
+def test_reject_topic(tmp_path):
+ app, q = _app(tmp_path)
+ tid = q.enqueue_topic(Task(
+ project="research", type="topic", title="[选题] 工业质检",
+ priority=3, mode="report", agent_role="planner",
+ ))
+ client = TestClient(app)
+ r = client.post(f"/api/tasks/{tid}/reject-topic", json={"reason": "资源不足"})
+ assert r.status_code == 200
+ conn = q._conn()
+ row = conn.execute("SELECT status FROM tasks WHERE id=?", (tid,)).fetchone()
+ conn.close()
+ assert row["status"] == "vetoed"
diff --git a/tests/test_dashboard_visual.py b/tests/test_dashboard_visual.py
new file mode 100644
index 0000000..935a4cd
--- /dev/null
+++ b/tests/test_dashboard_visual.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+import pytest
+from fastapi.testclient import TestClient
+from nmfs_agents.dashboard.api import make_app
+from nmfs_agents.core.queue import TaskQueue
+
+
+@pytest.fixture
+def client(tmp_path):
+ queue = TaskQueue(tmp_path / "tasks.db")
+ app = make_app(queue=queue, visual_db_path=str(tmp_path / "visual.db"))
+ return TestClient(app)
+
+
+def test_visual_runs_empty(client):
+ resp = client.get("/api/visual/runs")
+ assert resp.status_code == 200
+ assert resp.json() == []
+
+
+def test_visual_runs_with_data(client, tmp_path):
+ from visual_tester.db import VisualDB
+ db = VisualDB(tmp_path / "visual.db")
+ db.create_run("agents")
+ resp = client.get("/api/visual/runs")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) == 1
+ assert data[0]["project"] == "agents"
+
+
+def test_visual_cases_for_run(client, tmp_path):
+ from visual_tester.db import VisualDB
+ db = VisualDB(tmp_path / "visual.db")
+ run_id = db.create_run("agents")
+ db.create_case(run_id, "agents", "http://localhost:9080", "home")
+ resp = client.get(f"/api/visual/runs/{run_id}/cases")
+ assert resp.status_code == 200
+ assert len(resp.json()) == 1
+
+
+def test_approve_case_updates_baseline(client, tmp_path):
+ from visual_tester.db import VisualDB
+ from PIL import Image
+ db = VisualDB(tmp_path / "visual.db")
+ run_id = db.create_run("agents")
+ case_id = db.create_case(run_id, "agents", "http://localhost:9080", "home")
+ ss_path = tmp_path / "ss.png"
+ Image.new("RGB", (10, 10)).save(ss_path)
+ db.update_case_status(case_id, "needs_review",
+ diff_pct=5.0, screenshot_path=str(ss_path))
+ resp = client.post(f"/api/visual/cases/{case_id}/approve",
+ json={"reason": "looks good"})
+ assert resp.status_code == 200
+ updated = db.get_case(case_id)
+ assert updated["status"] == "passed"
+
+
+def test_reject_case_marks_failed(client, tmp_path):
+ from visual_tester.db import VisualDB
+ db = VisualDB(tmp_path / "visual.db")
+ run_id = db.create_run("agents")
+ case_id = db.create_case(run_id, "agents", "http://localhost:9080", "home")
+ db.update_case_status(case_id, "needs_review", diff_pct=5.0)
+ resp = client.post(f"/api/visual/cases/{case_id}/reject",
+ json={"reason": "regression"})
+ assert resp.status_code == 200
+ updated = db.get_case(case_id)
+ assert updated["status"] == "failed"
diff --git a/tests/test_developer.py b/tests/test_developer.py
index 80890f8..419bda5 100644
--- a/tests/test_developer.py
+++ b/tests/test_developer.py
@@ -4,9 +4,9 @@ import json
from pathlib import Path
from unittest.mock import patch, MagicMock
-from rockchip_agents.agents.developer import DeveloperAgent
-from rockchip_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
-from rockchip_agents.core.queue import Task
+from nmfs_agents.agents.developer import DeveloperAgent
+from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.core.queue import Task
def _cfg(tmp_path: Path) -> AgentsConfig:
@@ -21,23 +21,20 @@ def _cfg(tmp_path: Path) -> AgentsConfig:
def test_developer_report_mode_uses_readonly_tools(tmp_path):
"""report 模式下 CLI 命令应只包含 Read/Glob/Grep 工具,不含 dangerously-skip-permissions。"""
+ from nmfs_agents.agents.developer import AgentResult
task = Task(project="yolo", type="fix_bug", title="分析零检出",
priority=1, mode="report", agent_role="developer", id=1)
cfg = _cfg(tmp_path)
agent = DeveloperAgent(cfg)
- mock_result = MagicMock()
- mock_result.returncode = 0
- mock_result.stdout = json.dumps({"result": "分析完成:根本原因是 sigmoid 缺失", "is_error": False})
- mock_result.stderr = ""
-
- with patch("subprocess.run", return_value=mock_result) as mock_run:
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="done", summary="分析完成:根本原因是 sigmoid 缺失")) as mock_run:
result = agent.run(task)
assert result.status == "done"
assert "分析" in result.summary
- call_cmd = mock_run.call_args[0][0]
+ call_cmd = mock_run.call_args[0][0] # first positional arg is cmd list
assert "--allowedTools" in call_cmd
idx = call_cmd.index("--allowedTools")
assert "Read,Glob,Grep" in call_cmd[idx + 1]
@@ -45,26 +42,23 @@ def test_developer_report_mode_uses_readonly_tools(tmp_path):
def test_developer_auto_mode_returns_summary(tmp_path):
- """auto 模式不使用 dangerously-skip-permissions,由 hooks 控制,返回摘要。"""
+ """auto/confirm 模式使用 --dangerously-skip-permissions(由 hooks 实施逐工具审批),返回摘要。"""
+ from nmfs_agents.agents.developer import AgentResult
(tmp_path / "yolo").mkdir()
task = Task(project="yolo", type="code_review", title="检查代码质量",
priority=3, mode="auto", agent_role="developer", id=2)
cfg = _cfg(tmp_path)
agent = DeveloperAgent(cfg)
- mock_result = MagicMock()
- mock_result.returncode = 0
- mock_result.stdout = json.dumps({"result": "代码审查完成,发现 2 处改进点", "is_error": False})
- mock_result.stderr = ""
-
- with patch("subprocess.run", return_value=mock_result) as mock_run:
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="done", summary="代码审查完成,发现 2 处改进点")) as mock_run:
result = agent.run(task)
assert result.status == "done"
assert result.summary != ""
call_cmd = mock_run.call_args[0][0]
- assert "--dangerously-skip-permissions" not in call_cmd
+ assert "--dangerously-skip-permissions" in call_cmd
def test_developer_context_uses_description_not_hardcoded(tmp_path):
@@ -90,7 +84,7 @@ def test_developer_context_uses_description_not_hardcoded(tmp_path):
def test_developer_context_includes_memory(tmp_path):
"""_build_context includes ProjectMemory context when available."""
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.tools.project_memory import ProjectMemory
mem = ProjectMemory(db_path=tmp_path / "mem.db")
mem.set_goal("myproj", "稳定高性能后端", role="productizer")
@@ -112,14 +106,14 @@ def test_developer_context_includes_memory(tmp_path):
assert "test_cmd" in ctx
-def test_auto_mode_no_dangerously_skip_permissions(tmp_path):
- """auto 模式不再使用 --dangerously-skip-permissions"""
+def test_auto_mode_has_dangerously_skip_permissions(tmp_path):
+ """auto/confirm 模式包含 --dangerously-skip-permissions,由 hooks 实施逐工具审批"""
task = Task(project="yolo", type="fix_bug", title="优化精度",
priority=1, mode="auto", agent_role="developer", id=5)
cfg = _cfg(tmp_path)
agent = DeveloperAgent(cfg)
- cmd = agent._build_cmd(task)
- assert "--dangerously-skip-permissions" not in cmd
+ cmd, _ = agent._build_cmd(task)
+ assert "--dangerously-skip-permissions" in cmd
def test_auto_mode_sets_trust_env(tmp_path):
@@ -147,14 +141,14 @@ def test_confirm_mode_sets_default_env(tmp_path):
def test_confirm_mode_no_allowed_tools_restriction(tmp_path):
- """confirm 模式不限制 --allowedTools(由 hooks 控制),不含 dangerously-skip-permissions"""
+ """confirm 模式不限制 --allowedTools(由 hooks 控制),包含 dangerously-skip-permissions"""
task = Task(project="yolo", type="fix_bug", title="修复零检出",
priority=1, mode="confirm", agent_role="developer", id=6)
cfg = _cfg(tmp_path)
agent = DeveloperAgent(cfg)
- cmd = agent._build_cmd(task)
+ cmd, _ = agent._build_cmd(task)
assert "--allowedTools" not in cmd
- assert "--dangerously-skip-permissions" not in cmd
+ assert "--dangerously-skip-permissions" in cmd
def test_unknown_mode_env_falls_back_to_trust(tmp_path):
@@ -177,42 +171,55 @@ def test_build_env_without_task_no_agent_vars(tmp_path):
def test_mcp_sequential_thinking_injected(tmp_path):
- """dev-embedded 角色应注入 --mcp-server sequential-thinking"""
- from rockchip_agents.core.queue import Task
+ """dev-embedded 角色应注入 MCP 配置(--mcp-config 临时文件)"""
+ from nmfs_agents.core.queue import Task
agent = DeveloperAgent(_cfg(tmp_path))
task = Task(project="yolo", type="fix_bug", title="修复崩溃",
priority=1, mode="auto", agent_role="dev-embedded")
- cmd = agent._build_cmd(task)
+ cmd, tmp_files = agent._build_cmd(task)
cmd_str = " ".join(cmd)
- assert "--mcp-server" in cmd_str
- assert "sequential-thinking" in cmd_str
+ # MCP 通过 --mcp-config 临时 JSON 文件注入,不是 --mcp-server 参数
+ assert "--mcp-config" in cmd_str
+ # 清理临时文件
+ import os
+ for f in tmp_files:
+ try:
+ os.unlink(f)
+ except OSError:
+ pass
def test_mcp_playwright_injected_for_tester(tmp_path):
- """tester 角色应注入 playwright"""
- from rockchip_agents.core.queue import Task
+ """tester 角色应通过 --mcp-config 注入 playwright"""
+ from nmfs_agents.core.queue import Task
+ import os
agent = DeveloperAgent(_cfg(tmp_path))
task = Task(project="yolo", type="fix_bug", title="测试",
priority=1, mode="auto", agent_role="tester")
- cmd = agent._build_cmd(task)
+ cmd, tmp_files = agent._build_cmd(task)
cmd_str = " ".join(cmd)
- assert "playwright" in cmd_str
+ assert "--mcp-config" in cmd_str
+ for f in tmp_files:
+ try:
+ os.unlink(f)
+ except OSError:
+ pass
def test_report_mode_no_mcp(tmp_path):
- """report 模式仍然限制 allowedTools,不注入 MCP"""
- from rockchip_agents.core.queue import Task
+ """report 模式仍然限制 allowedTools,不注入 MCP 配置"""
+ from nmfs_agents.core.queue import Task
agent = DeveloperAgent(_cfg(tmp_path))
task = Task(project="yolo", type="fix_bug", title="分析",
priority=1, mode="report", agent_role="dev-embedded")
- cmd = agent._build_cmd(task)
+ cmd, _ = agent._build_cmd(task)
assert "--allowedTools" in cmd
- assert "--mcp-server" not in " ".join(cmd)
+ assert "--mcp-config" not in " ".join(cmd)
def test_build_device_context_with_devices(tmp_path):
"""_build_device_context 应包含 SSH 连接示例和设备信息"""
- from rockchip_agents.config import DeviceConfig
+ from nmfs_agents.config import DeviceConfig
cfg = AgentsConfig(
projects={"yolo": ProjectConfig(path=tmp_path / "yolo", mode="auto")},
scheduler=SchedulerConfig(),
@@ -238,7 +245,7 @@ def test_build_device_context_empty_when_no_devices(tmp_path):
def test_plan_first_prompt_in_context(tmp_path):
"""非 report 模式下 system context 应包含 sequentialthinking"""
- from rockchip_agents.core.queue import Task
+ from nmfs_agents.core.queue import Task
agent = DeveloperAgent(_cfg(tmp_path))
task = Task(project="yolo", type="fix_bug", title="修复",
priority=1, mode="auto", agent_role="dev-embedded")
@@ -248,7 +255,7 @@ def test_plan_first_prompt_in_context(tmp_path):
def test_report_mode_no_plan_first(tmp_path):
"""report 模式不应包含 Plan-First 提示"""
- from rockchip_agents.core.queue import Task
+ from nmfs_agents.core.queue import Task
agent = DeveloperAgent(_cfg(tmp_path))
task = Task(project="yolo", type="fix_bug", title="分析",
priority=1, mode="report", agent_role="dev-embedded")
@@ -258,7 +265,7 @@ def test_report_mode_no_plan_first(tmp_path):
def test_build_device_context_includes_edge_service_for_linux(tmp_path):
"""Linux 设备有 EdgeValidatorService 时应在 context 中包含 HTTP URL"""
- from rockchip_agents.config import DeviceConfig
+ from nmfs_agents.config import DeviceConfig
cfg = AgentsConfig(
projects={},
scheduler=SchedulerConfig(),
@@ -277,7 +284,7 @@ def test_build_device_context_includes_edge_service_for_linux(tmp_path):
def test_build_device_context_includes_serial_info(tmp_path):
"""ESP32 串口设备应在 context 中包含 pyserial 和端口路径"""
- from rockchip_agents.config import DeviceConfig
+ from nmfs_agents.config import DeviceConfig
cfg = AgentsConfig(
projects={},
scheduler=SchedulerConfig(),
@@ -296,8 +303,8 @@ def test_build_device_context_includes_serial_info(tmp_path):
def test_build_device_context_injects_env_models(tmp_path):
"""_build_device_context 在 __env__ 有数据时应注入已知模型摘要。"""
- from rockchip_agents.config import DeviceConfig
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.config import DeviceConfig
+ from nmfs_agents.tools.project_memory import ProjectMemory
cfg = AgentsConfig(
projects={},
@@ -326,8 +333,8 @@ def test_build_device_context_injects_env_models(tmp_path):
def test_build_device_context_no_env_data(tmp_path):
"""__env__ 无数据时,_build_device_context 不注入模型摘要(不崩溃)。"""
- from rockchip_agents.config import DeviceConfig
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.config import DeviceConfig
+ from nmfs_agents.tools.project_memory import ProjectMemory
cfg = AgentsConfig(
projects={},
diff --git a/tests/test_device_agent.py b/tests/test_device_agent.py
new file mode 100644
index 0000000..0246cdd
--- /dev/null
+++ b/tests/test_device_agent.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import patch, MagicMock
+from nmfs_agents.tools.device_agent import DeviceAgent
+from nmfs_agents.config import DeviceConfig
+
+
+def test_build_command_prepends_pre_cmd():
+ dc = DeviceConfig(host="192.168.0.127", user="pi", password="123123",
+ workspace="/home/pi/Desktop",
+ pre_cmd="clashon && clashproxy on")
+ agent = DeviceAgent(dc)
+ cmd = agent._build_cmd("pytest tests/ -v", "/home/pi/Desktop/yolo")
+ assert "clashon" in cmd
+ assert "pytest" in cmd
+ assert "/home/pi/Desktop/yolo" in cmd
+
+
+def test_sync_builds_rsync_command(tmp_path):
+ dc = DeviceConfig(host="192.168.0.127", user="pi", password="123123",
+ workspace="/home/pi/Desktop", pre_cmd="")
+ agent = DeviceAgent(dc)
+ cmd = agent._build_sync_cmd(str(tmp_path / "yolo"), "/home/pi/Desktop/yolo")
+ assert "rsync" in cmd
+ assert "192.168.0.127" in cmd
+
+
+def test_run_returns_output():
+ dc = DeviceConfig(host="192.168.0.127", user="pi", password="123123",
+ workspace="/home/pi/Desktop", pre_cmd="")
+ agent = DeviceAgent(dc)
+ with patch("nmfs_agents.tools.device_agent.paramiko.SSHClient") as MockSSH:
+ mock_client = MockSSH.return_value
+ mock_stdout = MagicMock()
+ mock_stdout.read.return_value = b"3 passed"
+ mock_stdout.channel.recv_exit_status.return_value = 0
+ mock_stderr = MagicMock()
+ mock_stderr.read.return_value = b""
+ mock_client.exec_command.return_value = (MagicMock(), mock_stdout, mock_stderr)
+ result = agent.run_command("pytest tests/", "/home/pi/Desktop/yolo")
+ assert "3 passed" in result
diff --git a/tests/test_device_manager.py b/tests/test_device_manager.py
new file mode 100644
index 0000000..55b0ec1
--- /dev/null
+++ b/tests/test_device_manager.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+from dataclasses import dataclass
+from unittest.mock import MagicMock, patch
+import pytest
+
+from nmfs_agents.config import DeviceConfig
+from nmfs_agents.tools.device_manager import (
+ DeviceManager, TestResult, DeviceMetrics,
+ SSHAdapter, SerialAdapter, GatewayAdapter,
+)
+
+
+def _ssh_cfg() -> DeviceConfig:
+ return DeviceConfig(host="192.168.1.1", user="pi", password="pi",
+ workspace="/home/pi", type="linux", connect="ssh")
+
+
+def _serial_cfg() -> DeviceConfig:
+ return DeviceConfig(type="esp32", connect="serial",
+ port="/dev/ttyUSB0", baud=115200, workspace="/tmp")
+
+
+def _gateway_cfg(devices: dict) -> DeviceConfig:
+ return DeviceConfig(type="esp32", connect="gateway", via="rk3588",
+ port="/dev/ttyUSB1", baud=115200)
+
+
+def test_device_manager_creates_ssh_adapter():
+ cfg = _ssh_cfg()
+ dm = DeviceManager({"rk3588": cfg})
+ adapter = dm.get("rk3588")
+ assert isinstance(adapter, SSHAdapter)
+
+
+def test_device_manager_creates_serial_adapter():
+ cfg = _serial_cfg()
+ dm = DeviceManager({"esp32-dev": cfg})
+ adapter = dm.get("esp32-dev")
+ assert isinstance(adapter, SerialAdapter)
+
+
+def test_device_manager_creates_gateway_adapter():
+ rk = _ssh_cfg()
+ esp = DeviceConfig(type="esp32", connect="gateway", via="rk3588",
+ port="/dev/ttyUSB1", baud=115200, workspace="")
+ dm = DeviceManager({"rk3588": rk, "esp32-via-rk": esp})
+ adapter = dm.get("esp32-via-rk")
+ assert isinstance(adapter, GatewayAdapter)
+
+
+def test_ssh_adapter_run_test_parses_pass():
+ with patch("paramiko.SSHClient") as MockSSH:
+ mock_client = MagicMock()
+ MockSSH.return_value = mock_client
+ mock_stdout = MagicMock()
+ mock_stdout.read.return_value = b"1 passed in 0.5s\n"
+ mock_stderr = MagicMock()
+ mock_stderr.read.return_value = b""
+ mock_stdout.channel.recv_exit_status.return_value = 0
+ mock_client.exec_command.return_value = (None, mock_stdout, mock_stderr)
+
+ adapter = SSHAdapter(_ssh_cfg())
+ result = adapter.run_test("pytest tests/ -q", "/home/pi/project")
+ assert result.rc == 0
+ assert result.pass_
+ assert "passed" in result.stdout
+
+
+def test_test_result_pass_detection():
+ r = TestResult(rc=0, stdout="1 passed in 0.5s", stderr="", duration_s=0.5)
+ assert r.pass_
+ r2 = TestResult(rc=1, stdout="FAILED test_foo", stderr="", duration_s=0.3)
+ assert not r2.pass_
diff --git a/tests/test_edge_validator.py b/tests/test_edge_validator.py
new file mode 100644
index 0000000..18e5ac3
--- /dev/null
+++ b/tests/test_edge_validator.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+import sys
+from pathlib import Path
+from unittest.mock import patch
+import pytest
+
+# 把 services/edge_validator 加入 path
+EVS_PATH = Path(__file__).parent.parent / "services" / "edge_validator"
+
+
+def _import_app():
+ sys.path.insert(0, str(EVS_PATH))
+ import importlib
+ import main as evs_main
+ sys.path.pop(0)
+ return evs_main.app
+
+
+def test_health_endpoint():
+ import asyncio
+ from httpx import AsyncClient, ASGITransport
+ app = _import_app()
+
+ async def _run():
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ r = await c.get("/health")
+ assert r.status_code == 200
+ assert r.json()["status"] == "ok"
+
+ asyncio.run(_run())
+
+
+def test_run_test_endpoint_echo():
+ """run-test 对 rk3588 执行 echo 命令应返回 rc=0"""
+ import asyncio
+ from httpx import AsyncClient, ASGITransport
+ from unittest.mock import MagicMock
+ app = _import_app()
+
+ async def _run():
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = "hello\n"
+ mock_result.stderr = ""
+ with patch("subprocess.run", return_value=mock_result):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ r = await c.post("/run-test/rk3588", json={
+ "cmd": "echo hello", "cwd": "/tmp", "timeout": 10
+ })
+ assert r.status_code == 200
+ data = r.json()
+ assert "rc" in data
+ assert "stdout" in data
+
+ asyncio.run(_run())
+
+
+def test_metrics_endpoint_returns_structure():
+ import asyncio
+ from httpx import AsyncClient, ASGITransport
+ from unittest.mock import MagicMock, patch as mock_patch
+ app = _import_app()
+
+ async def _run():
+ mock_result = MagicMock()
+ mock_result.stdout = ""
+ mock_result.returncode = 0
+ with mock_patch("subprocess.run", return_value=mock_result):
+ with mock_patch("builtins.open", side_effect=OSError):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ r = await c.get("/metrics/rk3588")
+ assert r.status_code == 200
+ data = r.json()
+ assert "timestamp" in data
+ assert "device" in data
+
+ asyncio.run(_run())
diff --git a/tests/test_env_collector.py b/tests/test_env_collector.py
index 3f67cde..f89fb71 100644
--- a/tests/test_env_collector.py
+++ b/tests/test_env_collector.py
@@ -2,9 +2,9 @@ from __future__ import annotations
import os
import pytest
from unittest.mock import MagicMock, patch
-from rockchip_agents.config import AgentsConfig, ClaudeConfig, DeviceConfig, FeishuConfig, SchedulerConfig
-from rockchip_agents.tools.env_collector import EnvCollector, run_collection
-from rockchip_agents.tools.project_memory import ProjectMemory
+from nmfs_agents.config import AgentsConfig, ClaudeConfig, DeviceConfig, FeishuConfig, SchedulerConfig
+from nmfs_agents.tools.env_collector import EnvCollector, run_collection
+from nmfs_agents.tools.project_memory import ProjectMemory
def test_collect_local_env_vars(tmp_path):
diff --git a/tests/test_executor_constraint.py b/tests/test_executor_constraint.py
new file mode 100644
index 0000000..d76d908
--- /dev/null
+++ b/tests/test_executor_constraint.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import MagicMock, patch
+from nmfs_agents.core.executor import Executor
+from nmfs_agents.core.queue import TaskQueue, Task
+from nmfs_agents.config import AgentsConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+
+def _cfg():
+ return AgentsConfig(
+ projects={},
+ scheduler=SchedulerConfig(max_concurrent=2),
+ claude=ClaudeConfig(api_key="x"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+
+
+def test_spawn_tag_creates_child_task(tmp_path):
+ """SPAWN 标签应创建子任务入队"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="主任务",
+ priority=1, mode="auto", agent_role="developer"))
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ executor = Executor(_cfg(), queue=q, memory=mem)
+
+ from nmfs_agents.agents.developer import AgentResult
+ mock_result = AgentResult(status="done",
+ summary="分析完成。[SPAWN:arch-reviewer] 请审查修复方案")
+ with patch("nmfs_agents.core.executor._make_agent") as mk:
+ mk.return_value.run.return_value = mock_result
+ asyncio.run(executor.run_next())
+
+ with q._conn() as c:
+ rows = c.execute(
+ "SELECT * FROM tasks WHERE agent_role='arch-reviewer'"
+ ).fetchall()
+ assert len(rows) == 1
+ assert rows[0]["parent_task_id"] == tid
+
+
+def test_veto_tag_requeues_parent(tmp_path):
+ """arch-reviewer 输出 [VETO] 应将父任务重入队"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ parent_id = q.enqueue(Task(project="p", type="fix_bug", title="主任务",
+ priority=1, mode="auto", agent_role="developer"))
+ # 将主任务标记为 waiting_approval,模拟已执行完毕等待审查的状态
+ q.mark_waiting_approval(parent_id)
+ q.enqueue(Task(project="p", type="code_review", title="审查主任务",
+ priority=1, mode="auto", agent_role="arch-reviewer",
+ parent_task_id=parent_id))
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ executor = Executor(_cfg(), queue=q, memory=mem)
+
+ from nmfs_agents.agents.developer import AgentResult
+ mock_result = AgentResult(
+ status="done",
+ summary="[VETO] 存在 SQL 注入风险,需重构数据库访问层"
+ )
+ with patch("nmfs_agents.core.executor._make_agent") as mk:
+ mk.return_value.run.return_value = mock_result
+ asyncio.run(executor.run_next())
+
+ with q._conn() as c:
+ parent = c.execute(
+ "SELECT * FROM tasks WHERE id=?", (parent_id,)
+ ).fetchone()
+ assert parent["status"] == "pending"
+ assert parent["retry_count"] == 1
+ assert "SQL 注入" in parent["context"]
+
+
+def test_await_tag_marks_waiting_approval(tmp_path):
+ """AWAIT 标签应将当前任务置为 waiting_approval"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="arch_optimize", title="架构方案",
+ priority=1, mode="auto", agent_role="arch-system"))
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ executor = Executor(_cfg(), queue=q, memory=mem)
+
+ from nmfs_agents.agents.developer import AgentResult
+ mock_result = AgentResult(
+ status="done",
+ summary="方案详见附件。[AWAIT:arch-security]"
+ )
+ with patch("nmfs_agents.core.executor._make_agent") as mk:
+ mk.return_value.run.return_value = mock_result
+ asyncio.run(executor.run_next())
+
+ with q._conn() as c:
+ row = c.execute("SELECT * FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["status"] == "waiting_approval"
+ assert row["awaiting_role"] == "arch-security"
diff --git a/tests/test_executor_tags.py b/tests/test_executor_tags.py
new file mode 100644
index 0000000..d56b700
--- /dev/null
+++ b/tests/test_executor_tags.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from nmfs_agents.core.executor import _parse_agent_tags
+
+
+def test_parse_spawn_tag():
+ output = "完成分析。[SPAWN:arch-reviewer] 请审查本次修复方案"
+ tags = _parse_agent_tags(output)
+ assert len(tags) == 1
+ assert tags[0]["type"] == "SPAWN"
+ assert tags[0]["role"] == "arch-reviewer"
+ assert "审查本次修复方案" in tags[0]["desc"]
+
+
+def test_parse_await_tag():
+ output = "方案已提交。[AWAIT:arch-security] 请审批架构方案"
+ tags = _parse_agent_tags(output)
+ assert tags[0]["type"] == "AWAIT"
+ assert tags[0]["role"] == "arch-security"
+ assert "desc" in tags[0] # AWAIT 应有 desc 字段
+
+
+def test_parse_veto_tag():
+ output = "[VETO] 此方案存在越权访问风险,需重新设计权限边界"
+ tags = _parse_agent_tags(output)
+ assert tags[0]["type"] == "VETO"
+ assert "越权" in tags[0]["reason"]
+
+
+def test_parse_approve_tag():
+ tags = _parse_agent_tags("[APPROVE] 方案可行")
+ assert tags[0]["type"] == "APPROVE"
+
+
+def test_parse_proposal_tag():
+ tags = _parse_agent_tags("[PROPOSAL:arch-system] 采用微服务架构,独立部署推理模块")
+ assert tags[0]["type"] == "PROPOSAL"
+ assert tags[0]["role"] == "arch-system"
+
+
+def test_parse_select_tag():
+ tags = _parse_agent_tags("[SELECT:arch-security] 选择安全优先方案")
+ assert tags[0]["type"] == "SELECT"
+ assert tags[0]["role"] == "arch-security"
+
+
+def test_no_tags_returns_empty():
+ tags = _parse_agent_tags("普通输出,没有任何标签")
+ assert tags == []
diff --git a/tests/test_feishu.py b/tests/test_feishu.py
new file mode 100644
index 0000000..45e5dd1
--- /dev/null
+++ b/tests/test_feishu.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+import pytest
+import json
+from unittest.mock import patch, MagicMock
+from nmfs_agents.tools.feishu import FeishuNotifier
+
+
+def test_send_text_formats_payload():
+ notifier = FeishuNotifier(webhook_url="http://fake-webhook")
+ with patch("nmfs_agents.tools.feishu.urllib.request.urlopen") as mock_open:
+ mock_open.return_value.__enter__ = lambda s: s
+ mock_open.return_value.__exit__ = MagicMock(return_value=False)
+ mock_open.return_value.read.return_value = b'{"code":0}'
+ notifier.send_text("任务完成", title="[yolo] 修复 yolo12n")
+ call_args = mock_open.call_args
+ request = call_args[0][0]
+ payload = json.loads(request.data)
+ assert payload["msg_type"] == "post"
+ assert "yolo" in json.dumps(payload)
+
+
+def test_send_text_no_crash_when_webhook_empty():
+ notifier = FeishuNotifier(webhook_url="")
+ # 空 webhook 静默跳过,不抛异常
+ notifier.send_text("test") # should not raise
diff --git a/tests/test_integration_full.py b/tests/test_integration_full.py
new file mode 100644
index 0000000..c557ee5
--- /dev/null
+++ b/tests/test_integration_full.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import asyncio
+import pytest
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+from nmfs_agents.agents.manager import ManagerAgent
+from nmfs_agents.agents.productizer import ProductizerAgent
+from nmfs_agents.agents.architect import ArchitectAgent
+from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.core.queue import TaskQueue, Task
+
+
+def _make_project(base: Path, name: str, has_issues: bool = True) -> Path:
+ p = base / name
+ p.mkdir(parents=True)
+ if has_issues:
+ (p / "README.md").write_text(f"# {name}\n## 已知问题\n- {name} 存在待修复问题\n")
+ else:
+ (p / "README.md").write_text(f"# {name}\n## 快速开始\nfoo\n")
+ (p / "RELEASE.md").write_text("## v0.1\n")
+ (p / "pyproject.toml").write_text("[project]\nname='x'\n")
+ (p / "tests").mkdir()
+ ((p / "tests") / "test_a.py").write_text("def test_a(): pass\n")
+ return p
+
+
+def _cfg(tmp_path: Path) -> tuple[AgentsConfig, TaskQueue]:
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ yolo = _make_project(tmp_path, "yolo", has_issues=True)
+ emb = _make_project(tmp_path, "embedding", has_issues=False)
+ cfg = AgentsConfig(
+ projects={
+ "yolo": ProjectConfig(path=yolo, mode="report"),
+ "embedding": ProjectConfig(path=emb, mode="report"),
+ },
+ scheduler=SchedulerConfig(max_concurrent=2),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+ return cfg, q
+
+
+@pytest.mark.asyncio
+async def test_manager_full_cycle(tmp_path):
+ """Manager 完整循环:扫描 → 入队 → 执行 → 全部 done。"""
+ cfg, q = _cfg(tmp_path)
+ manager = ManagerAgent(cfg, queue=q)
+ mock_result = MagicMock(status="done", summary="分析完成")
+ with patch("nmfs_agents.core.executor.DeveloperAgent") as MockDev:
+ MockDev.return_value.run.return_value = mock_result
+ stats = await manager.run_cycle()
+ assert stats["enqueued"] >= 1
+ assert stats["executed"] >= 1
+ status = manager.get_status()
+ assert status.get("done", 0) >= 1
+
+
+def test_productizer_evaluates_real_project(tmp_path):
+ """Productizer 评估真实项目结构,高质量项目应得分 >= 50。"""
+ cfg, q = _cfg(tmp_path)
+ agent = ProductizerAgent(cfg)
+ task = Task(project="embedding", type="productize", title="评估",
+ priority=3, mode="report", agent_role="productizer", id=10)
+ result = agent.run(task)
+ assert result.status == "done"
+ assert agent.last_report.score >= 50
+
+
+def test_architect_enqueues_from_two_projects(tmp_path):
+ """Architect 分析两个项目后应提取并入队预研任务。"""
+ from nmfs_agents.agents.developer import AgentResult
+ cfg, q = _cfg(tmp_path)
+ agent = ArchitectAgent(cfg, queue=q)
+ task = Task(project="ALL", type="architect", title="全局架构分析",
+ priority=2, mode="report", agent_role="architect", id=11)
+
+ report = "两个项目可整合。\n预研任务:\n- [预研] 视觉语义搜索引擎\n- [预研] 统一推理 SDK"
+ verify_ok = MagicMock(returncode=0, stdout="claude 1.0", stderr="")
+ with patch("subprocess.run", return_value=verify_ok), \
+ patch("nmfs_agents.agents.architect._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ result = agent.run(task)
+
+ assert result.status == "done"
+ with q._conn() as c:
+ count = c.execute(
+ "SELECT COUNT(*) FROM tasks WHERE type='architect'"
+ ).fetchone()[0]
+ assert count >= 2
diff --git a/tests/test_integration_p1.py b/tests/test_integration_p1.py
new file mode 100644
index 0000000..f3f5b04
--- /dev/null
+++ b/tests/test_integration_p1.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import asyncio
+import pytest
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.core.scanner import Scanner
+from nmfs_agents.core.queue import TaskQueue, Task
+from nmfs_agents.core.executor import Executor
+
+
+def test_full_scan_to_queue_to_execute(tmp_path):
+ """完整流程:扫描 → 入队 → 执行 → 状态 done"""
+ # 准备 mock 项目
+ proj = tmp_path / "yolo"
+ proj.mkdir()
+ (proj / "README.md").write_text("## 已知问题\n- yolo12n 零检出\n")
+
+ cfg = AgentsConfig(
+ projects={"yolo": ProjectConfig(path=proj, mode="report")},
+ scheduler=SchedulerConfig(max_concurrent=1),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ scanner = Scanner(cfg)
+ results = scanner.scan_all()
+ assert len(results) >= 1
+ for r in results:
+ q.enqueue(Task(project=r.project, type=r.type, title=r.title,
+ context=r.context, priority=r.priority,
+ mode=r.mode, agent_role=r.agent_role))
+
+ mock_result = MagicMock(status="done", summary="分析完成")
+ with patch("nmfs_agents.core.executor.DeveloperAgent") as MockAgent:
+ MockAgent.return_value.run.return_value = mock_result
+ asyncio.run(Executor(cfg, queue=q).run_all_pending())
+
+ with q._conn() as c:
+ done = c.execute("SELECT COUNT(*) FROM tasks WHERE status='done'").fetchone()[0]
+ assert done >= 1
diff --git a/tests/test_manager.py b/tests/test_manager.py
new file mode 100644
index 0000000..ef95e0c
--- /dev/null
+++ b/tests/test_manager.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import asyncio
+import pytest
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+from nmfs_agents.agents.manager import ManagerAgent
+from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.core.queue import TaskQueue, Task
+
+
+def _cfg(tmp_path: Path) -> AgentsConfig:
+ proj = tmp_path / "yolo"
+ proj.mkdir()
+ (proj / "README.md").write_text("## 已知问题\n- yolo12n 零检出\n")
+ return AgentsConfig(
+ projects={"yolo": ProjectConfig(path=proj, mode="report")},
+ scheduler=SchedulerConfig(max_concurrent=2),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+
+
+def test_scan_and_enqueue_returns_count(tmp_path):
+ """scan_and_enqueue 应返回新增任务数。"""
+ cfg = _cfg(tmp_path)
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ manager = ManagerAgent(cfg, queue=q)
+ count = manager.scan_and_enqueue()
+ assert count >= 1
+
+
+def test_get_status_returns_dict(tmp_path):
+ """get_status 应返回各状态计数字典。"""
+ cfg = _cfg(tmp_path)
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ q.enqueue(Task(project="yolo", type="fix_bug", title="test",
+ priority=1, mode="report", agent_role="developer"))
+ manager = ManagerAgent(cfg, queue=q)
+ status = manager.get_status()
+ assert "pending" in status
+ assert status["pending"] >= 1
+
+
+@pytest.mark.asyncio
+async def test_run_cycle_executes_tasks(tmp_path):
+ """run_cycle 应执行扫描+执行,返回统计。"""
+ cfg = _cfg(tmp_path)
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ manager = ManagerAgent(cfg, queue=q)
+
+ mock_result = MagicMock(status="done", summary="done")
+ with patch("nmfs_agents.core.executor.DeveloperAgent") as MockAgent:
+ MockAgent.return_value.run.return_value = mock_result
+ stats = await manager.run_cycle()
+
+ assert stats["enqueued"] >= 1
+ assert stats["executed"] >= 1
+
+
+def test_executor_confirm_mode_marks_done(tmp_path):
+ """confirm 模式的任务执行后直接标记为 done(hook 级审批已在执行中发生)。"""
+ cfg = _cfg(tmp_path)
+ # 覆盖项目 mode 为 confirm
+ cfg.projects["yolo"] = ProjectConfig(path=tmp_path / "yolo", mode="confirm")
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ tid = q.enqueue(Task(project="yolo", type="fix_bug", title="修复零检出",
+ priority=1, mode="confirm", agent_role="developer"))
+
+ mock_result = MagicMock(status="done", summary="diff: +sigmoid 修复")
+ with patch("nmfs_agents.core.executor.DeveloperAgent") as MockAgent:
+ MockAgent.return_value.run.return_value = mock_result
+ from nmfs_agents.core.executor import Executor
+ asyncio.run(Executor(cfg, queue=q).run_next())
+
+ with q._conn() as c:
+ row = c.execute("SELECT status FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["status"] == "done"
diff --git a/tests/test_market_intelligence.py b/tests/test_market_intelligence.py
new file mode 100644
index 0000000..553c6a5
--- /dev/null
+++ b/tests/test_market_intelligence.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from nmfs_agents.agents.market_pm import MarketPmAgent, MARKET_PM_SYSTEM
+from nmfs_agents.config import AgentsConfig, ClaudeConfig
+from nmfs_agents.tools.project_memory import ProjectMemory, WRITE_ROLES
+
+
+def _cfg():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ cfg.devices = {}
+ return cfg
+
+
+def test_market_pm_in_write_roles():
+ assert "market-pm" in WRITE_ROLES
+
+
+def test_market_intelligence_tag_parsed(tmp_path):
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = MarketPmAgent(_cfg(), memory=mem)
+ report = """
+竞品分析完成。
+
+[市场情报] __market__.competitor.rk3588.players=瑞芯微/全志/星辰
+[市场情报] __market__.trend.2026q2=边端AI相机需求增长40%
+"""
+ agent._extract_market_intelligence(report)
+ facts = mem.get_facts("__market__")
+ assert "competitor.rk3588.players" in facts
+ assert "trend.2026q2" in facts
+
+
+def test_market_pm_system_prompt_covers_intelligence():
+ assert "[市场情报]" in MARKET_PM_SYSTEM
diff --git a/tests/test_market_pm.py b/tests/test_market_pm.py
index 7d0c500..78864a0 100644
--- a/tests/test_market_pm.py
+++ b/tests/test_market_pm.py
@@ -1,8 +1,8 @@
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
-from rockchip_agents.agents.market_pm import MarketPmAgent, MARKET_PM_SYSTEM
-from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+from nmfs_agents.agents.market_pm import MarketPmAgent, MARKET_PM_SYSTEM
+from nmfs_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
def _make_config():
@@ -29,7 +29,7 @@ def test_market_pm_system_mentions_goal_prefix():
def test_extract_goal_prefixed_facts(tmp_path):
"""解析含 goal 前缀的市场情报标签。"""
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.tools.project_memory import ProjectMemory
mem = ProjectMemory(db_path=tmp_path / "m.db")
agent = MarketPmAgent(_make_config(), memory=mem)
report = "[市场情报] __market__.运动.summary=跑步市场增速30%\n[市场情报] __market__.适老.trends=语音交互需求上升"
diff --git a/tests/test_new_agents.py b/tests/test_new_agents.py
new file mode 100644
index 0000000..2b6fb37
--- /dev/null
+++ b/tests/test_new_agents.py
@@ -0,0 +1,243 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import MagicMock
+from nmfs_agents.core.queue import Task
+from nmfs_agents.config import AgentsConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+
+
+def _cfg():
+ return AgentsConfig(
+ projects={},
+ scheduler=SchedulerConfig(max_concurrent=2),
+ claude=ClaudeConfig(api_key="x"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+
+
+def _task(role: str, project: str = "p") -> Task:
+ return Task(project=project, type="fix_bug", title="t",
+ priority=1, mode="auto", agent_role=role)
+
+
+def test_make_agent_kernel_dev():
+ from nmfs_agents.agents.kernel_dev import KernelDevAgent
+ from nmfs_agents.core.executor import _make_agent
+ agent = _make_agent(_cfg(), _task("dev-kernel"), MagicMock())
+ assert isinstance(agent, KernelDevAgent)
+
+
+def test_make_agent_lowlevel_dev():
+ from nmfs_agents.agents.lowlevel_dev import LowlevelDevAgent
+ from nmfs_agents.core.executor import _make_agent
+ agent = _make_agent(_cfg(), _task("dev-lowlevel"), MagicMock())
+ assert isinstance(agent, LowlevelDevAgent)
+
+
+def test_make_agent_hw_engineer():
+ from nmfs_agents.agents.hw_engineer import HwEngineerAgent
+ from nmfs_agents.core.executor import _make_agent
+ agent = _make_agent(_cfg(), _task("hw-engineer"), MagicMock())
+ assert isinstance(agent, HwEngineerAgent)
+
+
+def test_kernel_dev_context_contains_kernel_guidance():
+ from nmfs_agents.agents.kernel_dev import KernelDevAgent
+ agent = KernelDevAgent(_cfg())
+ task = _task("dev-kernel")
+ ctx = agent._build_context(task)
+ assert "内核" in ctx or "kernel" in ctx.lower()
+
+
+def test_lowlevel_dev_context_contains_uboot_guidance():
+ from nmfs_agents.agents.lowlevel_dev import LowlevelDevAgent
+ agent = LowlevelDevAgent(_cfg())
+ task = _task("dev-lowlevel")
+ ctx = agent._build_context(task)
+ assert "U-Boot" in ctx or "uboot" in ctx.lower() or "底层" in ctx
+
+
+def test_hw_engineer_context_contains_chip_guidance():
+ from nmfs_agents.agents.hw_engineer import HwEngineerAgent
+ agent = HwEngineerAgent(_cfg())
+ task = _task("hw-engineer")
+ ctx = agent._build_context(task)
+ assert "选型" in ctx or "芯片" in ctx or "BOM" in ctx
+
+
+# ── KnowledgeBase ──────────────────────────────────────────────────
+
+def test_kb_store_and_search(tmp_path):
+ from nmfs_agents.tools.knowledge_base import KnowledgeBase
+ kb = KnowledgeBase(db_path=tmp_path / "kb.db")
+ kb.store("tech", "RTP", "RTP延迟优化", "使用 jitter buffer 将延迟降至 50ms", source="rtp-researcher")
+ kb.store("market", "竞品", "大疆竞品分析", "大疆 Osmo Pocket 3 稳定效果优秀", source="market-pm")
+ results = kb.search("延迟 jitter", domain="tech")
+ assert len(results) == 1
+ assert "RTP" in results[0].title
+ all_results = kb.search("分析")
+ assert any(r.domain == "market" for r in all_results)
+
+
+def test_kb_upsert(tmp_path):
+ from nmfs_agents.tools.knowledge_base import KnowledgeBase
+ kb = KnowledgeBase(db_path=tmp_path / "kb.db")
+ id1 = kb.store("tech", "cat", "同标题", "初始内容")
+ id2 = kb.store("tech", "cat", "同标题", "更新内容")
+ assert id1 == id2
+ results = kb.search("更新内容", domain="tech")
+ assert len(results) == 1 and "更新" in results[0].content
+
+
+def test_kb_summary_by_domain(tmp_path):
+ from nmfs_agents.tools.knowledge_base import KnowledgeBase
+ kb = KnowledgeBase(db_path=tmp_path / "kb.db")
+ kb.store("tech", "a", "T1", "内容1")
+ kb.store("tech", "b", "T2", "内容2")
+ kb.store("market", "c", "M1", "内容3")
+ stats = kb.summary_by_domain()
+ assert stats["tech"] == 2
+ assert stats["market"] == 1
+
+
+def test_kb_context_for_agent(tmp_path):
+ from nmfs_agents.tools.knowledge_base import KnowledgeBase
+ kb = KnowledgeBase(db_path=tmp_path / "kb.db")
+ kb.store("tech", "RTP", "RTP知识点", "关键知识内容")
+ ctx = kb.context_for_agent("tech")
+ assert "tech知识库" in ctx
+ assert "RTP知识点" in ctx
+
+
+# ── BossAgent ─────────────────────────────────────────────────────
+
+def test_boss_agent_context_contains_kb(tmp_path):
+ from nmfs_agents.agents.boss import BossAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.tools.knowledge_base import KnowledgeBase
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ kb = KnowledgeBase(db_path=tmp_path / "kb.db")
+ kb.store("tech", "RTP", "RTP测试", "测试内容")
+ agent = BossAgent(_cfg(), memory=mem, knowledge_base=kb)
+ ctx = agent._build_context(_task("boss", "company"))
+ assert "知识库" in ctx
+ assert "CEO" in ctx or "Boss" in ctx or "战略" in ctx
+
+
+def test_boss_extract_kb_facts(tmp_path):
+ from nmfs_agents.agents.boss import BossAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.tools.knowledge_base import KnowledgeBase
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ kb = KnowledgeBase(db_path=tmp_path / "kb.db")
+ agent = BossAgent(_cfg(), memory=mem, knowledge_base=kb)
+ agent._extract_kb_facts("[记忆] kb.market: RK3566 市场空间巨大")
+ results = kb.search("RK3566", domain="market")
+ assert len(results) == 1
+
+
+# ── GroupLeaderAgent ──────────────────────────────────────────────
+
+def test_os_base_leader_context(tmp_path):
+ from nmfs_agents.agents.group_leader import OsBaseLeaderAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = OsBaseLeaderAgent(_cfg(), memory=mem)
+ ctx = agent._build_context(_task("os-base-leader", "company"))
+ assert "OS-Base" in ctx
+ assert "rtp-researcher" in ctx or "组长" in ctx
+
+
+def test_group_leader_dispatch_task(tmp_path):
+ from nmfs_agents.agents.group_leader import OsBaseLeaderAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.core.queue import TaskQueue
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "q.db")
+ agent = OsBaseLeaderAgent(_cfg(), queue=q, memory=mem)
+ agent._dispatch_spawned_tasks(
+ "[调度] rtp-researcher: 调研 RTP 最新 NACK 重传策略",
+ _task("os-base-leader", "os-base"),
+ )
+ t = q.dequeue()
+ assert t is not None
+ assert t.agent_role == "rtp-researcher"
+
+
+def test_group_leader_escalation(tmp_path):
+ from nmfs_agents.agents.group_leader import OsBaseLeaderAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.core.queue import TaskQueue
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "q.db")
+ agent = OsBaseLeaderAgent(_cfg(), queue=q, memory=mem)
+ agent._handle_escalation(
+ "[越级汇报] 内核调度存在严重竞争条件",
+ _task("os-base-leader", "os-base"),
+ )
+ t = q.dequeue()
+ assert t is not None
+ assert t.agent_role == "boss"
+ assert "越级汇报" in t.title
+
+
+# ── SeniorDevAgent ────────────────────────────────────────────────
+
+def test_senior_dev_context(tmp_path):
+ from nmfs_agents.agents.senior_dev import SeniorDevAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = SeniorDevAgent(_cfg(), memory=mem)
+ ctx = agent._build_context(_task("senior-dev"))
+ assert "架构视角" in ctx or "Senior" in ctx or "高级研发" in ctx
+
+
+# ── OpsAgent ─────────────────────────────────────────────────────
+
+def test_ops_agent_context(tmp_path):
+ from nmfs_agents.agents.ops import OpsAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = OpsAgent(_cfg(), memory=mem)
+ ctx = agent._build_context(_task("ops", "company"))
+ assert "运营" in ctx or "巡检" in ctx or "Ops" in ctx
+
+
+def test_ops_health_alert_dispatch(tmp_path):
+ from nmfs_agents.agents.ops import OpsAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.core.queue import TaskQueue
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "q.db")
+ agent = OpsAgent(_cfg(), queue=q, memory=mem)
+ agent._extract_health_alerts(
+ "[健康警报] yolo: tests/ 目录不存在,缺乏测试覆盖",
+ _task("ops", "company"),
+ )
+ t = q.dequeue()
+ assert t is not None
+ assert t.project == "yolo"
+ assert t.agent_role == "developer"
+
+
+# ── executor 路由 ─────────────────────────────────────────────────
+
+def test_executor_routes_org_roles(tmp_path):
+ from nmfs_agents.core.executor import _make_agent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.agents.boss import BossAgent
+ from nmfs_agents.agents.group_leader import OsBaseLeaderAgent, VisionAlgoLeaderAgent
+ from nmfs_agents.agents.senior_dev import SeniorDevAgent
+ from nmfs_agents.agents.ops import OpsAgent
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ cfg = _cfg()
+ for role, cls in [
+ ("boss", BossAgent),
+ ("os-base-leader", OsBaseLeaderAgent),
+ ("vision-algo-leader", VisionAlgoLeaderAgent),
+ ("senior-dev", SeniorDevAgent),
+ ("ops", OpsAgent),
+ ]:
+ agent = _make_agent(cfg, _task(role, "company"), mem)
+ assert isinstance(agent, cls), f"{role} 应路由到 {cls.__name__}"
diff --git a/tests/test_os_engineer_perf.py b/tests/test_os_engineer_perf.py
new file mode 100644
index 0000000..2d26899
--- /dev/null
+++ b/tests/test_os_engineer_perf.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from nmfs_agents.agents.os_engineer import OsEngineerAgent, OS_ENGINEER_SYSTEM
+from nmfs_agents.config import AgentsConfig, ClaudeConfig
+from nmfs_agents.core.queue import Task
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+
+def _cfg():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ cfg.devices = {}
+ return cfg
+
+
+def test_os_engineer_system_covers_perf_domains():
+ for kw in ["内存", "网络", "NPU", "iperf", "valgrind", "perf_reports"]:
+ assert kw in OS_ENGINEER_SYSTEM, f"缺少关键词: {kw}"
+
+
+def test_os_engineer_does_not_cover_rtos_setup():
+ """新定位不再包含 RTOS 任务调度设置(基础搭建已移至 dev-kernel/dev-lowlevel)"""
+ assert "任务调度" not in OS_ENGINEER_SYSTEM
+ assert "U-Boot" not in OS_ENGINEER_SYSTEM
diff --git a/tests/test_planner.py b/tests/test_planner.py
new file mode 100644
index 0000000..8b78ef0
--- /dev/null
+++ b/tests/test_planner.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import MagicMock
+
+from nmfs_agents.agents.planner import PlannerAgent, PLANNER_SYSTEM
+from nmfs_agents.config import AgentsConfig, ClaudeConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="confirm")}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ return cfg
+
+
+def test_planner_parse_tags(tmp_path):
+ """[需求]/[集成]/[目标]/[平台目标] 均被正确解析"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = PlannerAgent(_make_config(), queue=q, memory=mem)
+
+ report = (
+ "分析报告内容...\n"
+ "- [需求] yolo: 支持多目标追踪\n"
+ "- [集成] embedding+yolo 联合检索\n"
+ "- [目标] yolo: 提升板端 FPS 至 30\n"
+ "- [平台目标] 成为 RK3588 推理生态标准方案\n"
+ )
+ agent._extract_and_enqueue(report)
+
+ conn = q._conn()
+ tasks = [dict(r) for r in conn.execute(
+ "SELECT project, type, title FROM tasks"
+ ).fetchall()]
+ conn.close()
+ titles = [t["title"] for t in tasks]
+ assert any("多目标追踪" in t for t in titles), f"需求任务未入队,tasks={titles}"
+ assert any("embedding+yolo" in t for t in titles), f"集成任务未入队,tasks={titles}"
+
+ yolo_goal = mem.get_goal("yolo")
+ assert yolo_goal is not None
+ assert "FPS" in yolo_goal.goal_text
+
+ platform_goal = mem.get_goal("__platform__")
+ assert platform_goal is not None
+ assert "标准方案" in platform_goal.goal_text
+
+
+def test_planner_dedup(tmp_path):
+ """相似标题(前 40 字匹配)不重复入队"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = PlannerAgent(_make_config(), queue=q, memory=mem)
+
+ report1 = "- [需求] yolo: 支持多目标追踪以提升产品能力\n"
+ report2 = "- [需求] yolo: 支持多目标追踪以提升产品能力(重复)\n"
+ agent._extract_and_enqueue(report1)
+ agent._extract_and_enqueue(report2)
+
+ conn = q._conn()
+ count = conn.execute("SELECT count(*) FROM tasks").fetchone()[0]
+ conn.close()
+ assert count == 1, "重复需求被重复入队"
+
+
+def test_planner_platform_init(tmp_path):
+ """首次运行自动初始化 platform_goal"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = PlannerAgent(_make_config(), queue=q, memory=mem)
+ assert mem.get_goal("__platform__") is None
+
+ agent._ensure_platform_goal()
+
+ g = mem.get_goal("__platform__")
+ assert g is not None
+ assert len(g.goal_text) > 10
+
+
+def test_planner_empty_output(tmp_path):
+ """Claude 无有效输出时不崩溃"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = PlannerAgent(_make_config(), queue=q, memory=mem)
+ # 不应抛异常
+ agent._extract_and_enqueue("")
+ agent._extract_and_enqueue("这是一段没有标签的分析报告。")
diff --git a/tests/test_planner_topic.py b/tests/test_planner_topic.py
new file mode 100644
index 0000000..1699591
--- /dev/null
+++ b/tests/test_planner_topic.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock, patch
+from nmfs_agents.agents.planner import PlannerAgent
+from nmfs_agents.config import AgentsConfig, ClaudeConfig
+from nmfs_agents.core.queue import TaskQueue
+
+
+def _cfg():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="auto")}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ cfg.devices = {}
+ return cfg
+
+
+def test_planner_parses_topic_tag(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = PlannerAgent(_cfg(), queue=q, memory=mem)
+ report = """
+分析结果:...
+
+[需求] yolo: 修复零检出问题
+[选题] 面向工业质检的边端视觉方案:YOLO精度满足需求,市场空白
+"""
+ agent._extract_and_enqueue(report)
+ # [需求] → pending
+ conn = q._conn()
+ pending = conn.execute("SELECT title, status FROM tasks WHERE status='pending'").fetchall()
+ topics = conn.execute("SELECT title, status FROM tasks WHERE status='topic_pending'").fetchall()
+ conn.close()
+ assert len(pending) == 1 and "[需求]" in pending[0]["title"]
+ assert len(topics) == 1 and "[选题]" in topics[0]["title"]
+
+
+def test_planner_topic_dedup(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = PlannerAgent(_cfg(), queue=q, memory=mem)
+ report = "[选题] 工业质检:分析"
+ agent._extract_and_enqueue(report)
+ agent._extract_and_enqueue(report) # 重复调用
+ conn = q._conn()
+ count = conn.execute("SELECT COUNT(*) as c FROM tasks WHERE status='topic_pending'").fetchone()["c"]
+ conn.close()
+ assert count == 1 # 不重复入队
diff --git a/tests/test_productizer.py b/tests/test_productizer.py
new file mode 100644
index 0000000..89530e8
--- /dev/null
+++ b/tests/test_productizer.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import pytest
+from pathlib import Path
+from nmfs_agents.agents.productizer import ProductizerAgent, MaturityReport
+from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.core.queue import Task
+
+
+def _cfg(tmp_path: Path) -> AgentsConfig:
+ return AgentsConfig(
+ projects={"test_proj": ProjectConfig(path=tmp_path / "proj", mode="report")},
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+
+
+def test_full_project_scores_high(tmp_path):
+ """完整项目(有 README/tests/RELEASE.md/pyproject.toml)得分应 >= 70。"""
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ (proj / "README.md").write_text("# Proj\n## 快速开始\nfoo\n## 特性\nbar\n")
+ (proj / "RELEASE.md").write_text("## v0.1.0\n- 初始版本\n")
+ (proj / "pyproject.toml").write_text("[project]\nname='proj'\n")
+ tests = proj / "tests"
+ tests.mkdir()
+ (tests / "test_basic.py").write_text("def test_a(): pass\n")
+ src = proj / "src"
+ src.mkdir()
+ (src / "main.py").write_text("# main\n")
+
+ cfg = _cfg(tmp_path)
+ agent = ProductizerAgent(cfg)
+ task = Task(project="test_proj", type="productize", title="评估成熟度",
+ priority=3, mode="report", agent_role="productizer", id=1)
+ result = agent.run(task)
+ report = agent.last_report
+ assert report is not None
+ assert report.score >= 70
+ assert result.status == "done"
+
+
+def test_empty_project_scores_low(tmp_path):
+ """空项目得分应 < 40。"""
+ proj = tmp_path / "proj"
+ proj.mkdir()
+
+ cfg = _cfg(tmp_path)
+ agent = ProductizerAgent(cfg)
+ task = Task(project="test_proj", type="productize", title="评估成熟度",
+ priority=3, mode="report", agent_role="productizer", id=2)
+ agent.run(task)
+ assert agent.last_report.score < 40
+
+
+def test_report_lists_missing_items(tmp_path):
+ """缺少 RELEASE.md 时,报告应列出该缺失项。"""
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ (proj / "README.md").write_text("# Proj\n")
+
+ cfg = _cfg(tmp_path)
+ agent = ProductizerAgent(cfg)
+ task = Task(project="test_proj", type="productize", title="评估成熟度",
+ priority=3, mode="report", agent_role="productizer", id=3)
+ result = agent.run(task)
+ assert "RELEASE.md" in result.summary or "release" in result.summary.lower()
diff --git a/tests/test_project_discovery.py b/tests/test_project_discovery.py
new file mode 100644
index 0000000..1fa0dff
--- /dev/null
+++ b/tests/test_project_discovery.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from nmfs_agents.config import DeviceConfig
+from nmfs_agents.tools.project_discovery import ProjectDiscovery
+
+
+def _write(path: Path, text: str = "") -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+
+
+# ── basic discovery ───────────────────────────────────────────────────────────
+
+def test_scan_parent_finds_subdirs_with_claude_signals(tmp_path):
+ """Parent directory mode: discover subdirs that have README/CLAUDE.md."""
+ (tmp_path / "proj_a").mkdir()
+ _write(tmp_path / "proj_a" / "README.md", "# Proj A\nsome description\n")
+ (tmp_path / "proj_b").mkdir()
+ _write(tmp_path / "proj_b" / "CLAUDE.md", "# Proj B\n")
+ (tmp_path / "empty_dir").mkdir() # no signals → should be skipped
+
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path)
+ names = {r.name for r in results}
+ assert "proj_a" in names
+ assert "proj_b" in names
+ assert "empty_dir" not in names
+
+
+def test_scan_single_project_returns_one(tmp_path):
+ """Single-project mode: target itself is a Claude project."""
+ _write(tmp_path / "README.md", "# My Project\n")
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path)
+ assert len(results) == 1
+ assert results[0].path == tmp_path
+
+
+def test_scan_nonexistent_path_returns_empty(tmp_path):
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path / "does_not_exist")
+ assert results == []
+
+
+def test_scan_skips_dotdirs_and_venv(tmp_path):
+ for skip in (".git", "venv", "__pycache__"):
+ d = tmp_path / skip
+ d.mkdir()
+ _write(d / "README.md", "should be skipped\n")
+ _write(tmp_path / "real" / "CLAUDE.md", "real project\n")
+
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path)
+ names = {r.name for r in results}
+ assert names == {"real"}
+
+
+# ── embedded vs host detection ────────────────────────────────────────────────
+
+def test_embedded_detected_from_readme_keyword(tmp_path):
+ proj = tmp_path / "rknn_demo"
+ proj.mkdir()
+ _write(proj / "README.md",
+ "# RKNN Demo\n使用 RK3588 NPU 进行推理加速。\n")
+
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path)
+ assert results[0].kind == "embedded"
+
+
+def test_embedded_detected_from_python_import(tmp_path):
+ proj = tmp_path / "inference"
+ (proj / "src").mkdir(parents=True)
+ _write(proj / "README.md", "# Inference\nsome text\n")
+ _write(proj / "src" / "infer.py", "from rknn.api import RKNNLite\n")
+
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path)
+ assert results[0].kind == "embedded"
+
+
+def test_host_detected_for_pure_python_project(tmp_path):
+ proj = tmp_path / "tools"
+ proj.mkdir()
+ _write(proj / "README.md", "# Tools\nA pure Python utility library.\n")
+ _write(proj / "setup.py", "from setuptools import setup\nsetup(name='tools')\n")
+
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path)
+ assert results[0].kind == "host"
+
+
+# ── device_workspace inference ────────────────────────────────────────────────
+
+def test_device_workspace_inferred_for_embedded(tmp_path):
+ proj = tmp_path / "yolo"
+ proj.mkdir()
+ _write(proj / "README.md", "# YOLO\n使用 rknn 推理框架。\n")
+
+ devices = {"rk3588": DeviceConfig(
+ host="192.168.0.127", user="pi", password="123",
+ workspace="/home/pi/Desktop",
+ )}
+ pd = ProjectDiscovery(devices=devices)
+ results = pd.scan(tmp_path)
+ assert results[0].device_workspace == "/home/pi/Desktop/yolo"
+
+
+def test_device_workspace_none_for_host(tmp_path):
+ proj = tmp_path / "host_tool"
+ proj.mkdir()
+ _write(proj / "README.md", "# Host Tool\npure x86 project.\n")
+
+ devices = {"rk3588": DeviceConfig(
+ host="192.168.0.127", user="pi", password="123",
+ workspace="/home/pi/Desktop",
+ )}
+ pd = ProjectDiscovery(devices=devices)
+ results = pd.scan(tmp_path)
+ assert results[0].device_workspace is None
+
+
+# ── mode inference ────────────────────────────────────────────────────────────
+
+def test_embedded_project_defaults_to_confirm_mode(tmp_path):
+ proj = tmp_path / "demo"
+ proj.mkdir()
+ _write(proj / "README.md", "# Demo\nrknn npu 板端项目。\n")
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path)
+ assert results[0].mode == "confirm"
+
+
+def test_host_project_defaults_to_report_mode(tmp_path):
+ proj = tmp_path / "scripts"
+ proj.mkdir()
+ _write(proj / "setup.py", "# setup\n")
+ pd = ProjectDiscovery()
+ results = pd.scan(tmp_path)
+ assert results[0].mode == "report"
diff --git a/tests/test_project_memory.py b/tests/test_project_memory.py
index 8c73519..cf3fc88 100644
--- a/tests/test_project_memory.py
+++ b/tests/test_project_memory.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import pytest
from pathlib import Path
-from rockchip_agents.tools.project_memory import ProjectMemory, MemoryBackend, WRITE_ROLES
+from nmfs_agents.tools.project_memory import ProjectMemory, MemoryBackend, WRITE_ROLES
def _mem(tmp_path: Path) -> ProjectMemory:
diff --git a/tests/test_queue.py b/tests/test_queue.py
new file mode 100644
index 0000000..07c1a6d
--- /dev/null
+++ b/tests/test_queue.py
@@ -0,0 +1,243 @@
+from __future__ import annotations
+
+import pytest
+from nmfs_agents.core.queue import TaskQueue, Task
+
+
+def test_enqueue_and_dequeue(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ task_id = q.enqueue(Task(
+ project="yolo", type="fix_bug", title="修复 yolo12n 零检出",
+ priority=1, mode="confirm", agent_role="developer",
+ context="zero detection on board"
+ ))
+ assert task_id > 0
+ next_task = q.dequeue()
+ assert next_task is not None
+ assert next_task.project == "yolo"
+ assert next_task.status == "running"
+
+
+def test_priority_ordering(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ q.enqueue(Task(project="mediapipe", type="improve_perf", title="提升 FPS",
+ priority=3, mode="auto", agent_role="developer"))
+ q.enqueue(Task(project="yolo", type="fix_bug", title="修复零检出",
+ priority=1, mode="confirm", agent_role="developer"))
+ first = q.dequeue()
+ assert first.priority == 1 # 低数字 = 高优先级
+
+
+def test_no_duplicate_pending(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ q.enqueue(Task(project="yolo", type="fix_bug", title="修复零检出",
+ priority=1, mode="confirm", agent_role="developer"))
+ # 同一 project+type+title 的任务已存在且 pending/running,不再重复入队
+ task_id = q.enqueue(Task(project="yolo", type="fix_bug", title="修复零检出",
+ priority=1, mode="confirm", agent_role="developer"))
+ assert task_id == -1 # -1 表示跳过
+
+
+def test_mark_done(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "tasks.db")
+ tid = q.enqueue(Task(project="yolo", type="fix_bug", title="修复",
+ priority=1, mode="auto", agent_role="developer"))
+ q.dequeue()
+ q.mark_done(tid, summary="修复成功")
+ assert q.dequeue() is None # 队列已空
+
+
+def test_task_new_fields_exist():
+ """Task 应有 retry_count/parent_task_id/constraint_type/awaiting_role 字段"""
+ t = Task(project="p", type="fix_bug", title="t",
+ priority=1, mode="auto", agent_role="developer")
+ assert t.retry_count == 0
+ assert t.parent_task_id == 0
+ assert t.constraint_type == ""
+ assert t.awaiting_role == ""
+
+def test_enqueue_persists_new_fields(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="t2",
+ priority=1, mode="auto", agent_role="developer",
+ retry_count=1, parent_task_id=10, constraint_type="veto_pending"))
+ with q._conn() as c:
+ row = c.execute("SELECT * FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["retry_count"] == 1
+ assert row["parent_task_id"] == 10
+ assert row["constraint_type"] == "veto_pending"
+
+def test_mark_waiting_approval(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="t3",
+ priority=1, mode="auto", agent_role="developer"))
+ q.mark_waiting_approval(tid, awaiting_role="arch-security")
+ with q._conn() as c:
+ row = c.execute("SELECT status,awaiting_role FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["status"] == "waiting_approval"
+ assert row["awaiting_role"] == "arch-security"
+
+def test_mark_vetoed_and_requeue(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="t4",
+ priority=1, mode="auto", agent_role="developer"))
+ # 先 dequeue(变 running),再 mark_vetoed
+ task = q.dequeue()
+ q.mark_vetoed(tid, reason="安全隐患")
+ # 重入队后应能再次 dequeue
+ task2 = q.dequeue()
+ assert task2 is not None
+ assert task2.retry_count == 1
+ assert "安全隐患" in task2.context
+
+def test_dequeue_skips_waiting_approval(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid1 = q.enqueue(Task(project="p", type="fix_bug", title="t5",
+ priority=1, mode="auto", agent_role="developer"))
+ q.mark_waiting_approval(tid1, awaiting_role="pm")
+ tid2 = q.enqueue(Task(project="p", type="fix_bug", title="t6",
+ priority=2, mode="auto", agent_role="developer"))
+ task = q.dequeue()
+ assert task.id == tid2 # t5 在 waiting_approval,跳过
+
+
+def test_mark_adversarial_pending(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="提案",
+ priority=1, mode="auto", agent_role="arch-system"))
+ q.mark_adversarial_pending(tid)
+ with q._conn() as c:
+ row = c.execute("SELECT status,constraint_type FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["status"] == "adversarial_pending"
+ assert row["constraint_type"] == "adversarial"
+
+def test_approve_waiting(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="等待审批",
+ priority=1, mode="auto", agent_role="developer"))
+ q.mark_waiting_approval(tid, awaiting_role="arch-security")
+ q.approve_waiting(tid)
+ with q._conn() as c:
+ row = c.execute("SELECT status,awaiting_role FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["status"] == "done"
+ assert row["awaiting_role"] == ""
+
+def test_get_waiting_approval_tasks(tmp_path):
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid1 = q.enqueue(Task(project="p", type="fix_bug", title="w1",
+ priority=1, mode="auto", agent_role="developer"))
+ tid2 = q.enqueue(Task(project="p", type="fix_bug", title="w2",
+ priority=2, mode="auto", agent_role="developer"))
+ q.mark_waiting_approval(tid1, awaiting_role="pm")
+ # tid2 仍为 pending,不在 waiting_approval 列表
+ tasks = q.get_waiting_approval_tasks()
+ assert len(tasks) == 1
+ assert tasks[0].id == tid1
+ assert tasks[0].awaiting_role == "pm"
+
+
+def test_reset_stale_running_all(tmp_path):
+ """timeout_minutes=0 重置所有 running 任务"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid1 = q.enqueue(Task(project="p", type="fix_bug", title="r1",
+ priority=1, mode="auto", agent_role="developer"))
+ tid2 = q.enqueue(Task(project="p", type="fix_bug", title="r2",
+ priority=2, mode="auto", agent_role="developer"))
+ q.dequeue()
+ q.dequeue()
+ count = q.reset_stale_running(timeout_minutes=0)
+ assert count == 2
+ with q._conn() as c:
+ rows = c.execute("SELECT status FROM tasks WHERE id IN (?,?)", (tid1, tid2)).fetchall()
+ assert all(r["status"] == "failed" for r in rows)
+
+
+def test_reset_stale_running_timeout(tmp_path):
+ """running 时间未超时的任务不被重置"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="r3",
+ priority=1, mode="auto", agent_role="developer"))
+ q.dequeue()
+ # 超时阈值设为 9999 分钟,刚启动的任务不应被重置
+ count = q.reset_stale_running(timeout_minutes=9999)
+ assert count == 0
+ with q._conn() as c:
+ row = c.execute("SELECT status FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["status"] == "running"
+
+
+def test_bump_stale_priorities(tmp_path):
+ """长期 pending 任务优先级应被提升(数值减 1)"""
+ from datetime import datetime, timedelta
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="aging",
+ priority=3, mode="auto", agent_role="developer"))
+ # 伪造 created_at 为 3 小时前
+ old_time = (datetime.utcnow() - timedelta(hours=3)).isoformat()
+ with q._conn() as c:
+ c.execute("UPDATE tasks SET created_at=? WHERE id=?", (old_time, tid))
+ count = q.bump_stale_priorities(threshold_minutes=120)
+ assert count == 1
+ with q._conn() as c:
+ row = c.execute("SELECT priority FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["priority"] == 2 # 3 → 2
+
+
+def test_bump_stale_priorities_no_below_one(tmp_path):
+ """优先级为 1 的任务不继续提升"""
+ from datetime import datetime, timedelta
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="max_pri",
+ priority=1, mode="auto", agent_role="developer"))
+ old_time = (datetime.utcnow() - timedelta(hours=3)).isoformat()
+ with q._conn() as c:
+ c.execute("UPDATE tasks SET created_at=? WHERE id=?", (old_time, tid))
+ count = q.bump_stale_priorities(threshold_minutes=120)
+ assert count == 0 # priority=1 不能再提升
+
+
+def test_mark_vetoed_exceeds_limit(tmp_path):
+ """连续 3 次否决后任务状态变为 failed(超出最大重试次数)"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(Task(project="p", type="fix_bug", title="超限测试",
+ priority=1, mode="auto", agent_role="developer"))
+ # 第 1 次否决
+ q.dequeue() # 变为 running
+ q.mark_vetoed(tid, reason="第1次")
+ # 第 2 次否决
+ q.dequeue() # 再次变为 running
+ q.mark_vetoed(tid, reason="第2次")
+ # 第 3 次否决 → 应变为 failed
+ q.dequeue()
+ q.mark_vetoed(tid, reason="第3次超限")
+ with q._conn() as c:
+ row = c.execute("SELECT status,result_summary FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["status"] == "failed"
+ assert "超限" in row["result_summary"]
+
+
+def test_dequeue_with_role_filter(tmp_path):
+ """dequeue 支持 role_filter,只取特定角色的任务"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ q.enqueue(Task(project="a", type="fix", title="base task",
+ context="", priority=3, mode="auto", agent_role="os-engineer"))
+ q.enqueue(Task(project="b", type="fix", title="project task",
+ context="", priority=3, mode="auto", agent_role="developer"))
+
+ base_roles = frozenset({"os-engineer", "algo-researcher"})
+ t = q.dequeue(role_filter=base_roles)
+ assert t is not None
+ assert t.agent_role == "os-engineer"
+
+ t2 = q.dequeue(role_filter=base_roles)
+ assert t2 is None # developer 任务不在 base_roles 中
+
+
+def test_dequeue_role_filter_none_returns_any(tmp_path):
+ """role_filter=None 时行为与原来相同(取所有角色)"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ q.enqueue(Task(project="a", type="fix", title="t1",
+ context="", priority=3, mode="auto", agent_role="developer"))
+ t = q.dequeue(role_filter=None)
+ assert t is not None
+ assert t.agent_role == "developer"
diff --git a/tests/test_queue_topic.py b/tests/test_queue_topic.py
new file mode 100644
index 0000000..8df8e61
--- /dev/null
+++ b/tests/test_queue_topic.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+import pytest
+from nmfs_agents.core.queue import TaskQueue, Task
+
+
+def _q(tmp_path):
+ return TaskQueue(db_path=tmp_path / "test.db")
+
+
+def _topic_task() -> Task:
+ return Task(
+ project="research", type="topic", title="[选题] 工业质检边端视觉",
+ priority=3, mode="report", agent_role="planner",
+ context="价值分析:YOLO精度满足工业质检,市场空白明显",
+ initiator="planner",
+ )
+
+
+def test_enqueue_topic_pending(tmp_path):
+ q = _q(tmp_path)
+ task = _topic_task()
+ tid = q.enqueue_topic(task)
+ assert tid > 0
+
+
+def test_topic_pending_not_dequeued(tmp_path):
+ q = _q(tmp_path)
+ q.enqueue_topic(_topic_task())
+ task = q.dequeue()
+ assert task is None # topic_pending 不应被 dequeue
+
+
+def test_approve_topic_makes_pending(tmp_path):
+ q = _q(tmp_path)
+ tid = q.enqueue_topic(_topic_task())
+ q.approve_topic(tid)
+ task = q.dequeue()
+ assert task is not None
+ assert task.title == "[选题] 工业质检边端视觉"
+
+
+def test_reject_topic(tmp_path):
+ q = _q(tmp_path)
+ tid = q.enqueue_topic(_topic_task())
+ q.reject_topic(tid, reason="方向不符合当前资源")
+ conn = q._conn()
+ row = conn.execute("SELECT status, result_summary FROM tasks WHERE id=?", (tid,)).fetchone()
+ conn.close()
+ assert row["status"] == "vetoed"
+ assert "方向不符合" in row["result_summary"]
+
+
+def test_get_topic_pending_tasks(tmp_path):
+ q = _q(tmp_path)
+ q.enqueue_topic(_topic_task())
+ topics = q.get_topic_pending_tasks()
+ assert len(topics) == 1
+ assert topics[0].status == "topic_pending"
+
+
+def test_execution_host_field(tmp_path):
+ q = _q(tmp_path)
+ task = Task(project="yolo", type="fix_bug", title="边端测试",
+ priority=2, mode="auto", agent_role="base-validator",
+ execution_host="edge-api")
+ tid = q.enqueue(task)
+ conn = q._conn()
+ row = conn.execute("SELECT execution_host FROM tasks WHERE id=?", (tid,)).fetchone()
+ conn.close()
+ assert row["execution_host"] == "edge-api"
diff --git a/tests/test_research_agents.py b/tests/test_research_agents.py
index 9b94b41..15a1c18 100644
--- a/tests/test_research_agents.py
+++ b/tests/test_research_agents.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
-from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+from nmfs_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
def _make_config():
@@ -15,15 +15,15 @@ def _make_config():
def test_rtp_researcher_system_contains_keywords():
- from rockchip_agents.agents.rtp_researcher import RTP_RESEARCHER_SYSTEM
+ from nmfs_agents.agents.rtp_researcher import RTP_RESEARCHER_SYSTEM
for kw in ["RTP", "实时", "延迟", "RK3588"]:
assert kw in RTP_RESEARCHER_SYSTEM, f"缺少关键词: {kw}"
def test_rtp_researcher_extract_research_facts(tmp_path):
"""解析 [调研报告] 标签写入 __research__.rtp.* facts。"""
- from rockchip_agents.agents.rtp_researcher import RtpResearcherAgent
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.agents.rtp_researcher import RtpResearcherAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
mem = ProjectMemory(db_path=tmp_path / "m.db")
agent = RtpResearcherAgent(_make_config(), memory=mem)
report = "[调研报告] __research__.rtp.summary=RTP延迟瓶颈在内核调度\n[调研报告] __research__.rtp.bottlenecks=中断亲和性未设置"
@@ -34,18 +34,18 @@ def test_rtp_researcher_extract_research_facts(tmp_path):
def test_net_researcher_system_contains_keywords():
- from rockchip_agents.agents.net_researcher import NET_RESEARCHER_SYSTEM
+ from nmfs_agents.agents.net_researcher import NET_RESEARCHER_SYSTEM
for kw in ["TCP", "网络", "RK3588", "优化"]:
assert kw in NET_RESEARCHER_SYSTEM, f"缺少关键词: {kw}"
def test_net_researcher_extract_research_facts(tmp_path):
- from rockchip_agents.agents.net_researcher import NetResearcherAgent
- from rockchip_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.agents.net_researcher import NetResearcherAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
mem = ProjectMemory(db_path=tmp_path / "m.db")
def _make_config():
- from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+ from nmfs_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
return AgentsConfig(
projects={}, scheduler=SchedulerConfig(),
claude=ClaudeConfig(api_key="fake"), feishu=FeishuConfig(), devices={},
@@ -59,15 +59,15 @@ def test_net_researcher_extract_research_facts(tmp_path):
def test_kernel_analyzer_system_contains_keywords():
- from rockchip_agents.agents.kernel_analyzer import KERNEL_ANALYZER_SYSTEM
+ from nmfs_agents.agents.kernel_analyzer import KERNEL_ANALYZER_SYSTEM
for kw in ["内核", "调度", "RK3588", "NPU"]:
assert kw in KERNEL_ANALYZER_SYSTEM, f"缺少关键词: {kw}"
def test_kernel_analyzer_extract_research_facts(tmp_path):
- from rockchip_agents.agents.kernel_analyzer import KernelAnalyzerAgent
- from rockchip_agents.tools.project_memory import ProjectMemory
- from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+ from nmfs_agents.agents.kernel_analyzer import KernelAnalyzerAgent
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
def _make_config():
return AgentsConfig(
@@ -85,11 +85,11 @@ def test_kernel_analyzer_extract_research_facts(tmp_path):
def test_executor_routes_rtp_researcher(tmp_path):
"""executor._make_agent 应将 rtp-researcher 路由到 RtpResearcherAgent。"""
- from rockchip_agents.core.executor import _make_agent
- from rockchip_agents.core.queue import Task
- from rockchip_agents.tools.project_memory import ProjectMemory
- from rockchip_agents.agents.rtp_researcher import RtpResearcherAgent
- from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+ from nmfs_agents.core.executor import _make_agent
+ from nmfs_agents.core.queue import Task
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.agents.rtp_researcher import RtpResearcherAgent
+ from nmfs_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
cfg = AgentsConfig(projects={}, scheduler=SchedulerConfig(),
claude=ClaudeConfig(api_key="fake"), feishu=FeishuConfig(), devices={})
@@ -100,11 +100,11 @@ def test_executor_routes_rtp_researcher(tmp_path):
def test_executor_routes_net_researcher(tmp_path):
- from rockchip_agents.core.executor import _make_agent
- from rockchip_agents.core.queue import Task
- from rockchip_agents.tools.project_memory import ProjectMemory
- from rockchip_agents.agents.net_researcher import NetResearcherAgent
- from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+ from nmfs_agents.core.executor import _make_agent
+ from nmfs_agents.core.queue import Task
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.agents.net_researcher import NetResearcherAgent
+ from nmfs_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
cfg = AgentsConfig(projects={}, scheduler=SchedulerConfig(),
claude=ClaudeConfig(api_key="fake"), feishu=FeishuConfig(), devices={})
@@ -115,11 +115,11 @@ def test_executor_routes_net_researcher(tmp_path):
def test_executor_routes_kernel_analyzer(tmp_path):
- from rockchip_agents.core.executor import _make_agent
- from rockchip_agents.core.queue import Task
- from rockchip_agents.tools.project_memory import ProjectMemory
- from rockchip_agents.agents.kernel_analyzer import KernelAnalyzerAgent
- from rockchip_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
+ from nmfs_agents.core.executor import _make_agent
+ from nmfs_agents.core.queue import Task
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ from nmfs_agents.agents.kernel_analyzer import KernelAnalyzerAgent
+ from nmfs_agents.config import AgentsConfig, ClaudeConfig, SchedulerConfig, FeishuConfig
cfg = AgentsConfig(projects={}, scheduler=SchedulerConfig(),
claude=ClaudeConfig(api_key="fake"), feishu=FeishuConfig(), devices={})
diff --git a/tests/test_researcher_dispatch.py b/tests/test_researcher_dispatch.py
new file mode 100644
index 0000000..ace1864
--- /dev/null
+++ b/tests/test_researcher_dispatch.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from nmfs_agents.agents.developer import AgentResult
+from nmfs_agents.agents.rtp_researcher import RtpResearcherAgent
+from nmfs_agents.agents.net_researcher import NetResearcherAgent
+from nmfs_agents.agents.kernel_analyzer import KernelAnalyzerAgent
+from nmfs_agents.agents.system_tester import SystemTesterAgent
+from nmfs_agents.config import AgentsConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+
+def _cfg():
+ return AgentsConfig(
+ projects={},
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+
+
+def _task(role: str, project: str = "os-base") -> Task:
+ return Task(project=project, type="research", title="调研任务",
+ priority=2, mode="auto", agent_role=role, id=10)
+
+
+# ── RtpResearcherAgent ───────────────────────────────────────────────────────
+
+def test_rtp_researcher_dispatches_optimize_tasks(tmp_path):
+ """[优化任务] 标签应派发后续任务给 os-engineer/dev-kernel。"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = RtpResearcherAgent(_cfg(), queue=q, memory=mem)
+ task = _task("rtp-researcher")
+
+ report = (
+ "[调研报告] __research__.rtp.summary=端到端延迟 80ms\n"
+ "[优化任务] os-engineer: 调整 SO_PRIORITY 和 TX queue 长度\n"
+ "[优化任务] dev-kernel: 优化 MPP 编码器与 RTP 封装同步\n"
+ )
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ result = agent.run(task)
+
+ assert result.status == "done"
+ with q._conn() as c:
+ rows = c.execute("SELECT agent_role, title FROM tasks ORDER BY id").fetchall()
+ roles = [r["agent_role"] for r in rows]
+ assert "os-engineer" in roles
+ assert "dev-kernel" in roles
+
+
+def test_rtp_researcher_extracts_research_facts(tmp_path):
+ """[调研报告] 标签应写入 ProjectMemory __research__ facts。"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = RtpResearcherAgent(_cfg(), queue=q, memory=mem)
+ task = _task("rtp-researcher")
+
+ report = "[调研报告] __research__.rtp.summary=延迟 80ms 可接受\n"
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ agent.run(task)
+
+ facts = mem.get_facts("__research__")
+ assert "rtp.summary" in facts
+ assert "80ms" in facts["rtp.summary"]
+
+
+def test_rtp_researcher_no_dispatch_on_failure(tmp_path):
+ """执行失败时不应派发优化任务。"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = RtpResearcherAgent(_cfg(), queue=q)
+ task = _task("rtp-researcher")
+
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="failed", summary="[优化任务] os-engineer: 不应派发")):
+ agent.run(task)
+
+ with q._conn() as c:
+ count = c.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
+ assert count == 0
+
+
+# ── NetResearcherAgent ───────────────────────────────────────────────────────
+
+def test_net_researcher_dispatches_optimize_tasks(tmp_path):
+ """NetResearcherAgent 应解析 [优化任务] 并派发。"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = NetResearcherAgent(_cfg(), queue=q, memory=mem)
+ task = _task("net-researcher")
+
+ report = "[优化任务] os-engineer: 启用 BBR 拥塞控制\n"
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ agent.run(task)
+
+ with q._conn() as c:
+ count = c.execute("SELECT COUNT(*) FROM tasks WHERE agent_role='os-engineer'").fetchone()[0]
+ assert count == 1
+
+
+# ── KernelAnalyzerAgent ──────────────────────────────────────────────────────
+
+def test_kernel_analyzer_dispatches_to_dev_kernel(tmp_path):
+ """KernelAnalyzerAgent 应将内核优化任务派发给 dev-kernel。"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = KernelAnalyzerAgent(_cfg(), queue=q, memory=mem)
+ task = _task("kernel-analyzer")
+
+ report = "[优化任务] dev-kernel: 启用 isolcpus=4-7 隔离 AI 推理核\n"
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ agent.run(task)
+
+ with q._conn() as c:
+ row = c.execute("SELECT agent_role, parent_task_id FROM tasks").fetchone()
+ assert row["agent_role"] == "dev-kernel"
+ assert row["parent_task_id"] == task.id
+
+
+# ── SystemTesterAgent ────────────────────────────────────────────────────────
+
+def test_system_tester_dispatches_fix_on_require_tag(tmp_path):
+ """[需求] 标签应派发 developer 修复任务。"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = SystemTesterAgent(_cfg(), queue=q, memory=mem)
+ task = _task("system-tester", project="yolo")
+
+ report = "[需求] yolo: 推理管道在空帧时崩溃,需修复 NoneType 访问\n"
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="done", summary=report)):
+ result = agent.run(task)
+
+ assert result.status == "done"
+ with q._conn() as c:
+ row = c.execute("SELECT * FROM tasks").fetchone()
+ assert row["agent_role"] == "developer"
+ assert row["project"] == "yolo"
+ assert row["initiator"] == "system-tester"
+ assert row["parent_task_id"] == task.id
+
+
+def test_system_tester_no_dispatch_without_require_tag(tmp_path):
+ """无 [需求] 标签时不派发任何任务。"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = SystemTesterAgent(_cfg(), queue=q)
+ task = _task("system-tester", project="yolo")
+
+ with patch("nmfs_agents.agents.developer._run_with_log",
+ return_value=AgentResult(status="done", summary="所有测试通过,无问题")):
+ agent.run(task)
+
+ with q._conn() as c:
+ count = c.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
+ assert count == 0
diff --git a/tests/test_scanner.py b/tests/test_scanner.py
new file mode 100644
index 0000000..8c0b9b2
--- /dev/null
+++ b/tests/test_scanner.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+import pytest
+from pathlib import Path
+from nmfs_agents.core.scanner import Scanner, ScanResult
+from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+
+
+def _make_config(project_path: Path, mode: str = "auto") -> AgentsConfig:
+ return AgentsConfig(
+ projects={"test_proj": ProjectConfig(path=project_path, mode=mode)},
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+
+
+def test_scanner_detects_known_issues_from_readme(tmp_path):
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ (proj / "README.md").write_text(
+ "## 已知问题\n- yolo12n: 零检出\n- Pose FPS 低于 80\n"
+ )
+ cfg = _make_config(proj)
+ scanner = Scanner(cfg)
+ results = scanner.scan_project("test_proj", cfg.projects["test_proj"])
+ assert len(results) >= 1
+ assert any("yolo12n" in r.title or "零检出" in r.title or "Pose" in r.title for r in results)
+
+
+def test_scanner_detects_todo_in_code(tmp_path):
+ proj = tmp_path / "proj"
+ (proj / "src").mkdir(parents=True)
+ (proj / "src" / "main.py").write_text(
+ "def foo():\n # TODO: fix edge case\n pass\n"
+ )
+ cfg = _make_config(proj)
+ scanner = Scanner(cfg)
+ results = scanner.scan_project("test_proj", cfg.projects["test_proj"])
+ assert any("TODO" in r.title or "todo" in r.title.lower() for r in results)
+
+
+def test_scanner_uses_project_mode(tmp_path):
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ (proj / "README.md").write_text("## 已知问题\n- 问题 A\n")
+ cfg = _make_config(proj, mode="confirm")
+ scanner = Scanner(cfg)
+ results = scanner.scan_project("test_proj", cfg.projects["test_proj"])
+ assert all(r.mode == "confirm" for r in results)
+
+
+def _make_config_with_desc(project_path: Path, description: str) -> AgentsConfig:
+ return AgentsConfig(
+ projects={"test_proj": ProjectConfig(path=project_path, mode="report",
+ description=description)},
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(),
+ feishu=FeishuConfig(),
+ devices={},
+ )
+
+
+def test_scan_description_bug_keyword(tmp_path):
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ cfg = _make_config_with_desc(proj, "RKNN YOLO 目标检测,yolo12n 板端零检出待修复")
+ scanner = Scanner(cfg)
+ results = scanner._scan_description("test_proj", cfg.projects["test_proj"])
+ assert len(results) == 1
+ assert results[0].type == "fix_bug"
+ assert results[0].priority == 2
+ assert "描述" in results[0].title
+
+
+def test_scan_description_perf_keyword(tmp_path):
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ cfg = _make_config_with_desc(proj, "MediaPipe 关键点检测,Pose FPS偏低")
+ scanner = Scanner(cfg)
+ results = scanner._scan_description("test_proj", cfg.projects["test_proj"])
+ assert len(results) == 1
+ assert results[0].type == "improve_perf"
+ assert results[0].priority == 3
+
+
+def test_scan_description_no_keyword(tmp_path):
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ cfg = _make_config_with_desc(proj, "向量数据库检索功能")
+ scanner = Scanner(cfg)
+ results = scanner._scan_description("test_proj", cfg.projects["test_proj"])
+ assert results == []
+
+
+def test_scan_description_empty(tmp_path):
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ cfg = _make_config_with_desc(proj, "")
+ scanner = Scanner(cfg)
+ results = scanner._scan_description("test_proj", cfg.projects["test_proj"])
+ assert results == []
+
+
+def test_scan_periodic_always_returns_one(tmp_path):
+ proj = tmp_path / "proj"
+ proj.mkdir()
+ cfg = _make_config(proj)
+ scanner = Scanner(cfg)
+ results = scanner._scan_periodic("test_proj", cfg.projects["test_proj"])
+ assert len(results) == 1
+ assert results[0].type == "code_review"
+ assert results[0].priority == 5
+ assert "test_proj" in results[0].context
+
+
+def test_scanner_routes_kernel_role(tmp_path):
+ """含 'kernel' 关键词的任务路由到 dev-kernel"""
+ from nmfs_agents.core.scanner import Scanner
+ from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+ proj_path = tmp_path / "proj"
+ proj_path.mkdir()
+ (proj_path / "README.md").write_text(
+ "## 已知问题\n- USB kernel module 崩溃导致系统重启\n"
+ )
+ cfg = AgentsConfig(
+ projects={"proj": ProjectConfig(path=proj_path, mode="auto")},
+ scheduler=SchedulerConfig(), claude=ClaudeConfig(api_key="x"),
+ feishu=FeishuConfig(), devices={},
+ )
+ scanner = Scanner(cfg)
+ results = scanner.scan_all()
+ assert any(r.agent_role == "dev-kernel" for r in results)
+
+
+def test_scanner_routes_hw_engineer_role(tmp_path):
+ """含 '芯片选型' 的任务路由到 hw-engineer"""
+ from nmfs_agents.core.scanner import Scanner
+ from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+ proj_path = tmp_path / "proj"
+ proj_path.mkdir()
+ (proj_path / "README.md").write_text(
+ "## 已知问题\n- 芯片选型方案未定,BOM 成本过高\n"
+ )
+ cfg = AgentsConfig(
+ projects={"proj": ProjectConfig(path=proj_path, mode="auto")},
+ scheduler=SchedulerConfig(), claude=ClaudeConfig(api_key="x"),
+ feishu=FeishuConfig(), devices={},
+ )
+ scanner = Scanner(cfg)
+ results = scanner.scan_all()
+ assert any(r.agent_role == "hw-engineer" for r in results)
+
+
+def test_scanner_new_feature_from_claude_md(tmp_path):
+ """CLAUDE.md 的 ## 规划功能 章节产生 new_feature 任务"""
+ from nmfs_agents.core.scanner import Scanner
+ from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+ proj_path = tmp_path / "proj"
+ proj_path.mkdir()
+ (proj_path / "CLAUDE.md").write_text(
+ "# CLAUDE.md\n\n## 规划功能\n- 添加 WebSocket 实时推流支持\n"
+ )
+ cfg = AgentsConfig(
+ projects={"proj": ProjectConfig(path=proj_path, mode="auto")},
+ scheduler=SchedulerConfig(), claude=ClaudeConfig(api_key="x"),
+ feishu=FeishuConfig(), devices={},
+ )
+ scanner = Scanner(cfg)
+ results = scanner.scan_all()
+ assert any(r.type == "new_feature" for r in results)
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
new file mode 100644
index 0000000..f4483da
--- /dev/null
+++ b/tests/test_scheduler.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import patch, MagicMock
+from nmfs_agents.core.queue import TaskQueue
+
+
+def test_planner_task_enqueue(tmp_path, monkeypatch):
+ """_enqueue_planner_task 创建 agent_role=planner 的任务"""
+ from nmfs_agents.core import scheduler as sched_module
+
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ monkeypatch.setattr(sched_module, "_get_queue", lambda: q)
+
+ sched_module._enqueue_planner_task()
+
+ conn = q._conn()
+ rows = conn.execute(
+ "SELECT agent_role, type, title FROM tasks WHERE agent_role='planner'"
+ ).fetchall()
+ conn.close()
+ assert len(rows) == 1
+ assert rows[0]["type"] == "planner"
+ assert "差距分析" in rows[0]["title"]
+
+
+def test_planner_task_dedup(tmp_path, monkeypatch):
+ """相同标题的 planner 任务不重复入队"""
+ from nmfs_agents.core import scheduler as sched_module
+
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ monkeypatch.setattr(sched_module, "_get_queue", lambda: q)
+
+ sched_module._enqueue_planner_task()
+ sched_module._enqueue_planner_task() # 第二次应被去重
+
+ conn = q._conn()
+ count = conn.execute("SELECT count(*) FROM tasks WHERE agent_role='planner'").fetchone()[0]
+ conn.close()
+ assert count == 1, "planner 任务被重复入队"
+
+
+def test_run_pending_queue_uses_role_filter(tmp_path, monkeypatch):
+ """_run_pending_queue 按队列配置的 roles 过滤任务"""
+ from nmfs_agents.core import scheduler as sched_module
+ from nmfs_agents.core.queue import TaskQueue, Task
+ from nmfs_agents.config import AgentsConfig, QueueConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+
+ # 构造一个 base_opt 队列,只有 os-engineer 角色
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ q.enqueue(Task(project="base", type="fix", title="os task",
+ context="", priority=3, mode="auto", agent_role="os-engineer"))
+ q.enqueue(Task(project="proj", type="fix", title="dev task",
+ context="", priority=3, mode="auto", agent_role="developer"))
+
+ fake_cfg = MagicMock(spec=AgentsConfig)
+ fake_cfg.queues = {
+ "base_opt": QueueConfig(
+ db=str(tmp_path / "t.db"),
+ max_concurrent=1,
+ scan_interval_hours=4,
+ roles=frozenset({"os-engineer"}),
+ )
+ }
+ fake_cfg.scheduler = SchedulerConfig()
+ fake_cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ fake_cfg.feishu = FeishuConfig()
+ fake_cfg.projects = {}
+ fake_cfg.claude_wx_url = ""
+
+ monkeypatch.setattr(sched_module, "load_config", lambda: fake_cfg)
+
+ # 模拟 Executor.run_all_pending 验证 role_filter 参数
+ captured = {}
+ import asyncio
+
+ async def mock_run_all(skip_projects=None, role_filter=None):
+ captured["role_filter"] = role_filter
+ return 0
+
+ with patch.object(sched_module.Executor, "run_all_pending", mock_run_all):
+ sched_module._run_pending_queue("base_opt")
+
+ assert captured.get("role_filter") == frozenset({"os-engineer"})
diff --git a/tests/test_system_tester.py b/tests/test_system_tester.py
new file mode 100644
index 0000000..889ad2a
--- /dev/null
+++ b/tests/test_system_tester.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from nmfs_agents.agents.system_tester import SystemTesterAgent, SYSTEM_TESTER_SYSTEM
+from nmfs_agents.config import AgentsConfig, ClaudeConfig
+from nmfs_agents.core.queue import Task
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="auto", path=MagicMock())}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ cfg.devices = {}
+ return cfg
+
+
+def test_system_tester_system_prompt_covers_all_layers():
+ for kw in ["功能测试", "视觉测试", "性能测试", "跨平台", "测试报告"]:
+ assert kw in SYSTEM_TESTER_SYSTEM, f"缺少关键词: {kw}"
+
+
+def test_system_tester_context_has_playwright_hint(tmp_path):
+ from nmfs_agents.tools.project_memory import ProjectMemory
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ agent = SystemTesterAgent(_make_config(), memory=mem)
+ task = Task(project="yolo", type="test", title="系统测试",
+ context="", priority=2, mode="auto", agent_role="system-tester")
+ ctx = agent._build_context(task)
+ assert "Playwright" in ctx or "playwright" in ctx
diff --git a/tests/test_tester.py b/tests/test_tester.py
new file mode 100644
index 0000000..248a3d1
--- /dev/null
+++ b/tests/test_tester.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import pytest
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+from nmfs_agents.agents.tester import TesterAgent
+from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
+from nmfs_agents.core.queue import Task
+
+
+def _cfg(tmp_path: Path, with_device: bool = False) -> AgentsConfig:
+ devices = {}
+ if with_device:
+ from nmfs_agents.config import DeviceConfig
+ devices = {"rk3588": DeviceConfig(
+ host="192.168.0.127", user="pi", password="123123",
+ workspace="/home/pi/Desktop", pre_cmd=""
+ )}
+ return AgentsConfig(
+ projects={"yolo": ProjectConfig(
+ path=tmp_path / "yolo", mode="auto",
+ device_workspace="/home/pi/Desktop/yolo" if with_device else None,
+ )},
+ scheduler=SchedulerConfig(),
+ claude=ClaudeConfig(api_key="fake"),
+ feishu=FeishuConfig(),
+ devices=devices,
+ )
+
+
+def test_tester_runs_local_test(tmp_path):
+ """本地测试结果应出现在摘要中。"""
+ (tmp_path / "yolo").mkdir()
+ task = Task(project="yolo", type="fix_bug", title="验证修复",
+ priority=1, mode="auto", agent_role="tester", id=1)
+ cfg = _cfg(tmp_path)
+ agent = TesterAgent(cfg)
+ with patch("nmfs_agents.agents.tester.run_command") as mock_cmd:
+ mock_cmd.return_value = "[returncode=0]\n3 passed"
+ result = agent.run(task)
+ assert "本地测试" in result.summary
+ assert result.status == "done"
+
+
+def test_tester_marks_failed_on_test_failure(tmp_path):
+ """本地测试失败时 status 应为 failed。"""
+ (tmp_path / "yolo").mkdir()
+ task = Task(project="yolo", type="fix_bug", title="验证修复",
+ priority=1, mode="auto", agent_role="tester", id=2)
+ cfg = _cfg(tmp_path)
+ agent = TesterAgent(cfg)
+ with patch("nmfs_agents.agents.tester.run_command") as mock_cmd:
+ mock_cmd.return_value = "[returncode=1]\n1 FAILED"
+ result = agent.run(task)
+ assert result.status == "failed"
+
+
+def test_tester_unknown_project_returns_failed(tmp_path):
+ """项目不存在时返回 failed。"""
+ cfg = _cfg(tmp_path)
+ task = Task(project="nonexistent", type="fix_bug", title="test",
+ priority=1, mode="auto", agent_role="tester", id=3)
+ agent = TesterAgent(cfg)
+ result = agent.run(task)
+ assert result.status == "failed"
+ assert "不存在" in result.summary
diff --git a/tests/test_vision_analyst.py b/tests/test_vision_analyst.py
new file mode 100644
index 0000000..22133e3
--- /dev/null
+++ b/tests/test_vision_analyst.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+import pytest
+from unittest.mock import MagicMock
+from nmfs_agents.agents.vision_analyst import VisionAnalystAgent, VISION_ANALYST_SYSTEM
+from nmfs_agents.config import AgentsConfig, ClaudeConfig
+from nmfs_agents.core.queue import Task, TaskQueue
+from nmfs_agents.tools.project_memory import ProjectMemory
+
+
+def _make_config():
+ cfg = MagicMock(spec=AgentsConfig)
+ cfg.projects = {"yolo": MagicMock(description="目标检测", mode="auto", path=MagicMock())}
+ cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
+ return cfg
+
+
+def test_vision_analyst_system_prompt_has_playwright_workflow():
+ for kw in ["Playwright", "截图", "检测框"]:
+ assert kw in VISION_ANALYST_SYSTEM, f"缺少: {kw}"
+
+
+def test_vision_analyst_extract_needs(tmp_path):
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = VisionAnalystAgent(_make_config(), queue=q, memory=mem)
+ report = "视觉分析结果:检测框偏移明显\n- [需求] yolo: 修正检测框坐标偏移\n"
+ agent._extract_needs(report)
+ conn = q._conn()
+ rows = conn.execute("SELECT title FROM tasks").fetchall()
+ conn.close()
+ assert any("检测框坐标偏移" in r["title"] for r in rows)
+
+
+def test_vision_analyst_unknown_project_skipped(tmp_path):
+ """未知项目的 [需求] 不入队"""
+ mem = ProjectMemory(db_path=tmp_path / "m.db")
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ agent = VisionAnalystAgent(_make_config(), queue=q, memory=mem)
+ report = "- [需求] unknown_proj: 某需求\n"
+ agent._extract_needs(report)
+ conn = q._conn()
+ count = conn.execute("SELECT count(*) FROM tasks").fetchone()[0]
+ conn.close()
+ assert count == 0
+
+
+def test_vision_analyst_system_covers_multiframe():
+ from nmfs_agents.agents.vision_analyst import VISION_ANALYST_SYSTEM
+ assert "多帧" in VISION_ANALYST_SYSTEM or "5帧" in VISION_ANALYST_SYSTEM
+ assert "[视觉结果]" in VISION_ANALYST_SYSTEM
+ assert "EdgeValidatorService" in VISION_ANALYST_SYSTEM or "8899" in VISION_ANALYST_SYSTEM
diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py
new file mode 100644
index 0000000..726fa8f
--- /dev/null
+++ b/tests/test_watchdog.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import time
+
+import pytest
+from nmfs_agents.core.queue import TaskQueue, Task
+from nmfs_agents.core.watchdog import Watchdog
+
+
+def _make_task(title: str, priority: int = 3) -> Task:
+ return Task(project="p", type="fix_bug", title=title,
+ priority=priority, mode="auto", agent_role="developer")
+
+
+def test_watchdog_starts_and_stops(tmp_path):
+ """Watchdog 线程可正常启动和停止"""
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ wd = Watchdog(q, interval=1)
+ wd.start()
+ assert wd.is_alive()
+ wd.stop()
+ wd.join(timeout=3)
+ assert not wd.is_alive()
+
+
+def test_watchdog_resets_stale_tasks(tmp_path):
+ """Watchdog tick 将超时任务标记为 failed"""
+ from datetime import datetime, timedelta
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(_make_task("stale_task"))
+ q.dequeue() # 变为 running
+ # 伪造 started_at 为 3 小时前(超过默认 2h 阈值)
+ old_time = (datetime.utcnow() - timedelta(hours=3)).isoformat()
+ with q._conn() as c:
+ c.execute("UPDATE tasks SET started_at=? WHERE id=?", (old_time, tid))
+
+ # 用 1 分钟超时触发(测试中用 1 分钟阈值)
+ wd = Watchdog(q, interval=1, task_timeout_minutes=1)
+ wd.start()
+ time.sleep(2) # 等 watchdog 至少执行一次 tick
+ wd.stop()
+ wd.join(timeout=3)
+
+ with q._conn() as c:
+ row = c.execute("SELECT status FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["status"] == "failed"
+
+
+def test_watchdog_ages_priorities(tmp_path):
+ """Watchdog tick 提升长期 pending 任务优先级"""
+ from datetime import datetime, timedelta
+ q = TaskQueue(db_path=tmp_path / "t.db")
+ tid = q.enqueue(_make_task("old_task", priority=3))
+ # 伪造 created_at 为 3 小时前
+ old_time = (datetime.utcnow() - timedelta(hours=3)).isoformat()
+ with q._conn() as c:
+ c.execute("UPDATE tasks SET created_at=? WHERE id=?", (old_time, tid))
+
+ wd = Watchdog(q, interval=1, task_timeout_minutes=9999)
+ wd.start()
+ time.sleep(2)
+ wd.stop()
+ wd.join(timeout=3)
+
+ with q._conn() as c:
+ row = c.execute("SELECT priority FROM tasks WHERE id=?", (tid,)).fetchone()
+ assert row["priority"] < 3 # 至少提升了一次(3 → 2 或更低,取决于 tick 次数)