Merge PR: fix(iq): 补齐 PR #27 的 5 个实现缺口 + 2 个 P2 residuals
This commit is contained in:
commit
8714056c46
|
|
@ -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
|
||||
|
|
@ -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(误暂停) |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue