Merge PR: fix(iq): 补齐 PR #27 的 5 个实现缺口 + 2 个 P2 residuals
Deploy to Production / deploy (push) Waiting to run Details
Test / backend-test (push) Waiting to run Details
Test / frontend-unit (push) Waiting to run Details
Test / api-e2e (push) Waiting to run Details
Test / frontend-e2e (push) Waiting to run Details

This commit is contained in:
Chiguyong 2026-07-06 20:38:12 +08:00
commit 8714056c46
23 changed files with 2162 additions and 106 deletions

View File

@ -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/U1KTD2 白名单)────────────────────────────
# 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 → RecoveryReflexionEngine 重试)→ Emergency规则分类器
# 仅在 chat REST 路径接入KTD5CLI / 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-inKTD6— 未设 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

View File

@ -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 residualsresume race、拒绝算失败。这些缺口导致 autonomy_paused 事件前端无法接收、prompt_reflection 记录永不过期、并行子任务有 TOCTOU 风险。
**Open Blockers**: None.
**Execution Profile**: Standard depth7 个 Implementation Units3 个维度可部分并行(前端、后端、配置独立)。
**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` 渲染 reasontimeout/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 调用,失败不阻塞 storefire-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_reasontrigger_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**:
- 注册先于 awaitFuture 在 `_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 | P2TOCTOU 风险) |
| U4 KTD5 门控 | gap analysis — R13/KTD5 PARTIAL | P2token 浪费) |
| U5 配置示例 | gap analysis — U1 NOT_IMPLEMENTED | P3文档缺失 |
| U6 resume race | review P2 #7 | P2竞态 |
| U7 拒绝算失败 | review P2 #8 | P2误暂停 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,159 @@
<template>
<div class="autonomy-card">
<div class="autonomy-card__header">
<span class="autonomy-card__icon"><PauseCircleOutlined /></span>
<span class="autonomy-card__title">自主执行已暂停</span>
<span class="autonomy-card__reason">{{ reasonLabel }}</span>
</div>
<div v-if="progressSummary" class="autonomy-card__progress">
<span class="autonomy-card__progress-label">进度:</span>
<span class="autonomy-card__progress-value">{{ progressSummary }}</span>
</div>
<div class="autonomy-card__meta">
<span v-if="elapsedLabel" class="autonomy-card__meta-item">
已运行 {{ elapsedLabel }}
</span>
<span
v-if="data.consecutive_failures !== undefined && data.consecutive_failures > 0"
class="autonomy-card__meta-item"
>
连续失败 {{ data.consecutive_failures }}
</span>
</div>
<div class="autonomy-card__actions">
<a-button type="primary" :loading="resumeLoading" @click="handleResume">
<template #icon><CaretRightOutlined /></template>
继续
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button as AButton } from 'ant-design-vue'
import { PauseCircleOutlined, CaretRightOutlined } from '@ant-design/icons-vue'
import type { IAutonomyPausedData, AutonomyPauseReason } from '@/api/types'
const props = defineProps<{
data: IAutonomyPausedData
}>()
const emit = defineEmits<{
resume: [resumeToken: string]
}>()
// IQ-Boost/U1: reason plan U1 §reason
const REASON_LABELS: Record<AutonomyPauseReason, string> = {
timeout: '自主执行超时',
consecutive_failures: '连续失败触发暂停',
manual: '用户手动暂停',
}
const reasonLabel = computed(
() => REASON_LABELS[props.data.reason] ?? props.data.reason,
)
// ponytail: progress dict{step, tool_name, total_steps}
// ceiling = progress
// = IAutonomyProgress
const progressSummary = computed(() => {
const p = props.data.progress
const parts: string[] = []
if (typeof p.step === 'number' && typeof p.total_steps === 'number') {
parts.push(`步骤 ${p.step} / ${p.total_steps}`)
} else if (typeof p.step === 'number') {
parts.push(`步骤 ${p.step}`)
}
if (typeof p.tool_name === 'string' && p.tool_name) {
parts.push(`工具 ${p.tool_name}`)
}
return parts.join(' · ')
})
const elapsedLabel = computed(() => {
const s = props.data.elapsed_seconds
if (typeof s !== 'number' || s <= 0) return ''
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return m > 0 ? `${m}m ${sec}s` : `${sec}s`
})
const resumeLoading = ref(false)
function handleResume(): void {
// Loading state gives immediate feedback; the card is cleared by the store
// (resumeAutonomy sets autonomyPaused = null) so this just bridges the click
// to the emit. If the user never sees it clear (e.g. socket down), the
// store still cleared local state.
resumeLoading.value = true
emit('resume', props.data.resume_token)
}
</script>
<style scoped>
.autonomy-card {
padding: 12px 14px;
border-left: 3px solid #fa8c16;
background: #fff7e6;
border-radius: 4px;
}
.autonomy-card__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.autonomy-card__icon {
font-size: 16px;
color: #fa8c16;
display: inline-flex;
align-items: center;
}
.autonomy-card__title {
font-weight: 600;
font-size: 14px;
color: #fa8c16;
}
.autonomy-card__reason {
font-size: 12px;
color: #666;
margin-left: auto;
background: #fff;
padding: 1px 8px;
border-radius: 10px;
border: 1px solid #ffe7ba;
}
.autonomy-card__progress {
font-size: 13px;
color: #333;
margin-bottom: 6px;
}
.autonomy-card__progress-label {
color: #999;
margin-right: 4px;
}
.autonomy-card__meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #666;
margin-bottom: 10px;
}
.autonomy-card__actions {
display: flex;
gap: 8px;
}
</style>

