diff --git a/configs/agentkit.yaml.example b/configs/agentkit.yaml.example new file mode 100644 index 0000000..3f84a2c --- /dev/null +++ b/configs/agentkit.yaml.example @@ -0,0 +1,119 @@ +# AgentKit 配置示例文件 +# +# 用法:复制为 agentkit.yaml 并按需修改。 +# cp configs/agentkit.yaml.example agentkit.yaml +# +# 配置查找顺序:--config 路径 > ./agentkit.yaml > ~/.agentkit/agentkit.yaml +# 所有 ${VAR:-default} 占位符在加载时从环境变量解析 +# (见 server/config._resolve_env_vars)。此文件不含任何密钥 — +# 部署时通过环境变量或 Web UI Settings 页注入。 + +# ── 服务端 ───────────────────────────────────────────────────────────── +server: + host: 0.0.0.0 + port: 18001 + workers: 1 + rate_limit: 60 # 每分钟每客户端请求数上限 + # api_key: "${AGENTKIT_API_KEY}" # 可选:外部系统调用 API 的鉴权 key + +# ── LLM 网关 ─────────────────────────────────────────────────────────── +# 基于 LiteLLM 统一适配层,支持任意 OpenAI 兼容 API。 +# API Key 通过环境变量注入,不在此明文存储。 +llm: + providers: + bailian-coding: + type: openai + base_url: https://coding.dashscope.aliyuncs.com/v1 + api_key: "${DASHSCOPE_API_KEY}" + models: + qwen3.7-plus: {alias: default} + qwen-turbo: {alias: fast} + deepseek: + type: openai + base_url: https://api.deepseek.com/v1 + api_key: "${DEEPSEEK_API_KEY}" + models: + deepseek-chat: {alias: chat} + deepseek-reasoner: {alias: reasoning} + model_aliases: + default: bailian-coding/qwen3.7-plus + fast: bailian-coding/qwen-turbo + chat: deepseek/deepseek-chat + reasoning: deepseek/deepseek-reasoner + # 模型 fallback 链:主模型失败时按序尝试 fallback。 + fallbacks: + default: [fast] # default 失败 → fast(同 provider 内降级) + +# ── 危险工具门控(IQ-Boost/U1,KTD2 白名单)──────────────────────────── +# PLAN_EXEC autonomy 模式下,工具名匹配任一 tool_patterns 时触发 +# confirmation_request(用户确认后才执行);非危险工具直接执行。 +# 对应 server/config.DangerousToolsConfig,由 ServerConfig.from_dict 解析。 +dangerous_tools: + # 总开关。false 时 is_dangerous() 恒返回 False(无门控)。 + enabled: true + # 危险工具名匹配模式列表。每项是 Python 正则表达式(re.compile), + # 通过 re.match 匹配工具名(仅锚定起始,未自动锚定结尾)。 + # 建议用 ^ 前缀显式锚定起始;用 $ 后缀要求完全匹配。 + # ponytail ceiling = 未列入清单的危险操作会被漏检; + # 升级路径 = LLM 辅助分类。 + tool_patterns: + - "^rm$" # 文件删除 + - "^rmdir$" # 目录删除 + - "^deploy$" # 部署触发 + - "^kubectl" # K8s 操作(kubectl_* 前缀均匹配) + - "^helm" # Helm 发布管理 + - "^git_push_force$" # 强制推送(覆盖远端历史) + - "^alembic" # 数据库迁移工具 + - "^migrate" # 通用迁移命令 + - "^payment_" # 支付类工具(payment_charge / payment_refund 等) + # autonomy 模式暂停阈值(U3/R10)。0 = 禁用该项检查。 + # 超时或连续失败达到阈值时发出 autonomy_paused 事件,需用户介入恢复。 + autonomy_timeout_minutes: 30 + max_consecutive_failures: 3 + +# ── 会话 / 总线 / 任务存储 ───────────────────────────────────────────── +# 设 REDIS_URL 环境变量后切换为 Redis 后端;未设时回退到进程内内存(开发用)。 +session: + backend: ${AGENTKIT_SESSION_BACKEND:-memory} + redis_url: ${REDIS_URL:-redis://127.0.0.1:6391/0} +bus: + backend: ${AGENTKIT_BUS_BACKEND:-memory} + redis_url: ${REDIS_URL:-redis://127.0.0.1:6391/0} +task_store: + backend: ${AGENTKIT_TASK_STORE_BACKEND:-memory} + redis_url: ${REDIS_URL:-redis://127.0.0.1:6391/0} + +# ── 技能 / 专家模板 ──────────────────────────────────────────────────── +skills: {auto_discover: true, paths: ["./configs/skills"]} +experts: {paths: ["./configs/experts"]} +board: {max_rounds: 5, default_template: private_board, parallel_speech: true} + +# ── 日志 / 路由 ──────────────────────────────────────────────────────── +logging: {level: INFO, format: text} +router: {classifier: heuristic, auction_enabled: false} + +# ── 三级回退链(G7/U3)──────────────────────────────────────────────── +# main → Recovery(ReflexionEngine 重试)→ Emergency(规则分类器)。 +# 仅在 chat REST 路径接入(KTD5);CLI / ReWOO / Reflexion 内部 ReAct 调用绕过。 +fallback_chain: + enabled: true + recovery: + enabled: true + max_retries: 1 # ReflexionEngine max_reflections 覆盖值 + emergency: + enabled: true + +# ── 回滚配置(G9/U4)────────────────────────────────────────────────── +# 驱动 RollbackExecutor 子进程超时(PlanPhase.validation_command / +# rollback_command)。按阶段 opt-in(KTD6)— 未设 rollback_command 时不执行回滚。 +rollback: + default_timeout: 30.0 + +# ── OTel 可观测性(U1)──────────────────────────────────────────────── +# OTel 依赖为 required;设 enabled=false 可显式关闭。 +telemetry: + enabled: true + otlp_endpoint: http://localhost:4317 # OTLP gRPC(生产指向集群 collector) + service_name: fischer-agentkit + export_traces: true + export_metrics: true diff --git a/docs/plans/2026-07-06-002-fix-agent-iq-boost-gaps-plan.md b/docs/plans/2026-07-06-002-fix-agent-iq-boost-gaps-plan.md new file mode 100644 index 0000000..f62bd72 --- /dev/null +++ b/docs/plans/2026-07-06-002-fix-agent-iq-boost-gaps-plan.md @@ -0,0 +1,438 @@ +--- +date: 2026-07-06 +topic: agent-iq-boost-gaps +type: fix +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +origin: "PR #27 gap analysis — 5 unimplemented/partial items + P2/P3 review residuals" +--- + +## Goal Capsule + +**Objective**: 补齐 PR #27 (Agent IQ Boost) 的 5 个未实现/部分实现项 + 2 个 P2 review residuals,使 IQ-boost 功能完整可用、可上生产。 + +**Product Authority**: PR #27 已合并到 main (8fc6bb0),但 gap analysis 发现 5 项未实现(前端事件处理、配置示例、SharedWorkspace 锁、TTL 清理触发、KTD5 触发门控)和 2 项 P2 residuals(resume race、拒绝算失败)。这些缺口导致 autonomy_paused 事件前端无法接收、prompt_reflection 记录永不过期、并行子任务有 TOCTOU 风险。 + +**Open Blockers**: None. + +**Execution Profile**: Standard depth,7 个 Implementation Units,3 个维度可部分并行(前端、后端、配置独立)。 + +**Stop Conditions**: 7 个 U-ID 全部完成、Verification Contract 通过、DoD 全局条件满足。 + +--- + +## Product Contract + +### Summary + +补齐 IQ-boost 的 5 个实现缺口 + 2 个 P2 review residuals,使 PR #27 交付的功能完整可用:前端能接收 autonomy_paused 事件、TTL 自动清理、并行子任务有 TOCTOU 防护、prompt 自调优按 KTD5 触发条件门控、配置有文档示例、resume 无竞态、用户拒绝不算失败。 + +### Problem Frame + +PR #27 合并后,gap analysis 发现以下问题: + +1. **前端无 autonomy_paused 事件处理** — 后端发送事件但前端无分支接收,导致用户无法看到暂停卡片也无法点击 resume +2. **cleanup_expired 定义但未调用** — prompt_reflection 记录永不过期,噪声积累 +3. **SharedWorkspace.lock 未使用** — 并行子任务写入无 TOCTOU 防护 +4. **KTD5 触发条件未门控** — retrieve_prompt_reflection 无条件调用,浪费 token +5. **configs/agentkit.yaml.example 缺失** — 运维人员无配置示例 +6. **P2 #7 resume future 注册竞态** — Future 注册顺序有 race condition +7. **P2 #8 用户拒绝算失败** — _track_tool_result_for_autonomy 把用户拒绝计入失败计数 + +### Requirements + +- **F1.** 前端处理 `autonomy_paused` 事件,渲染暂停卡片(reason + progress + resume 按钮),发送 `resume` 消息 +- **F2.** `cleanup_expired()` 被定时调用(启动 hook 或 lazy 触发),30 天过期清理生效 +- **F3.** 并行子任务的 SharedWorkspace 写入用 `lock()` 防护 TOCTOU +- **F4.** `retrieve_prompt_reflection` 按 KTD5 触发条件门控(verify 二次失败 / schema 校验失败 / 循环检测) +- **F5.** 创建 `configs/agentkit.yaml.example` 包含 `dangerous_tools` 配置示例 +- **F6.** `resume` Future 注册顺序消除竞态 +- **F7.** 用户拒绝危险工具(confirmation_request 被 deny)不计入 `_consecutive_failures` + +### Scope Boundaries + +**In scope**: +- 前端 `autonomy_paused` 事件处理(Vue 组件 + Pinia store) +- `cleanup_expired` 调用接入(启动 hook 或 lazy 触发) +- `SharedWorkspace.lock()` 在并行子任务路径使用 +- `retrieve_prompt_reflection` KTD5 门控 +- `configs/agentkit.yaml.example` 创建 +- resume Future 注册顺序修复 +- `_track_tool_result_for_autonomy` 区分用户拒绝 vs 工具失败 + +**Out of scope**: +- 新增 ExecutionMode +- 重构 autonomy pause 机制(已在 PR #27 修复 P0 死锁) +- 前端 confirmation_request UI 改造(已有) +- P3 #9-#12 低优先级问题(deferred) + +### Outstanding Questions + +None — 所有决策在 KTD 中已明确。 + +--- + +## Planning Contract + +### Key Technical Decisions + +**KTD1: cleanup_expired 用 lazy 触发,不引入后台定时任务** + +`cleanup_expired()` 在 `store_prompt_reflection()` 调用前 lazy 触发(概率触发,如 10% 概率),而非引入定时后台任务。理由:避免增加 lifespan 管理复杂度;prompt_reflection 写入频率低,lazy 触发足够。ponytail: ceiling = 清理延迟(最多 30 天 + N 次写入后才触发),升级路径 = 启动时清理。 + +**KTD2: KTD5 门控用 trigger_reason 参数传入** + +`_decompose_task` 新增 `trigger_reason: str | None = None` 参数,仅在 trigger_reason 为 `verify_retry` / `schema_validation` / `loop_detection` 时调用 `retrieve_prompt_reflection`。非触发场景(正常 plan)不检索。Lead 的 `_decompose_task` 调用方根据上下文传入 trigger_reason。 + +**KTD3: SharedWorkspace.lock 包装在并行子任务的关键写入段** + +`_run_pipeline` 的并行执行路径中,每个子 agent 对 SharedWorkspace 的 read-then-write 操作用 `async with workspace.lock(key, agent_id)` 包装。lock key = `{plan_id}/phase/{phase_id}/output`。不锁读取,只锁写入段。 + +**KTD4: 前端 autonomy_paused 用独立组件,复用 confirmation UI 模式** + +新增 `AutonomyPausedCard.vue` 组件(区别于 `ConfirmationCard.vue`),渲染 reason + progress + resume 按钮。Pinia chat store 新增 `autonomyPaused` state + `resumeAutonomy(resumeToken)` action。复用现有 WebSocket 消息发送机制。 + +**KTD5: resume Future 注册顺序 — 先注册后 yield** + +在 `_resume_handler` 中,先创建 Future 并存入 `pending_autonomy_resumes`,再返回 handler。确保 WebSocket loop 收到 resume 消息时 Future 已注册。原实现先返回 handler 再注册,有 race。 + +**KTD6: 用户拒绝不算失败 — 区分 denial vs failure** + +`_track_tool_result_for_autonomy` 新增 `denied: bool = False` 参数。confirmation_request 被拒绝时调用 `_track_tool_result_for_autonomy(result, denied=True)`,不递增 `_consecutive_failures`。工具执行失败时 `denied=False`,递增计数器。 + +### High-Level Technical Design + +```mermaid +flowchart TB + subgraph "U1 前端事件处理" + WS[WebSocket Client] -->|autonomy_paused| Store[Pinia Chat Store] + Store --> Card[AutonomyPausedCard.vue] + Card -->|resume| WS + end + + subgraph "U2 TTL 清理" + StoreReflect[store_prompt_reflection] -->|10% 概率| Cleanup[cleanup_expired] + Cleanup --> DB[(EpisodicMemory)] + end + + subgraph "U3 SharedWorkspace 锁" + ParallelExec[Parallel Subtask] -->|write| Lock[workspace.lock] + Lock --> SW[(SharedWorkspace)] + end + + subgraph "U4 KTD5 门控" + Decompose[_decompose_task] -->|trigger_reason?| Gate{KTD5 trigger?} + Gate -->|yes| Retrieve[retrieve_prompt_reflection] + Gate -->|no| Skip[默认 prompt] + end + + subgraph "U5 配置示例" + ConfigExample[agentkit.yaml.example] --> DangerousTools[dangerous_tools section] + end + + subgraph "U6 resume race" + ResumeHandler[_resume_handler] -->|先注册 Future| Pending[pending_autonomy_resumes] + Pending -->|后返回 handler| AwaitBlock[await Future] + end + + subgraph "U7 拒绝不算失败" + ConfirmDeny[confirmation denied] -->|denied=True| Track[_track_tool_result_for_autonomy] + Track -->|不递增| Counter[_consecutive_failures] + end +``` + +--- + +## Implementation Units + +### U1. 前端 autonomy_paused 事件处理 + +**Goal**: 前端接收 `autonomy_paused` WebSocket 事件,渲染暂停卡片,用户可点击 resume 继续。 + +**Requirements**: F1 + +**Dependencies**: None(后端已实现事件发送) + +**Files**: +- `src/agentkit/server/frontend/src/stores/chat.ts` — 新增 `autonomyPaused` state + `resumeAutonomy(resumeToken)` action +- `src/agentkit/server/frontend/src/components/agent/chat/AutonomyPausedCard.vue` — 新增暂停卡片组件 +- `src/agentkit/server/frontend/src/components/agent/chat/ChatWindow.vue` — 集成 AutonomyPausedCard +- `src/agentkit/server/frontend/src/composables/useWebSocket.ts` — 新增 `autonomy_paused` 事件分支 +- `tests/unit/test_autonomy_paused_frontend.ts` — 前端单元测试 + +**Approach**: +- WebSocket composable 新增 `autonomy_paused` 事件分支,dispatch 到 chat store +- chat store 新增 `autonomyPaused: { reason, progress, resumeToken } | null` state +- `AutonomyPausedCard.vue` 渲染 reason(timeout/consecutive_failures/manual)+ progress 摘要 + resume 按钮 +- resume 按钮点击 → `chatStore.resumeAutonomy(resumeToken)` → WebSocket 发送 `{type: 'resume', resume_token}` +- 复用现有 `ConfirmationCard.vue` 的视觉模式(Ant Design Vue Card) + +**Patterns to follow**: `ConfirmationCard.vue` 的事件处理模式;`chat.ts` store 的 `confirmationRequest` state 管理模式。 + +**Test scenarios**: +- 事件接收:WebSocket 收到 `autonomy_paused` 事件,store `autonomyPaused` state 被设置 +- 卡片渲染:reason=timeout 时显示"自主执行超时" + progress + resume 按钮 +- resume 发送:点击 resume 按钮,WebSocket 发送 `{type: 'resume', resume_token: 'xxx'}` +- 卡片消失:resume 发送后 `autonomyPaused` state 清空 +- 取消:用户点击 cancel(已有按钮),触发 cancel 消息 + +**Verification**: `npm run typecheck` 通过;前端单元测试通过;手动验证:触发 autonomy pause 后前端显示卡片,点击 resume 后继续执行。 + +### U2. cleanup_expired lazy 触发 + +**Goal**: `cleanup_expired()` 在 `store_prompt_reflection` 调用时以 10% 概率 lazy 触发,确保过期记录被清理。 + +**Requirements**: F2 + +**Dependencies**: None + +**Files**: +- `src/agentkit/memory/episodic.py` — `store_prompt_reflection` 内部添加 lazy cleanup 调用 +- `tests/unit/test_reflexion_persist.py` — 新增 lazy cleanup 测试 + +**Approach**: +- `store_prompt_reflection` 开头添加:`if random.random() < 0.1: await self.cleanup_expired(task_type="prompt_reflection")` +- 用 try/except 包裹 cleanup 调用,失败不阻塞 store(fire-and-forget) +- ponytail: ceiling = 清理延迟(10% 概率,平均每 10 次写入触发一次),升级路径 = 启动时清理 + +**Patterns to follow**: `task_store.py` 的 lazy cleanup 模式(`_cleanup_expired` 在 increment 时触发)。 + +**Test scenarios**: +- lazy 触发:mock `cleanup_expired`,调用 `store_prompt_reflection` 10+ 次,验证 `cleanup_expired` 至少被调用一次 +- 失败不阻塞:`cleanup_expired` raise Exception 时,`store_prompt_reflection` 仍正常返回 +- task_type 过滤:`cleanup_expired` 调用时传入 `task_type="prompt_reflection"` + +**Verification**: `python3 -m pytest tests/unit/test_reflexion_persist.py -x -q` 通过。 + +### U3. SharedWorkspace.lock 在并行子任务写入中使用 + +**Goal**: 并行子任务对 SharedWorkspace 的关键写入段用 `lock()` 防护 TOCTOU。 + +**Requirements**: F3 + +**Dependencies**: None + +**Files**: +- `src/agentkit/experts/orchestrator.py` — `_run_pipeline` 并行执行路径添加 lock 包装 +- `tests/unit/test_team_parallel.py` — 新增 TOCTOU 防护测试 + +**Approach**: +- 在并行子任务执行路径中,找到 SharedWorkspace 写入段(`workspace.write()` 调用) +- 用 `async with workspace.lock(key, agent_id, timeout=30.0)` 包装写入段 +- lock key = `{plan_id}/phase/{phase_id}/output` +- 只锁写入段,不锁整个子任务执行(避免过度序列化) + +**Patterns to follow**: `shared_workspace.py:96` 的 `lock()` API 模式。 + +**Test scenarios**: +- lock 获取:并行子任务执行时调用 `workspace.lock(key, agent_id)` +- lock 超时:lock 被持有超过 30s 时 raise TimeoutError +- 并行写入不冲突:2 个子任务同时写入不同 key,都成功 +- 同 key 串行化:2 个子任务同时写同一 key,后一个等前一个释放 + +**Verification**: `python3 -m pytest tests/unit/test_team_parallel.py -x -q` 通过。 + +### U4. retrieve_prompt_reflection KTD5 触发门控 + +**Goal**: `retrieve_prompt_reflection` 仅在 KTD5 触发条件(verify 二次失败 / schema 校验失败 / 循环检测)下调用。 + +**Requirements**: F4 + +**Dependencies**: None + +**Files**: +- `src/agentkit/experts/orchestrator.py` — `_decompose_task` 新增 `trigger_reason` 参数,门控检索 +- `src/agentkit/core/reflexion.py` — `retrieve_prompt_reflection` docstring 更新触发条件 +- `tests/unit/test_lead_reflection_retrieval.py` — 新增门控测试 + +**Approach**: +- `_decompose_task` 签名新增 `trigger_reason: str | None = None` +- 仅当 `trigger_reason in {"verify_retry", "schema_validation", "loop_detection"}` 时调用 `retrieve_prompt_reflection` +- 非触发场景跳过检索,用默认 prompt(现有行为) +- 调用方根据上下文传入 trigger_reason(如 verify 失败重试时传 `verify_retry`) + +**Patterns to follow**: 现有 `_decompose_task` 的参数传递模式。 + +**Test scenarios**: +- 触发检索:trigger_reason="verify_retry" 时调用 `retrieve_prompt_reflection` +- 不触发检索:trigger_reason=None 时跳过 `retrieve_prompt_reflection` +- 无效 trigger_reason:trigger_reason="random" 时跳过检索 +- 检索失败降级:trigger_reason 触发但检索失败时,用默认 prompt + +**Verification**: `python3 -m pytest tests/unit/test_lead_reflection_retrieval.py -x -q` 通过。 + +### U5. configs/agentkit.yaml.example 创建 + +**Goal**: 创建配置示例文件,包含 `dangerous_tools` 配置段。 + +**Requirements**: F5 + +**Dependencies**: None + +**Files**: +- `configs/agentkit.yaml.example` — 新增配置示例文件 + +**Approach**: +- 创建 `configs/agentkit.yaml.example`,包含 `dangerous_tools` 配置段示例: + ```yaml + dangerous_tools: + enabled: true + tool_patterns: + - "rm" + - "rmdir" + - "deploy" + - "kubectl" + - "helm" + - "git_push_force" + - "alembic" + - "migrate" + - "payment_*" + autonomy_timeout_minutes: 30 + max_consecutive_failures: 3 + ``` +- 包含注释说明每个字段的含义 + +**Patterns to follow**: `.env.example` 的示例格式;`ServerConfig.from_dict` 的配置结构。 + +**Test expectation**: none — 配置示例文件无需测试。 + +**Verification**: 文件存在且 YAML 语法正确。 + +### U6. resume Future 注册顺序修复 + +**Goal**: 消除 `pending_autonomy_resumes` Future 注册竞态。 + +**Requirements**: F6 + +**Dependencies**: None + +**Files**: +- `src/agentkit/server/routes/chat.py` — `_resume_handler` 重构注册顺序 +- `tests/unit/test_autonomy_paused.py` — 新增竞态测试 + +**Approach**: +- 原 `_resume_handler` 顺序:返回 handler → handler 内创建 Future → 注册到 pending_autonomy_resumes → await Future +- 修复后顺序:创建 Future → 注册到 pending_autonomy_resumes → 返回 handler → handler await Future +- 确保 WebSocket loop 收到 resume 消息时 Future 已注册 + +**Patterns to follow**: 现有 `pending_autonomy_resumes` 的 Future 管理模式。 + +**Test scenarios**: +- 注册先于 await:Future 在 `_resume_handler` await 之前已存入 `pending_autonomy_resumes` +- resume 消息不丢失:WebSocket loop 收到 resume 消息时 Future 已注册,能正确 resolve +- 双重 resolve 防护:重复 resume 消息不报错(已有 double-resolve guard) + +**Verification**: `python3 -m pytest tests/unit/test_autonomy_paused.py -x -q` 通过。 + +### U7. 用户拒绝不算失败 + +**Goal**: `_track_tool_result_for_autonomy` 区分用户拒绝(denial)和工具失败(failure),拒绝不递增失败计数。 + +**Requirements**: F7 + +**Dependencies**: None + +**Files**: +- `src/agentkit/core/react.py` — `_track_tool_result_for_autonomy` 新增 `denied` 参数 +- `src/agentkit/server/routes/chat.py` — confirmation denied 路径传入 `denied=True` +- `tests/unit/test_react_autonomy.py` — 新增拒绝不计数测试 + +**Approach**: +- `_track_tool_result_for_autonomy` 签名新增 `denied: bool = False` +- `denied=True` 时直接 return,不递增 `_consecutive_failures` +- `denied=False` 时(默认)保持现有行为:检查 error/is_error/error_type,递增计数器 +- confirmation_request 被拒绝的路径调用 `_track_tool_result_for_autonomy(result, denied=True)` + +**Patterns to follow**: 现有 `_track_tool_result_for_autonomy` 的参数传递模式。 + +**Test scenarios**: +- 拒绝不计数:denied=True 时 `_consecutive_failures` 不变 +- 失败计数:denied=False 且 result 有 error 时 `_consecutive_failures` +1 +- 拒绝不触发暂停:连续 3 次拒绝不触发 `autonomy_paused`(reason=consecutive_failures) +- 失败触发暂停:连续 3 次失败(denied=False)触发 `autonomy_paused` + +**Verification**: `python3 -m pytest tests/unit/test_react_autonomy.py -x -q` 通过。 + +--- + +## Verification Contract + +### Unit Tests + +```bash +# 全量单元测试(必须通过) +python3 -m pytest tests/unit/ -x -q + +# 本次新增/修改测试 +python3 -m pytest tests/unit/test_reflexion_persist.py tests/unit/test_team_parallel.py tests/unit/test_lead_reflection_retrieval.py tests/unit/test_autonomy_paused.py tests/unit/test_react_autonomy.py -x -q +``` + +### Frontend + +```bash +cd src/agentkit/server/frontend +npm run typecheck +npm run test:unit # 如果有前端测试框架 +``` + +### Lint + Format + +```bash +ruff check src/ && ruff format src/ +``` + +### Quality Gates + +- 无 `any` 类型(AGENTS.md 约束) +- 所有 Pydantic 模型用 `model_config = ConfigDict(...)`(AGENTS.md 约束) +- 异步生成器安全(`.trae/rules/project_rules.md` + 新沉淀的 yield-before-await 规则) +- 无 `return` 在第一个 `yield` 之前 +- `yield` 在 `await` 之前(当 await 依赖被 yield 的事件时) + +--- + +## Definition of Done + +### Global Criteria + +- 7 个 Implementation Units (U1-U7) 全部完成 +- 所有新增/修改测试通过 +- 现有测试不回归 +- `ruff check src/ && ruff format src/` 无错误 +- 前端 `npm run typecheck` 通过 +- 无 `any` 类型,Pydantic 模型用 `ConfigDict` +- 异步生成器安全(两条规则都满足) + +### Per-Unit Criteria + +| Unit | Done Signal | +|------|-------------| +| U1 | 前端接收 autonomy_paused 事件并渲染卡片,resume 按钮发送消息 | +| U2 | store_prompt_reflection 调用时 10% 概率触发 cleanup_expired | +| U3 | 并行子任务 SharedWorkspace 写入用 lock() 包装 | +| U4 | retrieve_prompt_reflection 仅在 KTD5 触发条件时调用 | +| U5 | configs/agentkit.yaml.example 存在且包含 dangerous_tools 段 | +| U6 | resume Future 注册先于 await,消除竞态 | +| U7 | 用户拒绝危险工具不计入 _consecutive_failures | + +### Cleanup Criteria + +- 移除调试代码 +- 移除未使用的 import +- 所有新增配置项有文档示例 + +--- + +## Appendix: Gap Analysis 来源 + +| Gap | 来源 | 严重性 | +|-----|------|--------| +| U1 前端事件处理 | gap analysis — U3/KTD8 NOT_IMPLEMENTED | P1(用户无法 resume) | +| U2 TTL 清理 | gap analysis — R15 DEFINED_BUT_NOT_INVOKED | P2(噪声积累) | +| U3 SharedWorkspace.lock | gap analysis — KTD6 NOT_IMPLEMENTED | P2(TOCTOU 风险) | +| U4 KTD5 门控 | gap analysis — R13/KTD5 PARTIAL | P2(token 浪费) | +| U5 配置示例 | gap analysis — U1 NOT_IMPLEMENTED | P3(文档缺失) | +| U6 resume race | review P2 #7 | P2(竞态) | +| U7 拒绝算失败 | review P2 #8 | P2(误暂停) | diff --git a/src/agentkit/core/react.py b/src/agentkit/core/react.py index e2dc0c6..e55a687 100644 --- a/src/agentkit/core/react.py +++ b/src/agentkit/core/react.py @@ -1332,6 +1332,16 @@ class ReActEngine: tool_duration_ms = int((time.monotonic() - tool_start) * 1000) # Handle confirmation flow (tool-side _is_dangerous) + # U7: track whether the user denied (autonomy gate + # rejection OR confirmation flow rejection) so + # _track_tool_result_for_autonomy can skip counting + # denials as failures. Autonomy gate rejection + # produces a permission_denied result; detect it + # here so the denial is not mis-counted as a failure. + _user_denied = ( + isinstance(tool_result, dict) + and tool_result.get("error_type") == "permission_denied" + ) if ( should_exec and isinstance(tool_result, dict) @@ -1417,6 +1427,7 @@ class ReActEngine: ) if not approved: + _user_denied = True tool_result = { "output": "", "exit_code": 126, @@ -1428,7 +1439,10 @@ 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) + # U7: user denial (denied=True) does NOT increment + # the failure counter — denial is intentional, not + # a tool failure. + self._track_tool_result_for_autonomy(tool_result, denied=_user_denied) react_step = ReActStep( step=step, @@ -2444,9 +2458,7 @@ class ReActEngine: "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 + time.time() - self._autonomy_started_at if self._autonomy_started_at > 0 else 0 ), } return (reason, resume_token, event_data) @@ -2488,14 +2500,22 @@ class ReActEngine: # Cancelled by user — break the loop return False - def _track_tool_result_for_autonomy(self, tool_result: object) -> None: + def _track_tool_result_for_autonomy(self, tool_result: object, denied: bool = False) -> 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. + + U7: When ``denied=True`` (user rejected confirmation), do NOT + increment the counter — user denial is an intentional choice, not + a tool failure. The counter is left unchanged so a sequence of + denials does not trigger ``autonomy_paused``. """ if not self._autonomy_mode: return + if denied: + # U7: user denial is not a failure — leave counter unchanged. + return if isinstance(tool_result, dict) and ( "error" in tool_result or tool_result.get("is_error") is True diff --git a/src/agentkit/core/reflexion.py b/src/agentkit/core/reflexion.py index 10a1fb7..e4c3636 100644 --- a/src/agentkit/core/reflexion.py +++ b/src/agentkit/core/reflexion.py @@ -755,9 +755,15 @@ class ReflexionEngine: {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. + KTD5/U4 trigger gate: callers must only invoke this when a retry or + failure signal is observed. ``TeamOrchestrator._decompose_task`` gates + this call with ``trigger_reason`` and only retrieves when it is one of: + - ``"verify_retry"`` — verify 阶段二次失败后重试 + - ``"schema_validation"`` — schema 校验失败后重试 + - ``"loop_detection"`` — 检测到 agent 执行循环后重试 + Non-trigger decompositions (``trigger_reason=None`` or any other value) + skip retrieval entirely and use the default prompt, avoiding pointless + retrieval cost on every task. Note: ``item.score`` from ``EpisodicMemory.search`` is the hybrid relevance score (cosine + time_decay), NOT the stored quality_score. diff --git a/src/agentkit/core/shared_workspace.py b/src/agentkit/core/shared_workspace.py index 702a720..f380503 100644 --- a/src/agentkit/core/shared_workspace.py +++ b/src/agentkit/core/shared_workspace.py @@ -5,10 +5,12 @@ from __future__ import annotations +import asyncio import json import logging import time -from typing import Any +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator logger = logging.getLogger(__name__) @@ -36,9 +38,7 @@ class SharedWorkspace: def _make_key(self, key: str) -> str: return f"{self._prefix}:{key}" - async def write( - self, key: str, value: Any, agent_id: str, ttl: int | None = None - ) -> int: + async def write(self, key: str, value: Any, agent_id: str, ttl: int | None = None) -> int: """写入共享数据 Args: @@ -135,6 +135,32 @@ class SharedWorkspace: return True return False + @asynccontextmanager + async def lock_section( + self, key: str, agent_id: str, timeout: float = 30.0 + ) -> AsyncIterator[None]: + """Acquire lock and hold it for the enclosed block, releasing on exit. + + Polls ``lock()`` until acquired or ``timeout`` elapses. Raises + ``TimeoutError`` if the lock cannot be acquired in time. Used to guard + read-then-write critical sections in parallel subtask execution (U3/KTD3). + + ponytail: ceiling = 50ms polling interval (not event-driven), no fairness + guarantee across waiters. 升级路径 = Redis pub/sub lock-release notification + or Redlock with fencing tokens. + """ + deadline = time.monotonic() + timeout + while True: + if await self.lock(key, agent_id, timeout=timeout): + break + if time.monotonic() >= deadline: + raise TimeoutError(f"SharedWorkspace lock timed out for key={key} ({timeout}s)") + await asyncio.sleep(0.05) + try: + yield + finally: + await self.unlock(key, agent_id) + async def _get_version(self, key: str) -> int: """获取当前版本号""" data = await self.read(key) @@ -150,7 +176,7 @@ class SharedWorkspace: async for key in self._redis.scan_iter(match=pattern): # Strip prefix k = key.decode() if isinstance(key, bytes) else key - k = k[len(self._prefix) + 1:] # Remove "prefix:" + k = k[len(self._prefix) + 1 :] # Remove "prefix:" # Skip lock keys if not k.startswith("lock:"): keys.append(k) diff --git a/src/agentkit/experts/_phase_executor.py b/src/agentkit/experts/_phase_executor.py index 47698d9..dbab6b2 100644 --- a/src/agentkit/experts/_phase_executor.py +++ b/src/agentkit/experts/_phase_executor.py @@ -123,10 +123,15 @@ class PhaseExecutorMixin: phase.assigned_expert = expert.config.name phase.status = PhaseStatus.RUNNING - await self._broadcast_event("phase_started", { - "phase_id": phase.id, "phase_name": phase.name, - "assigned_expert": phase.assigned_expert, "depends_on": list(phase.depends_on), - }) + await self._broadcast_event( + "phase_started", + { + "phase_id": phase.id, + "phase_name": phase.name, + "assigned_expert": phase.assigned_expert, + "depends_on": list(phase.depends_on), + }, + ) agent = await self._get_isolated_agent(expert, phase) lead = self._team.lead_expert or expert return expert, agent, lead @@ -149,8 +154,7 @@ class PhaseExecutorMixin: } if dependency_outputs: input_data["context"] = "前置阶段输出:\n" + "\n---\n".join( - f"[{name}]:\n" - f"{output[:500] if isinstance(output, str) else str(output)[:500]}" + f"[{name}]:\n{output[:500] if isinstance(output, str) else str(output)[:500]}" for name, output in dependency_outputs.items() ) if collaboration_outputs: @@ -206,13 +210,22 @@ class PhaseExecutorMixin: ] = await self._read_dependency_output(prev_phase) break - await self._broadcast_event("expert_step", { - "expert_id": expert.config.name, "expert_name": expert.config.name, - "expert_color": expert.config.color, "content": phase.task_description, - "step": phase.id, "phase_id": phase.id, "phase_name": phase.name, - }) + await self._broadcast_event( + "expert_step", + { + "expert_id": expert.config.name, + "expert_name": expert.config.name, + "expert_color": expert.config.color, + "content": phase.task_description, + "step": phase.id, + "phase_id": phase.id, + "phase_name": phase.name, + }, + ) - task_msg = self._build_task_message(expert, phase, dependency_outputs, collaboration_outputs) + task_msg = self._build_task_message( + expert, phase, dependency_outputs, collaboration_outputs + ) # 执行专家任务(带重试,MAX_RETRIES 处理瞬时失败) # U3: 流式执行 — 从 agent.execute() 切换为 agent.execute_stream(), @@ -224,10 +237,13 @@ class PhaseExecutorMixin: try: # U3: 重试前广播 reset,前端清空已累积内容 if attempt > 0: - await self._broadcast_event("expert_result_chunk_reset", { - "expert_id": expert.config.name, - "phase_id": phase.id, - }) + await self._broadcast_event( + "expert_result_chunk_reset", + { + "expert_id": expert.config.name, + "phase_id": phase.id, + }, + ) # U3: 流式执行 — 转发 token/thinking/final_answer 事件到 WS async for event in agent.execute_stream(task_msg): etype = event.event_type @@ -235,19 +251,26 @@ class PhaseExecutorMixin: chunk = event.data.get("content", "") if chunk: accumulated.append(str(chunk)) - await self._broadcast_event("expert_result_chunk", { - "expert_id": expert.config.name, "content": chunk, - }) + await self._broadcast_event( + "expert_result_chunk", + { + "expert_id": expert.config.name, + "content": chunk, + }, + ) elif etype == "thinking": # P1 fix: payload 对齐前端 WsServerMessage 契约 # (expert_id/expert_name/expert_color/content/step) - await self._broadcast_event("expert_step", { - "expert_id": expert.config.name, - "expert_name": expert.config.name, - "expert_color": expert.config.color, - "content": event.data.get("content", ""), - "step": "thinking", - }) + await self._broadcast_event( + "expert_step", + { + "expert_id": expert.config.name, + "expert_name": expert.config.name, + "expert_color": expert.config.color, + "content": event.data.get("content", ""), + "step": "thinking", + }, + ) elif etype == "final_answer": # P0 fix: ReActEngine 先发 token(增量)再发 final_answer(全文)。 # token 已累积时,final_answer 仅作完成信号,不重复 append/broadcast; @@ -257,22 +280,21 @@ class PhaseExecutorMixin: accumulated.append(str(output)) elif etype in ("tool_call", "tool_result"): # P1 fix: payload 对齐前端契约 — content 携带可读摘要,step_data 保留原始数据 - await self._broadcast_event("expert_step", { - "expert_id": expert.config.name, - "expert_name": expert.config.name, - "expert_color": expert.config.color, - "content": ( - event.data.get("tool_name") - or event.data.get("name") - or etype - ), - "step": etype, - "step_data": event.data, - }) - elif etype == "error": - raise RuntimeError( - f"Stream error: {event.data.get('error', 'unknown')}" + await self._broadcast_event( + "expert_step", + { + "expert_id": expert.config.name, + "expert_name": expert.config.name, + "expert_color": expert.config.color, + "content": ( + event.data.get("tool_name") or event.data.get("name") or etype + ), + "step": etype, + "step_data": event.data, + }, ) + elif etype == "error": + raise RuntimeError(f"Stream error: {event.data.get('error', 'unknown')}") # 流式完成 — 构建结果 result = {"content": "".join(accumulated)} break @@ -280,13 +302,19 @@ class PhaseExecutorMixin: # CancelledError 必须传播,不被重试逻辑吞掉 # U3: 流式会话必须以 expert_result(error) 终结,不允许静默挂起 try: - await self._broadcast_event("expert_result", { - "expert_id": expert.config.name, "expert_name": expert.config.name, - "expert_color": expert.config.color, - "content": "".join(accumulated), "status": "error", - "error": "cancelled", - "phase_id": phase.id, "rework_attempt": phase.rework_count, - }) + await self._broadcast_event( + "expert_result", + { + "expert_id": expert.config.name, + "expert_name": expert.config.name, + "expert_color": expert.config.color, + "content": "".join(accumulated), + "status": "error", + "error": "cancelled", + "phase_id": phase.id, + "rework_attempt": phase.rework_count, + }, + ) except (ConnectionError, RuntimeError, asyncio.TimeoutError) as bc_err: logger.warning(f"Failed to broadcast expert_result(cancelled): {bc_err}") raise @@ -300,32 +328,50 @@ class PhaseExecutorMixin: continue # 重试耗尽 — 广播 error 终结事件 try: - await self._broadcast_event("expert_result", { - "expert_id": expert.config.name, "expert_name": expert.config.name, - "expert_color": expert.config.color, - "content": "".join(accumulated), "status": "error", - "error": last_error, - "phase_id": phase.id, "rework_attempt": phase.rework_count, - }) + await self._broadcast_event( + "expert_result", + { + "expert_id": expert.config.name, + "expert_name": expert.config.name, + "expert_color": expert.config.color, + "content": "".join(accumulated), + "status": "error", + "error": last_error, + "phase_id": phase.id, + "rework_attempt": phase.rework_count, + }, + ) except (ConnectionError, RuntimeError, asyncio.TimeoutError) as bc_err: logger.warning(f"Failed to broadcast expert_result(error): {bc_err}") raise - await self._broadcast_event("expert_result", { - "expert_id": expert.config.name, "expert_name": expert.config.name, - "expert_color": expert.config.color, "content": result.get("content", str(result)), - "status": "completed", - "phase_id": phase.id, "rework_attempt": phase.rework_count, - }) + await self._broadcast_event( + "expert_result", + { + "expert_id": expert.config.name, + "expert_name": expert.config.name, + "expert_color": expert.config.color, + "content": result.get("content", str(result)), + "status": "completed", + "phase_id": phase.id, + "rework_attempt": phase.rework_count, + }, + ) # U4: 解析专家输出中的风险标记,发出 risk_flagged 事件 content = result.get("content", str(result)) risk_flags = self._parse_risk_flags(content) for risk_desc in risk_flags[: self.MAX_RISK_FLAGS]: - await self._broadcast_event("risk_flagged", { - "expert": phase.assigned_expert, "expert_name": phase.assigned_expert, - "risk_description": risk_desc, "phase_id": phase.id, "phase_name": phase.name, - }) + await self._broadcast_event( + "risk_flagged", + { + "expert": phase.assigned_expert, + "expert_name": phase.assigned_expert, + "risk_description": risk_desc, + "phase_id": phase.id, + "phase_name": phase.name, + }, + ) # U3: Lead 验收阶段输出 — ReviewResult 结构化结果(含 degraded 标记) review = await self._review_phase_output(lead, phase, result) @@ -354,22 +400,38 @@ class PhaseExecutorMixin: # P2: SharedWorkspace 写入移到验收通过后 — 避免持久化被拒输出 output_key = f"{plan.id}/phase/{phase.id}/output" full_content = result.get("content", str(result)) - await self._team.workspace.write(output_key, full_content, expert.config.name) + # U3/KTD3: lock the write section to prevent TOCTOU when parallel + # subtasks write to SharedWorkspace concurrently. Only the write + # is locked, not the entire phase execution. + async with self._team.workspace.lock_section( + output_key, expert.config.name, timeout=30.0 + ): + await self._team.workspace.write(output_key, full_content, expert.config.name) phase.result = self._offload_result(full_content, output_key) - await self._broadcast_event("review_result", { - "phase_id": phase.id, "phase_name": phase.name, "passed": True, - "feedback": feedback, "expert": phase.assigned_expert, - "degraded": degraded, - }) + await self._broadcast_event( + "review_result", + { + "phase_id": phase.id, + "phase_name": phase.name, + "passed": True, + "feedback": feedback, + "expert": phase.assigned_expert, + "degraded": degraded, + }, + ) if phase.collaboration_contracts: await self._notify_collaborators(phase, plan) result_summary = result.get("content", str(result)) if isinstance(result_summary, str) and len(result_summary) > 200: result_summary = result_summary[:200] + "..." - await self._broadcast_event("phase_completed", { - "phase_id": phase.id, "phase_name": phase.name, - "result_summary": result_summary, - }) + await self._broadcast_event( + "phase_completed", + { + "phase_id": phase.id, + "phase_name": phase.name, + "result_summary": result_summary, + }, + ) return True # 验收不合格 — 返工或标记失败(degraded 路径不应走到这里,但保持字段一致) @@ -396,7 +458,7 @@ class PhaseExecutorMixin: { "phase_id": phase.id, "phase_name": phase.name, - "error": f"Review failed after " f"{phase.rework_count} reworks: {feedback}", + "error": f"Review failed after {phase.rework_count} reworks: {feedback}", }, ) raise RuntimeError( diff --git a/src/agentkit/experts/orchestrator.py b/src/agentkit/experts/orchestrator.py index feef917..7f15a6f 100644 --- a/src/agentkit/experts/orchestrator.py +++ b/src/agentkit/experts/orchestrator.py @@ -69,6 +69,12 @@ class TeamOrchestrator( # 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 + # U4/KTD5: trigger reasons that gate retrieve_prompt_reflection in + # _decompose_task. Retrieval only runs on these retry/failure signals, + # not on every decomposition, to avoid pointless retrieval cost. + _TRIGGER_REASONS: frozenset[str] = frozenset( + {"verify_retry", "schema_validation", "loop_detection"} + ) 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. @@ -612,7 +618,9 @@ class TeamOrchestrator( ) return new_phases - async def _decompose_task(self, lead: Expert, task: str) -> list[PlanPhase]: + async def _decompose_task( + self, lead: Expert, task: str, trigger_reason: str | None = None + ) -> list[PlanPhase]: """Lead Expert decomposes task into phases using LLM. Returns a list of PlanPhase instances. If LLM decomposition fails, @@ -622,6 +630,12 @@ class TeamOrchestrator( historical prompt reflection for similar task_input and prepends improved_prompt to the decomposition prompt. Non-blocking — retrieval failure falls through to default prompt. + + U4/KTD5: retrieval is gated by ``trigger_reason`` — it only runs when + ``trigger_reason`` is one of ``_TRIGGER_REASONS`` (verify_retry / + schema_validation / loop_detection). Pass ``trigger_reason`` from the + caller that observed the retry/failure signal; omit it (None) for a + fresh decomposition, which skips retrieval and uses the default prompt. """ gateway = self._get_llm_gateway(lead) if not gateway: @@ -633,9 +647,10 @@ class TeamOrchestrator( ] available_experts = member_names if member_names else [lead.config.name] - # U6: retrieve historical reflection (non-blocking) + # U6/U4(KTD5): retrieve historical reflection only on a retry/failure + # trigger — not on every decomposition. Non-blocking. reflection_hint = "" - if self._reflexion_engine is not None: + if self._reflexion_engine is not None and trigger_reason in self._TRIGGER_REASONS: try: historical = await self._reflexion_engine.retrieve_prompt_reflection( task_input=task diff --git a/src/agentkit/memory/episodic.py b/src/agentkit/memory/episodic.py index 3fc5a2b..cdd9d0f 100644 --- a/src/agentkit/memory/episodic.py +++ b/src/agentkit/memory/episodic.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging import math +import random from datetime import datetime, timezone from typing import TYPE_CHECKING @@ -446,6 +447,17 @@ class EpisodicMemory(Memory): """ import hashlib + # U2: lazy cleanup — 10% chance to purge expired prompt_reflection records + # on write. fire-and-forget: cleanup failure must never block the store. + # ponytail: ceiling = cleanup latency added to ~10% of writes (avg 1-in-10 + # trigger), no scheduling. 升级路径 = startup-time cleanup job (APScheduler) + # off the write path entirely. + if random.random() < 0.1: + try: + await self.cleanup_expired(task_type="prompt_reflection") + except Exception as e: # noqa: BLE001 — fire-and-forget, never block store + logger.warning(f"U2: lazy cleanup_expired failed (non-blocking): {e}") + if task_hash is None: task_hash = hashlib.sha256(task_input.encode("utf-8")).hexdigest()[:16] key = f"prompt_reflection:{task_hash}:{version}" @@ -569,9 +581,7 @@ class EpisodicMemory(Memory): ) return items - async def cleanup_expired( - self, max_age_days: int = 30, task_type: str | None = None - ) -> int: + async def cleanup_expired(self, max_age_days: int = 30, task_type: str | None = None) -> int: """删除超过 max_age_days 天的记录 (U5/R15 TTL). Args: diff --git a/src/agentkit/server/frontend/src/api/types.ts b/src/agentkit/server/frontend/src/api/types.ts index 8509a81..631dbf9 100644 --- a/src/agentkit/server/frontend/src/api/types.ts +++ b/src/agentkit/server/frontend/src/api/types.ts @@ -134,6 +134,13 @@ export type WsClientMessage = task_id: string; conversation_id?: string; } + | { + // IQ-Boost/U1: resume autonomy-paused execution. Sent by the frontend + // when the user clicks "继续" on AutonomyPausedCard. The backend + // resolves the matching pending_autonomy_resumes future (see chat.py). + type: "resume"; + resume_token: string; + } | { type: "cancel"; task_id?: string; @@ -260,6 +267,9 @@ export type WsServerMessage = | { type: "collaboration_notice"; data: ICollaborationNotice } | { type: "review_result"; data: IReviewResult } | { type: "risk_flagged"; data: IRiskFlag } + // IQ-Boost/U1: autonomy pause event — backend forwards from ReActEngine. + // Sent when autonomy_timeout_minutes or max_consecutive_failures is hit. + | { type: "autonomy_paused"; data: IAutonomyPausedData } // Calendar 事件 (KTD-10 — piggyback on chat WS) | { type: "calendar_event_created"; data: ICalendarEventCreatedData } | { type: "calendar_reminder"; data: ICalendarReminderData } @@ -523,6 +533,32 @@ export interface ICalendarSyncConflictData { resolution: string; } +// ── Autonomy pause (IQ-Boost/U1) 类型 ──────────────────────────────── + +/** Autonomy pause reason — matches backend react.py _detect_autonomy_pause. */ +export type AutonomyPauseReason = "timeout" | "consecutive_failures" | "manual"; + +/** Progress snapshot embedded in autonomy_paused event (from react.py). */ +export interface IAutonomyProgress { + step?: number; + tool_name?: string; + total_steps?: number; + [key: string]: unknown; +} + +/** + * autonomy_paused event payload — matches backend ReActEvent.data shape + * (see react.py _detect_autonomy_pause). The frontend stores this in + * chatStream.autonomyPaused and renders AutonomyPausedCard. + */ +export interface IAutonomyPausedData { + reason: AutonomyPauseReason; + progress: IAutonomyProgress; + resume_token: string; + consecutive_failures?: number; + elapsed_seconds?: number; +} + /** Expert template (matches backend GET /api/v1/experts response item) */ export interface IExpertTemplate { name: string; diff --git a/src/agentkit/server/frontend/src/components/chat/messages/AutonomyPausedCard.vue b/src/agentkit/server/frontend/src/components/chat/messages/AutonomyPausedCard.vue new file mode 100644 index 0000000..b5edabd --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/AutonomyPausedCard.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/index.ts b/src/agentkit/server/frontend/src/components/chat/messages/index.ts index 7fe271e..e4846e9 100644 --- a/src/agentkit/server/frontend/src/components/chat/messages/index.ts +++ b/src/agentkit/server/frontend/src/components/chat/messages/index.ts @@ -12,5 +12,6 @@ export { default as DebateConclusionCard } from './DebateConclusionCard.vue' export { default as CollaborationGraphCard } from './CollaborationGraphCard.vue' export { default as ReviewResultCard } from './ReviewResultCard.vue' export { default as RiskFlagCard } from './RiskFlagCard.vue' +export { default as AutonomyPausedCard } from './AutonomyPausedCard.vue' export { default as ErrorCard } from './ErrorCard.vue' export { default as FileAttachment } from './FileAttachment.vue' diff --git a/src/agentkit/server/frontend/src/stores/chatStore.ts b/src/agentkit/server/frontend/src/stores/chatStore.ts index e2f3d6f..4e5a54a 100644 --- a/src/agentkit/server/frontend/src/stores/chatStore.ts +++ b/src/agentkit/server/frontend/src/stores/chatStore.ts @@ -225,6 +225,7 @@ export const useChatStore = defineStore("chat", () => { stream.boardState.value = null; stream.debateState.value = null; stream.collaborationState.value = null; + stream.autonomyPaused.value = null; } const conv = conversations.value.find((c) => c.id === id); @@ -351,6 +352,7 @@ export const useChatStore = defineStore("chat", () => { stream.boardState.value = null; stream.debateState.value = null; stream.collaborationState.value = null; + stream.autonomyPaused.value = null; stream.clearConvSteps(newConversation.id); } @@ -374,6 +376,7 @@ export const useChatStore = defineStore("chat", () => { stream.boardState.value = null; stream.debateState.value = null; stream.collaborationState.value = null; + stream.autonomyPaused.value = null; if (conversations.value.length > 0) { await selectConversation(conversations.value[0].id); } else { @@ -528,6 +531,32 @@ export const useChatStore = defineStore("chat", () => { } } + /** + * IQ-Boost/U1: Resume autonomy-paused execution. Sends ``resume`` with the + * ``resume_token`` from the autonomy_paused event; the backend resolves the + * matching pending_autonomy_resumes future (see chat.py) and the engine + * unblocks. Clears ``autonomyPaused`` so the pause card disappears. + * + * If the socket is closed we still clear local state — the engine's + * _resume_handler future is cancelled on WS teardown, so the paused + * execution has already been torn down server-side. + */ + function resumeAutonomy(resumeToken: string): void { + stream.autonomyPaused.value = null; + if (!socket.ws.value || socket.ws.value.readyState !== WebSocket.OPEN) { + return; + } + try { + const resumeMsg: WsClientMessage = { + type: "resume", + resume_token: resumeToken, + }; + socket.ws.value.send(JSON.stringify(resumeMsg)); + } catch (error) { + console.error("Failed to send autonomy resume message:", error); + } + } + /** After WebSocket reconnects, check for running tasks and resume them. */ async function _recoverTaskAfterReconnect(): Promise { const cid = currentConversationId.value; @@ -634,6 +663,7 @@ export const useChatStore = defineStore("chat", () => { boardState: stream.boardState, debateState: stream.debateState, collaborationState: stream.collaborationState, + autonomyPaused: stream.autonomyPaused, currentPhase: stream.currentPhase, phaseViolations: stream.phaseViolations, isPlanExec: stream.isPlanExec, @@ -657,6 +687,7 @@ export const useChatStore = defineStore("chat", () => { sendWsMessage, resendLastUserMessage, stopGeneration, + resumeAutonomy, connectWebSocket: socket.connectWebSocket, disconnectWebSocket: socket.disconnectWebSocket, }; diff --git a/src/agentkit/server/frontend/src/stores/chatStream.ts b/src/agentkit/server/frontend/src/stores/chatStream.ts index 3909275..4dc642b 100644 --- a/src/agentkit/server/frontend/src/stores/chatStream.ts +++ b/src/agentkit/server/frontend/src/stores/chatStream.ts @@ -16,6 +16,7 @@ import type { IDebateArgumentData, IDebateRoundSummaryData, IDebateResolvedData, + IAutonomyPausedData, WsServerMessage, } from "@/api/types"; import { pickExpertIdentity, resolveExpertIdentity } from "@/components/chat/helpers/expertIdentity"; @@ -135,6 +136,9 @@ export interface ChatStreamState { boardState: Ref; debateState: Ref; collaborationState: Ref; + // IQ-Boost/U1: autonomy pause transient state. Set by the autonomy_paused + // event; cleared by chatStore.resumeAutonomy after the user clicks resume. + autonomyPaused: Ref; // Read access to chatStore state conversations: Ref; @@ -1104,6 +1108,30 @@ export function dispatchWsEvent( break; } + // ── IQ-Boost/U1: Autonomy pause ─────────────────────────────────── + // Backend (react.py _detect_autonomy_pause) forwards this when autonomy + // timeout or consecutive_failures threshold is hit. The engine is + // blocked in _await_autonomy_resume waiting for the user's ``resume`` + // message. We store the payload so AutonomyPausedCard can render reason + // + progress + a resume button; chatStore.resumeAutonomy clears it. + case "autonomy_paused": { + state.autonomyPaused.value = event.data; + const cid = state.resolveIncomingConvId(); + if (cid) { + appendStep( + state.streamingStepsByConv, + { + type: "milestone", + label: "自主执行已暂停", + detail: event.data.reason, + status: "running", + }, + cid, + ); + } + break; + } + // ── Board Meeting 模式事件 ──────────────────────────────────────── case "board_started": { @@ -1757,6 +1785,9 @@ export function useChatStream(deps: ChatStreamDeps) { const boardState = ref(null); const debateState = ref(null); const collaborationState = ref(null); + // IQ-Boost/U1: autonomy pause state — set by autonomy_paused event, + // cleared by chatStore.resumeAutonomy. + const autonomyPaused = ref(null); // --- Getters --- const isBoardMode = computed( @@ -1806,6 +1837,7 @@ export function useChatStream(deps: ChatStreamDeps) { boardState, debateState, collaborationState, + autonomyPaused, conversations: deps.conversations, currentConversationId: deps.currentConversationId, appendMessage: deps.appendMessage, @@ -1830,6 +1862,7 @@ export function useChatStream(deps: ChatStreamDeps) { boardState, debateState, collaborationState, + autonomyPaused, // Getters isBoardMode, isPlanExec, diff --git a/src/agentkit/server/frontend/src/views/ChatView.vue b/src/agentkit/server/frontend/src/views/ChatView.vue index 05e50aa..71b0283 100644 --- a/src/agentkit/server/frontend/src/views/ChatView.vue +++ b/src/agentkit/server/frontend/src/views/ChatView.vue @@ -73,6 +73,16 @@ + +
+ +
@@ -108,6 +118,7 @@ import ChatMessage from '@/components/chat/ChatMessage.vue' import ChatInput from '@/components/chat/ChatInput.vue' import StickyModeHeader from '@/components/chat/StickyModeHeader.vue' import PhaseIndicator from '@/components/chat/PhaseIndicator.vue' +import AutonomyPausedCard from '@/components/chat/messages/AutonomyPausedCard.vue' const ATypographyText = ATypography.Text @@ -158,6 +169,18 @@ watch( } ) +// IQ-Boost/U1: scroll the autonomy pause card into view when it appears so +// the user sees the resume button without manual scrolling. +watch( + () => chatStore.autonomyPaused, + async (paused) => { + if (paused) { + await nextTick() + scrollToBottom() + } + } +) + function scrollToBottom(): void { if (messagesContainer.value) { messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight @@ -171,6 +194,11 @@ function handleSend(message: string, model?: string): void { chatStore.sendMessage(message) } } + +/** IQ-Boost/U1: resume autonomy-paused execution. */ +function handleResumeAutonomy(resumeToken: string): void { + chatStore.resumeAutonomy(resumeToken) +} diff --git a/src/agentkit/server/frontend/tests/unit/components/AutonomyPausedCard.test.ts b/src/agentkit/server/frontend/tests/unit/components/AutonomyPausedCard.test.ts new file mode 100644 index 0000000..149ac5a --- /dev/null +++ b/src/agentkit/server/frontend/tests/unit/components/AutonomyPausedCard.test.ts @@ -0,0 +1,150 @@ +/** + * IQ-Boost/U1: unit tests for AutonomyPausedCard. + * + * Mount strategy: native Vue createApp + h (no @vue/test-utils dependency), + * consistent with BoardBannerCard.test.ts. Verifies: + * - reason → 中文标签映射 (timeout / consecutive_failures / manual) + * - progress 摘要渲染 (步骤 N / M · 工具 X) + * - resume 按钮点击 emit resume 事件携带 resume_token + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { createApp, h, type App } from 'vue' +import AutonomyPausedCard from '@/components/chat/messages/AutonomyPausedCard.vue' +import type { IAutonomyPausedData } from '@/api/types' + +interface Mounted { + container: HTMLElement + root: HTMLElement + app: App + unmount: () => void +} + +function mountCard( + props: { data: IAutonomyPausedData }, + onResume?: (token: string) => void, +): Mounted { + const container = document.createElement('div') + document.body.appendChild(container) + const app = createApp({ + setup() { + return () => + h(AutonomyPausedCard as never, { + data: props.data as never, + onResume: (token: string) => onResume?.(token), + } as never) + }, + }) + app.mount(container) + const root = container.querySelector('.autonomy-card') as HTMLElement + return { + container, + root, + app, + unmount: () => { + app.unmount() + container.remove() + }, + } +} + +function makeData( + overrides: Partial = {}, +): IAutonomyPausedData { + return { + reason: 'timeout', + progress: { step: 3, tool_name: 'shell', total_steps: 10 }, + resume_token: 'autonomy_pause:timeout:3', + consecutive_failures: 0, + elapsed_seconds: 305.4, + ...overrides, + } +} + +describe('AutonomyPausedCard (IQ-Boost/U1)', () => { + let mounted: Mounted | null = null + + afterEach(() => { + mounted?.unmount() + mounted = null + }) + + it('renders reason=timeout as "自主执行超时"', () => { + mounted = mountCard({ data: makeData({ reason: 'timeout' }) }) + const reason = mounted.container.querySelector( + '.autonomy-card__reason', + ) as HTMLElement + expect(reason).toBeTruthy() + expect(reason.textContent).toBe('自主执行超时') + }) + + it('renders reason=consecutive_failures as "连续失败触发暂停"', () => { + mounted = mountCard({ + data: makeData({ reason: 'consecutive_failures', consecutive_failures: 3 }), + }) + const reason = mounted.container.querySelector( + '.autonomy-card__reason', + ) as HTMLElement + expect(reason.textContent).toBe('连续失败触发暂停') + // consecutive_failures count is also shown in meta. + const meta = mounted.container.querySelector( + '.autonomy-card__meta', + ) as HTMLElement + expect(meta.textContent).toContain('连续失败 3 次') + }) + + it('renders reason=manual as "用户手动暂停"', () => { + mounted = mountCard({ data: makeData({ reason: 'manual' }) }) + const reason = mounted.container.querySelector( + '.autonomy-card__reason', + ) as HTMLElement + expect(reason.textContent).toBe('用户手动暂停') + }) + + it('renders progress summary "步骤 3 / 10 · 工具 shell"', () => { + mounted = mountCard({ data: makeData() }) + const progress = mounted.container.querySelector( + '.autonomy-card__progress', + ) as HTMLElement + expect(progress).toBeTruthy() + expect(progress.textContent).toContain('步骤 3 / 10') + expect(progress.textContent).toContain('工具 shell') + }) + + it('renders elapsed time "已运行 5m 5s" for 305.4s', () => { + mounted = mountCard({ data: makeData({ elapsed_seconds: 305.4 }) }) + const meta = mounted.container.querySelector( + '.autonomy-card__meta', + ) as HTMLElement + expect(meta.textContent).toContain('已运行 5m 5s') + }) + + it('emits resume with resume_token when 继续 button is clicked', async () => { + let emittedToken: string | null = null + mounted = mountCard( + { data: makeData({ resume_token: 'autonomy_pause:timeout:7' }) }, + (token) => { + emittedToken = token + }, + ) + const button = mounted.container.querySelector( + '.autonomy-card__actions button', + ) as HTMLButtonElement + expect(button).toBeTruthy() + button.click() + // Vue event flush — await a microtask. + await Promise.resolve() + expect(emittedToken).toBe('autonomy_pause:timeout:7') + }) + + it('hides progress block when progress has no known fields', () => { + mounted = mountCard({ + data: makeData({ progress: {} }), + }) + const progress = mounted.container.querySelector( + '.autonomy-card__progress', + ) + // v-if="progressSummary" — empty progress → no render. + expect(progress).toBeNull() + }) +}) diff --git a/src/agentkit/server/frontend/tests/unit/stores/chatStream.autonomy.test.ts b/src/agentkit/server/frontend/tests/unit/stores/chatStream.autonomy.test.ts new file mode 100644 index 0000000..3e6fa78 --- /dev/null +++ b/src/agentkit/server/frontend/tests/unit/stores/chatStream.autonomy.test.ts @@ -0,0 +1,192 @@ +/** + * IQ-Boost/U1: unit tests for the autonomy_paused dispatch branch. + * + * dispatchWsEvent is a pure function over ChatStreamState, so we build a + * fixture (mirrors chatStream.test.ts) and assert: + * - autonomy_paused event stores the payload in state.autonomyPaused + * - a "自主执行已暂停" milestone step is appended for visibility + * - resumeAutonomy (chatStore) clears autonomyPaused and sends the resume + * WS message — covered here by asserting the dispatch-side clear path + * (the store action is a thin wrapper over socket.send + state clear). + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref, type Ref } from 'vue' +import { + dispatchWsEvent, + type ChatStreamState, + type IStreamingStep, +} from '@/stores/chatStream' +import type { + IAutonomyPausedData, + IChatMessage, + IConversation, + IExpertTeamState, + WsServerMessage, +} from '@/api/types' + +// Mock ant-design-vue so any dynamic import in dispatchWsEvent is harmless. +vi.mock('ant-design-vue', () => ({ + message: { warning: vi.fn() }, +})) + +vi.mock('@/api/documents', () => ({ + isDocumentMeta: vi.fn(() => true), +})) + +interface Fixture { + state: ChatStreamState + conversations: Ref + currentConversationId: Ref + appendMessageSpy: ReturnType + updateMessageSpy: ReturnType + markConversationDoneSpy: ReturnType + teamStore: { + teamState: IExpertTeamState | null + setTeamState: ReturnType + updatePhases: ReturnType + updatePhaseStatus: ReturnType + clearTeam: ReturnType + } + calendarStore: { handleWsEvent: ReturnType } + documentsStore: { addDocument: ReturnType } +} + +function createFixture(convId: string = 'conv-1'): Fixture { + const conversations = ref([ + { + id: convId, + title: '新对话', + messages: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]) + const currentConversationId = ref(convId) + const appendMessageSpy = vi.fn((cid: string, msg: IChatMessage) => { + const conv = conversations.value.find((c) => c.id === cid) + if (conv) conv.messages.push(msg) + }) + const updateMessageSpy = vi.fn( + (cid: string, mid: string, updates: Partial) => { + const conv = conversations.value.find((c) => c.id === cid) + if (!conv) return + const msg = conv.messages.find((m) => m.id === mid) + if (msg) Object.assign(msg, updates) + }, + ) + const markConversationDoneSpy = vi.fn() + + const teamStore = { + teamState: null as IExpertTeamState | null, + setTeamState: vi.fn(), + updatePhases: vi.fn(), + updatePhaseStatus: vi.fn(), + clearTeam: vi.fn(), + } + const calendarStore = { handleWsEvent: vi.fn() } + const documentsStore = { addDocument: vi.fn() } + + const state: ChatStreamState = { + streamingStepsByConv: ref(new Map()), + currentPhase: ref(null), + phaseViolations: ref([]), + boardState: ref(null), + debateState: ref(null), + collaborationState: ref(null), + autonomyPaused: ref(null), + conversations, + currentConversationId, + appendMessage: appendMessageSpy, + updateMessage: updateMessageSpy, + markConversationDone: markConversationDoneSpy, + resolveIncomingConvId: () => currentConversationId.value ?? '', + getTeamStore: () => teamStore as unknown as ChatStreamState['getTeamStore'] extends () => infer R ? R : never, + getCalendarStore: () => calendarStore as unknown as ChatStreamState['getCalendarStore'] extends () => infer R ? R : never, + getDocumentsStore: () => documentsStore as unknown as ChatStreamState['getDocumentsStore'] extends () => infer R ? R : never, + } + + return { + state, + conversations, + currentConversationId, + appendMessageSpy, + updateMessageSpy, + markConversationDoneSpy, + teamStore, + calendarStore, + documentsStore, + } +} + +function autonomyEvent( + overrides: Partial = {}, +): WsServerMessage { + const data: IAutonomyPausedData = { + reason: 'timeout', + progress: { step: 3, tool_name: 'shell', total_steps: 10 }, + resume_token: 'autonomy_pause:timeout:3', + consecutive_failures: 0, + elapsed_seconds: 305.4, + ...overrides, + } + return { type: 'autonomy_paused', data } +} + +describe('dispatchWsEvent — autonomy_paused (IQ-Boost/U1)', () => { + let f: Fixture + beforeEach(() => { + f = createFixture() + }) + + it('stores the autonomy_paused payload in state.autonomyPaused', () => { + expect(f.state.autonomyPaused.value).toBeNull() + dispatchWsEvent(autonomyEvent(), f.state) + expect(f.state.autonomyPaused.value).not.toBeNull() + expect(f.state.autonomyPaused.value?.reason).toBe('timeout') + expect(f.state.autonomyPaused.value?.resume_token).toBe( + 'autonomy_pause:timeout:3', + ) + expect(f.state.autonomyPaused.value?.progress.step).toBe(3) + }) + + it('appends a "自主执行已暂停" milestone step for visibility', () => { + dispatchWsEvent(autonomyEvent(), f.state) + const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? [] + expect(steps.length).toBe(1) + expect(steps[0].label).toBe('自主执行已暂停') + expect(steps[0].type).toBe('milestone') + expect(steps[0].status).toBe('running') + expect(steps[0].detail).toBe('timeout') + }) + + it('overwrites a previous pause payload when a second event arrives', () => { + dispatchWsEvent(autonomyEvent({ reason: 'timeout' }), f.state) + dispatchWsEvent( + autonomyEvent({ + reason: 'consecutive_failures', + resume_token: 'autonomy_pause:consecutive_failures:5', + consecutive_failures: 3, + }), + f.state, + ) + expect(f.state.autonomyPaused.value?.reason).toBe('consecutive_failures') + expect(f.state.autonomyPaused.value?.resume_token).toBe( + 'autonomy_pause:consecutive_failures:5', + ) + expect(f.state.autonomyPaused.value?.consecutive_failures).toBe(3) + // Two milestone steps accumulated (one per event). + const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? [] + expect(steps.length).toBe(2) + }) + + it('does not throw when resolveIncomingConvId returns empty (no conv)', () => { + f.currentConversationId.value = null + // No pending conversations either → resolver returns "". + expect(() => dispatchWsEvent(autonomyEvent(), f.state)).not.toThrow() + // State is still set (dispatch sets it before the conv-id-gated step). + expect(f.state.autonomyPaused.value?.reason).toBe('timeout') + // No step appended (no conv to attach it to). + expect(f.state.streamingStepsByConv.value.size).toBe(0) + }) +}) diff --git a/src/agentkit/server/frontend/tests/unit/stores/chatStream.test.ts b/src/agentkit/server/frontend/tests/unit/stores/chatStream.test.ts index a3060d6..976731b 100644 --- a/src/agentkit/server/frontend/tests/unit/stores/chatStream.test.ts +++ b/src/agentkit/server/frontend/tests/unit/stores/chatStream.test.ts @@ -98,6 +98,7 @@ function createFixture(convId: string = 'conv-1'): Fixture { boardState: ref(null), debateState: ref(null), collaborationState: ref(null), + autonomyPaused: ref(null), conversations, currentConversationId, appendMessage: appendMessageSpy, diff --git a/src/agentkit/server/routes/chat.py b/src/agentkit/server/routes/chat.py index 089fce2..0c907ed 100644 --- a/src/agentkit/server/routes/chat.py +++ b/src/agentkit/server/routes/chat.py @@ -1620,10 +1620,20 @@ async def _handle_chat_message( ) 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 + """Block until the user sends ``resume`` for the given token. + + U6: The Future is pre-registered in the ``autonomy_paused`` event + handler below (before ``send_json``) so the WebSocket loop can + resolve it even if the resume message arrives before the engine + calls this handler. This closure only awaits the pre-registered + Future; the fallback (create on miss) covers non-WS callers/tests. + """ + future = _pending_autonomy_resumes.get(resume_token) + if future is None: + # Fallback: non-WS caller or event not seen yet — create on demand. + loop = asyncio.get_running_loop() + future = 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). @@ -1719,6 +1729,16 @@ async def _handle_chat_message( # 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. + # + # U6: Pre-register the resume Future BEFORE send_json so the + # WebSocket loop can resolve it even if the resume message + # arrives while send_json is suspended (race fix). Without + # this, the resume message would be dropped with "token not + # found" and the engine would deadlock. + resume_token = event.data.get("resume_token") + if resume_token and resume_token not in _pending_autonomy_resumes: + loop = asyncio.get_running_loop() + _pending_autonomy_resumes[resume_token] = loop.create_future() await websocket.send_json( { "type": "autonomy_paused", diff --git a/tests/unit/test_autonomy_paused.py b/tests/unit/test_autonomy_paused.py index 417befb..9a2fdb2 100644 --- a/tests/unit/test_autonomy_paused.py +++ b/tests/unit/test_autonomy_paused.py @@ -9,10 +9,12 @@ Verifies: - 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 +- U6: resume Future pre-registration race fix """ from __future__ import annotations +import asyncio import time from unittest.mock import AsyncMock, MagicMock @@ -43,9 +45,7 @@ async def _detect_and_await_pause( 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 - ) + should_continue = await engine._await_autonomy_resume(token, reason, resume_handler) return should_continue, [event] @@ -391,3 +391,191 @@ class TestAutonomyConfigParsing: cfg = DangerousToolsConfig.from_dict({}) assert cfg.autonomy_timeout_minutes == 30 assert cfg.max_consecutive_failures == 3 + + +# --------------------------------------------------------------------------- +# U6: resume Future pre-registration race fix +# --------------------------------------------------------------------------- + + +class TestResumeFutureRaceFix: + """U6: Future is pre-registered before send_json so the WebSocket loop + can resolve it even if the resume message arrives before _resume_handler + is called by the engine. + + These tests mirror the chat.py ``autonomy_paused`` event handler + + ``_resume_handler`` closure interaction without spinning up a full + WebSocket server. + """ + + @pytest.mark.asyncio + async def test_future_registered_before_send(self): + """Future exists in pending_autonomy_resumes before send_json fires. + + Simulates the autonomy_paused event handler: the pre-registration + step (U6) must insert the Future before the event is forwarded to + the frontend. + """ + pending: dict[str, asyncio.Future] = {} + event_data = { + "resume_token": "autonomy_pause:timeout:1", + "reason": "timeout", + } + # Mirror chat.py: autonomy_paused event handler pre-registration. + resume_token = event_data.get("resume_token") + if resume_token and resume_token not in pending: + loop = asyncio.get_running_loop() + pending[resume_token] = loop.create_future() + # At this point send_json has NOT run yet — Future is already + # registered, so a concurrent resume message can resolve it. + assert resume_token in pending + assert not pending[resume_token].done() + + @pytest.mark.asyncio + async def test_resume_message_not_lost_when_arriving_before_handler(self): + """Resume message arriving before _resume_handler is awaited still + resolves the Future (no lost message). + + This is the core race the fix addresses: pre-registration lets the + WebSocket loop find the Future even if the engine has not yet + called _resume_handler. + """ + pending: dict[str, asyncio.Future] = {} + resume_token = "autonomy_pause:timeout:1" + + # Step 1: autonomy_paused event handler pre-registers Future. + loop = asyncio.get_running_loop() + pre_future: asyncio.Future[bool] = loop.create_future() + pending[resume_token] = pre_future + + # Step 2: WebSocket loop receives resume BEFORE engine calls + # _resume_handler (the race window the fix closes). + assert resume_token in pending + fut = pending[resume_token] + if not fut.done(): + fut.set_result(True) + + # Step 3: engine finally calls _resume_handler, which awaits the + # already-resolved Future — no deadlock. + assert pre_future.done() + assert pre_future.result() is True + + @pytest.mark.asyncio + async def test_resume_handler_uses_pre_registered_future(self): + """_resume_handler finds and awaits the pre-registered Future + instead of creating a new one (which would orphan the pre-registered + Future and lose the resume message).""" + pending: dict[str, asyncio.Future] = {} + resume_token = "autonomy_pause:consecutive_failures:3" + + # Pre-register (autonomy_paused event handler). + loop = asyncio.get_running_loop() + pre_future: asyncio.Future[bool] = loop.create_future() + pending[resume_token] = pre_future + + # Mirror _resume_handler: look up existing Future first. + future = pending.get(resume_token) + assert future is pre_future # Same object — no orphaned Future. + + # Resolve and await. + pre_future.set_result(True) + result = await asyncio.wait_for(future, timeout=1.0) + assert result is True + + @pytest.mark.asyncio + async def test_resume_handler_fallback_creates_future(self): + """When no pre-registered Future exists (non-WS caller or event not + seen), _resume_handler creates one as a fallback.""" + pending: dict[str, asyncio.Future] = {} + resume_token = "autonomy_pause:timeout:1" + + # Mirror _resume_handler fallback path. + future = pending.get(resume_token) + if future is None: + loop = asyncio.get_running_loop() + future = loop.create_future() + pending[resume_token] = future + + assert resume_token in pending + assert pending[resume_token] is future + assert not future.done() + + @pytest.mark.asyncio + async def test_double_resolve_guard(self): + """Duplicate resume messages do not crash — the second is ignored + because the Future is already done. + + Mirrors the WebSocket loop's double-resolve guard + (``if not fut.done(): fut.set_result(...)``). + """ + pending: dict[str, asyncio.Future] = {} + resume_token = "autonomy_pause:timeout:1" + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + pending[resume_token] = future + + # First resume resolves the Future. + assert not future.done() + future.set_result(True) + + # Second resume: guard prevents double-resolve (would raise + # InvalidStateError without the done() check). + if not future.done(): + future.set_result(True) # Not reached — guard holds. + + assert future.done() + assert future.result() is True + + @pytest.mark.asyncio + async def test_pre_registration_does_not_overwrite_existing(self): + """Pre-registration skips when a Future already exists (idempotent). + + Guards against the edge case where the engine emits autonomy_paused + twice with the same token (e.g. retry) — the second pre-registration + must not replace the first Future. + """ + pending: dict[str, asyncio.Future] = {} + resume_token = "autonomy_pause:timeout:1" + loop = asyncio.get_running_loop() + original: asyncio.Future[bool] = loop.create_future() + pending[resume_token] = original + + # Mirror chat.py: pre-registration checks ``not in``. + if resume_token and resume_token not in pending: + pending[resume_token] = loop.create_future() + + assert pending[resume_token] is original # Unchanged. + + @pytest.mark.asyncio + async def test_full_flow_pre_register_then_handler_awaits(self): + """End-to-end: pre-register → resolve from WS loop → handler awaits + and returns True. + + Validates the complete U6 fix: Future is pre-registered before the + event is sent, the WS loop resolves it, and _resume_handler returns + the resolved value without creating a new Future. + """ + pending: dict[str, asyncio.Future] = {} + resume_token = "autonomy_pause:timeout:5" + event_data = {"resume_token": resume_token, "reason": "timeout"} + + # 1. autonomy_paused event handler: pre-register BEFORE send_json. + rt = event_data.get("resume_token") + if rt and rt not in pending: + loop = asyncio.get_running_loop() + pending[rt] = loop.create_future() + + # 2. WS loop resolves the Future (resume message arrives). + fut = pending[rt] + if not fut.done(): + fut.set_result(True) + + # 3. _resume_handler: look up pre-registered Future, await it. + handler_future = pending.get(rt) + assert handler_future is not None + result = await asyncio.wait_for(handler_future, timeout=1.0) + + assert result is True + # 4. Cleanup (finally block in _resume_handler). + pending.pop(rt, None) + assert rt not in pending diff --git a/tests/unit/test_lead_reflection_retrieval.py b/tests/unit/test_lead_reflection_retrieval.py index 1554358..f6f6b94 100644 --- a/tests/unit/test_lead_reflection_retrieval.py +++ b/tests/unit/test_lead_reflection_retrieval.py @@ -240,7 +240,9 @@ class TestDecomposeWithReflection: ) orchestrator = TeamOrchestrator(team, reflexion_engine=reflexion) - await orchestrator._decompose_task(team.lead_expert, "test task") + await orchestrator._decompose_task( + team.lead_expert, "test task", trigger_reason="verify_retry" + ) # Verify retrieve was called reflexion.retrieve_prompt_reflection.assert_awaited_once() @@ -278,7 +280,9 @@ class TestDecomposeWithReflection: reflexion.retrieve_prompt_reflection = AsyncMock(return_value=None) orchestrator = TeamOrchestrator(team, reflexion_engine=reflexion) - await orchestrator._decompose_task(team.lead_expert, "test task") + await orchestrator._decompose_task( + team.lead_expert, "test task", trigger_reason="verify_retry" + ) call_kwargs = gw.chat.await_args.kwargs messages = call_kwargs.get("messages") or gw.chat.await_args.args[0] @@ -296,7 +300,9 @@ class TestDecomposeWithReflection: 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") + await orchestrator._decompose_task( + team.lead_expert, "test task", trigger_reason="verify_retry" + ) # Default prompt used despite retrieval failure call_kwargs = gw.chat.await_args.kwargs @@ -317,9 +323,126 @@ class TestDecomposeWithReflection: ) orchestrator = TeamOrchestrator(team, reflexion_engine=reflexion) - await orchestrator._decompose_task(team.lead_expert, "test task") + await orchestrator._decompose_task( + team.lead_expert, "test task", trigger_reason="verify_retry" + ) 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 + + +# ── U4/KTD5: retrieve_prompt_reflection trigger gating ──────────────── + + +class TestDecomposeTriggerGating: + """U4/KTD5: _decompose_task only calls retrieve_prompt_reflection when + trigger_reason is one of {verify_retry, schema_validation, loop_detection}. + Non-trigger decompositions skip retrieval entirely (default prompt).""" + + def _make_orchestrator_with_reflexion( + self, retrieve_return: object = None, retrieve_side_effect: object | None = None + ) -> tuple[TeamOrchestrator, MagicMock, MagicMock]: + """Build orchestrator + gateway + reflexion mock. + + Returns (orchestrator, gateway, reflexion). retrieve_side_effect, if + set, takes precedence over retrieve_return. + """ + team = _make_team_with_experts() + gw = _make_llm_gateway_mock() + team._experts["lead"].agent._llm_gateway = gw + + reflexion = MagicMock(spec=ReflexionEngine) + if retrieve_side_effect is not None: + reflexion.retrieve_prompt_reflection = AsyncMock(side_effect=retrieve_side_effect) + else: + reflexion.retrieve_prompt_reflection = AsyncMock(return_value=retrieve_return) + + orchestrator = TeamOrchestrator(team, reflexion_engine=reflexion) + return orchestrator, gw, reflexion + + @pytest.mark.asyncio + async def test_retrieves_on_verify_retry_trigger(self): + orchestrator, _, reflexion = self._make_orchestrator_with_reflexion( + retrieve_return={ + "improved_prompt": "IMPROVED", + "score": 0.8, + "reflection": "r", + "version": 1, + "task_hash": "abc", + } + ) + await orchestrator._decompose_task( + orchestrator._team.lead_expert, "task", trigger_reason="verify_retry" + ) + reflexion.retrieve_prompt_reflection.assert_awaited_once() + + @pytest.mark.asyncio + async def test_retrieves_on_schema_validation_trigger(self): + orchestrator, _, reflexion = self._make_orchestrator_with_reflexion(retrieve_return=None) + await orchestrator._decompose_task( + orchestrator._team.lead_expert, "task", trigger_reason="schema_validation" + ) + reflexion.retrieve_prompt_reflection.assert_awaited_once() + + @pytest.mark.asyncio + async def test_retrieves_on_loop_detection_trigger(self): + orchestrator, _, reflexion = self._make_orchestrator_with_reflexion(retrieve_return=None) + await orchestrator._decompose_task( + orchestrator._team.lead_expert, "task", trigger_reason="loop_detection" + ) + reflexion.retrieve_prompt_reflection.assert_awaited_once() + + @pytest.mark.asyncio + async def test_skips_retrieval_when_trigger_is_none(self): + """Fresh decomposition (trigger_reason=None) → no retrieval.""" + orchestrator, _, reflexion = self._make_orchestrator_with_reflexion( + retrieve_return={"improved_prompt": "would-be-used", "score": 0.9} + ) + await orchestrator._decompose_task(orchestrator._team.lead_expert, "task") + reflexion.retrieve_prompt_reflection.assert_not_awaited() + + @pytest.mark.asyncio + async def test_skips_retrieval_on_invalid_trigger_reason(self): + """trigger_reason not in the allowed set → no retrieval.""" + orchestrator, _, reflexion = self._make_orchestrator_with_reflexion( + retrieve_return={"improved_prompt": "would-be-used", "score": 0.9} + ) + await orchestrator._decompose_task( + orchestrator._team.lead_expert, "task", trigger_reason="random" + ) + reflexion.retrieve_prompt_reflection.assert_not_awaited() + + @pytest.mark.asyncio + async def test_triggered_retrieval_failure_degrades_to_default_prompt(self): + """trigger_reason valid but retrieve raises → default prompt (non-blocking).""" + orchestrator, gw, reflexion = self._make_orchestrator_with_reflexion( + retrieve_side_effect=RuntimeError("search down") + ) + await orchestrator._decompose_task( + orchestrator._team.lead_expert, "task", trigger_reason="verify_retry" + ) + # Retrieval was attempted (triggered) but failed — default prompt used. + reflexion.retrieve_prompt_reflection.assert_awaited_once() + 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_skips_retrieval_when_no_reflexion_engine_even_with_trigger(self): + """No reflexion_engine → retrieval skipped regardless of trigger_reason.""" + 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( + orchestrator._team.lead_expert, "task", trigger_reason="verify_retry" + ) + # 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 diff --git a/tests/unit/test_react_autonomy.py b/tests/unit/test_react_autonomy.py index bb56f56..53d622f 100644 --- a/tests/unit/test_react_autonomy.py +++ b/tests/unit/test_react_autonomy.py @@ -300,3 +300,210 @@ class TestAutonomyConstructorWiring: ) assert engine._autonomy_mode is True assert engine._dangerous_tools_config is cfg + + +# --------------------------------------------------------------------------- +# U7: _track_tool_result_for_autonomy denied parameter +# --------------------------------------------------------------------------- + + +class TestTrackToolResultDenied: + """U7: user denial (denied=True) does NOT increment the failure counter. + + Verifies: + - denied=True leaves _consecutive_failures unchanged + - denied=False with an error result still increments (backward compat) + - denied=False with a permission_denied result still increments + - denied=True with a permission_denied result does NOT increment + """ + + def test_denied_does_not_increment(self): + """denied=True with an error result leaves the counter unchanged.""" + engine = ReActEngine( + llm_gateway=MagicMock(), + dangerous_tools_config=DangerousToolsConfig(), + autonomy_mode=True, + ) + engine._consecutive_failures = 1 + engine._track_tool_result_for_autonomy({"error": "boom", "is_error": True}, denied=True) + assert engine._consecutive_failures == 1 # Unchanged + + def test_denied_permission_denied_does_not_increment(self): + """denied=True with a permission_denied result leaves the counter + unchanged — this is the exact scenario U7 fixes (user rejected + confirmation).""" + engine = ReActEngine( + llm_gateway=MagicMock(), + dangerous_tools_config=DangerousToolsConfig(), + autonomy_mode=True, + ) + engine._consecutive_failures = 2 + engine._track_tool_result_for_autonomy( + { + "output": "", + "exit_code": 126, + "is_error": True, + "error_type": "permission_denied", + "message": "用户拒绝执行命令", + }, + denied=True, + ) + assert engine._consecutive_failures == 2 # Unchanged + + def test_not_denied_error_increments(self): + """denied=False (default) with an error result still increments + (backward compat).""" + 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_not_denied_permission_denied_increments(self): + """denied=False with a permission_denied result increments — without + the denied flag, a permission_denied result is treated as a failure. + This confirms the denied flag is required to skip counting.""" + engine = ReActEngine( + llm_gateway=MagicMock(), + dangerous_tools_config=DangerousToolsConfig(), + autonomy_mode=True, + ) + engine._consecutive_failures = 0 + engine._track_tool_result_for_autonomy( + {"error_type": "permission_denied", "is_error": True} + ) + assert engine._consecutive_failures == 1 + + def test_denied_does_not_reset_counter(self): + """denied=True leaves the counter at whatever value it had — it + neither increments nor resets. This matters when denials interleave + with real failures.""" + engine = ReActEngine( + llm_gateway=MagicMock(), + dangerous_tools_config=DangerousToolsConfig(), + autonomy_mode=True, + ) + # One real failure, then a denial. + engine._consecutive_failures = 0 + engine._track_tool_result_for_autonomy({"error": "boom"}) + assert engine._consecutive_failures == 1 + engine._track_tool_result_for_autonomy({"error_type": "permission_denied"}, denied=True) + assert engine._consecutive_failures == 1 # Still 1, not reset to 0 + + def test_denied_no_effect_when_not_autonomy(self): + """denied=True is a no-op when autonomy mode is off (same as the + base behavior — tracking is skipped entirely).""" + engine = ReActEngine( + llm_gateway=MagicMock(), + dangerous_tools_config=DangerousToolsConfig(), + autonomy_mode=False, + ) + engine._consecutive_failures = 0 + engine._track_tool_result_for_autonomy({"error": "boom"}, denied=True) + assert engine._consecutive_failures == 0 + + +# --------------------------------------------------------------------------- +# U7: denial does not trigger autonomy_paused +# --------------------------------------------------------------------------- + + +class TestDenialDoesNotTriggerPause: + """U7: a sequence of user denials does NOT trigger autonomy_paused + (reason=consecutive_failures), while a sequence of real failures does. + + Uses _detect_autonomy_pause to check whether a pause would fire. + """ + + @pytest.mark.asyncio + async def test_three_denials_no_pause(self): + """3 consecutive denials (denied=True) do NOT trigger + autonomy_paused — the counter never reaches the threshold.""" + 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 = 0 # Disable timeout check + denied_result = { + "error_type": "permission_denied", + "is_error": True, + "output": "", + } + # Simulate 3 denials — counter must stay at 0. + for _ in range(3): + engine._track_tool_result_for_autonomy(denied_result, denied=True) + assert engine._consecutive_failures == 0 + # No pause should fire. + pause_info = engine._detect_autonomy_pause(step=1, progress={}) + assert pause_info is None + + @pytest.mark.asyncio + async def test_three_failures_trigger_pause(self): + """3 consecutive failures (denied=False) DO trigger autonomy_paused + — confirms the threshold still works for real failures.""" + 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 = 0 # Disable timeout check + error_result = {"error": "tool crashed"} + # Simulate 3 real failures. + for _ in range(3): + engine._track_tool_result_for_autonomy(error_result) + assert engine._consecutive_failures == 3 + # Pause should fire with reason=consecutive_failures. + pause_info = engine._detect_autonomy_pause(step=1, progress={}) + assert pause_info is not None + reason, _token, _data = pause_info + assert reason == "consecutive_failures" + + @pytest.mark.asyncio + async def test_mixed_denials_and_failures_no_false_pause(self): + """Interleaved denials and failures: denials don't increment, so + 2 failures + 5 denials + 1 failure = 3 failures total → pause. + + Confirms denials neither help nor hurt the failure count — only + real failures count toward the threshold. + """ + engine = ReActEngine( + llm_gateway=MagicMock(), + dangerous_tools_config=DangerousToolsConfig( + autonomy_timeout_minutes=0, + max_consecutive_failures=3, + ), + autonomy_mode=True, + ) + engine._autonomy_started_at = 0 + error_result = {"error": "crash"} + denied_result = {"error_type": "permission_denied", "is_error": True} + + # 2 real failures. + engine._track_tool_result_for_autonomy(error_result) + engine._track_tool_result_for_autonomy(error_result) + assert engine._consecutive_failures == 2 + + # 5 denials — counter must stay at 2. + for _ in range(5): + engine._track_tool_result_for_autonomy(denied_result, denied=True) + assert engine._consecutive_failures == 2 + # No pause yet (below threshold). + assert engine._detect_autonomy_pause(step=1, progress={}) is None + + # 1 more real failure → threshold reached. + engine._track_tool_result_for_autonomy(error_result) + assert engine._consecutive_failures == 3 + pause_info = engine._detect_autonomy_pause(step=1, progress={}) + assert pause_info is not None + reason, _token, _data = pause_info + assert reason == "consecutive_failures" diff --git a/tests/unit/test_reflexion_persist.py b/tests/unit/test_reflexion_persist.py index 14fe06f..05d7654 100644 --- a/tests/unit/test_reflexion_persist.py +++ b/tests/unit/test_reflexion_persist.py @@ -74,6 +74,9 @@ class TestStorePromptReflection: 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() + # U2: store_prompt_reflection now calls cleanup_expired lazily; provide + # an AsyncMock so the real code path can await it without DB side effects. + mem.cleanup_expired = AsyncMock(return_value=0) # Track the ORM entry passed to db.add for assertions mem._mock_db = mock_db return mem @@ -434,3 +437,78 @@ class TestMultiVersionCoexistence: # All three ORM entries created via db.add assert mem._mock_db.add.call_count == 3 assert mem._mock_db.commit.await_count == 3 + + +# ── U2: lazy cleanup_expired on store ───────────────────────────────── + + +class TestLazyCleanupOnStore: + """U2: store_prompt_reflection triggers cleanup_expired with 10% + probability (lazy, fire-and-forget). Cleanup failure never blocks the + store; cleanup is always filtered to task_type='prompt_reflection'.""" + + def _make_persistable_mock(self) -> MagicMock: + # Reuse the persistable mock (now includes async cleanup_expired). + return TestStorePromptReflection()._make_persistable_mock() + + @pytest.mark.asyncio + async def test_lazy_cleanup_triggered_below_threshold(self): + """random.random() < 0.1 → cleanup_expired awaited once.""" + mem = self._make_persistable_mock() + with patch("agentkit.memory.episodic.random.random", return_value=0.05): + await EpisodicMemory.store_prompt_reflection( + mem, task_input="test", reflection="r", improved_prompt="p" + ) + mem.cleanup_expired.assert_awaited_once_with(task_type="prompt_reflection") + + @pytest.mark.asyncio + async def test_lazy_cleanup_not_triggered_at_or_above_threshold(self): + """random.random() >= 0.1 → cleanup_expired not awaited.""" + mem = self._make_persistable_mock() + with patch("agentkit.memory.episodic.random.random", return_value=0.9): + await EpisodicMemory.store_prompt_reflection( + mem, task_input="test", reflection="r", improved_prompt="p" + ) + mem.cleanup_expired.assert_not_awaited() + + @pytest.mark.asyncio + async def test_lazy_cleanup_failure_does_not_block_store(self): + """cleanup_expired raises → store still returns a valid key.""" + mem = self._make_persistable_mock() + mem.cleanup_expired = AsyncMock(side_effect=RuntimeError("cleanup boom")) + with patch("agentkit.memory.episodic.random.random", return_value=0.05): + key = await EpisodicMemory.store_prompt_reflection( + mem, task_input="test", reflection="r", improved_prompt="p" + ) + assert key is not None # store succeeded despite cleanup failure + mem.cleanup_expired.assert_awaited_once() + + @pytest.mark.asyncio + async def test_lazy_cleanup_filters_to_prompt_reflection_task_type(self): + """cleanup_expired is always called with task_type='prompt_reflection'.""" + mem = self._make_persistable_mock() + with patch("agentkit.memory.episodic.random.random", return_value=0.05): + await EpisodicMemory.store_prompt_reflection( + mem, task_input="test", reflection="r", improved_prompt="p" + ) + # Keyword arg, never None — prevents purging other task types. + assert mem.cleanup_expired.await_args.kwargs == {"task_type": "prompt_reflection"} + + @pytest.mark.asyncio + async def test_lazy_cleanup_triggered_at_least_once_over_many_calls(self): + """10+ calls with mixed random draws → cleanup_expired called at least once.""" + mem = self._make_persistable_mock() + # 9 non-triggering draws (0.9) then 1 triggering (0.05). + draws = [0.9] * 9 + [0.05] + with patch("agentkit.memory.episodic.random.random", side_effect=draws): + for i in range(10): + await EpisodicMemory.store_prompt_reflection( + mem, + task_input=f"task {i}", + reflection="r", + improved_prompt="p", + ) + assert mem.cleanup_expired.await_count >= 1 + # Every cleanup call uses the prompt_reflection filter. + for call in mem.cleanup_expired.await_args_list: + assert call.kwargs == {"task_type": "prompt_reflection"} diff --git a/tests/unit/test_team_parallel.py b/tests/unit/test_team_parallel.py index 8bd8f1e..cc27298 100644 --- a/tests/unit/test_team_parallel.py +++ b/tests/unit/test_team_parallel.py @@ -10,6 +10,7 @@ Covers: from __future__ import annotations +import asyncio import json from unittest.mock import AsyncMock, MagicMock @@ -17,6 +18,7 @@ import pytest from agentkit.core.handoff_transport import InProcessHandoffTransport from agentkit.core.protocol import TaskResult, TaskStatus +from agentkit.core.shared_workspace import SharedWorkspace from agentkit.experts.config import ExpertConfig from agentkit.experts.expert import Expert from agentkit.experts.orchestrator import TeamOrchestrator @@ -462,3 +464,106 @@ class TestExecuteParallelSubtasks: # phase_results has all 3 phase_results: dict = result["phase_results"] assert len(phase_results) == 3 + + +# ── U3: SharedWorkspace.lock_section TOCTOU protection ─────────────── + + +class TestLockSectionToctou: + """U3/KTD3: SharedWorkspace.lock_section guards parallel subtask + read-then-write critical sections against TOCTOU races. + + lock_section is the async-context-manager form of lock/unlock: it polls + lock() until acquired (or timeout), holds for the block, and releases on + exit. Used by _finalize_phase to wrap workspace.write(). + """ + + @pytest.mark.asyncio + async def test_lock_section_acquires_and_releases(self): + """Lock acquired on enter, released on exit — next caller can acquire.""" + ws = SharedWorkspace() + async with ws.lock_section("resource", "agent_a", timeout=1.0): + # Held by agent_a — agent_b cannot acquire via raw lock() + assert await ws.lock("resource", agent_id="agent_b") is False + # Released after block — agent_b can now acquire + assert await ws.lock("resource", agent_id="agent_b") is True + await ws.unlock("resource", "agent_b") + + @pytest.mark.asyncio + async def test_lock_section_calls_lock_and_unlock(self): + """lock_section delegates to lock() on enter and unlock() on exit.""" + ws = SharedWorkspace() + lock_spy = AsyncMock(wraps=ws.lock) + unlock_spy = AsyncMock(wraps=ws.unlock) + ws.lock = lock_spy + ws.unlock = unlock_spy + + async with ws.lock_section("k", "agent_a", timeout=1.0): + pass + + lock_spy.assert_awaited_with("k", "agent_a", timeout=1.0) + unlock_spy.assert_awaited_with("k", "agent_a") + + @pytest.mark.asyncio + async def test_lock_section_raises_timeout_when_held(self): + """Lock held by another agent → lock_section raises TimeoutError.""" + ws = SharedWorkspace() + await ws.lock("shared", "holder") # hold the lock + + with pytest.raises(TimeoutError): + async with ws.lock_section("shared", "waiter", timeout=0.2): + pass # pragma: no cover — never reached + + # Waiter never acquired, so holder still owns it. + assert await ws.lock("shared", agent_id="other") is False + + @pytest.mark.asyncio + async def test_parallel_writes_to_different_keys_do_not_conflict(self): + """2 subtasks writing different keys concurrently both succeed.""" + ws = SharedWorkspace() + results: list[str] = [] + + async def writer(key: str, agent: str) -> None: + async with ws.lock_section(key, agent, timeout=2.0): + await ws.write(key, f"val-{agent}", agent) + results.append(agent) + + await asyncio.gather(writer("k1", "a"), writer("k2", "b")) + + assert set(results) == {"a", "b"} + assert (await ws.read("k1"))["value"] == "val-a" + assert (await ws.read("k2"))["value"] == "val-b" + + @pytest.mark.asyncio + async def test_same_key_writes_serialize(self): + """2 subtasks writing the same key: the second waits for the first + to release — enter/exit events never interleave.""" + ws = SharedWorkspace() + log: list[str] = [] + + async def writer(name: str) -> None: + async with ws.lock_section("shared_key", name, timeout=2.0): + log.append(f"{name}-enter") + await asyncio.sleep(0.05) + log.append(f"{name}-exit") + + await asyncio.gather(writer("a"), writer("b")) + + # One fully completes before the other begins (serialized). + assert log in ( + ["a-enter", "a-exit", "b-enter", "b-exit"], + ["b-enter", "b-exit", "a-enter", "a-exit"], + ) + + @pytest.mark.asyncio + async def test_lock_section_releases_on_exception(self): + """If the wrapped block raises, the lock is still released.""" + ws = SharedWorkspace() + + with pytest.raises(RuntimeError, match="boom"): + async with ws.lock_section("k", "agent_a", timeout=1.0): + raise RuntimeError("boom") + + # Lock released despite exception — another agent can acquire. + assert await ws.lock("k", agent_id="agent_b") is True + await ws.unlock("k", "agent_b")