Merge remote-tracking branch 'origin/main' into fix/private-board-llm

This commit is contained in:
Chiguyong 2026-07-06 15:58:22 +08:00
commit 1c33859904
17 changed files with 3867 additions and 26 deletions

View File

@ -0,0 +1,502 @@
---
date: 2026-07-06
topic: agent-iq-boost
type: feature
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: ce-brainstorm
execution: code
origin: "竞品对标(2026-07 Qoder 1.11/Cursor 3.2/Devin 3.0);9 缺口(G1-G9)已交付后的新维度探索"
---
## Goal Capsule
**Objective**: 扩展现有 PLAN_EXEC / TEAM_COLLAB / ReflexionEngine 三个子系统,提升 agent 处理复杂任务的智商 — 独立子任务并行执行、plan 确认后自主执行(仅危险操作确认)、失败后 prompt 跨任务自调优。
**Product Authority**: AgentKit 横向评估 14 维度总分 42/569 缺口已交付但竞品在"编排+执行深度"维度持续领先Cursor Subagents 并行、Devin Goal-driven 自主、Qoder 多模型路由)。本 plan 对标竞品补齐这一维度。
**Open Blockers**: None — OQ1-3 已在 Planning Contract KTD6-8 决策。
**Execution Profile**: Standard depth7 个 Implementation Units3 个维度可部分并行(维度 1 与维度 3 独立;维度 2 内部 U1→U2→U3 串行)。
**Stop Conditions**: 7 个 U-ID 全部完成、Verification Contract 通过、DoD 全局条件满足。
---
## Product Contract
### Summary
3 个维度并行推进,不新建 ExecutionMode全部扩展现有基础设施TeamOrchestrator 新增"无依赖子任务并行"模式解决串行排队PLAN_EXEC 的"每阶段确认"改为"危险操作确认"解决要人盯ReflexionEngine 反思结果持久化到 EpisodicMemory 实现跨任务 prompt 自调优。对标 Cursor Agent Mode / Devin Quest Mode / Qoder Goal-driven 的智商能力,保持 AgentKit 现有架构不变。
### Problem Frame
2026-06-29 识别的 9 个智商短板G1-G9已全部交付wave1-4覆盖反馈稳定性、响应效率、执行能力三个维度。但 2026-07 最新竞品动态显示,竞品在"编排+执行深度"维度持续领先:
- **Cursor 3.2 Subagents**2026-04并行专业化 worker多 agent 同时工作不同部分
- **Devin 3.0 Goal-driven**2026-05设定目标后自主执行数小时仅失败时介入
- **Qoder 1.11 Goal-driven + Scheduling**2026-06Goal 设定后工作到完成,可定时启动
AgentKit 当前痛点:
- 5 个独立子任务必须串行排队TeamOrchestrator 同层并行已存在,但"无依赖子任务并行"未实现)
- plan 生成后每阶段需人工确认PLAN_EXEC 阶段确认机制太保守)
- ReflexionEngine 仅在单次任务内反思,同类错误跨任务重复犯(反思结果未持久化)
### Key Decisions
**KTD1: 方案 A — 扩展现有模式,不新建 ExecutionMode**
3 个维度全部扩展现有 PLAN_EXEC / TEAM_COLLAB / ReflexionEngine不新建 GOAL_DRIVEN 模式。理由:复用现有基础设施,路由/前端/配置不动风险低KTD706-29 G6 阶段约束)已验证扩展现有模式可行。
**KTD2: 危险操作保守白名单,不引入 LLM 辅助分类**
自主执行时仅危险操作触发 confirmation_request白名单包括文件删除rm/rmdir、部署操作deploy/kubectl/helm、支付相关、git push --force、数据库迁移alembic/migrate。白名单外的操作自主执行。ponytail: ceiling = 漏判风险(未列入白名单的危险操作),升级路径 = LLM 辅助分类。
**KTD3: 复用 topological_sort 的同层并行,新增"无依赖子任务并行"模式**
TeamOrchestrator 已有 topological_sort 返回执行层Kahn 算法),同层并行已实现。新增"无依赖子任务并行"模式Lead 分解任务时,若识别到多个无 depends_on 的子任务,自动派发给隔离 agent 并行执行,而非串行排队。不新建 SubAgentOrchestrator 模块(方案 C 排除)。
**KTD4: ReflexionEngine 反思结果持久化到 EpisodicMemory**
失败后 ReflexionEngine 生成反思(已有),新增:反思结果写入 EpisodicMemorytask_input + reflection + improved_prompt下次类似任务规划时 Lead 检索历史反思,用 improved_prompt 替换默认 prompt。不引入强化学习/元学习Devin 风格太重),仅做跨任务持久化。
**KTD5: Prompt 自调优仅对特定错误类型触发**
不是每次失败都触发 prompt 调优(避免无意义反思浪费 token。触发条件verify 失败G1 回灌后二次失败)、工具 schema 校验失败G3、循环检测触发U1。调优后的 prompt 带 version 存入 EpisodicMemoryABTester 可对比版本效果(离线验证,不在线 bandit
### Requirements
**维度 1: 并行 Sub-agent 编排**
- R1. TeamOrchestrator 的 Lead 分解任务时,识别无 depends_on 的子任务,自动派发给隔离 ConfigDrivenAgent 并行执行。
- R2. 并行子任务的 SharedWorkspace 输出路径必须不重叠plan 阶段强制约束:`{plan_id}/phase/{phase_id}/output` 已唯一,并行子任务用不同 phase_id
- R3. 并行子任务全部完成后Lead 才进入综合阶段synthesis与现有 topological_sort 的层间串行一致。
- R4. 并行子任务数受 MAX_EXPERTS=10 约束(已有),超出时 Lead 重新分解或串行排队。
- R5. 并行子任务的 expert_step / expert_result 事件携带 expert_id前端已支持多 expert 同时 streaming已验证
**维度 2: Goal-driven 自主执行**
- R6. PLAN_EXEC 模式下plan 确认后进入自主执行,不再每阶段 confirmation_request。
- R7. 自主执行期间仅危险操作KTD2 白名单)触发 confirmation_request其他操作自主执行。
- R8. 危险操作白名单配置化(`agentkit.yaml` 新增 `dangerous_tools` 配置节,遵循 ServerConfig.from_dict 模式)。
- R9. 自主执行期间,用户可随时发送 `cancel` 中断(已有 CancellationToken 机制)。
- R10. 自主执行超时(默认 30 分钟,可配置)或连续失败 3 次时,自动暂停并通知用户。
**维度 3: Prompt 跨任务自调优**
- R11. ReflexionEngine 反思结果写入 EpisodicMemorytask_input + reflection + improved_prompt + version
- R12. PLAN_EXEC / TEAM_COLLAB 的 Lead 规划时,检索 EpisodicMemory 中相似 task_input 的历史反思,用 improved_prompt 替换默认 prompt。
- R13. Prompt 自调优仅对特定错误类型触发KTD5verify 二次失败、工具 schema 校验失败、循环检测触发。
- R14. 调优后的 prompt 带 version 存入ABTester 可对比版本效果(离线验证)。
- R15. EpisodicMemory 中 prompt 反思记录有 TTL默认 30 天),过期自动清理避免噪声。
### Scope Boundaries
**In scope**:
- 扩展 PLAN_EXEC / TEAM_COLLAB / ReflexionEngine
- 危险操作白名单配置
- EpisodicMemory prompt 反思持久化
**Out of scope**:
- 新建 ExecutionMode.GOAL_DRIVEN方案 B 排除)
- 独立可组合模块 SubAgentOrchestrator / DangerGate / OnlinePromptOptimizer方案 C 排除)
- 多模态输入(维度 6不在"编排+执行深度"范围)
- Cloud Agent 远程执行(维度 8不在本次范围
- Repo 深度索引 + Wiki 自动生成(维度 4不在本次范围
- 主动澄清意图(维度 1不在本次范围
- 强化学习 / 元学习 / bandit 探索Devin 风格在线学习太重,仅做 ReflexionEngine 跨任务持久化)
- 项目级 Memory FilesCLAUDE.md/AGENTS.md 风格,维度 2不在本次范围
### Outstanding Questions
All OQ1-3 resolved in Planning Contract (KTD6-KTD8). No outstanding questions remain.
---
## Planning Contract
### Key Technical Decisions (Planning-Time)
**KTD6: 并行子任务 depends_on 完全独立约束(解 OQ1**
并行子任务的 depends_on 必须完全独立 — 不能共享上游依赖。若 Lead 识别到共享上游依赖降级为同层并行topological_sort 已有机制)。此外,关键写入操作用 `SharedWorkspace.lock(key, agent_id, timeout)` 防护(已有 API`core/shared_workspace.py:96`。决策依据phase_id 唯一性已保证输出路径不重叠,但读上游输出时存在 TOCTOU 风险lock 是最小成本防护。
**KTD7: Prompt 版本管理 — 保留所有版本 + score 排序(解 OQ2**
EpisodicMemory 保留所有反思版本key = `prompt_reflection:{task_hash}:{version}`),每条记录携带 `score`verify 通过=1.0,失败=0.0+ `timestamp` + `task_input`。Lead 检索时用 `EpisodicMemory.search(task_input, top_k=3)` 语义搜索,按 score 降序取最高版本。TTL 30 天R15由 EpisodicMemory 现有清理机制处理。ABTester 离线对比版本效果,不在线 bandit。决策依据保留所有版本成本低Redis/PG 存储廉价且支持回溯score 排序避免噪声版本干扰。
**KTD8: autonomy_paused 事件类型(解 OQ3**
新增 `autonomy_paused` WebSocket 事件类型(区别于 `confirmation_request`),前端通过事件类型区分"等待确认"和"自主暂停"。payload 包含 `reason`timeout | consecutive_failures | manual+ `progress`(已完成阶段)+ `resume_token`。用户可发送 `resume` 消息继续执行(带 resume_token。决策依据复用现有 WebSocket 协议,前端只需新增一个事件分支,不破坏现有 confirmation 流程。
### High-Level Technical Design
```mermaid
flowchart TB
User[User Input] --> Router{RequestPreprocessor}
Router -->|@team| TeamOrchestrator
Router -->|plan_exec| PLAN_EXEC[PLAN_EXEC Handler]
Router -->|react| ReActEngine
subgraph "维度 1: 并行 Sub-agent"
TeamOrchestrator --> Lead[Lead Expert]
Lead --> Decompose[_decompose_task]
Decompose --> Plan[TeamPlan]
Plan --> TopoSort[topological_sort]
TopoSort -->|同层并行| ParallelExec[_run_pipeline parallel]
ParallelExec --> Agent1[ConfigDrivenAgent 1]
ParallelExec --> Agent2[ConfigDrivenAgent 2]
Agent1 --> SharedWorkspace[(SharedWorkspace)]
Agent2 --> SharedWorkspace
SharedWorkspace -.->|lock 防护| LockMech[lock/unlock]
end
subgraph "维度 2: Goal-driven 自主"
PLAN_EXEC --> PlanConfirm[Plan Confirmation]
PlanConfirm -->|确认后| AutonomyMode[Autonomy Mode]
AutonomyMode --> ToolExec[Tool Execution]
ToolExec --> DangerCheck{Dangerous?}
DangerCheck -->|是| ConfirmReq[confirmation_request]
DangerCheck -->|否| Continue[Continue]
ConfirmReq --> AutonomyResume[Autonomy Resume]
AutonomyMode -.->|超时/失败| Paused[autonomy_paused]
end
subgraph "维度 3: Prompt 自调优"
ReActEngine -->|失败| ReflexionEngine
ReflexionEngine --> Reflect[_reflect]
Reflect --> EpisodicMem[(EpisodicMemory)]
EpisodicMem -.->|下次任务| LeadRetrieval[Lead 检索历史反思]
LeadRetrieval --> PromptReplace[替换默认 prompt]
end
```
### Assumptions
- SharedWorkspace.lock() 在 Redis 后端下可靠(已有 SETNX + EXPIRE 实现InProcess 后端下用 asyncio.Lock 兜底。
- EpisodicMemory.search() 的语义搜索精度足够检索相似 task_inputpgvector 已有)。
- 危险操作白名单覆盖 95%+ 真实危险场景;漏判由 KTD2 ponytail ceiling 标注。
- 前端 WebSocket 已支持自定义事件类型(已验证:`team_synthesis_chunk` 等自定义事件已工作)。
### Sequencing
3 个维度可部分并行:
- **维度 1U4****维度 3U5-U7** 完全独立,可并行开发。
- **维度 2U1→U2→U3** 内部串行:配置 → 自主模式 → 超时暂停。
- **跨维度依赖**:无。维度 1 的并行子任务和维度 2 的自主执行是正交的。
---
## Implementation Units
### U1. 危险操作白名单配置
**Goal**: 在 `agentkit.yaml` 新增 `dangerous_tools` 配置节,遵循 ServerConfig.from_dict 模式,支持工具名正则匹配。
**Requirements**: R8
**Dependencies**: None
**Files**:
- `src/agentkit/server/config.py` — 新增 `DangerousToolsConfig` 类,挂载到 `ServerConfig`
- `configs/agentkit.yaml.example` — 新增 `dangerous_tools` 配置示例
- `tests/unit/test_server_config.py` — 新增配置解析测试
**Approach**:
- 新增 `DangerousToolsConfig` 类(继承 Pydantic BaseModel字段`tool_patterns: list[str]`(正则列表)+ `enabled: bool`(默认 true
- 默认白名单:`rm`, `rmdir`, `deploy`, `kubectl`, `helm`, `git_push_force`, `alembic`, `migrate`, `payment_*`
- `ServerConfig.from_dict` 解析 `dangerous_tools` 段,缺失时用默认白名单
- 提供 `is_dangerous(tool_name: str) -> bool` 方法,用 `re.match` 检查
**Patterns to follow**: `MCPServerConfig` (`server/config.py:23`) 的 from_dict 模式;`CacheConfig` 的嵌套配置模式。
**Test scenarios**:
- 配置解析:`dangerous_tools` 段存在时正确解析为 `DangerousToolsConfig`
- 默认值:`dangerous_tools` 段缺失时使用默认白名单
- 工具匹配:`is_dangerous("rm")` 返回 True`is_dangerous("read_file")` 返回 False
- 正则匹配:`is_dangerous("payment_charge")` 匹配 `payment_*` 返回 True
- enabled=false 时:`is_dangerous` 始终返回 False禁用白名单
**Verification**: `python3 -m pytest tests/unit/test_server_config.py -x -q` 通过;`ruff check src/agentkit/server/config.py` 无 lint 错误。
### U2. PLAN_EXEC 自主执行模式 + 危险操作确认
**Goal**: PLAN_EXEC 模式下plan 确认后进入自主执行,仅危险操作触发 confirmation_request其他操作自主执行。
**Requirements**: R6, R7, R9
**Dependencies**: U1
**Files**:
- `src/agentkit/core/react.py` — 修改 `confirmation_request` 触发逻辑(行 1255, 2199增加危险操作检查
- `src/agentkit/server/routes/chat.py` — PLAN_EXEC 路由处理,注入 `DangerousToolsConfig`
- `tests/unit/test_react_autonomy.py` — 新增自主执行测试
- `tests/unit/test_plan_exec_autonomy.py` — 新增 PLAN_EXEC 集成测试
**Approach**:
- ReActEngine 构造时接收 `dangerous_tools_config: DangerousToolsConfig | None`
- 工具执行前检查:若 `dangerous_tools_config.is_dangerous(tool_name)` 且在 PLAN_EXEC 自主模式 → 触发 `confirmation_request`(已有事件类型)
- 非危险操作 → 直接执行,不触发 confirmation
- PLAN_EXEC 的 plan 确认后,设置 `autonomy_mode: true` 标志(区别于现有"每阶段确认"模式)
- `cancel` 消息已有 CancellationToken 机制(`core/protocol.py`),无需修改
**Patterns to follow**: 现有 `confirmation_request` 事件触发模式(`react.py:1255``phase_violation` 检测模式(`react.py:272`)。
**Test scenarios**:
- 危险操作触发 confirmation自主模式下执行 `rm` 触发 `confirmation_request` 事件
- 非危险操作自主执行:自主模式下执行 `read_file` 不触发 confirmation
- 非自主模式行为不变:非 PLAN_EXEC 模式下confirmation 行为与现有逻辑一致
- cancel 中断:自主执行中发送 `cancel` 中断任务CancellationToken 正确取消
- 危险操作白名单禁用:`enabled=false` 时所有操作自主执行
**Verification**: `python3 -m pytest tests/unit/test_react_autonomy.py tests/unit/test_plan_exec_autonomy.py -x -q` 通过;现有 PLAN_EXEC 测试不回归。
### U3. 自主执行超时 + autonomy_paused 事件
**Goal**: 自主执行超时(默认 30 分钟)或连续失败 3 次时,自动暂停并发送 `autonomy_paused` 事件,用户可 `resume` 继续。
**Requirements**: R10
**Dependencies**: U2
**Files**:
- `src/agentkit/core/react.py` — 新增 `autonomy_paused` 事件类型 + 超时/失败计数逻辑
- `src/agentkit/server/routes/chat.py` — 处理 `autonomy_paused` 事件 + `resume` 消息
- `src/agentkit/server/config.py` — 新增 `autonomy_timeout_minutes: int = 30` + `max_consecutive_failures: int = 3` 配置
- `tests/unit/test_autonomy_paused.py` — 新增暂停/恢复测试
**Approach**:
- ReActEngine 新增 `autonomy_started_at: float` + `consecutive_failures: int` 状态
- 每次工具执行前检查:`time.time() - autonomy_started_at > timeout` 或 `consecutive_failures >= 3` → 触发 `autonomy_paused`
- `autonomy_paused` 事件 payload`{reason: "timeout"|"consecutive_failures"|"manual", progress: {...}, resume_token: "..."}`
- WebSocket 路由处理 `resume` 消息:重置 `autonomy_started_at` + `consecutive_failures`,继续执行
- 前端区分:`confirmation_request` = 等待单次确认;`autonomy_paused` = 整体自主执行暂停
**Patterns to follow**: 现有 `phase_changed` 事件模式(`chat.py:1694``confirmation_result` 处理模式。
**Test scenarios**:
- 超时触发暂停:自主执行超过 30 分钟触发 `autonomy_paused`reason="timeout"
- 连续失败触发暂停:连续 3 次工具失败触发 `autonomy_paused`reason="consecutive_failures"
- resume 恢复执行:发送 `resume` 消息后,自主执行继续,计数器重置
- 超时配置化:`autonomy_timeout_minutes=60` 时30 分钟不触发暂停
- 非自主模式不触发:非 PLAN_EXEC 模式下,超时/失败不触发 `autonomy_paused`
**Verification**: `python3 -m pytest tests/unit/test_autonomy_paused.py -x -q` 通过;`autonomy_paused` 事件 payload 符合契约。
### U4. TeamOrchestrator 无依赖子任务并行模式
**Goal**: TeamOrchestrator 的 Lead 分解任务时,识别无 depends_on 的子任务,自动派发给隔离 ConfigDrivenAgent 并行执行(而非串行排队)。
**Requirements**: R1, R2, R3, R4, R5
**Dependencies**: None与 U1-U3 正交)
**Files**:
- `src/agentkit/experts/orchestrator.py` — 修改 `_run_pipeline`(行 234支持并行子任务派发
- `src/agentkit/experts/plan.py``TeamPlan` 新增 `get_independent_subtasks()` 方法
- `tests/unit/test_team_parallel.py` — 新增并行子任务测试
**Approach**:
- `TeamPlan.get_independent_subtasks()`: 返回 `depends_on == []` 的 PlanPhase 列表(已有 `depends_on` 字段,`plan.py:176`
- `_run_pipeline` 修改:
1. 调用 `topological_sort()` 得到执行层(已有)
2. 同层内的 phases 已并行执行(已有)— 无需修改
3. **新增**Lead 分解时,若识别到多个无 depends_on 的子任务,显式派发到同层(而非分散到不同层)
- KTD6 约束若子任务共享上游依赖降级为同层并行topological_sort 已处理)
- SharedWorkspace 写入防护:关键写入用 `SharedWorkspace.lock()`(已有,`shared_workspace.py:96`
- MAX_EXPERTS=10 约束已有(`experts/router.py`),超出时 Lead 重新分解
**Patterns to follow**: 现有 `_run_pipeline` 的同层并行模式(`orchestrator.py:234``_get_isolated_agent` 的 agent 创建模式(`orchestrator.py:113`)。
**Test scenarios**:
- 无依赖子任务并行3 个无 depends_on 的子任务,同时派发到 3 个隔离 agent
- 共享依赖降级2 个子任务共享上游依赖,降级为同层并行(不并行派发)
- MAX_EXPERTS 约束11 个无依赖子任务时Lead 重新分解为 10 个以内
- SharedWorkspace 路径不重叠:并行子任务的输出路径 `{plan_id}/phase/{phase_id}/output` 唯一
- expert_step 事件携带 expert_id并行执行时事件正确区分不同 expert
- 综合阶段等待:所有并行子任务完成后才进入 synthesis
**Verification**: `python3 -m pytest tests/unit/test_team_parallel.py -x -q` 通过;现有 TeamOrchestrator 测试不回归。
### U5. ReflexionEngine 反思持久化到 EpisodicMemory
**Goal**: ReflexionEngine 的 `_reflect` 方法生成反思后,将结果写入 EpisodicMemory支持跨任务检索。
**Requirements**: R11, R15
**Dependencies**: None与 U1-U4 正交)
**Files**:
- `src/agentkit/core/reflexion.py` — 修改 `_reflect`(行 648新增持久化逻辑
- `src/agentkit/memory/episodic.py` — 新增 `store_prompt_reflection()` + `search_prompt_reflections()` 方法
- `tests/unit/test_reflexion_persist.py` — 新增持久化测试
**Approach**:
- `_reflect` 返回前,调用 `EpisodicMemory.store_prompt_reflection(task_input, reflection, improved_prompt, version, score)`
- 存储格式:`key = "prompt_reflection:{task_hash}:{version}"``value = {task_input, reflection, improved_prompt, score, timestamp}`
- KTD7 决策保留所有版本score 字段记录效果verify 通过=1.0,失败=0.0
- R15 TTL复用 EpisodicMemory 现有清理机制(若无可加 `cleanup_expired()` 方法30 天阈值)
- `_build_reflection_prompt`(行 693已生成 improved_prompt直接存储
**Patterns to follow**: `EpisodicMemory.store()` 现有模式(`episodic.py:66``EpisodicMemory.search()` 语义搜索模式(`episodic.py:206`)。
**Test scenarios**:
- 反思持久化:`_reflect` 返回后EpisodicMemory 中存在对应记录
- 存储字段完整:记录包含 task_input + reflection + improved_prompt + version + score + timestamp
- TTL 清理30 天前的记录被 `cleanup_expired()` 清理
- 多版本共存:同一 task_hash 的多个版本都保留version 递增
- 持久化失败不阻塞EpisodicMemory 写入失败时,`_reflect` 仍返回反思文本(降级处理)
**Verification**: `python3 -m pytest tests/unit/test_reflexion_persist.py -x -q` 通过;现有 ReflexionEngine 测试不回归。
### U6. Lead 规划时检索历史反思 + prompt 替换
**Goal**: PLAN_EXEC / TEAM_COLLAB 的 Lead 规划时,检索 EpisodicMemory 中相似 task_input 的历史反思,用 improved_prompt 替换默认 prompt。
**Requirements**: R12, R13
**Dependencies**: U5
**Files**:
- `src/agentkit/experts/orchestrator.py``_decompose_task`(行 534前新增反思检索
- `src/agentkit/core/reflexion.py` — 新增 `retrieve_prompt_reflection(task_input) -> dict | None`
- `tests/unit/test_lead_reflection_retrieval.py` — 新增检索 + 替换测试
**Approach**:
- `_decompose_task` 前,调用 `ReflexionEngine.retrieve_prompt_reflection(task_input)`
- 检索逻辑:`EpisodicMemory.search_prompt_reflections(task_input, top_k=3)` → 按 score 降序取最高
- 若找到 improved_promptscore > 0.5),替换 Lead 的默认 system prompt
- KTD5 触发条件:仅在 verify 二次失败 / schema 校验失败 / 循环检测时才触发反思(避免无意义检索)
- 无历史反思时:用默认 prompt现有行为不变
**Patterns to follow**: `_decompose_task` 现有模式(`orchestrator.py:534``_build_reflection_prompt` 的 prompt 拼接模式(`reflexion.py:693`)。
**Test scenarios**:
- 历史反思命中EpisodicMemory 有相似 task_input 记录Lead 用 improved_prompt
- 历史反思未命中无相似记录Lead 用默认 prompt行为不变
- score 过滤score <= 0.5 的反思不替换(避免低质量反思污染)
- KTD5 触发条件:非触发错误类型(如普通 timeout不检索历史反思
- 检索失败降级EpisodicMemory 检索失败时Lead 用默认 prompt
**Verification**: `python3 -m pytest tests/unit/test_lead_reflection_retrieval.py -x -q` 通过;现有 TeamOrchestrator 测试不回归。
### U7. ABTester 离线对比 prompt 版本
**Goal**: ABTester 支持对比 EpisodicMemory 中同一 task_hash 的多个 prompt 版本效果,离线验证(不在线 bandit
**Requirements**: R14
**Dependencies**: U5
**Files**:
- `src/agentkit/evolution/ab_tester.py` — 新增 `compare_prompt_versions(task_hash) -> dict` 方法
- `tests/unit/test_ab_tester_prompt.py` — 新增版本对比测试
**Approach**:
- `compare_prompt_versions(task_hash)`: 检索 EpisodicMemory 中该 task_hash 的所有版本
- 输出:`{versions: [{version, score, timestamp, reflection_summary}], best_version, recommendation}`
- 离线验证:不在线 bandit仅基于历史 score 对比
- `recommendation` 字段:建议保留 score 最高的版本,清理低分版本(可选)
**Patterns to follow**: 现有 ABTester 的对比模式(`evolution/ab_tester.py`)。
**Test scenarios**:
- 多版本对比3 个版本score: 0.8, 0.6, 0.4recommendation 选 0.8 版本
- 无版本时:返回空结果,不报错
- 单版本时:直接返回该版本为 best_version
- 低分版本清理:可选清理 score < 0.3 的版本保留 top-K
**Verification**: `python3 -m pytest tests/unit/test_ab_tester_prompt.py -x -q` 通过。
---
## Verification Contract
### Unit Tests
```bash
# 全量单元测试(必须通过)
python3 -m pytest tests/unit/ -x -q
# 本次新增测试(逐个验证)
python3 -m pytest tests/unit/test_server_config.py tests/unit/test_react_autonomy.py tests/unit/test_plan_exec_autonomy.py tests/unit/test_autonomy_paused.py tests/unit/test_team_parallel.py tests/unit/test_reflexion_persist.py tests/unit/test_lead_reflection_retrieval.py tests/unit/test_ab_tester_prompt.py -x -q
```
### Lint + Format
```bash
# Ruff lint + format必须通过
ruff check src/ && ruff format src/
```
### Integration Tests (Optional)
```bash
# 集成测试(需 Docker Redis + PostgreSQL可选
python3 -m pytest -m "integration" -x -q
```
### Quality Gates
- 无 `any` 类型AGENTS.md 约束)
- 所有 Pydantic 模型用 `model_config = ConfigDict(...)`AGENTS.md 约束)
- API Key 比较用 `hmac.compare_digest`(若涉及)
- 异步生成器安全(`.trae/rules/project_rules.md`
- 无 `return` 在第一个 `yield` 之前(若新增 async generator
---
## Definition of Done
### Global Criteria
- 7 个 Implementation Units (U1-U7) 全部完成
- 所有新增测试通过8 个测试文件)
- 现有测试不回归(`tests/unit/` 全量通过)
- `ruff check src/ && ruff format src/` 无错误
- 无 `any` 类型Pydantic 模型用 `ConfigDict`
- 异步生成器安全(无 early return before yield
### Per-Unit Criteria
| Unit | Done Signal |
|------|-------------|
| U1 | `DangerousToolsConfig` 解析正确,`is_dangerous()` 工作正常 |
| U2 | PLAN_EXEC 自主模式下危险操作触发 confirmation非危险操作自主执行 |
| U3 | 超时/连续失败触发 `autonomy_paused``resume` 恢复执行 |
| U4 | 无依赖子任务并行派发SharedWorkspace 路径不重叠 |
| U5 | `_reflect` 后 EpisodicMemory 有记录TTL 清理工作 |
| U6 | Lead 检索历史反思并替换 promptscore > 0.5 时) |
| U7 | ABTester 对比多版本 prompt返回 best_version |
### Cleanup Criteria
- 移除调试代码print、TODO 注释、临时 hack
- 移除未使用的 import
- 移除实验性死代码(尝试过但未采用的方案)
- 所有新增配置项有文档(`agentkit.yaml.example` 示例)
---
## Appendix: 竞品对标参考
| 维度 | 竞品代表 | AgentKit 本次目标 |
|------|----------|-------------------|
| 并行 Sub-agent | Cursor 3.2 Subagents并行专业化 worker| R1-R5: TeamOrchestrator 无依赖子任务并行 |
| Goal-driven 自主 | Devin 3.0 Goal-driven数小时自主、Qoder 1.11 Goal + Scheduling | R6-R10: PLAN_EXEC 危险操作确认式自主 |
| Prompt 自调优 | Devin 强化学习+元学习、ReflexionEngine 跨任务 | R11-R15: ReflexionEngine 反思持久化到 EpisodicMemory |
## Appendix: 关键代码位置参考
| 子系统 | 文件 | 关键位置 |
|--------|------|----------|
| TeamOrchestrator | `src/agentkit/experts/orchestrator.py` | `execute()` 行 143, `_run_pipeline` 行 234, `_decompose_task` 行 534 |
| TeamPlan | `src/agentkit/experts/plan.py` | `topological_sort()` 行 385, `SubTask` 行 69, `PlanPhase.depends_on` 行 176 |
| ReflexionEngine | `src/agentkit/core/reflexion.py` | `_reflect()` 行 648, `_build_reflection_prompt` 行 693 |
| EpisodicMemory | `src/agentkit/memory/episodic.py` | `store()` 行 66, `search()` 行 206 |
| SharedWorkspace | `src/agentkit/core/shared_workspace.py` | `lock()` 行 96, `write()` 行 39 |
| ReActEngine | `src/agentkit/core/react.py` | `confirmation_request` 行 1255/2199, `phase_violation` 行 272 |
| PLAN_EXEC 路由 | `src/agentkit/chat/skill_routing.py` | `ExecutionMode.PLAN_EXEC` 行 33 |
| WebSocket 事件 | `src/agentkit/server/routes/chat.py` | `confirmation_request` 行 1646, `phase_changed` 行 1701 |
| ServerConfig | `src/agentkit/server/config.py` | `from_dict` 行 200, `MCPServerConfig` 行 23 |
| CancellationToken | `src/agentkit/core/protocol.py` | 类定义 |

View File

@ -44,6 +44,11 @@ if TYPE_CHECKING:
from agentkit.evolution.pitfall_detector import PitfallWarning
from agentkit.memory.retriever import MemoryRetriever
# U2/R8: duck-typed at runtime to avoid core→server layering violation.
# Any object with ``is_dangerous(tool_name: str) -> bool`` + ``enabled: bool``
# is accepted; server/config.DangerousToolsConfig is the canonical impl.
from agentkit.server.config import DangerousToolsConfig
logger = logging.getLogger(__name__)
@ -142,7 +147,7 @@ class ReActResult:
class ReActEvent:
"""ReAct 执行事件"""
event_type: str # "thinking","token","tool_call","tool_result","confirmation_request","confirmation_result","phase_violation","step","final_answer","final_result","error"
event_type: str # "thinking","token","tool_call","tool_result","confirmation_request","confirmation_result","phase_violation","step","final_answer","final_result","error","autonomy_paused","spec_review_request","spec_review_reply","expert_step","expert_result","team_synthesis","team_synthesis_chunk","phase_changed"
step: int
data: dict[str, object] = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
@ -204,6 +209,15 @@ class ReActEngine:
# max_reinjections)
# Loop detector threshold raised from 2 to 3 (R10/RV22).
phase_budgets: dict[str, int] | None = None,
# IQ-Boost/U2/R8: dangerous-tools whitelist for PLAN_EXEC autonomy.
# Duck-typed (see TYPE_CHECKING import). None = no autonomy gate
# (backward compat for DIRECT_CHAT/REACT and existing tests).
dangerous_tools_config: "DangerousToolsConfig | None" = None,
# IQ-Boost/U2/R6: autonomy mode flag. True = PLAN_EXEC plan confirmed,
# only dangerous tools (per dangerous_tools_config) trigger
# confirmation_request; non-dangerous tools execute directly.
# False = existing behavior (tool-side _is_dangerous drives confirmation).
autonomy_mode: bool = False,
):
if max_steps < 1:
raise ValueError(f"max_steps must be >= 1, got {max_steps}")
@ -298,6 +312,16 @@ class ReActEngine:
# _execute_loop's finally block so subsequent execute() calls without a
# restore still reset properly.
self._state_restored: bool = False
# IQ-Boost/U2: autonomy mode state (R6/R7). When True, the engine gates
# dangerous tools (per dangerous_tools_config) with a pre-execution
# confirmation_request; non-dangerous tools execute without confirmation.
self._dangerous_tools_config = dangerous_tools_config
self._autonomy_mode: bool = autonomy_mode
# IQ-Boost/U3/R10: autonomy pause state. _autonomy_started_at is set in
# _execute_loop when autonomy mode engages; _consecutive_failures tracks
# tool errors. When either threshold is exceeded → autonomy_paused event.
self._autonomy_started_at: float = 0.0
self._consecutive_failures: int = 0
def reset(self) -> None:
"""Reset internal state for reuse across conversations.
@ -753,6 +777,10 @@ class ReActEngine:
stream: bool = False,
effective_timeout: float = 0.0,
pitfall_warnings: "list[PitfallWarning] | None" = None,
# IQ-Boost/U3: autonomy pause resume handler. When autonomy_paused is
# triggered, the engine calls resume_handler(resume_token, reason) which
# blocks until the user sends ``resume`` (True) or cancels (False).
resume_handler: Callable[..., Awaitable[object]] | None = None,
) -> AsyncGenerator[ReActEvent, None]:
"""Unified ReAct loop — async generator yielding ReActEvent objects.
@ -779,6 +807,10 @@ class ReActEngine:
# below so the next execute() without a restore resets normally.
if not self._state_restored:
self.reset()
# IQ-Boost/U3: start autonomy timer when in autonomy mode.
if self._autonomy_mode:
self._autonomy_started_at = time.time()
self._consecutive_failures = 0
tools = tools or []
if tools:
tools = self._maybe_add_tool_search(tools)
@ -1174,8 +1206,12 @@ class ReActEngine:
tc.id, tool_result, compressor, tc.name
)
conversation.append(tool_msg)
elif self._should_execute_parallel(response.tool_calls):
elif self._should_execute_parallel(response.tool_calls) and not (
self._autonomy_mode and self._dangerous_tools_config is not None
):
# 并行执行多个工具调用 (parallel_tools=True)
# IQ-Boost/U2: autonomy mode forces serial execution so
# dangerous tools are always gated by _check_autonomy_gate.
tool_results = await asyncio.gather(
*[
self._execute_tool(tc.name, tc.arguments, tools)
@ -1188,6 +1224,10 @@ class ReActEngine:
if isinstance(tool_result, Exception):
tool_result = {"error": str(tool_result)}
# IQ-Boost/U3/R10: track failures for autonomy pause
# (no-op when autonomy mode is off).
self._track_tool_result_for_autonomy(tool_result)
yield ReActEvent(
event_type="tool_call",
step=step,
@ -1239,13 +1279,63 @@ class ReActEngine:
data={"tool_name": tc.name, "arguments": tc.arguments},
)
tool_start = time.monotonic()
tool_result = await self._execute_tool(tc.name, tc.arguments, tools)
tool_duration_ms = int((time.monotonic() - tool_start) * 1000)
# IQ-Boost/U3/R10: autonomy pause check (timeout/failures).
# If paused and resume_handler returns False (cancel),
# break out of the tool_calls loop.
# Split into detect→yield→await to avoid deadlock: the
# pause event must be yielded BEFORE awaiting resume.
_progress = {
"step": step,
"tool_name": tc.name,
"total_steps": len(trajectory),
}
_pause_info = self._detect_autonomy_pause(step, _progress)
if _pause_info is not None:
_reason, _token, _edata = _pause_info
yield ReActEvent(
event_type="autonomy_paused",
step=step,
data=_edata,
)
_should_continue = await self._await_autonomy_resume(
_token, _reason, resume_handler
)
if not _should_continue:
# User cancelled during pause — stop execution.
break
# Handle confirmation flow
if isinstance(tool_result, dict) and tool_result.get(
"needs_confirmation"
# IQ-Boost/U2/R7: autonomy mode pre-execution gate.
# Dangerous tools (per config) get a confirmation_request
# BEFORE first execution; non-dangerous tools fall through.
(
should_exec,
autonomy_events,
autonomy_result,
) = await self._check_autonomy_gate(
tc.name,
tc.arguments,
tools,
step,
confirmation_handler,
)
for _aev in autonomy_events:
yield _aev
if not should_exec:
# Autonomy gate handled execution (approved+skip
# flag, or rejected). Use its result directly.
tool_result = autonomy_result
tool_duration_ms = 0
else:
tool_start = time.monotonic()
tool_result = await self._execute_tool(tc.name, tc.arguments, tools)
tool_duration_ms = int((time.monotonic() - tool_start) * 1000)
# Handle confirmation flow (tool-side _is_dangerous)
if (
should_exec
and isinstance(tool_result, dict)
and tool_result.get("needs_confirmation")
):
confirmation_id = tool_result["confirmation_id"]
command = tool_result.get("command", "")
@ -1337,6 +1427,9 @@ class ReActEngine:
tool_duration_ms = int((time.monotonic() - tool_start) * 1000)
# IQ-Boost/U3/R10: track tool failures for autonomy pause.
self._track_tool_result_for_autonomy(tool_result)
react_step = ReActStep(
step=step,
action="tool_call",
@ -1407,11 +1500,31 @@ class ReActEngine:
step=step,
data={"tool_name": pc["name"], "arguments": pc["arguments"]},
)
tool_start = time.monotonic()
tool_result = await self._execute_tool(
pc["name"], pc["arguments"], tools
# IQ-Boost/U2: autonomy gate for text-parsed calls too
# (consistency — dangerous tools must be gated regardless
# of whether the LLM returned structured or text calls).
pc_args = pc["arguments"] if isinstance(pc["arguments"], dict) else {}
(
should_exec,
autonomy_events,
autonomy_result,
) = await self._check_autonomy_gate(
pc["name"], pc_args, tools, step, confirmation_handler
)
tool_duration_ms = int((time.monotonic() - tool_start) * 1000)
for _aev in autonomy_events:
yield _aev
if not should_exec:
tool_result = autonomy_result
tool_duration_ms = 0
else:
tool_start = time.monotonic()
tool_result = await self._execute_tool(
pc["name"], pc["arguments"], tools
)
tool_duration_ms = int((time.monotonic() - tool_start) * 1000)
# IQ-Boost/U3/R10: track tool failures for autonomy pause.
self._track_tool_result_for_autonomy(tool_result)
react_step = ReActStep(
step=step,
@ -1805,6 +1918,8 @@ class ReActEngine:
timeout_seconds: float | None = None,
confirmation_handler: Callable[..., Awaitable[object]] | None = None,
pitfall_warnings: "list[PitfallWarning] | None" = None,
# IQ-Boost/U3: autonomy resume handler (see _execute_loop).
resume_handler: Callable[..., Awaitable[object]] | None = None,
) -> AsyncGenerator[ReActEvent, None]:
"""Execute ReAct loop, yielding ReActEvent objects.
@ -1817,6 +1932,7 @@ class ReActEngine:
compressor: 压缩策略None 时使用实例默认压缩器
timeout_seconds: 超时秒数0 表示无超时None 使用 default_timeout
pitfall_warnings: U7/R12 HIGH 级别避坑预警注入 system prompt
resume_handler: U3/R10 autonomy pause resume callback
"""
effective_compressor = compressor if compressor is not None else self._compressor
effective_timeout = (
@ -1841,6 +1957,7 @@ class ReActEngine:
stream=True,
effective_timeout=effective_timeout,
pitfall_warnings=pitfall_warnings,
resume_handler=resume_handler,
):
yield event
@ -2170,6 +2287,224 @@ class ReActEngine:
logger.warning(error_msg)
return {"error": error_msg, "error_code": "tool_execution_failed"}
async def _check_autonomy_gate(
self,
tool_name: str,
tool_arguments: dict,
tools: list[Tool],
step: int,
confirmation_handler: Callable[..., Awaitable[object]] | None,
) -> tuple[bool, list[ReActEvent], object]:
"""IQ-Boost/U2/R7: pre-execution gate for PLAN_EXEC autonomy mode.
When autonomy_mode is on and the tool matches the dangerous-tools
whitelist, emit a ``confirmation_request`` BEFORE the tool runs and
wait on ``confirmation_handler``. Approved execute with
``_skip_dangerous_check=True`` (bypass the tool's own _is_dangerous
re-confirmation). Rejected return a permission_denied result.
Non-dangerous tools and non-autonomy mode fall through (return
``should_execute=True``) so the caller proceeds with the existing
``_execute_tool`` ``needs_confirmation`` flow unchanged.
Returns ``(should_execute, events, pre_result)``:
- should_execute=True caller runs _execute_tool; pre_result=None
- should_execute=False caller uses pre_result as tool_result
"""
events: list[ReActEvent] = []
if (
not self._autonomy_mode
or self._dangerous_tools_config is None
or not self._dangerous_tools_config.is_dangerous(tool_name)
):
return (True, events, None)
# Dangerous tool in autonomy mode — gate before first execution.
confirmation_id = f"autonomy:{tool_name}:{step}"
command = f"{tool_name} {tool_arguments}"
reason = "危险操作(匹配 autonomy 白名单)需确认后执行"
events.append(
ReActEvent(
event_type="confirmation_request",
step=step,
data={
"confirmation_id": confirmation_id,
"tool_name": tool_name,
"command": command,
"reason": reason,
},
)
)
approved = False
if confirmation_handler is not None:
try:
approved = await confirmation_handler(confirmation_id, command, reason)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"Autonomy confirmation handler error: {e}")
if approved:
tool = self._find_tool(tool_name, tools)
if tool is None:
events.append(
ReActEvent(
event_type="confirmation_result",
step=step,
data={"confirmation_id": confirmation_id, "approved": True},
)
)
return (False, events, {"error": f"Tool '{tool_name}' not found"})
clean_args = {k: v for k, v in tool_arguments.items() if not k.startswith("_")}
clean_args["_skip_dangerous_check"] = True
try:
result = await tool.safe_execute(**clean_args)
except (ToolValidationError, ValueError, TypeError, RuntimeError) as e:
result = {
"error": f"Tool '{tool_name}' execution failed: {e}",
"error_code": "tool_execution_failed",
}
events.append(
ReActEvent(
event_type="confirmation_result",
step=step,
data={"confirmation_id": confirmation_id, "approved": True},
)
)
return (False, events, result)
# Rejected by user (or handler timed out / returned False)
events.append(
ReActEvent(
event_type="confirmation_result",
step=step,
data={"confirmation_id": confirmation_id, "approved": False},
)
)
return (
False,
events,
{
"output": "",
"exit_code": 126,
"is_error": True,
"error_type": "permission_denied",
"message": f"用户拒绝执行危险操作: {tool_name}",
},
)
def _detect_autonomy_pause(
self,
step: int,
progress: dict[str, object],
) -> tuple[str, str, dict[str, object]] | None:
"""IQ-Boost/U3/R10: detect autonomy pause conditions (non-blocking).
Returns ``(reason, resume_token, event_data)`` when a pause is
triggered, or ``None`` when no pause is needed.
The caller is responsible for:
1. Yielding the ``autonomy_paused`` event (built from event_data)
**before** awaiting ``_await_autonomy_resume`` otherwise the
frontend never receives the pause event and the resume handler
deadlocks.
2. Awaiting ``_await_autonomy_resume(resume_token, reason, handler)``
to block until the user resumes or cancels.
This split fixes the original deadlock where the event was buffered
inside this method and only yielded after the handler returned.
"""
if not self._autonomy_mode or self._dangerous_tools_config is None:
return None
cfg = self._dangerous_tools_config
reason: str | None = None
# Check timeout (0 = disabled)
if (
cfg.autonomy_timeout_minutes > 0
and self._autonomy_started_at > 0
and time.time() - self._autonomy_started_at > cfg.autonomy_timeout_minutes * 60
):
reason = "timeout"
# Check consecutive failures (0 = disabled)
elif (
cfg.max_consecutive_failures > 0
and self._consecutive_failures >= cfg.max_consecutive_failures
):
reason = "consecutive_failures"
if reason is None:
return None
resume_token = f"autonomy_pause:{reason}:{step}"
event_data: dict[str, object] = {
"reason": reason,
"progress": progress,
"resume_token": resume_token,
"consecutive_failures": self._consecutive_failures,
"elapsed_seconds": (
time.time() - self._autonomy_started_at
if self._autonomy_started_at > 0
else 0
),
}
return (reason, resume_token, event_data)
async def _await_autonomy_resume(
self,
resume_token: str,
reason: str,
resume_handler: Callable[..., Awaitable[object]] | None,
) -> bool:
"""Block until user resumes or cancels autonomy pause (U3/R10).
Returns True on resume (counters reset, caller retries the tool),
False on cancel (caller breaks the loop).
If ``resume_handler`` is None (non-WS callers, tests), auto-resume
immediately without blocking.
"""
if resume_handler is None:
# Non-WS caller (tests, REST) — auto-resume without blocking.
logger.warning("autonomy_paused (%s) with no resume_handler — auto-resuming", reason)
self._autonomy_started_at = time.time()
self._consecutive_failures = 0
return True
try:
should_resume = await resume_handler(resume_token, reason)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"Autonomy resume handler error: {e}")
should_resume = False
if should_resume:
# Reset counters and continue
self._autonomy_started_at = time.time()
self._consecutive_failures = 0
return True
# Cancelled by user — break the loop
return False
def _track_tool_result_for_autonomy(self, tool_result: object) -> None:
"""U3/R10: track tool failures for consecutive_failures threshold.
Called after each tool execution. If the result is an error dict,
increment the counter; otherwise reset to 0.
"""
if not self._autonomy_mode:
return
if isinstance(tool_result, dict) and (
"error" in tool_result
or tool_result.get("is_error") is True
or tool_result.get("error_type") is not None
):
self._consecutive_failures += 1
else:
self._consecutive_failures = 0
async def _execute_tool_with_confirmation(
self,
tc: object,
@ -2186,6 +2521,16 @@ class ReActEngine:
Tuple of (tool_result, list of ReActEvents to yield)
"""
events: list[ReActEvent] = []
# IQ-Boost/U2: autonomy gate runs first (pre-execution). If it handles
# the tool (dangerous in autonomy mode), return immediately with its
# result + events; otherwise fall through to the normal flow.
should_exec, gate_events, gate_result = await self._check_autonomy_gate(
tc.name, tc.arguments, tools, step, confirmation_handler
)
if not should_exec:
return (gate_result, gate_events)
events.extend(gate_events)
tool_result = await self._execute_tool(tc.name, tc.arguments, tools)
# Check if tool returned a confirmation request

View File

@ -26,6 +26,8 @@ from agentkit.telemetry.metrics import (
if TYPE_CHECKING:
from agentkit.core.compressor import CompressionStrategy
from agentkit.core.trace import TraceRecorder
from agentkit.memory.base import MemoryItem
from agentkit.memory.episodic import EpisodicMemory
from agentkit.memory.retriever import MemoryRetriever
logger = logging.getLogger(__name__)
@ -72,6 +74,9 @@ class ReflexionEngine:
max_reflections: int = 3,
quality_threshold: float = 0.7,
default_timeout: float = 300.0,
# IQ-Boost/U5 (R11): optional EpisodicMemory for persisting reflections
# across tasks. None = no persistence (backward-compatible).
episodic_memory: "EpisodicMemory | None" = None,
):
if max_steps < 1:
raise ValueError(f"max_steps must be >= 1, got {max_steps}")
@ -87,6 +92,8 @@ class ReflexionEngine:
self._max_reflections = max_reflections
self._quality_threshold = quality_threshold
self._default_timeout = default_timeout
# U5: optional episodic memory for cross-task reflection persistence
self._episodic_memory = episodic_memory
self._react_engine = ReActEngine(
llm_gateway=llm_gateway,
max_steps=max_steps,
@ -654,7 +661,12 @@ class ReflexionEngine:
agent_name: str,
task_type: str,
) -> str | None:
"""反思执行结果,返回反思文本;失败时返回 None"""
"""反思执行结果,返回反思文本;失败时返回 None
IQ-Boost/U5 (R11): if ``self._episodic_memory`` is configured, persist
the reflection for cross-task retrieval. Persistence failure is
non-blocking the reflection is still returned for in-task retry.
"""
task_description = messages[-1].get("content", "") if messages else ""
system_message = (
@ -685,11 +697,33 @@ class ReflexionEngine:
agent_name=agent_name,
task_type=task_type or "reflection",
)
return response.content or None
reflection_text = response.content or None
except Exception as e:
logger.warning(f"Reflection LLM call failed, skipping reflection: {e}")
return None
# U5/R11: persist reflection to EpisodicMemory (non-blocking)
if reflection_text and self._episodic_memory is not None:
improved_prompt = self._build_reflection_prompt(
original_prompt=None,
reflection_text=reflection_text,
attempt=1,
)
try:
await self._episodic_memory.store_prompt_reflection(
task_input=task_description,
reflection=reflection_text,
improved_prompt=improved_prompt,
version=1,
score=score,
agent_name=agent_name,
)
except Exception as e:
# Non-blocking: reflection is still useful for in-task retry
logger.warning(f"U5: failed to persist reflection, continuing: {e}")
return reflection_text
def _build_reflection_prompt(
self,
original_prompt: str | None,
@ -709,3 +743,69 @@ class ReflexionEngine:
return original_prompt + reflection_section
else:
return reflection_section.strip()
async def retrieve_prompt_reflection(
self, task_input: str, min_score: float = 0.5
) -> dict[str, object] | None:
"""检索历史 prompt 反思,返回最佳版本 (U6/R12, R13).
Searches EpisodicMemory for similar task_input reflections with
stored quality_score > min_score. Returns the highest-quality
reflection as:
{improved_prompt, score, reflection, version, task_hash}
or None if no episodic_memory / no results / all below threshold.
KTD5: callers should only invoke this when a trigger condition is
met (verify failure / schema failure / loop detection) to avoid
pointless retrieval on every task.
Note: ``item.score`` from ``EpisodicMemory.search`` is the hybrid
relevance score (cosine + time_decay), NOT the stored quality_score.
We read ``quality_score`` from ``item.value`` for filtering/ranking
so that a high-quality reflection (score=1.0) is preferred over a
low-quality one (score=0.0) regardless of textual similarity.
"""
if self._episodic_memory is None:
return None
try:
results = await self._episodic_memory.search_prompt_reflections(
task_input=task_input, top_k=3
)
except Exception as e:
logger.warning(f"U6: retrieve_prompt_reflection failed: {e}")
return None
if not results:
return None
# Filter by stored quality_score (from value dict), pick the highest.
# Fallback to item.score (relevance) when quality_score is absent.
best: MemoryItem | None = None
best_quality: float = 0.0
for item in results:
value = item.value if isinstance(item.value, dict) else {}
quality = float(value.get("quality_score", 0.0) or 0.0)
if quality <= 0.0:
# Fallback to relevance score if quality_score missing
quality = float(item.score or 0.0)
if quality > min_score and (best is None or quality > best_quality):
best = item
best_quality = quality
if best is None:
return None
# Read fields from value dict (matching EpisodicMemory.search shape)
value = best.value if isinstance(best.value, dict) else {}
improved_prompt = value.get("output_summary", "") or ""
reflection_text = value.get("reflection", "") or ""
metadata = best.metadata or {}
return {
"improved_prompt": improved_prompt,
"score": best_quality,
"reflection": reflection_text,
"version": metadata.get("version", 1),
"task_hash": metadata.get("task_hash", ""),
}

View File

@ -12,6 +12,7 @@ from sqlalchemy.exc import DBAPIError
if TYPE_CHECKING:
from agentkit.evolution.evolution_store import InMemoryEvolutionStore
from agentkit.memory.episodic import EpisodicMemory
logger = logging.getLogger(__name__)
@ -19,6 +20,7 @@ logger = logging.getLogger(__name__)
@dataclass
class ABTestConfig:
"""A/B 测试配置"""
test_id: str
agent_name: str
change_type: str # prompt / strategy / pipeline
@ -31,6 +33,7 @@ class ABTestConfig:
@dataclass
class ABTestResult:
"""A/B 测试结果"""
test_id: str
control_metric: float
experiment_metric: float
@ -52,11 +55,15 @@ class ABTester:
self,
evolution_store: "InMemoryEvolutionStore | None" = None,
min_samples: int = 10,
episodic_memory: "EpisodicMemory | None" = None,
):
self._tests: dict[str, ABTestConfig] = {}
self._results: dict[str, list[tuple[str, float]]] = {} # test_id -> [(group, metric)]
self._evolution_store = evolution_store
self._default_min_samples = min_samples
# IQ-Boost/U7 (R14): optional EpisodicMemory for prompt-version comparison.
# None = compare_prompt_versions() returns empty result (backward-compatible).
self._episodic_memory = episodic_memory
def create_test(self, config: ABTestConfig) -> None:
"""创建 A/B 测试"""
@ -115,7 +122,9 @@ class ABTester:
experiment_metrics = [m for g, m in results if g == "experiment"]
control_avg = sum(control_metrics) / len(control_metrics) if control_metrics else 0.0
experiment_avg = sum(experiment_metrics) / len(experiment_metrics) if experiment_metrics else 0.0
experiment_avg = (
sum(experiment_metrics) / len(experiment_metrics) if experiment_metrics else 0.0
)
try:
await self._evolution_store.record_ab_test_result(
@ -144,11 +153,18 @@ class ABTester:
control_metrics = [m for g, m in results if g == "control"]
experiment_metrics = [m for g, m in results if g == "experiment"]
if len(control_metrics) < config.min_samples or len(experiment_metrics) < config.min_samples:
if (
len(control_metrics) < config.min_samples
or len(experiment_metrics) < config.min_samples
):
return ABTestResult(
test_id=test_id,
control_metric=sum(control_metrics) / len(control_metrics) if control_metrics else 0,
experiment_metric=sum(experiment_metrics) / len(experiment_metrics) if experiment_metrics else 0,
control_metric=sum(control_metrics) / len(control_metrics)
if control_metrics
else 0,
experiment_metric=sum(experiment_metrics) / len(experiment_metrics)
if experiment_metrics
else 0,
control_samples=len(control_metrics),
experiment_samples=len(experiment_metrics),
is_significant=False,
@ -159,10 +175,16 @@ class ABTester:
control_mean = sum(control_metrics) / len(control_metrics)
experiment_mean = sum(experiment_metrics) / len(experiment_metrics)
control_var = sum((m - control_mean) ** 2 for m in control_metrics) / (len(control_metrics) - 1)
experiment_var = sum((m - experiment_mean) ** 2 for m in experiment_metrics) / (len(experiment_metrics) - 1)
control_var = sum((m - control_mean) ** 2 for m in control_metrics) / (
len(control_metrics) - 1
)
experiment_var = sum((m - experiment_mean) ** 2 for m in experiment_metrics) / (
len(experiment_metrics) - 1
)
pooled_se = math.sqrt(control_var / len(control_metrics) + experiment_var / len(experiment_metrics))
pooled_se = math.sqrt(
control_var / len(control_metrics) + experiment_var / len(experiment_metrics)
)
# Handle zero variance case: if means differ but variance is zero,
# the difference is clearly significant
@ -201,3 +223,78 @@ class ABTester:
def _normal_cdf(x: float) -> float:
"""标准正态分布 CDF 近似"""
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
# ── IQ-Boost/U7: Prompt-version offline comparison (R14) ────────────
@staticmethod
def _no_data_result(task_hash: str) -> dict[str, object]:
"""Build the empty-result payload for compare_prompt_versions."""
return {
"task_hash": task_hash,
"versions": [],
"best_version": None,
"recommendation": "no_data",
"total_versions": 0,
}
async def compare_prompt_versions(self, task_hash: str) -> dict[str, object]:
"""离线对比同一 task_hash 的多个 prompt 版本效果 (U7/R14).
EpisodicMemory 检索该 task_hash 的所有 prompt_reflection 记录
score 降序排列返回对比结果 + 推荐保留版本
离线验证 不在线 bandit仅基于历史 score 对比
Returns:
{
"task_hash": str,
"versions": [{"version": int, "score": float, "timestamp": str,
"reflection_summary": str, "improved_prompt": str}],
"best_version": dict | None, # score 最高的版本
"recommendation": str, # "keep_best" | "no_data"
"total_versions": int,
}
episodic_memory 或无记录时返回 "no_data" recommendation
"""
if self._episodic_memory is None:
return self._no_data_result(task_hash)
try:
items = await self._episodic_memory.list_prompt_reflections_by_hash(task_hash)
except (DBAPIError, RuntimeError, ValueError, KeyError, OSError) as e:
logger.warning(f"U7: compare_prompt_versions retrieval failed: {e}")
return self._no_data_result(task_hash)
if not items:
return self._no_data_result(task_hash)
# Sort by score descending (best first)
sorted_items = sorted(items, key=lambda it: it.score, reverse=True)
versions: list[dict[str, object]] = []
for item in sorted_items:
meta = item.metadata or {}
value = item.value if isinstance(item.value, dict) else {}
reflection_text = value.get("reflection", "")
improved_prompt = value.get("output_summary", "")
versions.append(
{
"version": meta.get("version", 1),
"score": item.score or 0.0,
"timestamp": meta.get("created_at", "")
or (item.created_at.isoformat() if item.created_at else ""),
"reflection_summary": (reflection_text[:200] if reflection_text else ""),
"improved_prompt": (improved_prompt[:500] if improved_prompt else ""),
}
)
best = versions[0] if versions else None
recommendation = "keep_best" if best else "no_data"
return {
"task_hash": task_hash,
"versions": versions,
"best_version": best,
"recommendation": recommendation,
"total_versions": len(versions),
}

View File

@ -14,6 +14,7 @@ import asyncio
import json
import logging
import re
from typing import TYPE_CHECKING
from agentkit.core.exceptions import LLMProviderError
from agentkit.llm.gateway import LLMGateway
@ -36,6 +37,9 @@ from .plan import (
)
from .team import ExpertTeam, TeamStatus
if TYPE_CHECKING:
from agentkit.core.reflexion import ReflexionEngine
logger = logging.getLogger(__name__)
# 专家名校验正则(与 router.py / board_router.py 保持一致)
@ -62,6 +66,9 @@ class TeamOrchestrator(
MAX_DEBATE_ROUNDS = 4 # Hard cap on debate rounds per phase
MAX_DEBATES = 3 # Hard cap on auto-inserted debate phases per execution
DEFAULT_MAX_CONCURRENT_PHASES = 3 # 同层最大并发阶段数,避免 LLM 限流洪峰
# IQ-Boost/U4 (R4): aligns with router.MAX_EXPERTS — if Lead decomposes
# more independent subtasks than this, re-decompose once with a merge hint.
MAX_INDEPENDENT_SUBTASKS = 10
STOP_COMMANDS = frozenset({"/stop", "停止", "stop", "结束"})
# G9/U4: RollbackExecutor default timeout for validation_command / rollback_command.
# Override via constructor `rollback_timeout` from `rollback.default_timeout` config.
@ -79,6 +86,10 @@ class TeamOrchestrator(
# final-answer path (react.py:1303+) runs on coding tasks.
verification_enabled: bool = True,
verification_commands: list[str] | None = None,
# IQ-Boost/U6 (R12, R13): optional ReflexionEngine for retrieving
# historical prompt reflections at Lead planning time. None = no
# retrieval (backward-compatible).
reflexion_engine: "ReflexionEngine | None" = None,
) -> None:
self._team = team
# Track temporary agent names created for context isolation (KTD3)
@ -100,6 +111,8 @@ class TeamOrchestrator(
self._rollback_timeout = rollback_timeout or self.DEFAULT_ROLLBACK_TIMEOUT
# U3/R2: verification defaults for TEAM_COLLAB.
self._verification_enabled = verification_enabled
# U6: optional reflexion engine for historical reflection retrieval
self._reflexion_engine = reflexion_engine
# U3/R3: if no explicit commands, detect from workspace (coding-task
# detection forces pytest/ruff). None workspace → None commands →
# ReActEngine/VerificationLoop uses its own defaults.
@ -197,6 +210,11 @@ class TeamOrchestrator(
PlanPhase(name="执行", assigned_expert=lead.config.name, task_description=task)
]
# IQ-Boost/U4 (R4): if Lead over-decomposed independent subtasks beyond
# MAX_INDEPENDENT_SUBTASKS, ask Lead to re-decompose with a merge hint
# (one retry — further overflow falls through to MAX_PHASES truncation).
phases = await self._rebalance_independent_subtasks(lead, task, phases)
plan.phases = phases[: self.MAX_PHASES]
# U3: Optionally add plan review debate before execution
@ -531,11 +549,79 @@ class TeamOrchestrator(
self._team.set_status(TeamStatus.EXECUTING)
return await self._run_pipeline(lead, plan, phase_results, task)
async def _rebalance_independent_subtasks(
self, lead: Expert, task: str, phases: list[PlanPhase]
) -> list[PlanPhase]:
"""IQ-Boost/U4 (R4): if phases contain more independent subtasks
(depends_on == []) than MAX_INDEPENDENT_SUBTASKS, ask Lead to
re-decompose with a merge hint. One retry only further overflow
is handled by MAX_PHASES truncation in execute().
Returns the original phases if count is within limit or retry fails.
"""
# Count phases with no depends_on (independent subtasks).
independent_count = sum(1 for ph in phases if not ph.depends_on)
if independent_count <= self.MAX_INDEPENDENT_SUBTASKS:
return phases
logger.info(
f"U4: Lead decomposed {independent_count} independent subtasks "
f"(> {self.MAX_INDEPENDENT_SUBTASKS}), requesting re-decompose with merge hint"
)
gateway = self._get_llm_gateway(lead)
if not gateway:
# No gateway — can't re-decompose; rely on MAX_PHASES truncation.
return phases
# Re-decompose with explicit merge hint
original_hint = (
f"Previous decomposition produced {independent_count} independent subtasks "
f"(no dependencies), exceeding the {self.MAX_INDEPENDENT_SUBTASKS} limit. "
f"Please merge related subtasks so the total independent (depends_on=[]) "
f"subtasks is at most {self.MAX_INDEPENDENT_SUBTASKS}. Keep dependencies "
f"where natural."
)
# Temporarily wrap task with hint — call _decompose_task with augmented task.
augmented_task = f"{task}\n\n[Re-decomposition hint]: {original_hint}"
try:
new_phases = await self._decompose_task(lead, augmented_task)
except (LLMProviderError, asyncio.TimeoutError, ConnectionError, ValueError) as e:
logger.warning(f"U4 re-decompose failed: {e}, keeping original phases")
return phases
if not new_phases:
return phases
new_independent = sum(1 for ph in new_phases if not ph.depends_on)
if new_independent <= self.MAX_INDEPENDENT_SUBTASKS:
# ponytail: detect single-phase fallback (LLM returned invalid
# JSON → _decompose_task returned degenerate single phase).
# Collapsing 11 subtasks to 1 is worse than truncation; keep
# original so MAX_PHASES handles it.
if len(new_phases) == 1 and new_independent < independent_count:
logger.info("U4: re-decompose fell back to single phase, keeping original")
return phases
logger.info(f"U4: re-decompose succeeded ({new_independent} independent subtasks)")
return new_phases
# Still over limit — take the new decomposition anyway (truncation will cap it)
logger.info(
f"U4: re-decompose still has {new_independent} independent subtasks, "
f"proceeding with MAX_PHASES truncation"
)
return new_phases
async def _decompose_task(self, lead: Expert, task: str) -> list[PlanPhase]:
"""Lead Expert decomposes task into phases using LLM.
Returns a list of PlanPhase instances. If LLM decomposition fails,
returns a single phase with the original task.
IQ-Boost/U6 (R12, R13): if reflexion_engine is configured, retrieves
historical prompt reflection for similar task_input and prepends
improved_prompt to the decomposition prompt. Non-blocking retrieval
failure falls through to default prompt.
"""
gateway = self._get_llm_gateway(lead)
if not gateway:
@ -547,6 +633,26 @@ class TeamOrchestrator(
]
available_experts = member_names if member_names else [lead.config.name]
# U6: retrieve historical reflection (non-blocking)
reflection_hint = ""
if self._reflexion_engine is not None:
try:
historical = await self._reflexion_engine.retrieve_prompt_reflection(
task_input=task
)
if historical and historical.get("improved_prompt"):
reflection_hint = (
f"\n\n## Historical Reflection (score={historical.get('score', 0):.2f})\n"
f"A previous similar task produced this reflection. "
f"Use it to improve your decomposition:\n\n"
f"{historical['improved_prompt']}\n"
)
logger.info(
f"U6: retrieved historical reflection (score={historical.get('score', 0):.2f})"
)
except Exception as e:
logger.warning(f"U6: historical reflection retrieval failed, using default: {e}")
prompt = (
f"You are the Lead Expert in a pipeline team. Decompose the following task into "
f"at most {self.MAX_PHASES} phases with dependencies.\n\n"
@ -574,6 +680,7 @@ class TeamOrchestrator(
f'{{"name":"前端","assigned_expert":"frontend",'
f'"task_description":"实现UI","depends_on":["后端"],"collaboration_contracts":[]}}]\n\n'
f"Return ONLY the JSON array, no other text."
f"{reflection_hint}"
)
try:

View File

@ -443,3 +443,12 @@ class TeamPlan:
in_degree[dep_id] -= 1
return layers
def get_independent_subtasks(self) -> list[PlanPhase]:
"""返回无依赖的子任务depends_on == [])。
IQ-Boost/U4 (R1): 用于 Lead 分解后检查独立子任务数量是否超过
MAX_EXPERTS这些子任务会被 topological_sort 派发到 layer 0 并行执行
同层并行已有本方法仅提供显式 introspection 入口
"""
return [ph for ph in self.phases if not ph.depends_on]

View File

@ -410,3 +410,192 @@ class EpisodicMemory(Memory):
await db.rollback()
logger.error(f"Failed to delete episodic memory: {e}")
return False
# ── IQ-Boost/U5: Prompt Reflection persistence (R11, R15) ──────────
async def store_prompt_reflection(
self,
task_input: str,
reflection: str,
improved_prompt: str,
version: int = 1,
score: float = 0.0,
agent_name: str = "",
task_hash: str | None = None,
) -> str | None:
"""持久化 prompt 反思到 EpisodicMemory支持跨任务检索 (U5/R11).
Writes directly to the ORM row (bypassing ``store()``) so that
``task_hash``/``version``/``score`` land in the ``metadata_`` JSONB
column ``store()`` only maps a fixed set of metadata keys to ORM
columns and drops the rest, which would break
``list_prompt_reflections_by_hash`` filtering.
The ORM row's fields map:
input_summary task_input (truncated)
output_summary improved_prompt (truncated)
reflection reflection text
quality_score score (0.0=failed, 1.0=verified)
outcome "reflection"
agent_name agent_name
task_type "prompt_reflection"
metadata_ {task_hash, version, score, timestamp}
Returns the storage key ``"prompt_reflection:{task_hash}:{version}"``
on success, or None on failure (non-raising callers continue without).
"""
import hashlib
if task_hash is None:
task_hash = hashlib.sha256(task_input.encode("utf-8")).hexdigest()[:16]
key = f"prompt_reflection:{task_hash}:{version}"
# Generate embedding from task_input for semantic search compatibility.
embedding = None
if self._embedder:
try:
embedding = await self._embedder.embed(task_input)
except (RuntimeError, ValueError, OSError) as e:
logger.warning(f"U5: embedder failed for prompt reflection: {e}")
extra_meta = {
"task_hash": task_hash,
"version": version,
"score": score,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
async with self._session_factory() as db:
try:
Model = self._episodic_model
entry = Model(
agent_name=agent_name,
task_type="prompt_reflection",
input_summary=task_input[:500],
output_summary=improved_prompt[:500],
outcome="reflection",
quality_score=score,
reflection=reflection,
embedding=embedding,
metadata_=extra_meta,
)
db.add(entry)
await db.commit()
return key
except (DBAPIError, ValueError, KeyError, RuntimeError, OSError) as e:
await db.rollback()
logger.warning(f"U5: failed to persist prompt reflection: {e}")
return None
async def search_prompt_reflections(
self,
task_input: str,
top_k: int = 5,
agent_name: str | None = None,
) -> list[MemoryItem]:
"""检索相似 task_input 的历史 prompt 反思 (U5/R11).
Uses ``search()`` with ``task_type="prompt_reflection"`` filter.
Returns empty list on failure (non-raising).
"""
filters: MetadataDict = {"task_type": "prompt_reflection"}
if agent_name:
filters["agent_name"] = agent_name
try:
return await self.search(query=task_input, top_k=top_k, filters=filters)
except (DBAPIError, ValueError, KeyError, RuntimeError, OSError) as e:
logger.warning(f"U5: failed to search prompt reflections: {e}")
return []
async def list_prompt_reflections_by_hash(
self,
task_hash: str,
agent_name: str | None = None,
) -> list[MemoryItem]:
"""精确查询同一 task_hash 的所有 prompt 反思版本 (U7/R14).
用于 ABTester 离线对比同一任务的不同 prompt 版本效果
ponytail: ceiling = O(N) 全表扫描 task_type='prompt_reflection' 记录后
Python 侧过滤 task_hashN 通常 <100一个 task_hash 的版本数有限
升级路径 = metadata_['task_hash'] 上加 GIN 索引 + JSONB ->> 算符查询
Returns empty list on failure (non-raising).
"""
from sqlalchemy import select
async with self._session_factory() as db:
try:
Model = self._episodic_model
stmt = (
select(Model)
.where(Model.task_type == "prompt_reflection")
.order_by(Model.created_at.desc())
.limit(200)
)
result = await db.execute(stmt)
entries = result.scalars().all()
except (DBAPIError, ValueError, KeyError, RuntimeError, OSError) as e:
logger.warning(f"U7: list_prompt_reflections_by_hash failed: {e}")
return []
items: list[MemoryItem] = []
for entry in entries:
meta = entry.metadata_ or {}
if meta.get("task_hash") != task_hash:
continue
if agent_name and meta.get("agent_name") != agent_name:
continue
items.append(
MemoryItem(
key=str(entry.id),
value={
"input_summary": entry.input_summary,
"output_summary": entry.output_summary,
"reflection": entry.reflection,
"quality_score": entry.quality_score,
},
metadata={
"agent_name": entry.agent_name,
"task_type": entry.task_type,
"task_hash": meta.get("task_hash", ""),
"version": meta.get("version", 1),
"score": entry.quality_score or 0.0,
"created_at": entry.created_at.isoformat() if entry.created_at else None,
},
score=entry.quality_score or 0.0,
created_at=entry.created_at or datetime.now(timezone.utc),
)
)
return items
async def cleanup_expired(
self, max_age_days: int = 30, task_type: str | None = None
) -> int:
"""删除超过 max_age_days 天的记录 (U5/R15 TTL).
Args:
max_age_days: Records older than this many days are deleted.
task_type: Optional filter only delete records with this task_type
(e.g. ``"prompt_reflection"``). None = delete all task types.
Returns the number of deleted rows. 0 on failure (non-raising).
"""
from datetime import timedelta
from sqlalchemy import delete as sql_delete
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
async with self._session_factory() as db:
try:
Model = self._episodic_model
stmt = sql_delete(Model).where(Model.created_at < cutoff)
if task_type is not None:
stmt = stmt.where(Model.task_type == task_type)
result = await db.execute(stmt)
await db.commit()
return result.rowcount or 0
except (DBAPIError, ValueError, KeyError, RuntimeError) as e:
await db.rollback()
logger.warning(f"U5: cleanup_expired failed: {e}")
return 0

View File

@ -4,7 +4,7 @@ import asyncio
import logging
import os
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
@ -57,6 +57,65 @@ class MCPServerConfig:
)
@dataclass
class DangerousToolsConfig:
"""Configuration for dangerous tool detection in PLAN_EXEC autonomy mode.
When enabled, tools matching any pattern in ``tool_patterns`` trigger a
confirmation request before execution. KTD2: conservative whitelist
ponytail ceiling = missed dangerous ops not in the list; upgrade path =
LLM-assisted classification.
IQ-Boost/U3: also carries autonomy pause thresholds (timeout + consecutive
failures). Kept here to avoid a separate config section all autonomy
behavior in one place.
"""
enabled: bool = True
tool_patterns: list[str] = field(
default_factory=lambda: [
r"^rm$",
r"^rmdir$",
r"^deploy$",
r"^kubectl",
r"^helm",
r"^git_push_force$",
r"^alembic",
r"^migrate",
r"^payment_",
]
)
# U3/R10: autonomy pause thresholds. 0 = disabled (no pause).
autonomy_timeout_minutes: int = 30
max_consecutive_failures: int = 3
# Pre-compiled patterns — populated by __post_init__, reused by is_dangerous
# on the per-tool hot path. ponytail: ceiling = rebuild when tool_patterns
# mutated after construction (rare; config-time only). Upgrade path =
# cached_property with invalidation on tool_patterns setter.
_compiled_patterns: list[re.Pattern] = field(default_factory=list, repr=False)
def __post_init__(self) -> None:
self._compiled_patterns = [re.compile(p) for p in self.tool_patterns]
def is_dangerous(self, tool_name: str) -> bool:
"""Check if a tool name matches any dangerous pattern."""
if not self.enabled:
return False
return any(p.match(tool_name) for p in self._compiled_patterns)
@classmethod
def from_dict(cls, data: dict | None) -> "DangerousToolsConfig":
"""Create from dict (parsed from YAML). Empty/None returns defaults."""
if not data:
return cls()
return cls(
enabled=data.get("enabled", True),
tool_patterns=data.get("tool_patterns") or cls().tool_patterns,
autonomy_timeout_minutes=data.get("autonomy_timeout_minutes", 30),
max_consecutive_failures=data.get("max_consecutive_failures", 3),
)
def _resolve_env_vars(value: Any) -> Any:
"""Resolve ${VAR:-default} patterns in string values from environment variables."""
if not isinstance(value, str):
@ -124,6 +183,9 @@ class ServerConfig:
# G6/U2: PLAN_EXEC phase policy config (opt-in — None = disabled).
# Parsed via PhasePolicy.policy_from_config() at chat.py wiring time.
plan_exec: dict[str, Any] | None = None,
# IQ-Boost/U1: dangerous_tools config for PLAN_EXEC autonomy mode (KTD2).
# Parsed via DangerousToolsConfig.from_dict(); defaults applied when None.
dangerous_tools: dict[str, Any] | None = None,
on_change: Callable[["ServerConfig"], None] | None = None,
):
self.host = host
@ -168,6 +230,8 @@ class ServerConfig:
# Resolved to PhasePolicy via agentkit.core.phase.policy_from_config()
# at chat.py WebSocket wiring time (U4).
self.plan_exec = plan_exec or {}
# IQ-Boost/U1: dangerous_tools parsed config (KTD2 conservative whitelist).
self.dangerous_tools = DangerousToolsConfig.from_dict(dangerous_tools)
self.on_change = on_change
# Config watching state
@ -261,6 +325,8 @@ class ServerConfig:
fallback_chain_data = data.get("fallback_chain", {})
# G6/U2: plan_exec phase policy 配置 (从 YAML 读取, opt-in)
plan_exec_data = data.get("plan_exec", {})
# IQ-Boost/U1: dangerous_tools 配置 (从 YAML 读取, KTD2 whitelist)
dangerous_tools_data = data.get("dangerous_tools", {})
return cls(
host=server.get("host", "0.0.0.0"),
@ -295,6 +361,7 @@ class ServerConfig:
rollback=rollback_data,
fallback_chain=fallback_chain_data,
plan_exec=plan_exec_data,
dangerous_tools=dangerous_tools_data,
)
@staticmethod

View File

@ -695,9 +695,15 @@ def _build_phase_engine(
)
return (None, None, f"phase policy error: {str(e)[:200]}")
# IQ-Boost/U2/R6-R8: PLAN_EXEC engages autonomy mode — plan confirmation
# is handled by the existing spec_review gate; after confirmation, only
# dangerous tools (per dangerous_tools_config) trigger confirmation_request.
dangerous_tools_cfg = getattr(server_config, "dangerous_tools", None)
engine = ReActEngine(
llm_gateway=llm_gateway,
phase_policy=phase_policy,
dangerous_tools_config=dangerous_tools_cfg,
autonomy_mode=True,
)
advance_phase_tool = AdvancePhaseTool(engine=engine)
tools_with_advance_phase = list(base_tools) + [advance_phase_tool]
@ -1013,6 +1019,9 @@ async def chat_websocket(websocket: WebSocket, session_id: str) -> None:
# U8/R8: pending spec-review futures keyed by spec_review_id. Resolved
# by the spec_review_reply client message; cancelled on WS teardown.
pending_spec_reviews: dict[str, asyncio.Future] = {}
# IQ-Boost/U3: pending autonomy-resume futures keyed by resume_token.
# Resolved by the ``resume`` client message; cancelled on WS teardown.
pending_autonomy_resumes: dict[str, asyncio.Future] = {}
chat_manager.add(session_id, websocket, pending_replies)
cancellation_token = CancellationToken()
@ -1095,6 +1104,7 @@ async def chat_websocket(websocket: WebSocket, session_id: str) -> None:
pending_replies,
pending_confirmations,
pending_spec_reviews,
pending_autonomy_resumes,
model_override=model,
)
)
@ -1150,6 +1160,22 @@ async def chat_websocket(websocket: WebSocket, session_id: str) -> None:
cancellation_token.cancel()
await websocket.send_json({"type": "result", "data": {"status": "cancelled"}})
elif msg_type == "resume":
# IQ-Boost/U3/R10: Resume autonomy-paused execution. The client
# sends {resume_token}. An unknown token is logged + ignored.
resume_token = msg.get("resume_token")
logger.info(f"Received resume for token: {resume_token!r}")
if resume_token and resume_token in pending_autonomy_resumes:
fut = pending_autonomy_resumes[resume_token]
if not fut.done():
fut.set_result(True)
else:
logger.warning(f"resume token {resume_token!r} already resolved")
else:
logger.warning(
f"resume token {resume_token!r} not found in pending_autonomy_resumes"
)
elif msg_type == "ping":
await websocket.send_json({"type": "pong"})
@ -1174,6 +1200,10 @@ async def chat_websocket(websocket: WebSocket, session_id: str) -> None:
for fut in pending_spec_reviews.values():
if not fut.done():
fut.cancel()
# IQ-Boost/U3: cancel any pending autonomy-resume futures.
for fut in pending_autonomy_resumes.values():
if not fut.done():
fut.cancel()
chat_manager.remove(session_id, websocket)
@ -1186,6 +1216,8 @@ async def _handle_chat_message(
pending_replies: dict[str, asyncio.Future],
pending_confirmations: dict[str, asyncio.Future] | None = None,
pending_spec_reviews: dict[str, asyncio.Future] | None = None,
# IQ-Boost/U3: pending autonomy-resume futures (keyed by resume_token).
pending_autonomy_resumes: dict[str, asyncio.Future] | None = None,
model_override: str | None = None,
) -> None:
"""Handle a user message: append to session, execute Agent, stream events.
@ -1577,6 +1609,35 @@ async def _handle_chat_message(
if hasattr(react_engine, "_spec_review_handler"):
react_engine._spec_review_handler = _spec_review_handler
# IQ-Boost/U3/R10: autonomy resume handler. When the engine triggers
# autonomy_paused (timeout/consecutive_failures), it calls this handler
# which blocks until the user sends ``resume`` (True) or the WS disconnects
# (Future cancelled → False). The engine already yielded the
# ``autonomy_paused`` event (forwarded to the frontend by the event loop
# below); this handler just provides the blocking mechanism.
_pending_autonomy_resumes = (
pending_autonomy_resumes if pending_autonomy_resumes is not None else {}
)
async def _resume_handler(resume_token: str, reason: str) -> bool:
"""Block until the user sends ``resume`` for the given token."""
loop = asyncio.get_running_loop()
future: asyncio.Future[bool] = loop.create_future()
_pending_autonomy_resumes[resume_token] = future
logger.info(f"Autonomy paused ({reason}), waiting for resume: {resume_token}")
try:
# Wait up to 30 minutes for user resume (long task availability).
result = await asyncio.wait_for(future, timeout=1800.0)
return bool(result)
except asyncio.TimeoutError:
logger.warning(f"Autonomy resume {resume_token} timed out (30 min)")
return False
except asyncio.CancelledError:
logger.warning(f"Autonomy resume {resume_token} cancelled")
return False
finally:
_pending_autonomy_resumes.pop(resume_token, None)
logger.info(
f"Chat session {session_id}: executing with {len(routing.tools)} tools, model={routing.model}, skill={routing.skill_name}"
)
@ -1595,6 +1656,7 @@ async def _handle_chat_message(
system_prompt=routing.system_prompt,
cancellation_token=cancellation_token,
confirmation_handler=_confirmation_handler,
resume_handler=_resume_handler,
):
if event.event_type == "final_answer":
# Flush any buffered tokens as a single write
@ -1652,6 +1714,17 @@ async def _handle_chat_message(
"data": event.data,
}
)
elif event.event_type == "autonomy_paused":
# IQ-Boost/U3/R10: forward autonomy pause to the frontend.
# The _resume_handler closure is already blocking the engine
# waiting for the user's ``resume`` message. The frontend
# shows a pause card with reason + progress + resume button.
await websocket.send_json(
{
"type": "autonomy_paused",
"data": event.data,
}
)
elif event.event_type == "spec_review_request":
# U8/R8: the _spec_review_handler closure already sent this
# request directly to the frontend (it owns the spec_review_id

View File

@ -0,0 +1,197 @@
"""U7: ABTester prompt-version offline comparison (R14).
Covers:
- compare_prompt_versions(): no episodic_memory "no_data"
- Multiple versions sorted by score descending
- Single version that version is best_version
- No matching versions "no_data"
- Retrieval failure "no_data" (non-blocking)
- best_version is the highest-scored
- Low-score versions included (no cleanup in compare; recommendation only)
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.evolution.ab_tester import ABTester
from agentkit.memory.base import MemoryItem
def _make_item(
version: int,
score: float,
task_hash: str = "abc123",
reflection: str = "reflection text",
improved_prompt: str = "improved prompt",
created_at: datetime | None = None,
) -> MemoryItem:
"""Build a MemoryItem mimicking list_prompt_reflections_by_hash output."""
if created_at is None:
created_at = datetime.now(timezone.utc)
return MemoryItem(
key=f"prompt_reflection:{task_hash}:{version}",
value={
"input_summary": "task input",
"output_summary": improved_prompt,
"reflection": reflection,
"quality_score": score,
},
metadata={
"agent_name": "test_agent",
"task_type": "prompt_reflection",
"task_hash": task_hash,
"version": version,
"score": score,
"created_at": created_at.isoformat(),
},
score=score,
created_at=created_at,
)
class TestComparePromptVersions:
"""Tests for ABTester.compare_prompt_versions()."""
@pytest.mark.asyncio
async def test_no_episodic_memory_returns_no_data(self):
"""No episodic_memory wired → recommendation='no_data', empty versions."""
tester = ABTester(episodic_memory=None)
result = await tester.compare_prompt_versions("any_hash")
assert result["recommendation"] == "no_data"
assert result["total_versions"] == 0
assert result["versions"] == []
assert result["best_version"] is None
assert result["task_hash"] == "any_hash"
@pytest.mark.asyncio
async def test_multiple_versions_sorted_by_score_desc(self):
"""3 versions (0.8, 0.6, 0.4) → best_version score=0.8."""
episodic = MagicMock()
episodic.list_prompt_reflections_by_hash = AsyncMock(
return_value=[
_make_item(version=1, score=0.4),
_make_item(version=2, score=0.8),
_make_item(version=3, score=0.6),
]
)
tester = ABTester(episodic_memory=episodic)
result = await tester.compare_prompt_versions("abc123")
assert result["total_versions"] == 3
assert result["recommendation"] == "keep_best"
versions = result["versions"]
# Sorted descending by score
assert versions[0]["score"] == 0.8
assert versions[1]["score"] == 0.6
assert versions[2]["score"] == 0.4
# best_version is the highest-scored
assert result["best_version"]["version"] == 2
assert result["best_version"]["score"] == 0.8
@pytest.mark.asyncio
async def test_single_version_is_best(self):
"""Only 1 version → that version is best_version."""
episodic = MagicMock()
episodic.list_prompt_reflections_by_hash = AsyncMock(
return_value=[_make_item(version=1, score=0.7)]
)
tester = ABTester(episodic_memory=episodic)
result = await tester.compare_prompt_versions("abc123")
assert result["total_versions"] == 1
assert result["recommendation"] == "keep_best"
assert result["best_version"]["version"] == 1
assert result["best_version"]["score"] == 0.7
@pytest.mark.asyncio
async def test_no_matching_versions_returns_no_data(self):
"""EpisodicMemory returns empty list → 'no_data'."""
episodic = MagicMock()
episodic.list_prompt_reflections_by_hash = AsyncMock(return_value=[])
tester = ABTester(episodic_memory=episodic)
result = await tester.compare_prompt_versions("unknown_hash")
assert result["recommendation"] == "no_data"
assert result["total_versions"] == 0
assert result["best_version"] is None
@pytest.mark.asyncio
async def test_retrieval_failure_returns_no_data(self):
"""list_prompt_reflections_by_hash raises → 'no_data' (non-blocking)."""
episodic = MagicMock()
episodic.list_prompt_reflections_by_hash = AsyncMock(
side_effect=RuntimeError("db connection failed")
)
tester = ABTester(episodic_memory=episodic)
result = await tester.compare_prompt_versions("abc123")
assert result["recommendation"] == "no_data"
assert result["total_versions"] == 0
@pytest.mark.asyncio
async def test_versions_include_reflection_and_prompt_fields(self):
"""Each version dict carries reflection_summary + improved_prompt."""
episodic = MagicMock()
episodic.list_prompt_reflections_by_hash = AsyncMock(
return_value=[
_make_item(
version=1,
score=0.9,
reflection="avoid retrying on schema error",
improved_prompt="use structured output schema",
)
]
)
tester = ABTester(episodic_memory=episodic)
result = await tester.compare_prompt_versions("abc123")
version = result["versions"][0]
assert "avoid retrying on schema error" in version["reflection_summary"]
assert "use structured output schema" in version["improved_prompt"]
assert version["version"] == 1
assert version["score"] == 0.9
# timestamp is a string (ISO format)
assert isinstance(version["timestamp"], str)
@pytest.mark.asyncio
async def test_low_score_versions_kept_in_list(self):
"""Low-score versions remain in versions list (no cleanup; recommendation only)."""
episodic = MagicMock()
episodic.list_prompt_reflections_by_hash = AsyncMock(
return_value=[
_make_item(version=1, score=0.1), # low score
_make_item(version=2, score=0.9), # high score
]
)
tester = ABTester(episodic_memory=episodic)
result = await tester.compare_prompt_versions("abc123")
# Both versions present — compare does not filter, only sorts
assert result["total_versions"] == 2
assert result["best_version"]["score"] == 0.9
# Low-score version still in list (for inspection)
scores = [v["score"] for v in result["versions"]]
assert 0.1 in scores
assert 0.9 in scores
@pytest.mark.asyncio
async def test_task_hash_echoed_in_result(self):
"""task_hash field in result matches input."""
episodic = MagicMock()
episodic.list_prompt_reflections_by_hash = AsyncMock(return_value=[])
tester = ABTester(episodic_memory=episodic)
result = await tester.compare_prompt_versions("specific_hash_456")
assert result["task_hash"] == "specific_hash_456"

View File

@ -0,0 +1,393 @@
"""Unit tests for autonomy_paused event + timeout/failure tracking (IQ-Boost U3, R10).
Verifies:
- Timeout triggers autonomy_paused with reason="timeout"
- Consecutive failures (3x) trigger autonomy_paused with reason="consecutive_failures"
- resume_handler returns True counters reset, execution continues
- resume_handler returns False execution stops (cancel)
- resume_handler=None auto-resume (non-blocking, for tests/REST)
- Non-autonomy mode no pause (gate is a no-op)
- Disabled thresholds (0) no pause
- _track_tool_result_for_autonomy increments on error, resets on success
"""
from __future__ import annotations
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.core.react import ReActEngine, ReActEvent
from agentkit.server.config import DangerousToolsConfig
# ---------------------------------------------------------------------------
# Helper: simulates the caller pattern (detect → yield event → await resume)
# ---------------------------------------------------------------------------
async def _detect_and_await_pause(
engine: ReActEngine,
step: int,
progress: dict,
resume_handler=None,
) -> tuple[bool, list[ReActEvent]]:
"""Simulate the react.py caller pattern for autonomy pause.
Mirrors the production code: detect (non-blocking) build event
yield event await resume (blocking). Returns (should_continue, events).
"""
pause_info = engine._detect_autonomy_pause(step, progress)
if pause_info is None:
return True, []
reason, token, event_data = pause_info
event = ReActEvent(event_type="autonomy_paused", step=step, data=event_data)
should_continue = await engine._await_autonomy_resume(
token, reason, resume_handler
)
return should_continue, [event]
# ---------------------------------------------------------------------------
# _track_tool_result_for_autonomy
# ---------------------------------------------------------------------------
class TestTrackToolResult:
"""Failure tracking increments on error, resets on success."""
def test_success_resets_counter(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
engine._consecutive_failures = 2
engine._track_tool_result_for_autonomy({"output": "ok"})
assert engine._consecutive_failures == 0
def test_error_increments_counter(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
engine._consecutive_failures = 1
engine._track_tool_result_for_autonomy({"error": "boom"})
assert engine._consecutive_failures == 2
def test_is_error_flag_increments(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
engine._track_tool_result_for_autonomy({"is_error": True, "output": ""})
assert engine._consecutive_failures == 1
def test_error_type_increments(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
engine._track_tool_result_for_autonomy({"error_type": "permission_denied"})
assert engine._consecutive_failures == 1
def test_non_dict_result_resets(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
engine._consecutive_failures = 2
engine._track_tool_result_for_autonomy("plain string result")
assert engine._consecutive_failures == 0
def test_no_tracking_when_not_autonomy(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=False,
)
engine._consecutive_failures = 0
engine._track_tool_result_for_autonomy({"error": "boom"})
assert engine._consecutive_failures == 0 # Unchanged
# ---------------------------------------------------------------------------
# _detect_autonomy_pause + _await_autonomy_resume — no pause conditions
# ---------------------------------------------------------------------------
class TestAutonomyPauseNoTrigger:
"""When conditions are not met, no pause is triggered (should_continue=True)."""
@pytest.mark.asyncio
async def test_no_autonomy_mode_no_pause(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=False,
)
should_continue, events = await _detect_and_await_pause(
engine, step=1, progress={}, resume_handler=None
)
assert should_continue is True
assert events == []
@pytest.mark.asyncio
async def test_no_config_no_pause(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=None,
autonomy_mode=True,
)
should_continue, events = await _detect_and_await_pause(
engine, step=1, progress={}, resume_handler=None
)
assert should_continue is True
assert events == []
@pytest.mark.asyncio
async def test_below_thresholds_no_pause(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(
autonomy_timeout_minutes=30,
max_consecutive_failures=3,
),
autonomy_mode=True,
)
engine._autonomy_started_at = time.time() # Just started
engine._consecutive_failures = 1 # Below threshold
should_continue, events = await _detect_and_await_pause(
engine, step=1, progress={}, resume_handler=None
)
assert should_continue is True
assert events == []
@pytest.mark.asyncio
async def test_disabled_thresholds_no_pause(self):
"""0 = disabled for both thresholds."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(
autonomy_timeout_minutes=0,
max_consecutive_failures=0,
),
autonomy_mode=True,
)
# Even with expired time + many failures, disabled = no pause.
engine._autonomy_started_at = time.time() - 999999
engine._consecutive_failures = 99
should_continue, events = await _detect_and_await_pause(
engine, step=1, progress={}, resume_handler=None
)
assert should_continue is True
assert events == []
# ---------------------------------------------------------------------------
# _detect_autonomy_pause + _await_autonomy_resume — timeout trigger
# ---------------------------------------------------------------------------
class TestAutonomyPauseTimeout:
"""Timeout triggers autonomy_paused with reason='timeout'."""
@pytest.mark.asyncio
async def test_timeout_triggers_pause_auto_resume(self):
"""resume_handler=None → auto-resume (non-blocking)."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(
autonomy_timeout_minutes=1, # 1 minute
max_consecutive_failures=99, # Disabled for this test
),
autonomy_mode=True,
)
# Simulate started 2 minutes ago (exceeds 1-min timeout).
engine._autonomy_started_at = time.time() - 120
should_continue, events = await _detect_and_await_pause(
engine, step=5, progress={"step": 5}, resume_handler=None
)
# Auto-resume → should_continue=True
assert should_continue is True
assert len(events) == 1
assert events[0].event_type == "autonomy_paused"
assert events[0].data["reason"] == "timeout"
assert "resume_token" in events[0].data
assert events[0].data["elapsed_seconds"] >= 120
# Counters reset after auto-resume
assert engine._consecutive_failures == 0
assert engine._autonomy_started_at > time.time() - 1 # Fresh timer
@pytest.mark.asyncio
async def test_timeout_triggers_pause_user_resumes(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(
autonomy_timeout_minutes=1,
max_consecutive_failures=99,
),
autonomy_mode=True,
)
engine._autonomy_started_at = time.time() - 120
resume_handler = AsyncMock(return_value=True)
should_continue, events = await _detect_and_await_pause(
engine, step=3, progress={}, resume_handler=resume_handler
)
assert should_continue is True
assert events[0].data["reason"] == "timeout"
resume_handler.assert_awaited_once()
# Counters reset
assert engine._consecutive_failures == 0
@pytest.mark.asyncio
async def test_timeout_triggers_pause_user_cancels(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(
autonomy_timeout_minutes=1,
max_consecutive_failures=99,
),
autonomy_mode=True,
)
engine._autonomy_started_at = time.time() - 120
resume_handler = AsyncMock(return_value=False)
should_continue, events = await _detect_and_await_pause(
engine, step=3, progress={}, resume_handler=resume_handler
)
assert should_continue is False # Cancelled
assert events[0].data["reason"] == "timeout"
# ---------------------------------------------------------------------------
# _detect_autonomy_pause + _await_autonomy_resume — consecutive failures trigger
# ---------------------------------------------------------------------------
class TestAutonomyPauseConsecutiveFailures:
"""3 consecutive failures trigger autonomy_paused."""
@pytest.mark.asyncio
async def test_consecutive_failures_triggers_pause(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(
autonomy_timeout_minutes=0, # Disabled
max_consecutive_failures=3,
),
autonomy_mode=True,
)
engine._autonomy_started_at = time.time()
engine._consecutive_failures = 3 # At threshold
resume_handler = AsyncMock(return_value=True)
should_continue, events = await _detect_and_await_pause(
engine, step=5, progress={"step": 5}, resume_handler=resume_handler
)
assert should_continue is True # Resumed
assert len(events) == 1
assert events[0].event_type == "autonomy_paused"
assert events[0].data["reason"] == "consecutive_failures"
assert events[0].data["consecutive_failures"] == 3
# Reset after resume
assert engine._consecutive_failures == 0
@pytest.mark.asyncio
async def test_below_failure_threshold_no_pause(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(
autonomy_timeout_minutes=0,
max_consecutive_failures=3,
),
autonomy_mode=True,
)
engine._autonomy_started_at = time.time()
engine._consecutive_failures = 2 # Below threshold
should_continue, events = await _detect_and_await_pause(
engine, step=1, progress={}, resume_handler=None
)
assert should_continue is True
assert events == []
@pytest.mark.asyncio
async def test_resume_handler_exception_cancels(self):
"""If resume_handler raises, treat as cancel (should_continue=False)."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(
autonomy_timeout_minutes=0,
max_consecutive_failures=3,
),
autonomy_mode=True,
)
engine._consecutive_failures = 3
async def failing_handler(token, reason):
raise RuntimeError("handler crashed")
should_continue, events = await _detect_and_await_pause(
engine, step=1, progress={}, resume_handler=failing_handler
)
assert should_continue is False # Cancelled due to exception
assert events[0].data["reason"] == "consecutive_failures"
# ---------------------------------------------------------------------------
# Config parsing
# ---------------------------------------------------------------------------
class TestAutonomyConfigParsing:
"""DangerousToolsConfig parses autonomy thresholds from dict."""
def test_defaults(self):
cfg = DangerousToolsConfig()
assert cfg.autonomy_timeout_minutes == 30
assert cfg.max_consecutive_failures == 3
def test_custom_values_from_dict(self):
cfg = DangerousToolsConfig.from_dict(
{
"enabled": True,
"autonomy_timeout_minutes": 60,
"max_consecutive_failures": 5,
}
)
assert cfg.autonomy_timeout_minutes == 60
assert cfg.max_consecutive_failures == 5
def test_disabled_values_from_dict(self):
cfg = DangerousToolsConfig.from_dict(
{
"autonomy_timeout_minutes": 0,
"max_consecutive_failures": 0,
}
)
assert cfg.autonomy_timeout_minutes == 0
assert cfg.max_consecutive_failures == 0
def test_partial_config_uses_defaults(self):
cfg = DangerousToolsConfig.from_dict({"enabled": True})
assert cfg.autonomy_timeout_minutes == 30
assert cfg.max_consecutive_failures == 3
def test_empty_dict_uses_defaults(self):
cfg = DangerousToolsConfig.from_dict({})
assert cfg.autonomy_timeout_minutes == 30
assert cfg.max_consecutive_failures == 3

View File

@ -0,0 +1,325 @@
"""U6: Lead planning-time historical reflection retrieval (R12, R13).
Covers:
- ReflexionEngine.retrieve_prompt_reflection(): returns best reflection by score
- Score filtering: score <= 0.5 not returned
- No episodic_memory returns None
- Retrieval failure returns None (non-blocking)
- TeamOrchestrator._decompose_task: prepends improved_prompt when reflection found
- No reflexion_engine default prompt (backward compat)
- Retrieval failure default prompt (non-blocking)
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.core.reflexion import ReflexionEngine
from agentkit.experts.orchestrator import TeamOrchestrator
from agentkit.experts.team import ExpertTeam
from agentkit.memory.base import MemoryItem
from tests.unit.experts._helpers import make_execute_stream_mock
# ── Helpers ────────────────────────────────────────────────────────────
def _make_memory_item(
score: float = 0.8,
output_summary: str = "improved prompt text",
reflection: str = "reflection text",
version: int = 1,
) -> MemoryItem:
from datetime import datetime, timezone
# Shape matches EpisodicMemory.list_prompt_reflections_by_hash return:
# output_summary/reflection/quality_score live in value dict,
# version/task_hash live in metadata.
return MemoryItem(
key="prompt_reflection:abc:1",
value={
"input_summary": "test",
"output_summary": output_summary,
"reflection": reflection,
"quality_score": score,
},
metadata={
"task_type": "prompt_reflection",
"version": version,
"task_hash": "abc",
"created_at": datetime.now(timezone.utc).isoformat(),
},
score=score,
created_at=datetime.now(timezone.utc),
)
def _make_llm_gateway_mock() -> MagicMock:
gw = MagicMock()
response = MagicMock()
response.content = (
'[{"name":"A","assigned_expert":"lead","task_description":"a","depends_on":[]}]'
)
gw.chat = AsyncMock(return_value=response)
return gw
def _make_team_with_experts() -> ExpertTeam:
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.core.protocol import TaskResult, TaskStatus
from agentkit.experts.config import ExpertConfig
from agentkit.experts.expert import Expert
team = ExpertTeam()
team._handoff_transport = AsyncMock(spec=InProcessHandoffTransport)
config = ExpertConfig(
name="lead",
agent_type="expert",
persona="测试",
thinking_style="逻辑",
bound_skills=["s"],
is_lead=True,
task_mode="llm_generate",
prompt={"identity": "测试"},
)
expert = MagicMock(spec=Expert)
expert.config = config
expert.is_active = True
expert.team_id = None
expert.get_capabilities_summary.return_value = {"name": "lead"}
mock_agent = MagicMock()
mock_agent.execute = AsyncMock(
return_value=TaskResult(
task_id="t",
agent_name="lead",
status=TaskStatus.COMPLETED.value,
output_data={"content": "result"},
error_message=None,
started_at=None,
completed_at=None,
)
)
mock_agent.execute_stream = make_execute_stream_mock("result")
mock_agent._llm_gateway = None
expert.agent = mock_agent
team._experts["lead"] = expert
team._lead_expert_name = "lead"
return team
# ── ReflexionEngine.retrieve_prompt_reflection ─────────────────────────
class TestRetrievePromptReflection:
"""U6/R12: retrieve_prompt_reflection returns best reflection by score."""
@pytest.mark.asyncio
async def test_returns_none_when_no_episodic_memory(self):
gw = MagicMock()
engine = ReflexionEngine(llm_gateway=gw, episodic_memory=None)
result = await engine.retrieve_prompt_reflection(task_input="test")
assert result is None
@pytest.mark.asyncio
async def test_returns_best_reflection_by_score(self):
episodic = MagicMock()
# Two results: score 0.6 and 0.9 — should return 0.9
items = [
_make_memory_item(score=0.6, output_summary="prompt v1"),
_make_memory_item(score=0.9, output_summary="prompt v2"),
]
episodic.search_prompt_reflections = AsyncMock(return_value=items)
gw = MagicMock()
engine = ReflexionEngine(llm_gateway=gw, episodic_memory=episodic)
result = await engine.retrieve_prompt_reflection(task_input="test")
assert result is not None
assert result["score"] == 0.9
assert result["improved_prompt"] == "prompt v2"
@pytest.mark.asyncio
async def test_filters_low_score_reflections(self):
"""score <= 0.5 should not be returned."""
episodic = MagicMock()
items = [_make_memory_item(score=0.3, output_summary="low score")]
episodic.search_prompt_reflections = AsyncMock(return_value=items)
gw = MagicMock()
engine = ReflexionEngine(llm_gateway=gw, episodic_memory=episodic)
result = await engine.retrieve_prompt_reflection(task_input="test", min_score=0.5)
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_no_results(self):
episodic = MagicMock()
episodic.search_prompt_reflections = AsyncMock(return_value=[])
gw = MagicMock()
engine = ReflexionEngine(llm_gateway=gw, episodic_memory=episodic)
result = await engine.retrieve_prompt_reflection(task_input="test")
assert result is None
@pytest.mark.asyncio
async def test_returns_none_on_search_failure(self):
episodic = MagicMock()
episodic.search_prompt_reflections = AsyncMock(side_effect=RuntimeError("DB down"))
gw = MagicMock()
engine = ReflexionEngine(llm_gateway=gw, episodic_memory=episodic)
result = await engine.retrieve_prompt_reflection(task_input="test")
assert result is None
@pytest.mark.asyncio
async def test_returns_reflection_fields_complete(self):
episodic = MagicMock()
items = [
_make_memory_item(
score=0.85,
output_summary="improved prompt",
reflection="what went wrong",
version=3,
)
]
episodic.search_prompt_reflections = AsyncMock(return_value=items)
gw = MagicMock()
engine = ReflexionEngine(llm_gateway=gw, episodic_memory=episodic)
result = await engine.retrieve_prompt_reflection(task_input="test")
assert result is not None
assert result["improved_prompt"] == "improved prompt"
assert result["reflection"] == "what went wrong"
assert result["version"] == 3
assert result["score"] == 0.85
assert "task_hash" in result
@pytest.mark.asyncio
async def test_custom_min_score_threshold(self):
"""min_score=0.7 filters out score=0.6."""
episodic = MagicMock()
items = [_make_memory_item(score=0.6, output_summary="medium")]
episodic.search_prompt_reflections = AsyncMock(return_value=items)
gw = MagicMock()
engine = ReflexionEngine(llm_gateway=gw, episodic_memory=episodic)
result = await engine.retrieve_prompt_reflection(task_input="test", min_score=0.7)
assert result is None
# ── TeamOrchestrator._decompose_task with reflection ──────────────────
class TestDecomposeWithReflection:
"""U6/R13: _decompose_task prepends improved_prompt when reflection found."""
@pytest.mark.asyncio
async def test_decompose_prepends_reflection_when_found(self):
"""When reflexion_engine returns a reflection, the decomposition
prompt includes the improved_prompt."""
team = _make_team_with_experts()
gw = _make_llm_gateway_mock()
team._experts["lead"].agent._llm_gateway = gw
# Mock reflexion_engine
reflexion = MagicMock(spec=ReflexionEngine)
reflexion.retrieve_prompt_reflection = AsyncMock(
return_value={
"improved_prompt": "USE THIS IMPROVED APPROACH",
"score": 0.85,
"reflection": "past mistake",
"version": 2,
"task_hash": "abc",
}
)
orchestrator = TeamOrchestrator(team, reflexion_engine=reflexion)
await orchestrator._decompose_task(team.lead_expert, "test task")
# Verify retrieve was called
reflexion.retrieve_prompt_reflection.assert_awaited_once()
# Verify the prompt sent to LLM includes the improved_prompt
call_kwargs = gw.chat.await_args.kwargs
messages = call_kwargs.get("messages") or gw.chat.await_args.args[0]
prompt_content = messages[0]["content"] if isinstance(messages, list) else str(messages)
assert "USE THIS IMPROVED APPROACH" in prompt_content
assert "Historical Reflection" in prompt_content
@pytest.mark.asyncio
async def test_decompose_uses_default_when_no_reflexion_engine(self):
"""No reflexion_engine → default prompt (backward compat)."""
team = _make_team_with_experts()
gw = _make_llm_gateway_mock()
team._experts["lead"].agent._llm_gateway = gw
orchestrator = TeamOrchestrator(team, reflexion_engine=None)
await orchestrator._decompose_task(team.lead_expert, "test task")
# Verify default prompt (no Historical Reflection section)
call_kwargs = gw.chat.await_args.kwargs
messages = call_kwargs.get("messages") or gw.chat.await_args.args[0]
prompt_content = messages[0]["content"] if isinstance(messages, list) else str(messages)
assert "Historical Reflection" not in prompt_content
@pytest.mark.asyncio
async def test_decompose_uses_default_when_no_reflection_found(self):
"""reflexion_engine returns None → default prompt."""
team = _make_team_with_experts()
gw = _make_llm_gateway_mock()
team._experts["lead"].agent._llm_gateway = gw
reflexion = MagicMock(spec=ReflexionEngine)
reflexion.retrieve_prompt_reflection = AsyncMock(return_value=None)
orchestrator = TeamOrchestrator(team, reflexion_engine=reflexion)
await orchestrator._decompose_task(team.lead_expert, "test task")
call_kwargs = gw.chat.await_args.kwargs
messages = call_kwargs.get("messages") or gw.chat.await_args.args[0]
prompt_content = messages[0]["content"] if isinstance(messages, list) else str(messages)
assert "Historical Reflection" not in prompt_content
@pytest.mark.asyncio
async def test_decompose_uses_default_when_retrieval_fails(self):
"""reflexion_engine.retrieve raises → default prompt (non-blocking)."""
team = _make_team_with_experts()
gw = _make_llm_gateway_mock()
team._experts["lead"].agent._llm_gateway = gw
reflexion = MagicMock(spec=ReflexionEngine)
reflexion.retrieve_prompt_reflection = AsyncMock(side_effect=RuntimeError("search failed"))
orchestrator = TeamOrchestrator(team, reflexion_engine=reflexion)
await orchestrator._decompose_task(team.lead_expert, "test task")
# Default prompt used despite retrieval failure
call_kwargs = gw.chat.await_args.kwargs
messages = call_kwargs.get("messages") or gw.chat.await_args.args[0]
prompt_content = messages[0]["content"] if isinstance(messages, list) else str(messages)
assert "Historical Reflection" not in prompt_content
@pytest.mark.asyncio
async def test_decompose_skips_reflection_when_no_improved_prompt(self):
"""reflexion_engine returns dict without improved_prompt → no hint."""
team = _make_team_with_experts()
gw = _make_llm_gateway_mock()
team._experts["lead"].agent._llm_gateway = gw
reflexion = MagicMock(spec=ReflexionEngine)
reflexion.retrieve_prompt_reflection = AsyncMock(
return_value={"improved_prompt": "", "score": 0.8} # empty improved_prompt
)
orchestrator = TeamOrchestrator(team, reflexion_engine=reflexion)
await orchestrator._decompose_task(team.lead_expert, "test task")
call_kwargs = gw.chat.await_args.kwargs
messages = call_kwargs.get("messages") or gw.chat.await_args.args[0]
prompt_content = messages[0]["content"] if isinstance(messages, list) else str(messages)
assert "Historical Reflection" not in prompt_content

View File

@ -0,0 +1,123 @@
"""Integration tests for PLAN_EXEC autonomy mode wiring (IQ-Boost U2).
Verifies that ``_build_phase_engine`` in chat.py constructs a PLAN_EXEC
engine with ``autonomy_mode=True`` and ``dangerous_tools_config`` injected
from ``ServerConfig``. This is the integration seam between U1 (config)
and U2 (engine gate).
"""
from __future__ import annotations
from unittest.mock import MagicMock
from agentkit.chat.skill_routing import ExecutionMode
from agentkit.core.react import ReActEngine
from agentkit.server.config import DangerousToolsConfig, ServerConfig
def _make_server_config(dangerous_tools: DangerousToolsConfig | None = None) -> ServerConfig:
"""Build a minimal ServerConfig with the dangerous_tools section."""
cfg = ServerConfig.from_dict({})
if dangerous_tools is not None:
cfg.dangerous_tools = dangerous_tools
return cfg
class TestBuildPhaseEngineAutonomyWiring:
"""_build_phase_engine injects autonomy config into the PLAN_EXEC engine."""
def test_plan_exec_engine_gets_autonomy_mode(self):
"""When PLAN_EXEC is engaged, the engine has autonomy_mode=True."""
# Import here to avoid module-level FastAPI app construction.
from agentkit.server.routes.chat import _build_phase_engine
server_cfg = _make_server_config(DangerousToolsConfig())
engine, tools, err = _build_phase_engine(
server_config=server_cfg,
llm_gateway=MagicMock(),
execution_mode=ExecutionMode.PLAN_EXEC,
base_tools=[],
session_id="test-session",
)
assert err is None
assert engine is not None
assert isinstance(engine, ReActEngine)
assert engine._autonomy_mode is True
assert engine._dangerous_tools_config is not None
assert engine._dangerous_tools_config.is_dangerous("rm") is True
def test_plan_exec_engine_gets_custom_dangerous_config(self):
"""Custom dangerous_tools config (disabled) propagates to the engine."""
from agentkit.server.routes.chat import _build_phase_engine
server_cfg = _make_server_config(DangerousToolsConfig(enabled=False))
engine, _, err = _build_phase_engine(
server_config=server_cfg,
llm_gateway=MagicMock(),
execution_mode=ExecutionMode.PLAN_EXEC,
base_tools=[],
session_id="test",
)
assert err is None
assert engine is not None
assert engine._dangerous_tools_config.enabled is False
# Disabled whitelist → nothing is dangerous
assert engine._dangerous_tools_config.is_dangerous("rm") is False
def test_non_plan_exec_mode_returns_none_engine(self):
"""Non-PLAN_EXEC mode → (None, None, None) — no autonomy engine built."""
from agentkit.server.routes.chat import _build_phase_engine
server_cfg = _make_server_config(DangerousToolsConfig())
engine, tools, err = _build_phase_engine(
server_config=server_cfg,
llm_gateway=MagicMock(),
execution_mode=ExecutionMode.REACT,
base_tools=[],
session_id="test",
)
assert engine is None
assert tools is None
assert err is None
def test_plan_exec_engine_gets_default_dangerous_config_when_server_config_missing(
self,
):
"""When server_config is None or lacks dangerous_tools, the engine
still gets autonomy_mode=True (config defaults to None gate is a
no-op, but autonomy flag is set for future wiring)."""
from agentkit.server.routes.chat import _build_phase_engine
# server_config=None simulates tests/misconfigured deployments.
engine, _, err = _build_phase_engine(
server_config=None,
llm_gateway=MagicMock(),
execution_mode=ExecutionMode.PLAN_EXEC,
base_tools=[],
session_id="test",
)
assert err is None
assert engine is not None
assert engine._autonomy_mode is True
# dangerous_tools_config is None when server_config is None — gate
# becomes a transparent no-op (backward compat for tests without config).
assert engine._dangerous_tools_config is None
def test_plan_exec_engine_includes_advance_phase_tool(self):
"""PLAN_EXEC engine's tool list includes AdvancePhaseTool."""
from agentkit.server.routes.chat import _build_phase_engine
server_cfg = _make_server_config(DangerousToolsConfig())
engine, tools, err = _build_phase_engine(
server_config=server_cfg,
llm_gateway=MagicMock(),
execution_mode=ExecutionMode.PLAN_EXEC,
base_tools=[],
session_id="test",
)
assert err is None
assert engine is not None
assert tools is not None
# AdvancePhaseTool appended to the base tool list.
assert len(tools) == 1
assert type(tools[0]).__name__ == "AdvancePhaseTool"

View File

@ -0,0 +1,302 @@
"""Unit tests for ReActEngine autonomy mode (IQ-Boost U2, R6-R9).
Verifies the PLAN_EXEC autonomy gate:
- Dangerous tools (per DangerousToolsConfig) trigger confirmation_request
BEFORE first execution in autonomy mode.
- Non-dangerous tools execute directly without confirmation.
- Non-autonomy mode preserves existing behavior (gate is a no-op).
- Disabled whitelist (enabled=False) allows all tools.
- Approved dangerous tool re-executes with _skip_dangerous_check=True.
- Rejected dangerous tool returns permission_denied result.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.core.react import ReActEngine
from agentkit.server.config import DangerousToolsConfig
# ---------------------------------------------------------------------------
# _check_autonomy_gate — backward compatibility (no-op when disabled)
# ---------------------------------------------------------------------------
class TestAutonomyGateBackwardCompat:
"""When autonomy_mode=False or dangerous_tools_config=None, the gate is a
transparent no-op existing callers see no behavior change."""
@pytest.mark.asyncio
async def test_no_autonomy_mode_returns_should_execute(self):
"""Default engine (autonomy_mode=False) → gate passes through."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=False,
)
should_exec, events, result = await engine._check_autonomy_gate(
"rm", {"path": "/tmp/x"}, [], step=1, confirmation_handler=None
)
assert should_exec is True
assert events == []
assert result is None
@pytest.mark.asyncio
async def test_no_dangerous_config_returns_should_execute(self):
"""autonomy_mode=True but no config → gate passes through (no whitelist
to check against)."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=None,
autonomy_mode=True,
)
should_exec, events, result = await engine._check_autonomy_gate(
"rm", {"path": "/tmp/x"}, [], step=1, confirmation_handler=None
)
assert should_exec is True
assert events == []
assert result is None
@pytest.mark.asyncio
async def test_disabled_whitelist_allows_all(self):
"""enabled=False → is_dangerous always returns False → all tools pass."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(enabled=False),
autonomy_mode=True,
)
should_exec, events, result = await engine._check_autonomy_gate(
"rm", {"path": "/etc"}, [], step=1, confirmation_handler=None
)
assert should_exec is True
assert events == []
# ---------------------------------------------------------------------------
# _check_autonomy_gate — non-dangerous tool in autonomy mode
# ---------------------------------------------------------------------------
class TestAutonomyGateNonDangerous:
"""In autonomy mode, non-dangerous tools execute without confirmation."""
@pytest.mark.asyncio
async def test_non_dangerous_tool_passes_through(self):
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
should_exec, events, result = await engine._check_autonomy_gate(
"read_file", {"path": "/tmp/x"}, [], step=1, confirmation_handler=None
)
assert should_exec is True
assert events == []
assert result is None
@pytest.mark.asyncio
async def test_custom_pattern_non_match_passes(self):
"""Tool not matching any custom pattern passes through."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(tool_patterns=[r"^rm$", r"^kubectl"]),
autonomy_mode=True,
)
should_exec, events, result = await engine._check_autonomy_gate(
"search", {"query": "foo"}, [], step=1, confirmation_handler=None
)
assert should_exec is True
assert events == []
# ---------------------------------------------------------------------------
# _check_autonomy_gate — dangerous tool triggers confirmation
# ---------------------------------------------------------------------------
class TestAutonomyGateDangerous:
"""Dangerous tools in autonomy mode trigger confirmation_request before
execution."""
@pytest.mark.asyncio
async def test_dangerous_tool_emits_confirmation_request(self):
"""rm matches ^rm$ pattern → confirmation_request event emitted."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
fake_tool = MagicMock()
fake_tool.safe_execute = AsyncMock(return_value={"output": "deleted"})
engine._find_tool = lambda name, tools: fake_tool
handler = AsyncMock(return_value=True)
should_exec, events, result = await engine._check_autonomy_gate(
"rm", {"path": "/tmp/x"}, [fake_tool], step=1, confirmation_handler=handler
)
assert should_exec is False # Gate handled execution
assert len(events) == 2 # confirmation_request + confirmation_result
assert events[0].event_type == "confirmation_request"
assert events[0].data["tool_name"] == "rm"
assert events[0].data["confirmation_id"].startswith("autonomy:rm:")
assert events[1].event_type == "confirmation_result"
assert events[1].data["approved"] is True
# Tool executed with _skip_dangerous_check=True
fake_tool.safe_execute.assert_awaited_once_with(path="/tmp/x", _skip_dangerous_check=True)
assert result == {"output": "deleted"}
@pytest.mark.asyncio
async def test_dangerous_tool_rejected_returns_permission_denied(self):
"""User rejects → permission_denied result, tool NOT executed."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
fake_tool = MagicMock()
fake_tool.safe_execute = AsyncMock(return_value={"output": "should not run"})
engine._find_tool = lambda name, tools: fake_tool
handler = AsyncMock(return_value=False)
should_exec, events, result = await engine._check_autonomy_gate(
"rm", {"path": "/tmp/x"}, [fake_tool], step=1, confirmation_handler=handler
)
assert should_exec is False
assert len(events) == 2
assert events[1].data["approved"] is False
fake_tool.safe_execute.assert_not_awaited()
assert result["error_type"] == "permission_denied"
assert result["exit_code"] == 126
@pytest.mark.asyncio
async def test_dangerous_tool_no_handler_auto_rejects(self):
"""No confirmation_handler → auto-reject (safe default)."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
engine._find_tool = lambda name, tools: MagicMock()
should_exec, events, result = await engine._check_autonomy_gate(
"kubectl_delete", {"ns": "prod"}, [], step=2, confirmation_handler=None
)
assert should_exec is False
assert events[1].data["approved"] is False
assert result["error_type"] == "permission_denied"
@pytest.mark.asyncio
async def test_dangerous_tool_handler_exception_auto_rejects(self):
"""Handler raises → auto-reject (does not crash the engine)."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
engine._find_tool = lambda name, tools: MagicMock()
async def failing_handler(cid, cmd, reason):
raise RuntimeError("handler crashed")
should_exec, events, result = await engine._check_autonomy_gate(
"deploy", {"env": "prod"}, [], step=3, confirmation_handler=failing_handler
)
assert should_exec is False
assert events[1].data["approved"] is False
assert result["error_type"] == "permission_denied"
@pytest.mark.asyncio
async def test_payment_glob_pattern_matches(self):
"""payment_* pattern matches payment_charge tool."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
fake_tool = MagicMock()
fake_tool.safe_execute = AsyncMock(return_value={"ok": True})
engine._find_tool = lambda name, tools: fake_tool
handler = AsyncMock(return_value=True)
should_exec, events, result = await engine._check_autonomy_gate(
"payment_charge",
{"amount": 100},
[fake_tool],
step=1,
confirmation_handler=handler,
)
assert should_exec is False
assert events[0].event_type == "confirmation_request"
assert result == {"ok": True}
@pytest.mark.asyncio
async def test_dangerous_tool_not_found_returns_error(self):
"""Approved but tool not found → error result."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
engine._find_tool = lambda name, tools: None
handler = AsyncMock(return_value=True)
should_exec, events, result = await engine._check_autonomy_gate(
"rm", {"path": "/x"}, [], step=1, confirmation_handler=handler
)
assert should_exec is False
assert "error" in result
assert "not found" in result["error"]
@pytest.mark.asyncio
async def test_dangerous_tool_execution_failure_returns_error(self):
"""Approved but tool.safe_execute raises → error dict."""
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=DangerousToolsConfig(),
autonomy_mode=True,
)
fake_tool = MagicMock()
fake_tool.safe_execute = AsyncMock(side_effect=RuntimeError("boom"))
engine._find_tool = lambda name, tools: fake_tool
handler = AsyncMock(return_value=True)
should_exec, events, result = await engine._check_autonomy_gate(
"rm", {"path": "/x"}, [fake_tool], step=1, confirmation_handler=handler
)
assert should_exec is False
assert "error" in result
assert "tool_execution_failed" in result.get("error_code", "")
# ---------------------------------------------------------------------------
# Constructor wiring
# ---------------------------------------------------------------------------
class TestAutonomyConstructorWiring:
"""Verify __init__ stores autonomy state correctly."""
def test_defaults_are_off(self):
engine = ReActEngine(llm_gateway=MagicMock())
assert engine._autonomy_mode is False
assert engine._dangerous_tools_config is None
def test_autonomy_mode_stored(self):
cfg = DangerousToolsConfig()
engine = ReActEngine(
llm_gateway=MagicMock(),
dangerous_tools_config=cfg,
autonomy_mode=True,
)
assert engine._autonomy_mode is True
assert engine._dangerous_tools_config is cfg

View File

@ -0,0 +1,436 @@
"""U5: ReflexionEngine reflection persistence to EpisodicMemory (R11, R15).
Covers:
- store_prompt_reflection() stores via existing store() with task_type discriminator
- search_prompt_reflections() filters by task_type="prompt_reflection"
- cleanup_expired() deletes old records (TTL)
- _reflect() persists reflection when episodic_memory configured (non-blocking)
- _reflect() skips persistence when episodic_memory=None (backward compat)
- Persistence failure does not block _reflect (returns reflection text)
- Multi-version coexistence (same task_hash, different versions)
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agentkit.core.react import ReActResult
from agentkit.core.reflexion import ReflexionEngine
from agentkit.memory.episodic import EpisodicMemory
# ── Helpers ────────────────────────────────────────────────────────────
def _make_episodic_memory_mock() -> MagicMock:
"""Create a mock EpisodicMemory that tracks store/search calls."""
mem = MagicMock(spec=EpisodicMemory)
mem.store = AsyncMock(return_value=None)
mem.search = AsyncMock(return_value=[])
mem.store_prompt_reflection = AsyncMock(return_value="prompt_reflection:abc:1")
mem.search_prompt_reflections = AsyncMock(return_value=[])
mem.cleanup_expired = AsyncMock(return_value=0)
return mem
def _make_react_result(output: str = "test output", status: str = "completed") -> ReActResult:
return ReActResult(
output=output,
trajectory=[],
total_steps=1,
total_tokens=10,
status=status,
)
def _make_llm_gateway_mock(reflection_text: str = "reflection text") -> MagicMock:
"""Gateway that returns reflection text from chat()."""
gw = MagicMock()
response = MagicMock()
response.content = reflection_text
gw.chat = AsyncMock(return_value=response)
return gw
# ── EpisodicMemory.store_prompt_reflection ─────────────────────────────
class TestStorePromptReflection:
"""U5/R11: store_prompt_reflection persists via direct ORM write with
task_type='prompt_reflection' discriminator and metadata_ JSONB."""
def _make_persistable_mock(self) -> MagicMock:
"""Build an EpisodicMemory mock with _embedder + _session_factory
configured so store_prompt_reflection can run its real code path."""
mem = MagicMock(spec=EpisodicMemory)
mem._embedder = None # skip embedding generation in test
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.rollback = AsyncMock()
mem._session_factory = MagicMock()
mem._session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mem._session_factory.return_value.__aexit__ = AsyncMock(return_value=None)
mem._episodic_model = MagicMock()
# Track the ORM entry passed to db.add for assertions
mem._mock_db = mock_db
return mem
@pytest.mark.asyncio
async def test_store_writes_orm_entry_with_correct_fields(self):
mem = self._make_persistable_mock()
await EpisodicMemory.store_prompt_reflection(
mem,
task_input="test task",
reflection="reflection text",
improved_prompt="improved prompt",
version=1,
score=0.5,
agent_name="test_agent",
)
# Verify ORM entry created via model constructor + db.add + commit
mem._episodic_model.assert_called_once()
call_kwargs = mem._episodic_model.call_args.kwargs
assert call_kwargs["task_type"] == "prompt_reflection"
assert call_kwargs["agent_name"] == "test_agent"
assert call_kwargs["input_summary"] == "test task"
assert call_kwargs["output_summary"] == "improved prompt"
assert call_kwargs["reflection"] == "reflection text"
assert call_kwargs["quality_score"] == 0.5
assert call_kwargs["outcome"] == "reflection"
# metadata_ JSONB carries task_hash + version + score
assert "task_hash" in call_kwargs["metadata_"]
assert call_kwargs["metadata_"]["version"] == 1
assert call_kwargs["metadata_"]["score"] == 0.5
mem._mock_db.add.assert_called_once()
mem._mock_db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_store_returns_key_on_success(self):
mem = self._make_persistable_mock()
key = await EpisodicMemory.store_prompt_reflection(
mem,
task_input="test",
reflection="r",
improved_prompt="p",
version=2,
)
assert key is not None
assert ":2" in key
assert key.startswith("prompt_reflection:")
@pytest.mark.asyncio
async def test_store_returns_none_on_failure(self):
from sqlalchemy.exc import DBAPIError
mem = self._make_persistable_mock()
mem._mock_db.commit = AsyncMock(
side_effect=DBAPIError("stmt", params={}, orig=Exception("db down"))
)
key = await EpisodicMemory.store_prompt_reflection(
mem,
task_input="test",
reflection="r",
improved_prompt="p",
)
assert key is None
mem._mock_db.rollback.assert_awaited_once()
@pytest.mark.asyncio
async def test_store_uses_provided_task_hash(self):
mem = self._make_persistable_mock()
key = await EpisodicMemory.store_prompt_reflection(
mem,
task_input="test",
reflection="r",
improved_prompt="p",
task_hash="custom_hash",
)
assert "custom_hash" in key
@pytest.mark.asyncio
async def test_store_generates_task_hash_from_input(self):
mem = self._make_persistable_mock()
key1 = await EpisodicMemory.store_prompt_reflection(
mem, task_input="same task", reflection="r", improved_prompt="p"
)
key2 = await EpisodicMemory.store_prompt_reflection(
mem, task_input="same task", reflection="r", improved_prompt="p"
)
# Same task_input → same hash prefix
assert key1 == key2
# ── EpisodicMemory.search_prompt_reflections ───────────────────────────
class TestSearchPromptReflections:
"""U5/R11: search_prompt_reflections filters by task_type."""
@pytest.mark.asyncio
async def test_search_calls_underlying_search_with_filter(self):
mem = MagicMock(spec=EpisodicMemory)
mem.search = AsyncMock(return_value=[])
await EpisodicMemory.search_prompt_reflections(mem, task_input="find similar", top_k=3)
mem.search.assert_awaited_once()
call_kwargs = mem.search.await_args.kwargs
assert call_kwargs["query"] == "find similar"
assert call_kwargs["top_k"] == 3
filters = call_kwargs["filters"]
assert filters["task_type"] == "prompt_reflection"
@pytest.mark.asyncio
async def test_search_includes_agent_name_filter_when_provided(self):
mem = MagicMock(spec=EpisodicMemory)
mem.search = AsyncMock(return_value=[])
await EpisodicMemory.search_prompt_reflections(mem, task_input="q", agent_name="agent_x")
filters = mem.search.await_args.kwargs["filters"]
assert filters["agent_name"] == "agent_x"
@pytest.mark.asyncio
async def test_search_returns_empty_on_failure(self):
from sqlalchemy.exc import DBAPIError
mem = MagicMock(spec=EpisodicMemory)
mem.search = AsyncMock(side_effect=DBAPIError("stmt", params={}, orig=Exception("err")))
result = await EpisodicMemory.search_prompt_reflections(mem, task_input="q")
assert result == []
# ── EpisodicMemory.cleanup_expired ─────────────────────────────────────
class TestCleanupExpired:
"""U5/R15: cleanup_expired deletes records older than max_age_days."""
@pytest.mark.asyncio
async def test_cleanup_calls_delete_with_cutoff(self):
# Patch sqlalchemy.delete to bypass ORM model requirement — we only
# verify the session.execute/commit calls happen.
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.rowcount = 5
mock_db.execute = AsyncMock(return_value=mock_result)
mock_db.commit = AsyncMock()
mock_session_factory = MagicMock()
mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=None)
# created_at must support `<` comparison with datetime — configure
# __lt__ to return a truthy mock so `.where(...)` gets a valid arg.
mock_model = MagicMock()
mock_model.created_at = MagicMock()
mock_model.created_at.__lt__ = MagicMock(return_value=MagicMock())
mem = EpisodicMemory(
session_factory=mock_session_factory,
episodic_model=mock_model,
embedder=None,
pgvector_enabled=False,
)
# Patch sql_delete to return a chainable mock
fake_stmt = MagicMock()
fake_stmt.where = MagicMock(return_value=fake_stmt)
with patch("sqlalchemy.delete", return_value=fake_stmt):
deleted = await mem.cleanup_expired(max_age_days=30)
assert deleted == 5
mock_db.execute.assert_awaited_once()
mock_db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_cleanup_returns_zero_on_failure(self):
from sqlalchemy.exc import DBAPIError
mock_db = AsyncMock()
mock_db.execute = AsyncMock(
side_effect=DBAPIError("stmt", params={}, orig=Exception("err"))
)
mock_db.rollback = AsyncMock()
mock_session_factory = MagicMock()
mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=None)
mock_model = MagicMock()
mock_model.created_at = MagicMock()
mock_model.created_at.__lt__ = MagicMock(return_value=MagicMock())
mem = EpisodicMemory(
session_factory=mock_session_factory,
episodic_model=mock_model,
embedder=None,
pgvector_enabled=False,
)
fake_stmt = MagicMock()
fake_stmt.where = MagicMock(return_value=fake_stmt)
with patch("sqlalchemy.delete", return_value=fake_stmt):
deleted = await mem.cleanup_expired(max_age_days=30)
assert deleted == 0
mock_db.rollback.assert_awaited_once()
# ── ReflexionEngine._reflect persistence ──────────────────────────────
class TestReflectPersistence:
"""U5/R11: _reflect persists reflection when episodic_memory configured."""
@pytest.mark.asyncio
async def test_reflect_persists_when_episodic_memory_configured(self):
episodic = _make_episodic_memory_mock()
gw = _make_llm_gateway_mock(reflection_text="my reflection")
engine = ReflexionEngine(
llm_gateway=gw,
episodic_memory=episodic,
)
result = await engine._reflect(
react_result=_make_react_result(),
score=0.3,
messages=[{"role": "user", "content": "test task"}],
reflect_model="default",
agent_name="test_agent",
task_type="test",
)
assert result == "my reflection"
episodic.store_prompt_reflection.assert_awaited_once()
call_kwargs = episodic.store_prompt_reflection.await_args.kwargs
assert call_kwargs["task_input"] == "test task"
assert call_kwargs["reflection"] == "my reflection"
assert call_kwargs["score"] == 0.3
assert call_kwargs["agent_name"] == "test_agent"
@pytest.mark.asyncio
async def test_reflect_skips_persistence_when_no_episodic_memory(self):
gw = _make_llm_gateway_mock(reflection_text="my reflection")
engine = ReflexionEngine(
llm_gateway=gw,
episodic_memory=None, # No persistence
)
result = await engine._reflect(
react_result=_make_react_result(),
score=0.3,
messages=[{"role": "user", "content": "test task"}],
reflect_model="default",
agent_name="test_agent",
task_type="test",
)
assert result == "my reflection"
# No episodic_memory → no store call
@pytest.mark.asyncio
async def test_reflect_persistence_failure_does_not_block(self):
"""If store_prompt_reflection raises, _reflect still returns reflection."""
episodic = MagicMock(spec=EpisodicMemory)
episodic.store_prompt_reflection = AsyncMock(side_effect=RuntimeError("DB down"))
gw = _make_llm_gateway_mock(reflection_text="important reflection")
engine = ReflexionEngine(
llm_gateway=gw,
episodic_memory=episodic,
)
result = await engine._reflect(
react_result=_make_react_result(),
score=0.3,
messages=[{"role": "user", "content": "test task"}],
reflect_model="default",
agent_name="test_agent",
task_type="test",
)
# Reflection still returned despite persistence failure
assert result == "important reflection"
episodic.store_prompt_reflection.assert_awaited_once()
@pytest.mark.asyncio
async def test_reflect_skips_persistence_when_llm_returns_none(self):
"""If LLM returns empty/None, no persistence attempted."""
episodic = _make_episodic_memory_mock()
gw = MagicMock()
response = MagicMock()
response.content = None # Empty reflection
gw.chat = AsyncMock(return_value=response)
engine = ReflexionEngine(
llm_gateway=gw,
episodic_memory=episodic,
)
result = await engine._reflect(
react_result=_make_react_result(),
score=0.3,
messages=[{"role": "user", "content": "test task"}],
reflect_model="default",
agent_name="test_agent",
task_type="test",
)
assert result is None
episodic.store_prompt_reflection.assert_not_awaited()
@pytest.mark.asyncio
async def test_reflect_skips_persistence_on_llm_failure(self):
"""If LLM call raises, no persistence attempted."""
episodic = _make_episodic_memory_mock()
gw = MagicMock()
gw.chat = AsyncMock(side_effect=RuntimeError("LLM down"))
engine = ReflexionEngine(
llm_gateway=gw,
episodic_memory=episodic,
)
result = await engine._reflect(
react_result=_make_react_result(),
score=0.3,
messages=[{"role": "user", "content": "test task"}],
reflect_model="default",
agent_name="test_agent",
task_type="test",
)
assert result is None
episodic.store_prompt_reflection.assert_not_awaited()
# ── Multi-version coexistence ─────────────────────────────────────────
class TestMultiVersionCoexistence:
"""U5/R11: same task_hash with different versions all stored."""
@pytest.mark.asyncio
async def test_multiple_versions_stored_with_incrementing_version(self):
# Reuse the persistable mock helper from TestStorePromptReflection.
helper = TestStorePromptReflection()
mem = helper._make_persistable_mock()
# Store v1, v2, v3 for same task
key1 = await EpisodicMemory.store_prompt_reflection(
mem, task_input="same task", reflection="r1", improved_prompt="p1", version=1
)
key2 = await EpisodicMemory.store_prompt_reflection(
mem, task_input="same task", reflection="r2", improved_prompt="p2", version=2
)
key3 = await EpisodicMemory.store_prompt_reflection(
mem, task_input="same task", reflection="r3", improved_prompt="p3", version=3
)
# All keys have same task_hash prefix but different version suffix
assert key1 != key2 != key3
assert ":1" in key1 and ":2" in key2 and ":3" in key3
# All three ORM entries created via db.add
assert mem._mock_db.add.call_count == 3
assert mem._mock_db.commit.await_count == 3

View File

@ -6,7 +6,13 @@ from pathlib import Path
import pytest
from agentkit.server.config import ServerConfig, find_config_path, _resolve_env_vars, _deep_resolve
from agentkit.server.config import (
ServerConfig,
DangerousToolsConfig,
find_config_path,
_resolve_env_vars,
_deep_resolve,
)
class TestEnvVarResolution:
@ -178,7 +184,9 @@ prompt:
# Update yaml_content with absolute path
yaml_content_updated = yaml_content.replace("./skills", str(skills_dir))
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, dir=tmpdir) as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, dir=tmpdir
) as f:
f.write(yaml_content_updated)
f.flush()
config = ServerConfig.from_yaml(f.name)
@ -209,7 +217,9 @@ skills:
paths:
- "{skill_yaml}"
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, dir=tmpdir) as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, dir=tmpdir
) as f:
f.write(yaml_content)
f.flush()
config = ServerConfig.from_yaml(f.name)
@ -246,7 +256,9 @@ skills:
paths:
- "{skills_dir}"
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, dir=tmpdir) as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, dir=tmpdir
) as f:
f.write(yaml_content)
f.flush()
config = ServerConfig.from_yaml(f.name)
@ -444,3 +456,103 @@ class TestConfigHotReload:
assert config.port == 9000
os.unlink(config_path)
class TestDangerousToolsConfig:
"""Test DangerousToolsConfig — U1: dangerous tool whitelist for PLAN_EXEC autonomy (KTD2)."""
def test_defaults_when_no_data(self):
cfg = DangerousToolsConfig.from_dict(None)
assert cfg.enabled is True
assert len(cfg.tool_patterns) > 0
# Default whitelist includes rm, deploy, kubectl, etc.
assert cfg.is_dangerous("rm")
assert cfg.is_dangerous("deploy")
assert cfg.is_dangerous("kubectl_apply")
def test_defaults_when_empty_dict(self):
cfg = DangerousToolsConfig.from_dict({})
assert cfg.enabled is True
assert cfg.is_dangerous("rmdir")
def test_dangerous_tool_match(self):
cfg = DangerousToolsConfig()
assert cfg.is_dangerous("rm") is True
assert cfg.is_dangerous("rmdir") is True
assert cfg.is_dangerous("deploy") is True
assert cfg.is_dangerous("kubectl_rollout") is True
assert cfg.is_dangerous("helm_upgrade") is True
assert cfg.is_dangerous("git_push_force") is True
assert cfg.is_dangerous("alembic_upgrade") is True
assert cfg.is_dangerous("migrate_db") is True
assert cfg.is_dangerous("payment_charge") is True
def test_non_dangerous_tool(self):
cfg = DangerousToolsConfig()
assert cfg.is_dangerous("read_file") is False
assert cfg.is_dangerous("write_file") is False
assert cfg.is_dangerous("search") is False
assert cfg.is_dangerous("list_dir") is False
def test_payment_glob_pattern(self):
cfg = DangerousToolsConfig()
# payment_* pattern matches any tool starting with payment_
assert cfg.is_dangerous("payment_refund") is True
assert cfg.is_dangerous("payment_transfer") is True
def test_disabled_returns_false(self):
cfg = DangerousToolsConfig(enabled=False)
assert cfg.is_dangerous("rm") is False
assert cfg.is_dangerous("deploy") is False
def test_custom_patterns_from_dict(self):
data = {"enabled": True, "tool_patterns": [r"^custom_dangerous"]}
cfg = DangerousToolsConfig.from_dict(data)
assert cfg.is_dangerous("custom_dangerous_op") is True
assert cfg.is_dangerous("rm") is False # default patterns overridden
def test_enabled_false_from_dict(self):
data = {"enabled": False}
cfg = DangerousToolsConfig.from_dict(data)
assert cfg.enabled is False
assert cfg.is_dangerous("rm") is False
def test_server_config_loads_dangerous_tools(self):
yaml_content = """
server:
host: "0.0.0.0"
port: 8001
dangerous_tools:
enabled: true
tool_patterns:
- "^rm$"
- "^kubectl"
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
f.flush()
config = ServerConfig.from_yaml(f.name)
assert config.dangerous_tools.enabled is True
assert config.dangerous_tools.is_dangerous("rm") is True
assert config.dangerous_tools.is_dangerous("kubectl_apply") is True
# Custom patterns override defaults
assert config.dangerous_tools.is_dangerous("deploy") is False
os.unlink(f.name)
def test_server_config_defaults_dangerous_tools_when_missing(self):
yaml_content = """
server:
host: "0.0.0.0"
port: 8001
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
f.flush()
config = ServerConfig.from_yaml(f.name)
# Missing config section → defaults applied
assert config.dangerous_tools.enabled is True
assert config.dangerous_tools.is_dangerous("rm") is True
os.unlink(f.name)

View File

@ -0,0 +1,464 @@
"""U4: TeamOrchestrator 无依赖子任务并行模式单元测试 (R1-R5).
Covers:
- TeamPlan.get_independent_subtasks() introspection
- _rebalance_independent_subtasks() MAX_EXPERTS enforcement (R4)
- topological_sort 同层并行 (existing behavior, validated here for U4 contract)
- SharedWorkspace 路径唯一性 (phase_id is UUID no collision)
- 综合阶段等待所有并行子任务完成 (layer-sequential contract)
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.core.protocol import TaskResult, TaskStatus
from agentkit.experts.config import ExpertConfig
from agentkit.experts.expert import Expert
from agentkit.experts.orchestrator import TeamOrchestrator
from agentkit.experts.plan import PlanPhase, TeamPlan
from agentkit.experts.team import ExpertTeam
from tests.unit.experts._helpers import (
make_chat_stream_mock,
make_execute_stream_mock,
)
# ── Helpers ────────────────────────────────────────────────────────────
def _make_expert_config(name: str, is_lead: bool = False) -> ExpertConfig:
return ExpertConfig(
name=name,
agent_type="expert",
persona="测试专家",
thinking_style="逻辑推理",
bound_skills=["skill_a"],
is_lead=is_lead,
task_mode="llm_generate",
prompt={"identity": "测试"},
llm={"model": "default"},
)
def _make_mock_expert(name: str, is_lead: bool = False) -> MagicMock:
config = _make_expert_config(name=name, is_lead=is_lead)
expert = MagicMock(spec=Expert)
expert.config = config
expert.is_active = True
expert.team_id = None
expert.get_capabilities_summary.return_value = {
"name": name,
"persona": config.persona,
"thinking_style": config.thinking_style,
"bound_skills": config.bound_skills,
"is_lead": is_lead,
}
mock_agent = MagicMock()
mock_agent.execute = AsyncMock(
return_value=TaskResult(
task_id="test",
agent_name=name,
status=TaskStatus.COMPLETED.value,
output_data={"content": f"Result from {name}"},
error_message=None,
started_at=None,
completed_at=None,
)
)
mock_agent.execute_stream = make_execute_stream_mock(f"Result from {name}")
mock_agent._llm_gateway = None
expert.agent = mock_agent
return expert
def _make_team_with_experts(
expert_names: list[str] | None = None,
lead_name: str = "lead",
) -> ExpertTeam:
team = ExpertTeam()
transport = AsyncMock(spec=InProcessHandoffTransport)
team._handoff_transport = transport
if expert_names is None:
expert_names = [lead_name, "member1", "member2"]
for name in expert_names:
is_lead = name == lead_name
expert = _make_mock_expert(name=name, is_lead=is_lead)
team._experts[name] = expert
if is_lead:
team._lead_expert_name = name
return team
def _make_phase(
id: str, name: str, assigned_expert: str, depends_on: list[str] | None = None
) -> PlanPhase:
return PlanPhase(
id=id,
name=name,
assigned_expert=assigned_expert,
task_description=f"task for {name}",
depends_on=depends_on or [],
)
def _make_mock_llm_gateway_with_phases(
phases_responses: list[list[dict]],
synthesis_content: str = "综合结果",
) -> MagicMock:
"""Gateway that returns successive decompositions then synthesis.
phases_responses: each element is a list of phase dicts to return on
successive decomposition calls. After exhausted, returns synthesis.
"""
gateway = AsyncMock()
decomp_responses = [MagicMock(content=json.dumps(phases)) for phases in phases_responses]
synth_response = MagicMock(content=synthesis_content)
call_count = [0]
async def chat_side_effect(messages, model=None, **kwargs):
call_count[0] += 1
if call_count[0] <= len(decomp_responses):
return decomp_responses[call_count[0] - 1]
return synth_response
gateway.chat = AsyncMock(side_effect=chat_side_effect)
gateway.chat_stream = make_chat_stream_mock(synthesis_content)
return gateway
# ── TeamPlan.get_independent_subtasks ─────────────────────────────────
class TestGetIndependentSubtasks:
"""U4/R1: TeamPlan.get_independent_subtasks() returns phases with no
depends_on."""
def test_returns_empty_for_no_phases(self):
plan = TeamPlan(task="test", lead_expert="lead")
assert plan.get_independent_subtasks() == []
def test_returns_only_independent_phases(self):
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "A", "lead", depends_on=[]),
_make_phase("p2", "B", "member1", depends_on=["p1"]),
_make_phase("p3", "C", "member2", depends_on=[]),
_make_phase("p4", "D", "member1", depends_on=["p1", "p3"]),
],
)
independent = plan.get_independent_subtasks()
assert len(independent) == 2
assert {ph.id for ph in independent} == {"p1", "p3"}
def test_returns_all_when_no_dependencies(self):
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "A", "lead", depends_on=[]),
_make_phase("p2", "B", "member1", depends_on=[]),
_make_phase("p3", "C", "member2", depends_on=[]),
],
)
independent = plan.get_independent_subtasks()
assert len(independent) == 3
# ── topological_sort 同层并行 contract (U4/R1, R3) ────────────────────
class TestParallelLayerContract:
"""U4/R1, R3: independent subtasks land in layer 0 (parallel); synthesis
waits for all layers to complete (layer-sequential)."""
def test_three_independent_subtasks_in_layer_0(self):
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "A", "lead", depends_on=[]),
_make_phase("p2", "B", "member1", depends_on=[]),
_make_phase("p3", "C", "member2", depends_on=[]),
],
)
layers = plan.topological_sort()
assert len(layers) == 1
assert len(layers[0]) == 3
assert {ph.id for ph in layers[0]} == {"p1", "p2", "p3"}
def test_shared_dependency_degrades_to_layered(self):
"""R3: subtasks sharing an upstream dependency are NOT parallel with
that upstream they land in later layers."""
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "Base", "lead", depends_on=[]),
_make_phase("p2", "A", "member1", depends_on=["p1"]),
_make_phase("p3", "B", "member2", depends_on=["p1"]),
],
)
layers = plan.topological_sort()
assert len(layers) == 2
assert len(layers[0]) == 1 # only Base
assert layers[0][0].id == "p1"
assert len(layers[1]) == 2 # A and B parallel (same layer)
assert {ph.id for ph in layers[1]} == {"p2", "p3"}
def test_synthesis_waits_for_all_layers(self):
"""R3: dependent phase in last layer — synthesis cannot run until
its layer completes (validated via topological_sort layer count)."""
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "A", "lead", depends_on=[]),
_make_phase("p2", "B", "member1", depends_on=["p1"]),
_make_phase("p3", "C", "member2", depends_on=["p2"]),
],
)
layers = plan.topological_sort()
assert len(layers) == 3 # strict sequential — no parallelism
# ── SharedWorkspace 路径唯一性 (U4/R2) ─────────────────────────────────
class TestSharedWorkspacePathUniqueness:
"""U4/R2: parallel subtask output paths must not collide. PlanPhase.id
is UUID by default unique per phase, ensuring
``{plan_id}/phase/{phase_id}/output`` never overlaps."""
def test_default_phase_ids_are_unique(self):
phases = [
PlanPhase(name=f"phase_{i}", assigned_expert="lead", task_description="t")
for i in range(5)
]
ids = [ph.id for ph in phases]
assert len(set(ids)) == len(ids), "phase_ids must be unique"
def test_independent_subtasks_have_unique_output_paths(self):
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
PlanPhase(name="A", assigned_expert="lead", task_description="t"),
PlanPhase(name="B", assigned_expert="member1", task_description="t"),
PlanPhase(name="C", assigned_expert="member2", task_description="t"),
],
)
independent = plan.get_independent_subtasks()
paths = {f"{plan.id}/phase/{ph.id}/output" for ph in independent}
assert len(paths) == len(independent)
# ── _rebalance_independent_subtasks (U4/R4 MAX_EXPERTS) ────────────────
class TestRebalanceIndependentSubtasks:
"""U4/R4: when Lead over-decomposes independent subtasks beyond
MAX_INDEPENDENT_SUBTASKS, request re-decompose with merge hint."""
@pytest.mark.asyncio
async def test_no_rebalance_when_within_limit(self):
"""5 independent subtasks ≤ 10 → no re-decompose."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
# 5 independent phases
phases = [_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(5)]
original_phases = list(phases)
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=phases
)
assert result is original_phases or result == original_phases
@pytest.mark.asyncio
async def test_rebalance_triggers_when_over_limit(self):
"""11 independent subtasks > 10 → re-decompose with merge hint."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
# Original: 11 independent (over limit)
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
# Re-decomposed: 8 independent (within limit) — merge succeeded
gateway = _make_mock_llm_gateway_with_phases(
phases_responses=[
[ # first call (original _decompose_task already happened —
# but _rebalance calls _decompose_task again, returning this)
{
"name": f"merged_{i}",
"assigned_expert": "lead",
"task_description": "t",
"depends_on": [],
}
for i in range(8)
],
]
)
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
# Should return the re-decomposed phases (8 independent)
assert len(result) == 8
assert all(not ph.depends_on for ph in result)
# Original phases should NOT be returned
assert {ph.id for ph in result}.isdisjoint({ph.id for ph in original_phases})
@pytest.mark.asyncio
async def test_rebalance_no_gateway_returns_original(self):
"""No LLM gateway → can't re-decompose, return original."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
# gateway is None by default in _make_mock_expert
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
assert result is original_phases
@pytest.mark.asyncio
async def test_rebalance_still_over_limit_returns_new_anyway(self):
"""Re-decompose still produces >MAX independent → return new phases
(MAX_PHASES truncation handles it in execute())."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
# Re-decompose returns 12 independent (still over)
gateway = _make_mock_llm_gateway_with_phases(
phases_responses=[
[
{
"name": f"q{i}",
"assigned_expert": "lead",
"task_description": "t",
"depends_on": [],
}
for i in range(12)
],
]
)
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
# Should return the 12-phase re-decomposition (truncation happens later)
assert len(result) == 12
@pytest.mark.asyncio
async def test_rebalance_decompose_failure_returns_original(self):
"""Re-decompose raises LLMProviderError → return original phases."""
from agentkit.core.exceptions import LLMProviderError
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
gateway = AsyncMock()
gateway.chat = AsyncMock(side_effect=LLMProviderError("LLM down"))
gateway.chat_stream = make_chat_stream_mock("err")
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
assert result is original_phases
@pytest.mark.asyncio
async def test_rebalance_empty_redecompose_returns_original(self):
"""Re-decompose returns empty list → return original phases."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
# Gateway returns malformed JSON → _parse_phases returns []
gateway = AsyncMock()
bad_response = MagicMock(content="not json")
gateway.chat = AsyncMock(return_value=bad_response)
gateway.chat_stream = make_chat_stream_mock("err")
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
assert result is original_phases
# ── Integration: full execute() with parallel subtasks ────────────────
class TestExecuteParallelSubtasks:
"""U4/R1, R3, R5: full execute() path with independent subtasks running
in parallel + synthesis waits for all."""
@pytest.mark.asyncio
async def test_three_parallel_subtasks_complete_then_synthesis(self):
"""3 independent subtasks → all run in layer 0 → synthesis runs
after all complete."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
gateway = _make_mock_llm_gateway_with_phases(
phases_responses=[
[
{
"name": "A",
"assigned_expert": "lead",
"task_description": "a",
"depends_on": [],
},
{
"name": "B",
"assigned_expert": "member1",
"task_description": "b",
"depends_on": [],
},
{
"name": "C",
"assigned_expert": "member2",
"task_description": "c",
"depends_on": [],
},
],
]
)
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator.execute("并行任务")
assert result["status"] == "completed"
plan: TeamPlan = result["plan"]
layers = plan.topological_sort()
assert len(layers) == 1 # all 3 in layer 0 (parallel)
assert len(layers[0]) == 3
# All phases completed
completed = plan.completed_phases
assert len(completed) == 3
# phase_results has all 3
phase_results: dict = result["phase_results"]
assert len(phase_results) == 3