View File

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

View File

@ -228,6 +228,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);
@ -354,6 +355,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);
}
@ -377,6 +379,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 {
@ -531,6 +534,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<void> {
const cid = currentConversationId.value;
@ -637,6 +666,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,
@ -660,6 +690,7 @@ export const useChatStore = defineStore("chat", () => {
sendWsMessage,
resendLastUserMessage,
stopGeneration,
resumeAutonomy,
connectWebSocket: socket.connectWebSocket,
disconnectWebSocket: socket.disconnectWebSocket,
};

View File

@ -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<BoardState | null>;
debateState: Ref<DebateState | null>;
collaborationState: Ref<ICollaborationGraphData | null>;
// IQ-Boost/U1: autonomy pause transient state. Set by the autonomy_paused
// event; cleared by chatStore.resumeAutonomy after the user clicks resume.
autonomyPaused: Ref<IAutonomyPausedData | null>;
// Read access to chatStore state
conversations: Ref<IConversation[]>;
@ -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": {
@ -1768,6 +1796,9 @@ export function useChatStream(deps: ChatStreamDeps) {
const boardState = ref<BoardState | null>(null);
const debateState = ref<DebateState | null>(null);
const collaborationState = ref<ICollaborationGraphData | null>(null);
// IQ-Boost/U1: autonomy pause state — set by autonomy_paused event,
// cleared by chatStore.resumeAutonomy.
const autonomyPaused = ref<IAutonomyPausedData | null>(null);
// --- Getters ---
const isBoardMode = computed(
@ -1817,6 +1848,7 @@ export function useChatStream(deps: ChatStreamDeps) {
boardState,
debateState,
collaborationState,
autonomyPaused,
conversations: deps.conversations,
currentConversationId: deps.currentConversationId,
appendMessage: deps.appendMessage,
@ -1841,6 +1873,7 @@ export function useChatStream(deps: ChatStreamDeps) {
boardState,
debateState,
collaborationState,
autonomyPaused,
// Getters
isBoardMode,
isPlanExec,

View File

@ -73,6 +73,16 @@
</li>
</ul>
</div>
<!-- IQ-Boost/U1: autonomy pause card. Rendered when the backend
sends autonomy_paused (timeout/consecutive_failures). The
user clicks 继续 to resume; chatStore.resumeAutonomy sends
the resume message and clears autonomyPaused. -->
<div v-if="chatStore.autonomyPaused" class="chat-view__autonomy-pause">
<AutonomyPausedCard
:data="chatStore.autonomyPaused"
@resume="handleResumeAutonomy"
/>
</div>
</div>
</div>
<div class="chat-view__input-wrap">
@ -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)
}
</script>
<style scoped>
@ -394,4 +422,12 @@ function handleSend(message: string, model?: string): void {
.chat-view__step--error .chat-view__step-detail {
color: var(--color-error, #ef4444);
}
.chat-view__autonomy-pause {
margin: var(--space-2) 0;
max-width: var(--max-chat-width);
width: 100%;
margin-left: auto;
margin-right: auto;
}
</style>

View File

@ -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> = {},
): 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()
})
})

View File

@ -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<IConversation[]>
currentConversationId: Ref<string | null>
appendMessageSpy: ReturnType<typeof vi.fn>
updateMessageSpy: ReturnType<typeof vi.fn>
markConversationDoneSpy: ReturnType<typeof vi.fn>
teamStore: {
teamState: IExpertTeamState | null
setTeamState: ReturnType<typeof vi.fn>
updatePhases: ReturnType<typeof vi.fn>
updatePhaseStatus: ReturnType<typeof vi.fn>
clearTeam: ReturnType<typeof vi.fn>
}
calendarStore: { handleWsEvent: ReturnType<typeof vi.fn> }
documentsStore: { addDocument: ReturnType<typeof vi.fn> }
}
function createFixture(convId: string = 'conv-1'): Fixture {
const conversations = ref<IConversation[]>([
{
id: convId,
title: '新对话',
messages: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
])
const currentConversationId = ref<string | null>(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<IChatMessage>) => {
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<string, IStreamingStep[]>()),
currentPhase: ref<string | null>(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<IAutonomyPausedData> = {},
): 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)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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