Преглед изворни кода

完善 recursive 审核门禁、状态恢复与可视化

SamLee пре 1 дан
родитељ
комит
38d95c3ab9

+ 290 - 12
cyber_agent/core/runner.py

@@ -15,6 +15,7 @@ Agent Runner - Agent 执行引擎
 """
 
 import asyncio
+from copy import deepcopy
 import json
 import logging
 import os
@@ -45,6 +46,10 @@ from cyber_agent.core.agent_mode import (
     policy_from_context,
     policy_from_environment,
 )
+from cyber_agent.core.task_protocol import (
+    ensure_task_protocol,
+    protocol_error_report,
+)
 from cyber_agent.core.prompts import (
     DEFAULT_SYSTEM_PREFIX,
     TRUNCATION_HINT,
@@ -518,7 +523,7 @@ class AgentRunner:
     ) -> Tuple[Trace, Optional[GoalTree], int]:
         """创建新 Trace"""
         # 在任何标题生成/LLM 调用前完成模式校验。
-        policy = policy_from_environment()
+        policy = policy_from_environment(recursive_revision=2)
         trace_id = str(uuid.uuid4())
 
         # 生成任务名称
@@ -530,6 +535,8 @@ class AgentRunner:
         trace_context = apply_policy_to_context(config.context, policy)
         trace_context.setdefault("agent_depth", 0)
         trace_context.setdefault("root_trace_id", trace_id)
+        if policy.requires_task_protocol:
+            ensure_task_protocol(trace_context)
 
         trace_obj = Trace(
             trace_id=trace_id,
@@ -1076,7 +1083,8 @@ class AgentRunner:
     ) -> AsyncIterator[Union[Trace, Message]]:
         """ReAct 循环"""
         trace_id = trace.trace_id
-        tool_schemas = self._get_tool_schemas(config.tools, config.tool_groups, config.exclude_tools)
+        tool_schemas = self._get_runtime_tool_schemas(config, trace)
+        completion_status = "completed"
 
         # 当前主路径头节点的 sequence(用于设置 parent_sequence)
         head_seq = trace.head_sequence
@@ -1272,6 +1280,16 @@ class AgentRunner:
                 self.log.info(f"进入侧分支: {branch_type}, branch_id={branch_id}")
                 continue  # 跳过本轮,下一轮开始侧分支
 
+            if self.trace_store:
+                fresh_trace = await self.trace_store.get_trace(trace_id)
+                if fresh_trace:
+                    trace = fresh_trace
+            tool_schemas = self._get_runtime_tool_schemas(
+                config,
+                trace,
+                in_side_branch=side_branch_ctx is not None,
+            )
+
             # 构建 LLM messages(注入上下文,移除内部字段)
             llm_messages = [{k: v for k, v in msg.items() if not k.startswith("_")} for msg in history]
 
@@ -1304,6 +1322,34 @@ class AgentRunner:
             cache_creation_tokens = result.get("cache_creation_tokens")
             cache_read_tokens = result.get("cache_read_tokens")
 
+            lifecycle_tools = {"agent", "submit_task_report", "review_task_result"}
+            lifecycle_call_count = sum(
+                1 for tc in (tool_calls or [])
+                if tc.get("function", {}).get("name") in lifecycle_tools
+            )
+            runtime_policy = policy_from_context(trace.context)
+            protocol_batch_error = None
+            if (
+                runtime_policy.requires_task_protocol
+                and lifecycle_call_count
+                and len(tool_calls or []) != 1
+            ):
+                protocol_batch_error = (
+                    "Recursive lifecycle tools must be the only tool call in an LLM turn"
+                )
+            runtime_protocol_state = (
+                ensure_task_protocol(trace.context)
+                if runtime_policy.requires_task_protocol
+                else None
+            )
+            protocol_lifecycle_required = bool(
+                runtime_protocol_state
+                and (
+                    runtime_protocol_state["pending_reviews"]
+                    or runtime_protocol_state["next_actions"]
+                )
+            )
+
             # 周期性自动注入上下文(仅主路径)
             if not side_branch_ctx and iteration % CONTEXT_INJECTION_INTERVAL == 0:
                 # 检查是否已经调用了 get_current_context
@@ -1316,7 +1362,11 @@ class AgentRunner:
                     has_context_call = False
                     tool_calls = []
 
-                if not has_context_call:
+                if (
+                    not has_context_call
+                    and not lifecycle_call_count
+                    and not protocol_lifecycle_required
+                ):
                     # 手动添加 get_current_context 工具调用
                     context_call_id = f"call_context_{uuid.uuid4().hex[:8]}"
                     tool_calls.append({
@@ -1327,7 +1377,13 @@ class AgentRunner:
                     self.log.info(f"[周期性注入] 自动添加 get_current_context 工具调用 (iteration={iteration})")
 
             # Skill 指定注入(仅主路径,首轮 iteration==0 时执行)
-            if not side_branch_ctx and inject_skills and iteration == 0:
+            if (
+                not side_branch_ctx
+                and inject_skills
+                and iteration == 0
+                and not lifecycle_call_count
+                and not protocol_lifecycle_required
+            ):
                 skills_to_inject = self._check_skills_need_injection(
                     trace, inject_skills, history, skill_recency_threshold
                 )
@@ -1662,6 +1718,15 @@ class AgentRunner:
                     async def _execute_single_tool(tc: dict) -> tuple:
                         tool_name = tc["function"]["name"]
                         tool_args = tc["function"]["arguments"]
+                        if protocol_batch_error:
+                            return (
+                                tc,
+                                {},
+                                json.dumps({
+                                    "status": "failed",
+                                    "error": protocol_batch_error,
+                                }, ensure_ascii=False),
+                            )
                         if isinstance(tool_args, str):
                             if not tool_args.strip():
                                 tool_args = {}
@@ -1813,11 +1878,17 @@ class AgentRunner:
                             except ImportError:
                                 pass
     
-                        tool_result = await self.tools.execute(
-                            tool_name,
-                            tool_args,
-                            uid=config.uid or "",
-                            context={
+                        if protocol_batch_error:
+                            tool_result = json.dumps({
+                                "status": "failed",
+                                "error": protocol_batch_error,
+                            }, ensure_ascii=False)
+                        else:
+                            tool_result = await self.tools.execute(
+                                tool_name,
+                                tool_args,
+                                uid=config.uid or "",
+                                context={
                                 "store": self.trace_store,
                                 "trace_id": trace_id,
                                 "goal_id": current_goal_id,
@@ -1835,8 +1906,8 @@ class AgentRunner:
                                 } if side_branch_ctx else None,
                                 # 合并用户自定义 context(RunConfig.context)
                                 **(config.context or {}),
-                            },
-                        )
+                                },
+                            )
     
                         # 如果是 goal 工具,记录执行后的状态
                         if tool_name == "goal" and goal_tree:
@@ -1989,6 +2060,63 @@ class AgentRunner:
             # 如果在侧分支中,已经在上面处理过了(不会走到这里)
             # 主路径无工具调用 → 任务完成,检查是否需要完成后反思或知识评估
 
+            if not side_branch_ctx and self.trace_store:
+                fresh_trace = await self.trace_store.get_trace(trace_id)
+                if fresh_trace:
+                    trace = fresh_trace
+                policy = policy_from_context(trace.context)
+                if policy.requires_task_protocol:
+                    state = ensure_task_protocol(trace.context)
+                    missing_report = bool(
+                        trace.parent_trace_id and state.get("task_report") is None
+                    )
+                    pending_reviews = bool(state["pending_reviews"])
+                    pending_actions = bool(state["next_actions"])
+                    if missing_report or pending_reviews or pending_actions:
+                        attempts = state["protocol_correction_attempts"]
+                        if attempts < 2:
+                            state["protocol_correction_attempts"] = attempts + 1
+                            await self.trace_store.update_trace(
+                                trace_id,
+                                context=trace.context,
+                            )
+                            if pending_reviews:
+                                required_action = (
+                                    "review every pending child report with "
+                                    "review_task_result"
+                                )
+                            elif pending_actions:
+                                required_action = (
+                                    "execute the approved next action with agent"
+                                )
+                            else:
+                                required_action = (
+                                    "submit a valid TaskReport with submit_task_report"
+                                )
+                            history.append({
+                                "role": "user",
+                                "content": (
+                                    "Protocol gate: you cannot finish yet. You must "
+                                    f"{required_action}."
+                                ),
+                            })
+                            continue
+
+                        if missing_report:
+                            report = protocol_error_report(
+                                trace_id,
+                                "No valid TaskReport after two correction attempts",
+                            )
+                            state["task_report"] = report.model_dump()
+                            state["task_report_submitted_at_sequence"] = sequence
+                        completion_status = "failed"
+                        await self.trace_store.update_trace(
+                            trace_id,
+                            context=trace.context,
+                            error_message="Recursive task protocol gate failed",
+                        )
+                        break
+
             # 检查是否有待评估的知识
             if not side_branch_ctx and self.trace_store:
                 pending = await self.trace_store.get_pending_knowledge_entries(trace_id)
@@ -2009,6 +2137,32 @@ class AgentRunner:
 
             break
 
+        # max_iterations 等非正常退出也必须经过同一完成门禁。
+        if self.trace_store:
+            fresh_trace = await self.trace_store.get_trace(trace_id)
+            if fresh_trace:
+                trace = fresh_trace
+            policy = policy_from_context(trace.context)
+            if policy.requires_task_protocol:
+                state = ensure_task_protocol(trace.context)
+                if trace.parent_trace_id and state.get("task_report") is None:
+                    report = protocol_error_report(
+                        trace_id,
+                        "Agent loop ended without a valid TaskReport",
+                    )
+                    state["task_report"] = report.model_dump()
+                    state["task_report_submitted_at_sequence"] = sequence
+                    completion_status = "failed"
+                    await self.trace_store.update_trace(
+                        trace_id,
+                        context=trace.context,
+                        error_message="Recursive task protocol gate failed",
+                    )
+                if state["pending_reviews"]:
+                    completion_status = "failed"
+                if state["next_actions"]:
+                    completion_status = "failed"
+
         # 清理 trace 相关的跟踪数据
         self._context_warned.pop(trace_id, None)
         self._context_usage.pop(trace_id, None)
@@ -2018,7 +2172,7 @@ class AgentRunner:
         if self.trace_store:
             await self.trace_store.update_trace(
                 trace_id,
-                status="completed",
+                status=completion_status,
                 head_sequence=head_seq,
                 completed_at=datetime.now(),
             )
@@ -2130,6 +2284,59 @@ class AgentRunner:
             goal_tree.goals = new_tree.goals
             goal_tree.current_id = new_tree.current_id
 
+        trace = await self.trace_store.get_trace(trace_id)
+        if trace and policy_from_context(trace.context).requires_task_protocol:
+            state = ensure_task_protocol(trace.context)
+            submitted_at = state.get("task_report_submitted_at_sequence")
+            if isinstance(submitted_at, int) and submitted_at > cutoff:
+                if state.get("task_report"):
+                    state["report_history"].append(state["task_report"])
+                state["task_report"] = None
+                state["task_report_submitted_at_sequence"] = None
+            state["pending_reviews"] = {
+                child_id: entry
+                for child_id, entry in state["pending_reviews"].items()
+                if entry.get("received_at_sequence", 0) <= cutoff
+            }
+            reverted_reviews = [
+                review for review in state["reviews"]
+                if review.get("reviewed_at_sequence", 0) > cutoff
+            ]
+            for review in reverted_reviews:
+                entry = review.get("pending_review")
+                child_id = review.get("child_trace_id")
+                if (
+                    child_id
+                    and isinstance(entry, dict)
+                    and entry.get("received_at_sequence", 0) <= cutoff
+                ):
+                    state["pending_reviews"][child_id] = entry
+            state["reviews"] = [
+                review for review in state["reviews"]
+                if review.get("reviewed_at_sequence", 0) <= cutoff
+            ]
+            state["next_actions"] = [
+                action for action in state["next_actions"]
+                if action.get("created_at_sequence", 0) <= cutoff
+            ]
+            state["protocol_correction_attempts"] = 0
+            await self.trace_store.update_trace(trace_id, context=trace.context)
+
+            projected_statuses: dict[str, str] = {}
+            for entry in state["pending_reviews"].values():
+                if entry.get("goal_id"):
+                    projected_statuses[entry["goal_id"]] = "pending_review"
+            for action in state["next_actions"]:
+                goal_id = action.get("goal_id")
+                if goal_id and goal_id not in projected_statuses:
+                    projected_statuses[goal_id] = "in_progress"
+            if goal_tree and projected_statuses:
+                for goal_id, status in projected_statuses.items():
+                    goal = goal_tree.find(goal_id)
+                    if goal:
+                        goal.status = status
+                await self.trace_store.update_goal_tree(trace_id, goal_tree)
+
         # 4. 更新 head_sequence 到 rewind 点
         await self.trace_store.update_trace(trace_id, head_sequence=cutoff)
 
@@ -3031,6 +3238,77 @@ class AgentRunner:
             tool_names -= set(exclude_tools)
         return self.tools.get_schemas(list(tool_names))
 
+    def _get_runtime_tool_schemas(
+        self,
+        config: RunConfig,
+        trace: Trace,
+        *,
+        in_side_branch: bool = False,
+    ) -> List[Dict]:
+        """按 Trace 持久化模式和当前协议状态过滤工具 Schema。"""
+        schemas = deepcopy(self._get_tool_schemas(
+            config.tools,
+            config.tool_groups,
+            config.exclude_tools,
+        ))
+        policy = policy_from_context(trace.context)
+        protocol_tools = {"submit_task_report", "review_task_result"}
+
+        if not policy.requires_task_protocol or in_side_branch:
+            schemas = [
+                schema for schema in schemas
+                if schema.get("function", {}).get("name") not in protocol_tools
+            ]
+        else:
+            state = ensure_task_protocol(trace.context)
+            if state["pending_reviews"]:
+                schemas = [
+                    schema for schema in schemas
+                    if schema.get("function", {}).get("name") == "review_task_result"
+                ]
+            elif state["next_actions"]:
+                schemas = [
+                    schema for schema in schemas
+                    if schema.get("function", {}).get("name") == "agent"
+                ]
+            else:
+                is_child = bool(trace.parent_trace_id)
+                has_report = state.get("task_report") is not None
+                schemas = [
+                    schema for schema in schemas
+                    if not (
+                        schema.get("function", {}).get("name") == "review_task_result"
+                        or (
+                            schema.get("function", {}).get("name") == "submit_task_report"
+                            and (not is_child or has_report)
+                        )
+                    )
+                ]
+
+        for schema in schemas:
+            function = schema.get("function", {})
+            if function.get("name") != "agent":
+                continue
+            parameters = function.get("parameters", {})
+            properties = parameters.get("properties", {})
+            required = parameters.setdefault("required", [])
+            if policy.requires_task_protocol:
+                if "task" in properties:
+                    properties["task"]["description"] = (
+                        "Only for agent_type=remote_*; local Recursive calls use task_brief."
+                    )
+                if "task_brief" in properties:
+                    properties["task_brief"]["description"] = (
+                        "Required for local Recursive delegation; not used by remote_* calls."
+                    )
+                if "task" in required:
+                    required.remove("task")
+            else:
+                properties.pop("task_brief", None)
+                if "task" in properties and "task" not in required:
+                    required.append("task")
+        return schemas
+
     # 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)
     # 注意:此常量已迁移到 cyber_agent.core.prompts,这里保留引用以保持向后兼容
 

+ 2 - 0
cyber_agent/core/task_protocol.py

@@ -60,6 +60,8 @@ class NextStepSuggestion(StrictProtocolModel):
     def validate_suggested_task(self) -> "NextStepSuggestion":
         if self.direction in {"DESCEND", "REFINE"} and not self.suggested_next_task:
             raise ValueError("DESCEND/REFINE requires suggested_next_task")
+        if self.direction in {"ASCEND", "NONE"} and self.suggested_next_task:
+            raise ValueError("ASCEND/NONE does not accept suggested_next_task")
         return self
 
 

+ 1 - 1
cyber_agent/docs/architecture.md

@@ -442,7 +442,7 @@ class Goal:
     reason: str = ""                         # 创建理由
     parent_id: Optional[str] = None          # 父 Goal ID
     type: GoalType = "normal"                # normal | agent_call
-    status: GoalStatus = "pending"           # pending | in_progress | completed | abandoned
+    status: GoalStatus = "pending"           # pending | in_progress | waiting_children | pending_review | completed | failed | abandoned
     summary: Optional[str] = None            # 完成/放弃时的总结
 
     # agent_call 特有(启动 Sub-Trace)

+ 24 - 1
cyber_agent/docs/tools.md

@@ -980,6 +980,27 @@ AGENT_MODE=legacy      # 或 recursive
 旧配置 `AGENT_RECURSION_ENABLED` 已删除。环境中仍存在时创建或续跑
 Trace 会直接报错,必须迁移到 `AGENT_MODE`。
 
+##### Recursive revision 2 任务协议
+
+新建的 `recursive` Trace 使用结构化的父子任务协议:
+
+- 本地 `agent()` 用 `task_brief` 下发 `objective`、
+  `completion_criteria`、可选 `context/constraints`;`remote_*` 仍使用 `task`。
+- 非根 Sub-Agent 结束前必须调用 `submit_task_report`,提交带
+  `Validation`、产出、证据和下一步建议的 `TaskReport`。
+- 子报告返回后,父 Goal 从 `waiting_children` 进入 `pending_review`;
+  父 Agent 必须用 `review_task_result` 逐份生成 `TaskReview`。
+- 审核决策为 `ACCEPT_REFINE / REJECT_REFINE / REVISE_CHILD /
+  DESCEND_AGAIN / ASCEND / FAIL`。新孩子只能使用审核批准的
+  `TaskBrief`;`REVISE_CHILD` 只能 `continue_from` 原直接孩子。
+- 已批准的 next action 执行完前不能提交父级报告或
+  `ASCEND/FAIL`;批准新孩子前会同时校验现有孩子和已排队名额。
+- 待审核期间不允许新建孩子或修改 Goal;子 Agent 缺少合法报告时
+  最多纠正两轮,仍失败则由框架生成 `protocol_error` 报告交父级处理。
+- 该协议权威状态保存在 `Trace.context.task_protocol`,可在续跑和服务
+  重启后恢复;回溯到审核之前时会恢复原报告和 `pending_review`。
+  Legacy 和已有 Recursive revision 1 Trace 不进入该协议。
+
 #### 远端模式
 
 `remote_` 前缀的 `agent_type` 通过 HTTP 调用 KnowHub 服务器的 `POST /api/agent` 端点执行。
@@ -997,7 +1018,9 @@ Trace 会直接报错,必须迁移到 `AGENT_MODE`。
 
 **续跑**:完全由 caller 负责记住和传入 `continue_from`(服务器不再维护 `caller_trace_id → sub_trace_id` 的映射)。首次调用不传 `continue_from`,服务器创建新 trace 并在返回值中给出 `sub_trace_id`;下次调用时 caller 把它作为 `continue_from` 传回。
 
-**返回值**:本地和远端统一返回 `{status, sub_trace_id, summary, stats}`。`summary` 是 Agent 最终产出的 message 文本——Agent 之间通过 message 通信,不定义 per-agent 的返回 schema。如果需要结构化输出(引用来源、ID 列表等),由 Agent 的 prompt 约定写进 message 文本,调用方自行 parse。
+**返回值**:Legacy、Recursive revision 1 和远端调用仍返回
+`{status, sub_trace_id, summary, stats}` 的自然语言结果;Recursive revision 2
+的本地调用还返回已校验的 `task_report`(批量时为 `task_reports`)。
 
 #### SDK / CLI 调用
 

+ 3 - 0
cyber_agent/tools/builtin/__init__.py

@@ -16,6 +16,7 @@ from cyber_agent.tools.builtin.file.grep import grep_content
 from cyber_agent.tools.builtin.bash import bash_command
 from cyber_agent.tools.builtin.skill import skill, list_skills
 from cyber_agent.tools.builtin.subagent import agent, evaluate
+from cyber_agent.tools.builtin.task_protocol import submit_task_report, review_task_result
 # sandbox 工具已废弃(2026-04);search.py / crawler.py 已重构为 content/ 工具族(2026-04)
 from cyber_agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_save_pending,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
 # Memory / Dream(见 cyber_agent/docs/memory.md)
@@ -60,6 +61,8 @@ __all__ = [
     "list_skills",
     "agent",
     "evaluate",
+    "submit_task_report",
+    "review_task_result",
     # 内容工具族(重构自 search.py + crawler.py)
     "content_platforms",
     "content_search",

+ 307 - 38
cyber_agent/tools/builtin/subagent.py

@@ -8,10 +8,13 @@ evaluate: 评估目标执行结果是否满足要求
 """
 
 import asyncio
+import json
 import os
 from datetime import datetime
 from typing import Any, Dict, List, Optional, Union
 
+from pydantic import ValidationError
+
 from cyber_agent.tools import tool
 from cyber_agent.core.agent_mode import (
     AgentMode,
@@ -20,6 +23,14 @@ from cyber_agent.core.agent_mode import (
     assert_removed_config_absent,
     policy_from_context,
 )
+from cyber_agent.core.task_protocol import (
+    TaskBrief,
+    TaskReport,
+    ensure_task_protocol,
+    format_task_brief,
+    pending_review_entry,
+    protocol_error_report,
+)
 from cyber_agent.trace.models import Trace, Messages
 from cyber_agent.trace.trace_id import generate_sub_trace_id
 from cyber_agent.trace.goal_models import GoalTree
@@ -157,6 +168,7 @@ async def _update_goal_start(
     sub_trace_ids: List[Dict[str, str]],
     *,
     accumulate_sub_trace_ids: bool,
+    status: str = "in_progress",
 ) -> None:
     """标记 Goal 开始执行"""
     if not goal_id:
@@ -179,7 +191,7 @@ async def _update_goal_start(
         trace_id, goal_id,
         type="agent_call",
         agent_call_mode=mode,
-        status="in_progress",
+        status=status,
         sub_trace_ids=next_entries,
     )
 
@@ -198,6 +210,90 @@ async def _update_goal_complete(
     )
 
 
+async def _load_or_create_task_report(
+    store,
+    child_trace_id: str,
+    fallback_reason: str,
+    child_result_status: str,
+    generated_at_sequence: int,
+) -> TaskReport:
+    child = await store.get_trace(child_trace_id)
+    if not child:
+        return protocol_error_report(child_trace_id, fallback_reason)
+    state = ensure_task_protocol(child.context)
+    report_data = state.get("task_report")
+    execution_failed = (
+        child_result_status != "completed"
+        or child.status != "completed"
+    )
+    if report_data:
+        try:
+            report = TaskReport.model_validate(report_data)
+            if report.child_trace_id != child_trace_id:
+                state["report_history"].append(report.model_dump())
+                fallback_reason = (
+                    "Child persisted a TaskReport whose child_trace_id does not "
+                    "match its Trace"
+                )
+            elif not execution_failed or report.outcome in {"failed", "protocol_error"}:
+                return report
+            else:
+                state["report_history"].append(report.model_dump())
+                fallback_reason = (
+                    f"Child execution ended as {child_result_status} after submitting "
+                    f"a {report.outcome} TaskReport: {fallback_reason}"
+                )
+        except ValidationError:
+            fallback_reason = "Child persisted an invalid TaskReport"
+    report = protocol_error_report(child_trace_id, fallback_reason)
+    state["task_report"] = report.model_dump()
+    state["task_report_submitted_at_sequence"] = generated_at_sequence
+    await store.update_trace(child_trace_id, context=child.context)
+    return report
+
+
+async def _record_pending_task_reports(
+    store,
+    parent_trace: Trace,
+    goal_id: str | None,
+    child_results: List[tuple[str, Dict[str, Any]]],
+    received_at_sequence: int,
+) -> List[TaskReport]:
+    state = ensure_task_protocol(parent_trace.context)
+    reports: List[TaskReport] = []
+    for child_trace_id, result in child_results:
+        reason = result.get("error") or result.get("summary") or "Child ended without TaskReport"
+        report = await _load_or_create_task_report(
+            store,
+            child_trace_id,
+            reason,
+            result.get("status", "unknown"),
+            received_at_sequence,
+        )
+        state["pending_reviews"][child_trace_id] = pending_review_entry(
+            goal_id=goal_id,
+            report=report,
+            received_at_sequence=received_at_sequence,
+        )
+        reports.append(report)
+    await store.update_trace(parent_trace.trace_id, context=parent_trace.context)
+    if goal_id:
+        await store.update_goal(
+            parent_trace.trace_id,
+            goal_id,
+            cascade_completion=False,
+            status="pending_review",
+        )
+    return reports
+
+
+def _project_goal_status(context: dict, goal_id: str | None, status: str) -> None:
+    tree = context.get("goal_tree")
+    goal = tree.find(goal_id) if tree and goal_id else None
+    if goal:
+        goal.status = status
+
+
 def _aggregate_stats(results: List[Dict[str, Any]]) -> Dict[str, Any]:
     """聚合多个结果的统计信息"""
     total_messages = 0
@@ -227,6 +323,8 @@ def _get_allowed_tools(
     runner = context.get("runner")
     if runner and hasattr(runner, "tools") and hasattr(runner.tools, "get_tool_names"):
         blocked_tools = {"evaluate", "bash_command"}
+        if not policy.requires_task_protocol:
+            blocked_tools.update({"submit_task_report", "review_task_result"})
         if policy.mode is AgentMode.LEGACY or agent_depth >= policy.max_depth:
             blocked_tools.add("agent")
         return [
@@ -480,6 +578,7 @@ async def _run_agents(
     store, trace_id: str, goal_id: str, runner, context: dict,
     agent_type: Optional[str] = None,
     skills: Optional[List[str]] = None,
+    task_briefs: Optional[List[TaskBrief]] = None,
 ) -> Dict[str, Any]:
     """
     统一 agent 执行逻辑。
@@ -499,6 +598,57 @@ async def _run_agents(
     except ValueError as exc:
         return {"status": "failed", "error": str(exc)}
 
+    protocol_state = None
+    approved_action = None
+    if policy.requires_task_protocol:
+        protocol_state = ensure_task_protocol(parent_trace.context)
+        if goal_id:
+            parent_tree = await store.get_goal_tree(trace_id)
+            parent_goal = parent_tree.find(goal_id) if parent_tree else None
+            if parent_goal and parent_goal.status in {"completed", "failed", "abandoned"}:
+                return {
+                    "status": "failed",
+                    "error": "Cannot create a child from a terminal Goal",
+                }
+        if protocol_state["pending_reviews"]:
+            return {
+                "status": "failed",
+                "error": "Review pending child TaskReports before creating another child",
+            }
+        if not task_briefs or len(task_briefs) != len(tasks):
+            return {"status": "failed", "error": "Recursive revision 2 requires task_brief"}
+        if protocol_state["next_actions"]:
+            approved_action = protocol_state["next_actions"][0]
+            decision = approved_action["decision"]
+            expected_brief = approved_action.get("task_brief")
+            if decision == "REVISE_CHILD":
+                if continue_from != approved_action["child_trace_id"] or len(tasks) != 1:
+                    return {
+                        "status": "failed",
+                        "error": "REVISE_CHILD requires continue_from for the reviewed child",
+                    }
+                if expected_brief and task_briefs[0].model_dump() != expected_brief:
+                    return {
+                        "status": "failed",
+                        "error": "task_brief does not match the approved revision",
+                    }
+            else:
+                if continue_from or len(tasks) != 1:
+                    return {
+                        "status": "failed",
+                        "error": f"{decision} requires exactly one new approved child",
+                    }
+                if not expected_brief or task_briefs[0].model_dump() != expected_brief:
+                    return {
+                        "status": "failed",
+                        "error": "task_brief does not match the approved next task",
+                    }
+        elif continue_from:
+            return {
+                "status": "failed",
+                "error": "continue_from requires a pending REVISE_CHILD action",
+            }
+
     # continue_from: 复用已有 trace(仅 single)
     sub_trace_id = None
     continued = False
@@ -573,6 +723,11 @@ async def _run_agents(
             }, policy),
             "is_new": False,
         }]
+        if task_briefs:
+            child_state = ensure_task_protocol(child_records[0]["context"])
+            child_state["task_brief"] = task_briefs[0].model_dump()
+            existing.context = child_records[0]["context"]
+            await store.update_trace(existing.trace_id, context=existing.context)
     else:
         parent_depth, root_trace_id = await _resolve_trace_lineage(store, parent_trace)
         if parent_depth >= policy.max_depth:
@@ -588,6 +743,11 @@ async def _run_agents(
         async def create_child_traces() -> None:
             child_depth = parent_depth + 1
             for i, task_item in enumerate(tasks):
+                task_label = (
+                    task_briefs[i].objective
+                    if task_briefs
+                    else task_item
+                )
                 resolved_agent_type = agent_type or ("delegate" if single else "explore")
                 suffix = "delegate" if single else f"explore-{i+1:03d}"
                 stid = generate_sub_trace_id(trace_id, suffix)
@@ -596,10 +756,16 @@ async def _run_agents(
                     "agent_depth": child_depth,
                     "root_trace_id": root_trace_id,
                 }, policy)
+                if policy.requires_task_protocol:
+                    child_context["task_protocol"] = ensure_task_protocol({
+                        "task_protocol": {
+                            "task_brief": task_briefs[i].model_dump(),
+                        }
+                    })
                 sub_trace = Trace(
                     trace_id=stid,
                     mode="agent",
-                    task=task_item,
+                    task=task_label,
                     parent_trace_id=trace_id,
                     parent_goal_id=goal_id,
                     agent_type=resolved_agent_type,
@@ -610,8 +776,8 @@ async def _run_agents(
                     created_at=datetime.now(),
                 )
                 await store.create_trace(sub_trace)
-                await store.update_goal_tree(stid, GoalTree(mission=task_item))
-                all_sub_trace_ids.append({"trace_id": stid, "mission": task_item})
+                await store.update_goal_tree(stid, GoalTree(mission=task_label))
+                all_sub_trace_ids.append({"trace_id": stid, "mission": task_label})
                 child_records.append({
                     "trace_id": stid,
                     "depth": child_depth,
@@ -653,9 +819,17 @@ async def _run_agents(
             "delegate" if single else "explore",
             all_sub_trace_ids,
             accumulate_sub_trace_ids=policy.accumulate_sub_trace_ids,
+            status=("waiting_children" if policy.requires_task_protocol else "in_progress"),
         )
+        if policy.requires_task_protocol:
+            _project_goal_status(context, goal_id, "waiting_children")
         goal_started = True
 
+    if approved_action and protocol_state is not None:
+        protocol_state["next_actions"].pop(0)
+        protocol_state["protocol_correction_attempts"] = 0
+        await store.update_trace(trace_id, context=parent_trace.context)
+
     # 创建执行协程。Trace 在上面已经按批次预创建,
     # 因此孩子配额不会被并发调用绕过。
     coros = []
@@ -664,20 +838,21 @@ async def _run_agents(
     ):
         cur_stid = child_record["trace_id"]
         child_depth = child_record["depth"]
+        task_label = task_briefs[i].objective if task_briefs else task_item
         if child_record["is_new"]:
             await broadcast_sub_trace_started(
                 trace_id, cur_stid, goal_id or "",
-                child_record["agent_type"], task_item,
+                child_record["agent_type"], task_label,
             )
 
         # 注册为活跃协作者
-        collab_name = task_item[:30] if single and not continued else (
+        collab_name = task_label[:30] if single and not continued else (
             f"delegate-{cur_stid[:8]}" if single else f"explore-{i+1}"
         )
         await _update_collaborator(
             store, trace_id,
             name=collab_name, sub_trace_id=cur_stid,
-            status="running", summary=task_item[:80],
+            status="running", summary=task_label[:80],
         )
 
         # 构建消息
@@ -702,7 +877,7 @@ async def _run_agents(
                 uid=parent_trace.uid if parent_trace else None,
                 tools=allowed_tools,
                 tool_groups=[],  # tools 是精确白名单,不再合并默认 core 组
-                name=task_item[:50],
+                name=task_label[:50],
                 skills=skills,
                 knowledge=context.get("knowledge_config"),
                 context=child_record["context"],
@@ -718,7 +893,10 @@ async def _run_agents(
             "delegate" if single else "explore",
             all_sub_trace_ids,
             accumulate_sub_trace_ids=policy.accumulate_sub_trace_ids,
+            status=("waiting_children" if policy.requires_task_protocol else "in_progress"),
         )
+        if policy.requires_task_protocol:
+            _project_goal_status(context, goal_id, "waiting_children")
 
     # 执行
     if single:
@@ -742,11 +920,22 @@ async def _run_agents(
 
             formatted = _format_single_result(result, stid, continued)
 
-            await _update_goal_complete(
-                store, trace_id, goal_id,
-                result.get("status", "completed"),
-                formatted["summary"],
-            )
+            if policy.requires_task_protocol:
+                reports = await _record_pending_task_reports(
+                    store,
+                    parent_trace,
+                    goal_id,
+                    [(stid, result)],
+                    context.get("sequence", 0),
+                )
+                formatted["task_report"] = reports[0].model_dump()
+                _project_goal_status(context, goal_id, "pending_review")
+            else:
+                await _update_goal_complete(
+                    store, trace_id, goal_id,
+                    result.get("status", "completed"),
+                    formatted["summary"],
+                )
             return formatted
 
         except Exception as e:
@@ -759,16 +948,28 @@ async def _run_agents(
                 name=collab_name, sub_trace_id=stid,
                 status="failed", summary=error_msg[:80],
             )
-            await _update_goal_complete(
-                store, trace_id, goal_id,
-                "failed", f"委托任务失败: {error_msg}",
-            )
-            return {
+            failed_result = {
                 "mode": "delegate",
                 "status": "failed",
                 "error": error_msg,
                 "sub_trace_id": stid,
             }
+            if policy.requires_task_protocol:
+                reports = await _record_pending_task_reports(
+                    store,
+                    parent_trace,
+                    goal_id,
+                    [(stid, failed_result)],
+                    context.get("sequence", 0),
+                )
+                failed_result["task_report"] = reports[0].model_dump()
+                _project_goal_status(context, goal_id, "pending_review")
+            else:
+                await _update_goal_complete(
+                    store, trace_id, goal_id,
+                    "failed", f"委托任务失败: {error_msg}",
+                )
+            return failed_result
     else:
         # 检查父 Agent 是否开启了并发配置
         is_parallel = getattr(runner.config, "parallel_tool_execution", True) if runner and hasattr(runner, "config") else True
@@ -824,11 +1025,25 @@ async def _run_agents(
 
         formatted = _format_multi_result(tasks, processed_results, all_sub_trace_ids)
 
-        await _update_goal_complete(
-            store, trace_id, goal_id,
-            formatted["status"],
-            formatted["summary"],
-        )
+        if policy.requires_task_protocol:
+            reports = await _record_pending_task_reports(
+                store,
+                parent_trace,
+                goal_id,
+                [
+                    (entry["trace_id"], processed_results[index])
+                    for index, entry in enumerate(all_sub_trace_ids)
+                ],
+                context.get("sequence", 0),
+            )
+            formatted["task_reports"] = [report.model_dump() for report in reports]
+            _project_goal_status(context, goal_id, "pending_review")
+        else:
+            await _update_goal_complete(
+                store, trace_id, goal_id,
+                formatted["status"],
+                formatted["summary"],
+            )
         return formatted
 
 
@@ -895,7 +1110,8 @@ async def _run_remote_agent(
 
 @tool(description="创建子 Agent 执行任务(本地执行或路由到远端服务器,由 agent_type 决定)", hidden_params=["context"], groups=["core"])
 async def agent(
-    task: Union[str, List[str]],
+    task: Optional[Union[str, List[str]]] = None,
+    task_brief: Optional[Union[TaskBrief, List[TaskBrief]]] = None,
     messages: Optional[Union[Messages, List[Messages]]] = None,
     continue_from: Optional[str] = None,
     agent_type: Optional[str] = None,
@@ -910,7 +1126,8 @@ async def agent(
     - 否则本地执行:单任务 str = delegate(全量工具);多任务 List[str] = explore(并行、只读)
 
     Args:
-        task: 任务描述。字符串=单任务,列表=多任务并行(远端模式只支持单任务)
+        task: Legacy/Recursive revision 1 任务描述。
+        task_brief: Recursive revision 2 的结构化任务说明。
         messages: 预置消息。1D 列表=所有 agent 共享;2D 列表=per-agent
         continue_from: 继续已有 trace(仅单任务)
         agent_type: 子 Agent 类型。带 "remote_" 前缀走远端;否则本地 preset
@@ -924,6 +1141,26 @@ async def agent(
     except ValueError as exc:
         return {"status": "failed", "error": str(exc)}
 
+    if isinstance(messages, str):
+        try:
+            messages = json.loads(messages)
+        except json.JSONDecodeError as exc:
+            return {"status": "failed", "error": f"Invalid messages JSON: {exc}"}
+    if messages is not None:
+        is_1d = isinstance(messages, list) and all(
+            isinstance(message, dict) for message in messages
+        )
+        is_2d = isinstance(messages, list) and bool(messages) and all(
+            isinstance(message_list, list)
+            and all(isinstance(message, dict) for message in message_list)
+            for message_list in messages
+        )
+        if not (is_1d or is_2d):
+            return {
+                "status": "failed",
+                "error": "messages must be a 1D or 2D message list without mixed dimensions",
+            }
+
     # 远端路由:agent_type 以 remote_ 开头
     if agent_type and agent_type.startswith(REMOTE_PREFIX):
         if not isinstance(task, str):
@@ -961,20 +1198,51 @@ async def agent(
     if missing:
         return {"status": "failed", "error": f"Missing required context: {', '.join(missing)}"}
 
-    # 归一化 task → list
-    if isinstance(task, str):
-        task_str = task.strip()
-        if task_str.startswith("[") and task_str.endswith("]"):
-            try:
-                import json
-                parsed_task = json.loads(task_str)
-                if isinstance(parsed_task, list):
-                    task = parsed_task
-            except:
-                pass
+    parent_trace = await store.get_trace(trace_id)
+    if not parent_trace:
+        return {"status": "failed", "error": f"Parent trace not found: {trace_id}"}
+    try:
+        policy = policy_from_context(parent_trace.context)
+    except ValueError as exc:
+        return {"status": "failed", "error": str(exc)}
 
-    single = isinstance(task, str)
-    tasks = [task] if single else task
+    parsed_task_briefs: Optional[List[TaskBrief]] = None
+    if policy.requires_task_protocol:
+        if task is not None or task_brief is None:
+            return {
+                "status": "failed",
+                "error": "Recursive revision 2 requires task_brief and does not accept task",
+            }
+        if isinstance(task_brief, str):
+            try:
+                task_brief = json.loads(task_brief)
+            except json.JSONDecodeError as exc:
+                return {"status": "failed", "error": f"Invalid TaskBrief JSON: {exc}"}
+        raw_briefs = task_brief if isinstance(task_brief, list) else [task_brief]
+        try:
+            parsed_task_briefs = [TaskBrief.model_validate(item) for item in raw_briefs]
+        except ValidationError as exc:
+            return {"status": "failed", "error": f"Invalid TaskBrief: {exc}"}
+        tasks = [format_task_brief(brief) for brief in parsed_task_briefs]
+        single = len(tasks) == 1
+    else:
+        if task_brief is not None:
+            return {"status": "failed", "error": "task_brief requires Recursive revision 2"}
+        if task is None:
+            return {"status": "failed", "error": "task is required"}
+
+        # 归一化 task → list(保留 Legacy 字符串 JSON 列表兼容)
+        if isinstance(task, str):
+            task_str = task.strip()
+            if task_str.startswith("[") and task_str.endswith("]"):
+                try:
+                    parsed_task = json.loads(task_str)
+                    if isinstance(parsed_task, list):
+                        task = parsed_task
+                except (json.JSONDecodeError, TypeError):
+                    pass
+        single = isinstance(task, str)
+        tasks = [task] if single else task
 
     if not tasks:
         return {"status": "failed", "error": "task is required"}
@@ -1003,6 +1271,7 @@ async def agent(
         store, trace_id, goal_id, runner, context,
         agent_type=agent_type,
         skills=skills,
+        task_briefs=parsed_task_briefs,
     )
 
 

+ 48 - 9
cyber_agent/tools/builtin/task_protocol.py

@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+from copy import deepcopy
 from datetime import datetime
 from typing import Any
 
@@ -51,11 +52,17 @@ async def submit_task_report(
     state = ensure_task_protocol(trace.context)
     if state["pending_reviews"]:
         return _error("Review all child TaskReports before submitting this TaskReport")
+    if state["next_actions"]:
+        return _error("Execute every approved next action before submitting this TaskReport")
     if state["task_report"] is not None:
         return _error("A TaskReport has already been submitted")
 
     try:
-        submission = TaskReportSubmission.model_validate(task_report)
+        submission = (
+            TaskReportSubmission.model_validate_json(task_report)
+            if isinstance(task_report, str)
+            else TaskReportSubmission.model_validate(task_report)
+        )
         report = TaskReport(
             child_trace_id=trace_id,
             **submission.model_dump(),
@@ -124,7 +131,11 @@ async def review_task_result(
     if not entry:
         return _error("This direct child has no pending TaskReport")
 
+    revision_task_brief = None
+    child_state = None
     try:
+        if isinstance(approved_next_task, str):
+            approved_next_task = TaskBrief.model_validate_json(approved_next_task)
         review = TaskReview.model_validate({
             "parent_trace_id": parent_trace_id,
             "child_trace_id": child_trace_id,
@@ -133,6 +144,15 @@ async def review_task_result(
             "approved_next_task": approved_next_task,
         })
         report = TaskReport.model_validate(entry["task_report"])
+        if report.child_trace_id != child_trace_id:
+            return _error(
+                "Pending TaskReport child_trace_id does not match the direct child"
+            )
+        if review.decision == "REVISE_CHILD":
+            child_state = ensure_task_protocol(child.context)
+            revision_task_brief = TaskBrief.model_validate(
+                review.approved_next_task or child_state.get("task_brief")
+            )
     except ValidationError as exc:
         return _error(f"Invalid TaskReview: {exc}")
 
@@ -143,36 +163,55 @@ async def review_task_result(
         return _error("Failed/protocol_error reports only allow REVISE_CHILD or FAIL")
     if review.decision in {"ASCEND", "FAIL"} and len(pending) != 1:
         return _error("ASCEND/FAIL is only allowed for the final pending report")
+    if review.decision in {"ASCEND", "FAIL"} and state["next_actions"]:
+        return _error("ASCEND/FAIL requires every approved next action to finish first")
+    if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
+        child_limit = policy.max_children_per_parent
+        if child_limit is not None:
+            existing_children = await store.list_traces(
+                parent_trace_id=parent_trace_id,
+                created_by_tool="agent",
+                limit=child_limit + 1,
+            )
+            reserved_children = sum(
+                action.get("decision") in {"ACCEPT_REFINE", "DESCEND_AGAIN"}
+                for action in state["next_actions"]
+            )
+            if len(existing_children) + reserved_children + 1 > child_limit:
+                return _error(
+                    "Cannot approve another child: local child Agent limit "
+                    f"would be exceeded (existing={len(existing_children)}, "
+                    f"reserved={reserved_children}, max={child_limit})"
+                )
 
     pending.pop(child_trace_id)
     review_record = {
         **review.model_dump(),
+        "pending_review": deepcopy(entry),
         "reviewed_at": datetime.now().isoformat(),
         "reviewed_at_sequence": context.get("sequence", 0),
     }
     state["reviews"].append(review_record)
+    state["protocol_correction_attempts"] = 0
 
     if review.decision == "REVISE_CHILD":
-        child_state = ensure_task_protocol(child.context)
+        assert child_state is not None and revision_task_brief is not None
         old_report = child_state.get("task_report")
         if old_report:
             child_state["report_history"].append(old_report)
         child_state["task_report"] = None
         child_state["task_report_submitted_at_sequence"] = None
         child_state["protocol_correction_attempts"] = 0
-        if review.approved_next_task:
-            child_state["task_brief"] = review.approved_next_task.model_dump()
+        child_state["task_brief"] = revision_task_brief.model_dump()
         await store.update_trace(child_trace_id, context=child.context)
 
     if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN", "REVISE_CHILD"}:
+        action_task_brief = revision_task_brief or review.approved_next_task
         state["next_actions"].append({
             "decision": review.decision,
             "child_trace_id": child_trace_id,
-            "task_brief": (
-                review.approved_next_task.model_dump()
-                if review.approved_next_task
-                else None
-            ),
+            "goal_id": entry.get("goal_id"),
+            "task_brief": action_task_brief.model_dump() if action_task_brief else None,
             "created_at_sequence": context.get("sequence", 0),
         })
 

+ 38 - 9
cyber_agent/trace/goal_models.py

@@ -13,7 +13,15 @@ import json
 
 
 # Goal 状态
-GoalStatus = Literal["pending", "in_progress", "completed", "abandoned"]
+GoalStatus = Literal[
+    "pending",
+    "in_progress",
+    "waiting_children",
+    "pending_review",
+    "completed",
+    "failed",
+    "abandoned",
+]
 
 # Goal 类型
 GoalType = Literal["normal", "agent_call"]
@@ -322,7 +330,15 @@ class GoalTree:
         self.current_id = goal_id
         return goal
 
-    def complete(self, goal_id: str, summary: str, clear_focus: bool = True) -> Goal:
+    def complete(
+        self,
+        goal_id: str,
+        summary: str,
+        clear_focus: bool = True,
+        *,
+        auto_advance: bool = True,
+        cascade_parent: bool = True,
+    ) -> Goal:
         """
         完成指定 Goal
 
@@ -341,14 +357,16 @@ class GoalTree:
         # 如果完成的是当前焦点,根据参数决定是否清除焦点
         if clear_focus and self.current_id == goal_id:
             # 不直接清空,尝试自动切换到下一个 pending goal
-            next_goal = self._find_next_pending_goal(goal_id)
+            next_goal = self._find_next_pending_goal(goal_id) if auto_advance else None
             if next_goal:
                 self.current_id = next_goal.id
+            elif not auto_advance and goal.parent_id:
+                self.current_id = goal.parent_id
             else:
                 self.current_id = None
 
         # 检查是否所有兄弟都完成了,如果是则自动完成父节点
-        if goal.parent_id:
+        if cascade_parent and goal.parent_id:
             siblings = self.get_children(goal.parent_id)
             all_completed = all(g.status == "completed" for g in siblings)
             if all_completed:
@@ -361,7 +379,7 @@ class GoalTree:
 
         return goal
 
-    def abandon(self, goal_id: str, reason: str) -> Goal:
+    def abandon(self, goal_id: str, reason: str, *, auto_advance: bool = True) -> Goal:
         """放弃指定 Goal"""
         goal = self.find(goal_id)
         if not goal:
@@ -372,7 +390,7 @@ class GoalTree:
 
         # 如果放弃的是当前焦点,尝试自动切换到下一个 pending goal
         if self.current_id == goal_id:
-            next_goal = self._find_next_pending_goal(goal_id)
+            next_goal = self._find_next_pending_goal(goal_id) if auto_advance else None
             if next_goal:
                 self.current_id = next_goal.id
             else:
@@ -380,6 +398,17 @@ class GoalTree:
 
         return goal
 
+    def fail(self, goal_id: str, reason: str) -> Goal:
+        """将 Goal 标记为失败,不级联父级也不自动切换兄弟。"""
+        goal = self.find(goal_id)
+        if not goal:
+            raise ValueError(f"Goal not found: {goal_id}")
+        goal.status = "failed"
+        goal.summary = reason
+        if self.current_id == goal_id:
+            self.current_id = goal.parent_id
+        return goal
+
     def to_prompt(self, include_abandoned: bool = False, include_summary: bool = False) -> str:
         """
         格式化为 Prompt 注入文本
@@ -430,9 +459,9 @@ class GoalTree:
             # 状态图标
             if goal.status == "completed":
                 icon = "[✓]"
-            elif goal.status == "in_progress":
+            elif goal.status in {"in_progress", "waiting_children", "pending_review"}:
                 icon = "[→]"
-            elif goal.status == "abandoned":
+            elif goal.status in {"failed", "abandoned"}:
                 icon = "[✗]"
             else:
                 icon = "[ ]"
@@ -566,7 +595,7 @@ class GoalTree:
             if goal.parent_id and goal.parent_id not in surviving_ids:
                 goal.parent_id = None
             # 将 in_progress 重置为 pending(回溯后需要重新执行)
-            if goal.status == "in_progress":
+            if goal.status in {"in_progress", "waiting_children", "pending_review"}:
                 goal.status = "pending"
 
         new_tree = GoalTree(

+ 45 - 4
cyber_agent/trace/goal_tool.py

@@ -8,6 +8,8 @@ import logging
 from typing import Optional, List, TYPE_CHECKING
 
 from cyber_agent.tools import tool
+from cyber_agent.core.agent_mode import policy_from_context
+from cyber_agent.core.task_protocol import ensure_task_protocol
 
 if TYPE_CHECKING:
     from .goal_models import GoalTree, Goal
@@ -198,6 +200,29 @@ async def goal_tool(
         更新后的计划状态文本
     """
     changes = []
+    recursive_protocol = False
+    if store and trace_id:
+        trace = await store.get_trace(trace_id)
+        if trace:
+            recursive_protocol = policy_from_context(
+                trace.context
+            ).requires_task_protocol
+            if recursive_protocol:
+                state = ensure_task_protocol(trace.context)
+                has_goal_mutation = any(
+                    value is not None
+                    for value in (add, done, abandon, focus)
+                )
+                if state["pending_reviews"] and has_goal_mutation:
+                    return (
+                        "错误:当前有子任务报告待审核,只允许调用 "
+                        "review_task_result。"
+                    )
+                if state["next_actions"] and has_goal_mutation:
+                    return (
+                        "错误:当前有已批准的下一步待执行,只允许调用 "
+                        "agent 执行该 action。"
+                    )
 
     # 1. 处理 done(完成当前目标)
     if done is not None:
@@ -208,16 +233,28 @@ async def goal_tool(
         # 如果同时指定了 focus,则不清空焦点(后面会切换到新目标)
         # 如果只有 done,则清空焦点
         clear_focus = (focus is None)
-        goal = tree.complete(tree.current_id, done, clear_focus=clear_focus)
+        goal = tree.complete(
+            tree.current_id,
+            done,
+            clear_focus=clear_focus,
+            auto_advance=not recursive_protocol,
+            cascade_parent=not recursive_protocol,
+        )
         display_id = tree._generate_display_id(goal)
         changes.append(f"已完成: {display_id}. {goal.description}")
 
         # 推送事件
         if store and trace_id:
-            await store.update_goal(trace_id, goal.id, status="completed", summary=done)
+            await store.update_goal(
+                trace_id,
+                goal.id,
+                cascade_completion=not recursive_protocol,
+                status="completed",
+                summary=done,
+            )
 
         # 检查是否有级联完成的父目标(complete方法已经处理,这里只需要记录)
-        if goal.parent_id:
+        if goal.parent_id and not recursive_protocol:
             parent = tree.find(goal.parent_id)
             if parent and parent.status == "completed":
                 parent_display_id = tree._generate_display_id(parent)
@@ -245,7 +282,11 @@ async def goal_tool(
     if abandon is not None:
         if not tree.current_id:
             return f"错误:没有当前目标可以放弃。当前焦点为空。\n\n当前计划:\n{tree.to_prompt()}"
-        goal = tree.abandon(tree.current_id, abandon)
+        goal = tree.abandon(
+            tree.current_id,
+            abandon,
+            auto_advance=not recursive_protocol,
+        )
         display_id = tree._generate_display_id(goal)
         changes.append(f"已放弃: {display_id}. {goal.description}")
 

+ 9 - 2
cyber_agent/trace/store.py

@@ -219,7 +219,14 @@ class FileSystemTraceStore:
             reason_preview = goal.reason[:60] + "..." if len(goal.reason) > 60 else goal.reason
             print(f"  💡 {reason_preview}")
 
-    async def update_goal(self, trace_id: str, goal_id: str, **updates) -> None:
+    async def update_goal(
+        self,
+        trace_id: str,
+        goal_id: str,
+        *,
+        cascade_completion: bool = True,
+        **updates,
+    ) -> None:
         """更新 Goal 字段"""
         tree = await self.get_goal_tree(trace_id)
         if not tree:
@@ -243,7 +250,7 @@ class FileSystemTraceStore:
         # 如果状态变为 completed,检查是否需要级联完成父 Goal
         affected_goals = [{"goal_id": goal_id, "updates": updates}]
 
-        if updates.get("status") == "completed":
+        if cascade_completion and updates.get("status") == "completed":
             # 检查级联完成:如果所有兄弟 Goal 都完成,父 Goal 也完成
             cascade_completed = await self._check_cascade_completion(trace_id, goal)
             affected_goals.extend(cascade_completed)

+ 32 - 0
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -7,6 +7,7 @@ import {
   visibleSubTraceEntries,
   type SubTraceEntry,
 } from "./subTraceEntries";
+import { goalStatusPresentation } from "./goalStatus";
 import { ArrowMarkers } from "./components/ArrowMarkers";
 import styles from "./styles/FlowChart.module.css";
 import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
@@ -1113,6 +1114,9 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                 const subTraceEntries = isGoal
                   ? visibleSubTraceEntries(data, agentMode)
                   : [];
+                const statusPresentation = isGoal
+                  ? goalStatusPresentation(data.status)
+                  : undefined;
                 let text = isGoal ? data.description : (node.data as Message).description || "";
 
                 let thumbnail: string | null = null;
@@ -1163,6 +1167,11 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                   if (node.level === 0) { nodeFill = "#ecfdf5"; nodeStroke = "#10b981"; }
                   else if (node.level === 1) { nodeFill = "#fef2f2"; nodeStroke = "#ef4444"; }
                   else if (node.level === 2) { nodeFill = "#eff6ff"; nodeStroke = "#3b82f6"; }
+                  if (statusPresentation) {
+                    nodeFill = statusPresentation.fill;
+                    nodeStroke = statusPresentation.stroke;
+                    textColor = statusPresentation.text;
+                  }
                 }
 
                 if (node.id === "SYSTEM_START" || node.id === "SYSTEM_END") {
@@ -1228,6 +1237,29 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                         </text>
                       </g>
                     )}
+                    {statusPresentation && (
+                      <g transform="translate(0, -35)" style={{ pointerEvents: "none" }}>
+                        <rect
+                          x={-42}
+                          y={-8}
+                          width={84}
+                          height={16}
+                          rx={8}
+                          fill={statusPresentation.fill}
+                          stroke={statusPresentation.stroke}
+                        />
+                        <text
+                          x={0}
+                          y={4}
+                          fontSize={9}
+                          fontWeight="bold"
+                          fill={statusPresentation.text}
+                          textAnchor="middle"
+                        >
+                          {statusPresentation.label}
+                        </text>
+                      </g>
+                    )}
                     {subTraceEntries.map((entry, index) => {
                       const spacing = 22;
                       const startX = -((subTraceEntries.length - 1) * spacing) / 2;

+ 11 - 0
frontend/react-template/src/components/FlowChart/goalStatus.test.ts

@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+
+import { goalStatusPresentation } from "./goalStatus";
+
+describe("Recursive Goal status presentation", () => {
+  it("provides clear waiting, review and failure labels", () => {
+    expect(goalStatusPresentation("waiting_children")?.label).toBe("等待子任务");
+    expect(goalStatusPresentation("pending_review")?.label).toBe("待审核");
+    expect(goalStatusPresentation("failed")?.label).toBe("失败");
+  });
+});

+ 33 - 0
frontend/react-template/src/components/FlowChart/goalStatus.ts

@@ -0,0 +1,33 @@
+import type { Goal } from "../../types/goal";
+
+export type GoalStatusPresentation = {
+  label: string;
+  fill: string;
+  stroke: string;
+  text: string;
+};
+
+const PRESENTATIONS: Partial<Record<Goal["status"], GoalStatusPresentation>> = {
+  waiting_children: {
+    label: "等待子任务",
+    fill: "#fffbeb",
+    stroke: "#f59e0b",
+    text: "#92400e",
+  },
+  pending_review: {
+    label: "待审核",
+    fill: "#f5f3ff",
+    stroke: "#8b5cf6",
+    text: "#6d28d9",
+  },
+  failed: {
+    label: "失败",
+    fill: "#fef2f2",
+    stroke: "#ef4444",
+    text: "#b91c1c",
+  },
+};
+
+export const goalStatusPresentation = (
+  status: Goal["status"],
+): GoalStatusPresentation | undefined => PRESENTATIONS[status];

+ 9 - 1
frontend/react-template/src/types/goal.ts

@@ -25,7 +25,15 @@ export interface Goal {
   id: string;
   description: string;
   reason?: string;
-  status: "pending" | "running" | "completed" | "failed";
+  status:
+    | "pending"
+    | "running"
+    | "in_progress"
+    | "waiting_children"
+    | "pending_review"
+    | "completed"
+    | "failed"
+    | "abandoned";
   summary?: string;
   parent_id?: string;
   created_at: string;

+ 2 - 0
tests/test_agent_mode.py

@@ -86,6 +86,8 @@ class AgentModePolicyTest(unittest.IsolatedAsyncioTestCase):
 
     async def test_existing_trace_keeps_persisted_mode_after_env_switch(self):
         trace, _, _ = await self._prepare_new("recursive")
+        self.assertEqual(2, trace.context["agent_mode_revision"])
+        self.assertIn("task_protocol", trace.context)
         os.environ["AGENT_MODE"] = "legacy"
 
         continued, _, _ = await self.runner._prepare_trace(

+ 1195 - 0
tests/test_task_protocol.py

@@ -0,0 +1,1195 @@
+import json
+import tempfile
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from pydantic import ValidationError
+
+from cyber_agent.core.runner import AgentRunner, RunConfig
+from cyber_agent.core.task_protocol import (
+    TaskBrief,
+    TaskReport,
+    TaskReview,
+    ensure_task_protocol,
+    new_task_protocol,
+)
+from cyber_agent.tools import get_tool_registry
+from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
+from cyber_agent.tools.builtin.subagent import agent
+from cyber_agent.tools.builtin.task_protocol import (
+    review_task_result,
+    submit_task_report,
+)
+from cyber_agent.trace.goal_models import Goal, GoalTree
+from cyber_agent.trace.goal_tool import goal_tool
+from cyber_agent.trace.models import Message, Trace
+from cyber_agent.trace.store import FileSystemTraceStore
+
+
+BRIEF = {
+    "objective": "research one concrete question",
+    "completion_criteria": ["provide one checked conclusion"],
+    "constraints": ["do not guess"],
+}
+
+
+def report_payload(*, outcome="satisfied"):
+    return {
+        "summary": "checked result",
+        "outcome": outcome,
+        "validation": {
+            "hard_passed": outcome == "satisfied",
+            "open_issues": [] if outcome == "satisfied" else ["still failing"],
+        },
+        "next_step_suggestion": {
+            "direction": "NONE",
+            "reason": "No additional task is needed",
+        },
+        "outputs": [{"kind": "note", "value": "result"}],
+        "evidence": [{"kind": "test", "value": "passed"}],
+        "remaining_issues": [] if outcome == "satisfied" else ["still failing"],
+    }
+
+
+class FakeToolRegistry:
+    def get_tool_names(self):
+        return [
+            "agent",
+            "evaluate",
+            "bash_command",
+            "goal",
+            "read_file",
+            "submit_task_report",
+            "review_task_result",
+        ]
+
+
+class ReportingRunner:
+    def __init__(self, store):
+        self.store = store
+        self.tools = FakeToolRegistry()
+        self.debug = False
+        self.stdin_check = None
+        self._cancel_events = {}
+        self.configs = []
+
+    async def run_result(self, messages, config, on_event=None):
+        self.configs.append(config)
+        submitted = await submit_task_report(
+            task_report=report_payload(),
+            context={
+                "store": self.store,
+                "trace_id": config.trace_id,
+                "sequence": 3,
+            },
+        )
+        if submitted["status"] != "completed":
+            raise AssertionError(submitted)
+        await self.store.update_trace(config.trace_id, status="completed")
+        return {
+            "status": "completed",
+            "summary": "child submitted TaskReport",
+            "saved_knowledge_ids": [],
+            "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
+        }
+
+
+class FailedAfterReportingRunner(ReportingRunner):
+    async def run_result(self, messages, config, on_event=None):
+        result = await super().run_result(messages, config, on_event)
+        await self.store.update_trace(
+            config.trace_id,
+            status="failed",
+            error_message="runner failed after report submission",
+        )
+        return {
+            **result,
+            "status": "failed",
+            "error": "runner failed after report submission",
+        }
+
+
+class ForgedIdentityRunner(ReportingRunner):
+    async def run_result(self, messages, config, on_event=None):
+        trace = await self.store.get_trace(config.trace_id)
+        state = ensure_task_protocol(trace.context)
+        state["task_report"] = TaskReport(
+            child_trace_id="someone-elses-trace",
+            **report_payload(),
+        ).model_dump()
+        await self.store.update_trace(
+            config.trace_id,
+            context=trace.context,
+            status="completed",
+        )
+        return {
+            "status": "completed",
+            "summary": "forged report identity",
+            "saved_knowledge_ids": [],
+            "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
+        }
+
+
+class RecursiveTaskProtocolTest(unittest.IsolatedAsyncioTestCase):
+    async def asyncSetUp(self):
+        self.temp_dir = tempfile.TemporaryDirectory()
+        self.store = FileSystemTraceStore(self.temp_dir.name)
+        self.runner = ReportingRunner(self.store)
+        self.root_id = "recursive-root"
+        context = {
+            "agent_mode": "recursive",
+            "agent_mode_revision": 2,
+            "agent_depth": 0,
+            "root_trace_id": self.root_id,
+            "task_protocol": new_task_protocol(),
+        }
+        await self.store.create_trace(Trace(
+            trace_id=self.root_id,
+            mode="agent",
+            task="root",
+            uid="user-1",
+            model="fake-model",
+            context=context,
+        ))
+        self.tree = GoalTree(
+            mission="root",
+            goals=[Goal(id="1", description="delegate", status="in_progress")],
+            current_id="1",
+        )
+        await self.store.update_goal_tree(self.root_id, self.tree)
+
+    async def asyncTearDown(self):
+        self.temp_dir.cleanup()
+
+    def context(self, trace_id=None, goal_id="1", tree=None):
+        return {
+            "store": self.store,
+            "trace_id": trace_id or self.root_id,
+            "goal_id": goal_id,
+            "runner": self.runner,
+            "goal_tree": tree or self.tree,
+            "sequence": 10,
+        }
+
+    async def children(self):
+        return await self.store.list_traces(
+            parent_trace_id=self.root_id,
+            created_by_tool="agent",
+            limit=20,
+        )
+
+    async def test_models_validate_and_round_trip(self):
+        brief = TaskBrief.model_validate(BRIEF)
+        self.assertEqual(BRIEF, brief.model_dump(exclude_none=True))
+        with self.assertRaises(ValidationError):
+            TaskBrief(objective="", completion_criteria=[])
+        with self.assertRaises(ValidationError):
+            TaskReview(
+                parent_trace_id="p",
+                child_trace_id="c",
+                decision="ACCEPT_REFINE",
+                reason="continue",
+            )
+        invalid_suggestion = report_payload()
+        invalid_suggestion["next_step_suggestion"] = {
+            "direction": "NONE",
+            "reason": "No next task should be attached",
+            "suggested_next_task": BRIEF,
+        }
+        with self.assertRaises(ValidationError):
+            TaskReport(child_trace_id="child", **invalid_suggestion)
+        root_submission = await submit_task_report(
+            task_report=report_payload(),
+            context={"store": self.store, "trace_id": self.root_id},
+        )
+        self.assertEqual("failed", root_submission["status"])
+        self.assertIn("Root Agent", root_submission["error"])
+
+    async def test_child_report_blocks_new_child_until_parent_review(self):
+        result = await agent(task_brief=BRIEF, context=self.context())
+        self.assertEqual("completed", result["status"])
+        child_id = result["sub_trace_id"]
+        self.assertEqual(child_id, result["task_report"]["child_trace_id"])
+
+        parent = await self.store.get_trace(self.root_id)
+        state = ensure_task_protocol(parent.context)
+        self.assertEqual([child_id], list(state["pending_reviews"]))
+        tree = await self.store.get_goal_tree(self.root_id)
+        self.assertEqual("pending_review", tree.find("1").status)
+
+        restarted_runner = AgentRunner(tool_registry=get_tool_registry())
+        restored = await self.store.get_trace(self.root_id)
+        restored_names = {
+            schema["function"]["name"]
+            for schema in restarted_runner._get_runtime_tool_schemas(
+                RunConfig(), restored
+            )
+        }
+        self.assertEqual({"review_task_result"}, restored_names)
+
+        blocked = await agent(task_brief=BRIEF, context=self.context())
+        self.assertEqual("failed", blocked["status"])
+        self.assertIn("Review pending", blocked["error"])
+
+    async def test_qwen_json_string_containers_are_normalized(self):
+        result = await agent(
+            task_brief=json.dumps(BRIEF),
+            messages=json.dumps([{
+                "role": "system",
+                "content": "controlled child context",
+            }]),
+            context=self.context(),
+        )
+        self.assertEqual("completed", result["status"])
+        child = await self.store.get_trace(result["sub_trace_id"])
+        self.assertEqual(
+            TaskBrief.model_validate(BRIEF).model_dump(),
+            ensure_task_protocol(child.context)["task_brief"],
+        )
+
+    async def test_messages_reject_mixed_dimensions(self):
+        mixed_messages = [
+            {"role": "system", "content": "shared context"},
+            [{"role": "user", "content": "per-agent context"}],
+        ]
+        for messages in (mixed_messages, json.dumps(mixed_messages)):
+            with self.subTest(messages_type=type(messages).__name__):
+                result = await agent(
+                    task_brief=BRIEF,
+                    messages=messages,
+                    context=self.context(),
+                )
+                self.assertEqual("failed", result["status"])
+                self.assertIn("without mixed dimensions", result["error"])
+
+    async def test_no_focused_goal_still_persists_and_reviews_report(self):
+        result = await agent(
+            task_brief=BRIEF,
+            context=self.context(goal_id=None),
+        )
+        parent = await self.store.get_trace(self.root_id)
+        entry = ensure_task_protocol(parent.context)["pending_reviews"][
+            result["sub_trace_id"]
+        ]
+        self.assertIsNone(entry["goal_id"])
+        reviewed = await review_task_result(
+            child_trace_id=result["sub_trace_id"],
+            decision="ASCEND",
+            reason="Standalone delegation is complete",
+            context=self.context(goal_id=None),
+        )
+        self.assertEqual("completed", reviewed["status"])
+
+    async def test_accept_refine_approves_exactly_one_next_child(self):
+        first = await agent(task_brief=BRIEF, context=self.context())
+        next_brief = {
+            "objective": "check the independent gap",
+            "completion_criteria": ["close the gap"],
+        }
+        parent = await self.store.get_trace(self.root_id)
+        ensure_task_protocol(parent.context)["protocol_correction_attempts"] = 2
+        await self.store.update_trace(self.root_id, context=parent.context)
+        reviewed = await review_task_result(
+            child_trace_id=first["sub_trace_id"],
+            decision="ACCEPT_REFINE",
+            reason="The gap belongs to this parent",
+            approved_next_task=json.dumps(next_brief),
+            context=self.context(),
+        )
+        self.assertEqual("completed", reviewed["status"])
+        parent = await self.store.get_trace(self.root_id)
+        self.assertEqual(
+            0,
+            ensure_task_protocol(parent.context)["protocol_correction_attempts"],
+        )
+        schemas = AgentRunner(
+            tool_registry=get_tool_registry()
+        )._get_runtime_tool_schemas(RunConfig(), parent)
+        self.assertEqual(
+            {"agent"},
+            {schema["function"]["name"] for schema in schemas},
+        )
+
+        wrong = await agent(task_brief=BRIEF, context=self.context())
+        self.assertEqual("failed", wrong["status"])
+        self.assertIn("approved next task", wrong["error"])
+
+        second = await agent(task_brief=next_brief, context=self.context())
+        self.assertEqual("completed", second["status"])
+        self.assertEqual(2, len(await self.children()))
+        parent = await self.store.get_trace(self.root_id)
+        self.assertEqual([], ensure_task_protocol(parent.context)["next_actions"])
+
+    async def test_batch_reports_are_reviewed_one_by_one(self):
+        briefs = [
+            BRIEF,
+            {
+                "objective": "research second question",
+                "completion_criteria": ["provide second conclusion"],
+            },
+        ]
+        result = await agent(task_brief=briefs, context=self.context())
+        child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
+        self.assertEqual(2, len(result["task_reports"]))
+
+        premature = await review_task_result(
+            child_trace_id=child_ids[0],
+            decision="ASCEND",
+            reason="Cannot finish while another report is pending",
+            context=self.context(),
+        )
+        self.assertEqual("failed", premature["status"])
+        self.assertIn("final pending report", premature["error"])
+
+        first_review = await review_task_result(
+            child_trace_id=child_ids[0],
+            decision="REJECT_REFINE",
+            reason="No extra task needed for this report",
+            context=self.context(),
+        )
+        self.assertEqual(1, first_review["pending_review_count"])
+        tree = await self.store.get_goal_tree(self.root_id)
+        self.assertEqual("pending_review", tree.find("1").status)
+
+        final_review = await review_task_result(
+            child_trace_id=child_ids[1],
+            decision="ASCEND",
+            reason="All completion criteria are satisfied",
+            context=self.context(),
+        )
+        self.assertEqual(0, final_review["pending_review_count"])
+        tree = await self.store.get_goal_tree(self.root_id)
+        self.assertEqual("completed", tree.find("1").status)
+
+        duplicate = await review_task_result(
+            child_trace_id=child_ids[1],
+            decision="ASCEND",
+            reason="Duplicate review must fail",
+            context=self.context(),
+        )
+        self.assertEqual("failed", duplicate["status"])
+        self.assertIn("no pending TaskReport", duplicate["error"])
+
+    async def test_terminal_review_waits_for_queued_next_action(self):
+        result = await agent(
+            task_brief=[
+                BRIEF,
+                {
+                    "objective": "second batch child",
+                    "completion_criteria": ["finish the second child"],
+                },
+            ],
+            context=self.context(),
+        )
+        child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
+        next_brief = {
+            "objective": "approved follow-up",
+            "completion_criteria": ["finish the approved follow-up"],
+        }
+        accepted = await review_task_result(
+            child_trace_id=child_ids[0],
+            decision="ACCEPT_REFINE",
+            reason="A separate follow-up is required",
+            approved_next_task=next_brief,
+            context=self.context(),
+        )
+        self.assertEqual("completed", accepted["status"])
+
+        blocked = await review_task_result(
+            child_trace_id=child_ids[1],
+            decision="ASCEND",
+            reason="Cannot ascend before the accepted follow-up",
+            context=self.context(),
+        )
+        self.assertEqual("failed", blocked["status"])
+        self.assertIn("approved next action", blocked["error"])
+        tree = await self.store.get_goal_tree(self.root_id)
+        self.assertEqual("pending_review", tree.find("1").status)
+
+        final_non_terminal = await review_task_result(
+            child_trace_id=child_ids[1],
+            decision="REJECT_REFINE",
+            reason="This report adds no other follow-up",
+            context=self.context(),
+        )
+        self.assertEqual("completed", final_non_terminal["status"])
+        tree = await self.store.get_goal_tree(self.root_id)
+        self.assertEqual("in_progress", tree.find("1").status)
+
+    async def test_runner_failure_overrides_stale_satisfied_report(self):
+        self.runner = FailedAfterReportingRunner(self.store)
+        result = await agent(task_brief=BRIEF, context=self.context())
+        self.assertEqual("failed", result["status"])
+        self.assertEqual("protocol_error", result["task_report"]["outcome"])
+
+        child = await self.store.get_trace(result["sub_trace_id"])
+        child_state = ensure_task_protocol(child.context)
+        self.assertEqual(1, len(child_state["report_history"]))
+        self.assertEqual("satisfied", child_state["report_history"][0]["outcome"])
+
+        invalid_ascend = await review_task_result(
+            child_trace_id=result["sub_trace_id"],
+            decision="ASCEND",
+            reason="A failed execution cannot ascend",
+            context=self.context(),
+        )
+        self.assertEqual("failed", invalid_ascend["status"])
+        self.assertIn("only allow", invalid_ascend["error"])
+
+    async def test_review_rejects_wrong_owner_even_if_report_is_injected(self):
+        first = await agent(task_brief=BRIEF, context=self.context())
+        child_id = first["sub_trace_id"]
+        await self.store.update_trace(child_id, uid="another-user")
+
+        rejected = await review_task_result(
+            child_trace_id=child_id,
+            decision="ASCEND",
+            reason="Owner mismatch must be rejected",
+            context=self.context(),
+        )
+        self.assertEqual("failed", rejected["status"])
+        self.assertIn("same owner", rejected["error"])
+
+    async def test_report_identity_must_match_direct_child(self):
+        self.runner = ForgedIdentityRunner(self.store)
+        result = await agent(task_brief=BRIEF, context=self.context())
+        child_id = result["sub_trace_id"]
+        self.assertEqual("protocol_error", result["task_report"]["outcome"])
+        self.assertEqual(child_id, result["task_report"]["child_trace_id"])
+
+        parent = await self.store.get_trace(self.root_id)
+        pending_report = ensure_task_protocol(parent.context)["pending_reviews"][
+            child_id
+        ]["task_report"]
+        pending_report["child_trace_id"] = "corrupted-after-recording"
+        await self.store.update_trace(self.root_id, context=parent.context)
+
+        rejected = await review_task_result(
+            child_trace_id=child_id,
+            decision="FAIL",
+            reason="Corrupted identity cannot be reviewed",
+            context=self.context(),
+        )
+        self.assertEqual("failed", rejected["status"])
+        self.assertIn("does not match", rejected["error"])
+
+    async def test_remote_agent_keeps_legacy_task_interface(self):
+        remote_result = {
+            "status": "completed",
+            "summary": "remote result",
+        }
+        with patch(
+            "cyber_agent.tools.builtin.subagent._run_remote_agent",
+            new=AsyncMock(return_value=remote_result),
+        ) as remote_call:
+            result = await agent(
+                task="remote free text",
+                agent_type="remote_librarian",
+            )
+        self.assertEqual(remote_result, result)
+        self.assertEqual("remote free text", remote_call.await_args.kwargs["task"])
+
+    async def test_revise_child_reuses_trace_and_preserves_report_history(self):
+        first = await agent(task_brief=BRIEF, context=self.context())
+        child_id = first["sub_trace_id"]
+        reviewed = await review_task_result(
+            child_trace_id=child_id,
+            decision="REVISE_CHILD",
+            reason="Please strengthen the evidence",
+            context=self.context(),
+        )
+        self.assertEqual("completed", reviewed["status"])
+
+        wrong = await agent(
+            task_brief={
+                "objective": "silently replace the reviewed task",
+                "completion_criteria": ["this must be rejected"],
+            },
+            continue_from=child_id,
+            context=self.context(),
+        )
+        self.assertEqual("failed", wrong["status"])
+        self.assertIn("approved revision", wrong["error"])
+
+        revised = await agent(
+            task_brief=BRIEF,
+            continue_from=child_id,
+            context=self.context(),
+        )
+        self.assertEqual(child_id, revised["sub_trace_id"])
+        self.assertTrue(revised["continue_from"])
+        self.assertEqual(1, len(await self.children()))
+        child = await self.store.get_trace(child_id)
+        self.assertEqual(1, len(ensure_task_protocol(child.context)["report_history"]))
+        self.assertEqual(
+            TaskBrief.model_validate(BRIEF).model_dump(),
+            ensure_task_protocol(child.context)["task_brief"],
+        )
+
+    async def test_review_cannot_reserve_more_children_than_quota(self):
+        briefs = [
+            {
+                "objective": f"batch child {index}",
+                "completion_criteria": [f"finish child {index}"],
+            }
+            for index in range(5)
+        ]
+        result = await agent(task_brief=briefs, context=self.context())
+        child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
+        next_brief = {
+            "objective": "one allowed reserved child",
+            "completion_criteria": ["use the final available slot"],
+        }
+        first = await review_task_result(
+            child_trace_id=child_ids[0],
+            decision="ACCEPT_REFINE",
+            reason="Reserve the sixth and final child slot",
+            approved_next_task=next_brief,
+            context=self.context(),
+        )
+        self.assertEqual("completed", first["status"])
+
+        overflow = await review_task_result(
+            child_trace_id=child_ids[1],
+            decision="DESCEND_AGAIN",
+            reason="A seventh child must not be approved",
+            approved_next_task={
+                "objective": "overflow child",
+                "completion_criteria": ["must never be scheduled"],
+            },
+            context=self.context(),
+        )
+        self.assertEqual("failed", overflow["status"])
+        self.assertIn("reserved=1", overflow["error"])
+
+        parent = await self.store.get_trace(self.root_id)
+        state = ensure_task_protocol(parent.context)
+        self.assertEqual(1, len(state["next_actions"]))
+        self.assertIn(child_ids[1], state["pending_reviews"])
+
+    async def test_recursive_goal_completion_does_not_cascade_or_select_sibling(self):
+        tree = GoalTree(mission="recursive")
+        parent = tree.add_goals(["parent"])[0]
+        first, second = tree.add_goals(["first", "second"], parent_id=parent.id)
+        tree.focus(parent.id)
+        tree.focus(first.id)
+
+        tree.complete(
+            first.id,
+            "done",
+            auto_advance=False,
+            cascade_parent=False,
+        )
+        self.assertEqual(parent.id, tree.current_id)
+        self.assertEqual("pending", second.status)
+        self.assertNotEqual("completed", parent.status)
+
+    async def test_rewind_before_review_restores_report_and_review_gate(self):
+        delegated = await agent(task_brief=BRIEF, context=self.context())
+        child_id = delegated["sub_trace_id"]
+        reviewed = await review_task_result(
+            child_trace_id=child_id,
+            decision="ASCEND",
+            reason="Initially accepted before rewind",
+            context={**self.context(), "sequence": 20},
+        )
+        self.assertEqual("completed", reviewed["status"])
+
+        for sequence in (1, 15):
+            await self.store.add_message(Message.create(
+                trace_id=self.root_id,
+                role="user",
+                sequence=sequence,
+                content=f"message-{sequence}",
+            ))
+        tree = await self.store.get_goal_tree(self.root_id)
+        runner = AgentRunner(
+            trace_store=self.store,
+            tool_registry=get_tool_registry(),
+        )
+        await runner._rewind(self.root_id, 15, tree)
+
+        parent = await self.store.get_trace(self.root_id)
+        state = ensure_task_protocol(parent.context)
+        self.assertIn(child_id, state["pending_reviews"])
+        self.assertEqual([], state["reviews"])
+        restored_tree = await self.store.get_goal_tree(self.root_id)
+        self.assertEqual("pending_review", restored_tree.find("1").status)
+        schemas = runner._get_runtime_tool_schemas(RunConfig(), parent)
+        self.assertEqual(
+            {"review_task_result"},
+            {schema["function"]["name"] for schema in schemas},
+        )
+
+    async def test_grandchild_report_is_reviewed_only_by_direct_child(self):
+        child_id = "manual-child"
+        child_context = {
+            "agent_mode": "recursive",
+            "agent_mode_revision": 2,
+            "agent_depth": 1,
+            "root_trace_id": self.root_id,
+            "created_by_tool": "agent",
+            "task_protocol": new_task_protocol(BRIEF),
+        }
+        await self.store.create_trace(Trace(
+            trace_id=child_id,
+            mode="agent",
+            task="child",
+            parent_trace_id=self.root_id,
+            uid="user-1",
+            model="fake-model",
+            context=child_context,
+        ))
+        child_tree = GoalTree(
+            mission="child",
+            goals=[Goal(id="1", description="delegate grandchild", status="in_progress")],
+            current_id="1",
+        )
+        await self.store.update_goal_tree(child_id, child_tree)
+
+        grandchild = await agent(
+            task_brief=BRIEF,
+            context=self.context(trace_id=child_id, tree=child_tree),
+        )
+        child = await self.store.get_trace(child_id)
+        child_state = ensure_task_protocol(child.context)
+        self.assertIn(grandchild["sub_trace_id"], child_state["pending_reviews"])
+        root = await self.store.get_trace(self.root_id)
+        self.assertEqual({}, ensure_task_protocol(root.context)["pending_reviews"])
+
+        blocked = await agent(
+            task_brief=BRIEF,
+            context=self.context(trace_id=child_id, tree=child_tree),
+        )
+        self.assertEqual("failed", blocked["status"])
+
+        reviewed = await review_task_result(
+            child_trace_id=grandchild["sub_trace_id"],
+            decision="ASCEND",
+            reason="Grandchild satisfied the child criteria",
+            context=self.context(trace_id=child_id, tree=child_tree),
+        )
+        self.assertEqual("completed", reviewed["status"])
+        submitted = await submit_task_report(
+            task_report=json.dumps(report_payload()),
+            context={"store": self.store, "trace_id": child_id, "sequence": 20},
+        )
+        self.assertEqual("completed", submitted["status"])
+
+    async def test_child_must_execute_approved_action_before_own_report(self):
+        child_id = "child-with-approved-action"
+        child_context = {
+            "agent_mode": "recursive",
+            "agent_mode_revision": 2,
+            "agent_depth": 1,
+            "root_trace_id": self.root_id,
+            "created_by_tool": "agent",
+            "task_protocol": new_task_protocol(BRIEF),
+        }
+        await self.store.create_trace(Trace(
+            trace_id=child_id,
+            mode="agent",
+            task="child",
+            parent_trace_id=self.root_id,
+            uid="user-1",
+            model="fake-model",
+            context=child_context,
+        ))
+        child_tree = GoalTree(
+            mission="child",
+            goals=[Goal(id="1", description="delegate", status="in_progress")],
+            current_id="1",
+        )
+        await self.store.update_goal_tree(child_id, child_tree)
+        child_tool_context = self.context(trace_id=child_id, tree=child_tree)
+
+        first = await agent(task_brief=BRIEF, context=child_tool_context)
+        next_brief = {
+            "objective": "check a second grandchild task",
+            "completion_criteria": ["provide a second checked conclusion"],
+        }
+        reviewed = await review_task_result(
+            child_trace_id=first["sub_trace_id"],
+            decision="ACCEPT_REFINE",
+            reason="A separate gap remains",
+            approved_next_task=next_brief,
+            context=child_tool_context,
+        )
+        self.assertEqual("completed", reviewed["status"])
+
+        blocked_report = await submit_task_report(
+            task_report=report_payload(),
+            context={"store": self.store, "trace_id": child_id, "sequence": 20},
+        )
+        self.assertEqual("failed", blocked_report["status"])
+        self.assertIn("approved next action", blocked_report["error"])
+
+        second = await agent(task_brief=next_brief, context=child_tool_context)
+        self.assertEqual("completed", second["status"])
+        child = await self.store.get_trace(child_id)
+        state = ensure_task_protocol(child.context)
+        self.assertEqual([], state["next_actions"])
+        self.assertIn(second["sub_trace_id"], state["pending_reviews"])
+
+    async def test_goal_tool_cannot_bypass_queued_next_action(self):
+        root = await self.store.get_trace(self.root_id)
+        state = ensure_task_protocol(root.context)
+        state["next_actions"] = [{
+            "decision": "ACCEPT_REFINE",
+            "child_trace_id": "reviewed-child",
+            "goal_id": "1",
+            "task_brief": BRIEF,
+            "created_at_sequence": 5,
+        }]
+        await self.store.update_trace(self.root_id, context=root.context)
+
+        result = await goal_tool(
+            tree=self.tree,
+            store=self.store,
+            trace_id=self.root_id,
+            done="attempt to bypass the approved action",
+        )
+        self.assertIn("已批准的下一步待执行", result)
+        persisted_tree = await self.store.get_goal_tree(self.root_id)
+        self.assertEqual("in_progress", persisted_tree.find("1").status)
+
+    async def test_failed_report_allows_only_revise_or_fail(self):
+        child_id = "failed-child"
+        failed_report = TaskReport(
+            child_trace_id=child_id,
+            **report_payload(outcome="failed"),
+        )
+        child_context = {
+            "agent_mode": "recursive",
+            "agent_mode_revision": 2,
+            "agent_depth": 1,
+            "root_trace_id": self.root_id,
+            "created_by_tool": "agent",
+            "task_protocol": new_task_protocol(BRIEF),
+        }
+        child_context["task_protocol"]["task_report"] = failed_report.model_dump()
+        await self.store.create_trace(Trace(
+            trace_id=child_id,
+            mode="agent",
+            task="failed",
+            parent_trace_id=self.root_id,
+            uid="user-1",
+            context=child_context,
+        ))
+        root = await self.store.get_trace(self.root_id)
+        state = ensure_task_protocol(root.context)
+        state["pending_reviews"][child_id] = {
+            "goal_id": "1",
+            "task_report": failed_report.model_dump(),
+            "received_at_sequence": 2,
+        }
+        await self.store.update_trace(self.root_id, context=root.context)
+
+        rejected = await review_task_result(
+            child_trace_id=child_id,
+            decision="ASCEND",
+            reason="incorrect",
+            context=self.context(),
+        )
+        self.assertEqual("failed", rejected["status"])
+        self.assertIn("only allow", rejected["error"])
+
+
+class RunnerCompletionGateTest(unittest.IsolatedAsyncioTestCase):
+    async def asyncSetUp(self):
+        self.temp_dir = tempfile.TemporaryDirectory()
+        self.store = FileSystemTraceStore(self.temp_dir.name)
+        self.trace_id = "gate-child"
+        await self.store.create_trace(Trace(
+            trace_id=self.trace_id,
+            mode="agent",
+            task="must report",
+            parent_trace_id="root",
+            uid="user-1",
+            context={
+                "agent_mode": "recursive",
+                "agent_mode_revision": 2,
+                "agent_depth": 1,
+                "root_trace_id": "root",
+                "created_by_tool": "agent",
+                "task_protocol": new_task_protocol(BRIEF),
+            },
+        ))
+        await self.store.update_goal_tree(self.trace_id, GoalTree(mission="must report"))
+
+    async def asyncTearDown(self):
+        self.temp_dir.cleanup()
+
+    async def test_missing_report_becomes_protocol_error_after_two_corrections(self):
+        calls = 0
+
+        async def llm_call(**kwargs):
+            nonlocal calls
+            calls += 1
+            return {
+                "content": "I am done",
+                "tool_calls": [],
+                "finish_reason": "stop",
+            }
+
+        runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
+        result = await runner.run_result(
+            messages=[],
+            config=RunConfig(
+                trace_id=self.trace_id,
+                max_iterations=8,
+                knowledge=KnowledgeConfig(
+                    enable_extraction=False,
+                    enable_completion_extraction=False,
+                    enable_injection=False,
+                ),
+            ),
+        )
+        self.assertEqual("failed", result["status"])
+        trace = await self.store.get_trace(self.trace_id)
+        report = ensure_task_protocol(trace.context)["task_report"]
+        self.assertEqual("protocol_error", report["outcome"])
+        self.assertGreaterEqual(calls, 3)
+
+    async def test_valid_report_allows_child_to_finish(self):
+        calls = 0
+
+        async def llm_call(**kwargs):
+            nonlocal calls
+            calls += 1
+            if calls == 1:
+                return {
+                    "content": "",
+                    "tool_calls": [{
+                        "id": "submit-1",
+                        "type": "function",
+                        "function": {
+                            "name": "submit_task_report",
+                            "arguments": json.dumps({
+                                "task_report": report_payload(),
+                            }),
+                        },
+                    }],
+                    "finish_reason": "tool_calls",
+                }
+            return {
+                "content": "Structured report submitted.",
+                "tool_calls": [],
+                "finish_reason": "stop",
+            }
+
+        runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
+        result = await runner.run_result(
+            messages=[],
+            config=RunConfig(
+                trace_id=self.trace_id,
+                max_iterations=4,
+                knowledge=KnowledgeConfig(
+                    enable_extraction=False,
+                    enable_completion_extraction=False,
+                    enable_injection=False,
+                ),
+            ),
+        )
+        self.assertEqual("completed", result["status"])
+        trace = await self.store.get_trace(self.trace_id)
+        self.assertEqual(
+            "satisfied",
+            ensure_task_protocol(trace.context)["task_report"]["outcome"],
+        )
+
+    async def test_lifecycle_tool_batch_is_rejected_before_execution(self):
+        root_id = "batch-gate-root"
+        await self.store.create_trace(Trace(
+            trace_id=root_id,
+            mode="agent",
+            task="root",
+            uid="user-1",
+            context={
+                "agent_mode": "recursive",
+                "agent_mode_revision": 2,
+                "agent_depth": 0,
+                "root_trace_id": root_id,
+                "task_protocol": new_task_protocol(),
+            },
+        ))
+        await self.store.update_goal_tree(root_id, GoalTree(mission="root"))
+        calls = 0
+
+        async def llm_call(**kwargs):
+            nonlocal calls
+            calls += 1
+            if calls == 1:
+                return {
+                    "content": "",
+                    "tool_calls": [
+                        {
+                            "id": "agent-1",
+                            "type": "function",
+                            "function": {
+                                "name": "agent",
+                                "arguments": json.dumps({
+                                    "task_brief": BRIEF,
+                                }),
+                            },
+                        },
+                        {
+                            "id": "context-1",
+                            "type": "function",
+                            "function": {
+                                "name": "get_current_context",
+                                "arguments": "{}",
+                            },
+                        },
+                    ],
+                    "finish_reason": "tool_calls",
+                }
+            return {
+                "content": "Corrected the invalid batch.",
+                "tool_calls": [],
+                "finish_reason": "stop",
+            }
+
+        runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
+        result = await runner.run_result(
+            messages=[],
+            config=RunConfig(
+                trace_id=root_id,
+                max_iterations=3,
+                knowledge=KnowledgeConfig(
+                    enable_extraction=False,
+                    enable_completion_extraction=False,
+                    enable_injection=False,
+                ),
+            ),
+        )
+        self.assertEqual("completed", result["status"])
+        children = await self.store.list_traces(parent_trace_id=root_id, limit=10)
+        self.assertEqual([], children)
+        tool_messages = [
+            message
+            for message in await self.store.get_trace_messages(root_id)
+            if message.role == "tool"
+        ]
+        self.assertEqual(2, len(tool_messages))
+        self.assertTrue(all(
+            "only tool call" in str(message.content)
+            for message in tool_messages
+        ))
+
+    async def test_rewind_clears_protocol_state_after_cutoff(self):
+        trace = await self.store.get_trace(self.trace_id)
+        state = ensure_task_protocol(trace.context)
+        report = TaskReport(
+            child_trace_id=self.trace_id,
+            **report_payload(),
+        ).model_dump()
+        state["task_report"] = report
+        state["task_report_submitted_at_sequence"] = 5
+        state["pending_reviews"] = {
+            "grandchild": {
+                "goal_id": "1",
+                "task_report": report,
+                "received_at_sequence": 6,
+            }
+        }
+        state["reviews"] = [{"reviewed_at_sequence": 7}]
+        state["next_actions"] = [{"created_at_sequence": 8}]
+        await self.store.update_trace(self.trace_id, context=trace.context)
+
+        tree = GoalTree(
+            mission="rewind",
+            goals=[Goal(id="1", description="active", status="pending_review")],
+            current_id="1",
+        )
+        await self.store.update_goal_tree(self.trace_id, tree)
+        for sequence in (1, 2):
+            await self.store.add_message(Message.create(
+                trace_id=self.trace_id,
+                role="user",
+                sequence=sequence,
+                content=f"message-{sequence}",
+            ))
+
+        runner = AgentRunner(trace_store=self.store)
+        cutoff = await runner._rewind(self.trace_id, 2, tree)
+        self.assertEqual(3, cutoff)
+        rewound = await self.store.get_trace(self.trace_id)
+        state = ensure_task_protocol(rewound.context)
+        self.assertIsNone(state["task_report"])
+        self.assertEqual(1, len(state["report_history"]))
+        self.assertEqual({}, state["pending_reviews"])
+        self.assertEqual([], state["reviews"])
+        self.assertEqual([], state["next_actions"])
+        rewound_tree = await self.store.get_goal_tree(self.trace_id)
+        self.assertEqual("pending", rewound_tree.find("1").status)
+
+    async def test_rewind_restores_preserved_pending_review_projection(self):
+        trace = await self.store.get_trace(self.trace_id)
+        state = ensure_task_protocol(trace.context)
+        report = TaskReport(
+            child_trace_id="preserved-grandchild",
+            **report_payload(),
+        ).model_dump()
+        state["pending_reviews"] = {
+            "preserved-grandchild": {
+                "goal_id": "1",
+                "task_report": report,
+                "received_at_sequence": 2,
+            }
+        }
+        await self.store.update_trace(self.trace_id, context=trace.context)
+        tree = GoalTree(
+            mission="rewind",
+            goals=[Goal(id="1", description="review", status="pending_review")],
+            current_id="1",
+        )
+        await self.store.update_goal_tree(self.trace_id, tree)
+        for sequence in (1, 2):
+            await self.store.add_message(Message.create(
+                trace_id=self.trace_id,
+                role="user",
+                sequence=sequence,
+                content=f"message-{sequence}",
+            ))
+
+        runner = AgentRunner(trace_store=self.store)
+        await runner._rewind(self.trace_id, 2, tree)
+        rewound = await self.store.get_trace(self.trace_id)
+        self.assertIn(
+            "preserved-grandchild",
+            ensure_task_protocol(rewound.context)["pending_reviews"],
+        )
+        rewound_tree = await self.store.get_goal_tree(self.trace_id)
+        self.assertEqual("pending_review", rewound_tree.find("1").status)
+
+    async def test_root_cannot_finish_with_unconsumed_next_action(self):
+        root_id = "next-action-gate-root"
+        state = new_task_protocol()
+        state["next_actions"] = [{
+            "decision": "ACCEPT_REFINE",
+            "child_trace_id": "reviewed-child",
+            "goal_id": None,
+            "task_brief": BRIEF,
+            "created_at_sequence": 1,
+        }]
+        await self.store.create_trace(Trace(
+            trace_id=root_id,
+            mode="agent",
+            task="root",
+            uid="user-1",
+            context={
+                "agent_mode": "recursive",
+                "agent_mode_revision": 2,
+                "agent_depth": 0,
+                "root_trace_id": root_id,
+                "task_protocol": state,
+            },
+        ))
+        await self.store.update_goal_tree(root_id, GoalTree(mission="root"))
+        calls = 0
+
+        async def llm_call(**kwargs):
+            nonlocal calls
+            calls += 1
+            return {
+                "content": "Trying to finish without the approved action.",
+                "tool_calls": [],
+                "finish_reason": "stop",
+            }
+
+        runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
+        result = await runner.run_result(
+            messages=[],
+            config=RunConfig(
+                trace_id=root_id,
+                max_iterations=5,
+                knowledge=KnowledgeConfig(
+                    enable_extraction=False,
+                    enable_completion_extraction=False,
+                    enable_injection=False,
+                ),
+            ),
+        )
+        self.assertEqual("failed", result["status"])
+        self.assertEqual(3, calls)
+        root = await self.store.get_trace(root_id)
+        self.assertEqual(1, len(ensure_task_protocol(root.context)["next_actions"]))
+
+
+class RuntimeProtocolSchemaTest(unittest.TestCase):
+    def setUp(self):
+        self.runner = AgentRunner(tool_registry=get_tool_registry())
+
+    def names(self, schemas):
+        return {schema["function"]["name"] for schema in schemas}
+
+    def test_legacy_and_recursive_runtime_schemas_are_isolated(self):
+        legacy = Trace(
+            trace_id="legacy",
+            mode="agent",
+            context={"agent_mode": "legacy", "agent_mode_revision": 1},
+        )
+        legacy_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), legacy)
+        self.assertNotIn("submit_task_report", self.names(legacy_schemas))
+        self.assertNotIn("review_task_result", self.names(legacy_schemas))
+        legacy_agent = next(
+            item for item in legacy_schemas if item["function"]["name"] == "agent"
+        )
+        properties = legacy_agent["function"]["parameters"]["properties"]
+        self.assertIn("task", properties)
+        self.assertNotIn("task_brief", properties)
+
+        child = Trace(
+            trace_id="child",
+            mode="agent",
+            parent_trace_id="root",
+            context={
+                "agent_mode": "recursive",
+                "agent_mode_revision": 2,
+                "task_protocol": new_task_protocol(BRIEF),
+            },
+        )
+        child_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), child)
+        self.assertIn("submit_task_report", self.names(child_schemas))
+        self.assertNotIn("review_task_result", self.names(child_schemas))
+
+        state = ensure_task_protocol(child.context)
+        state["pending_reviews"]["grandchild"] = {"task_report": {}}
+        review_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), child)
+        self.assertEqual({"review_task_result"}, self.names(review_schemas))
+
+    def test_protocol_tools_publish_nested_object_schemas(self):
+        schemas = {
+            schema["function"]["name"]: schema
+            for schema in get_tool_registry().get_schemas([
+                "submit_task_report",
+                "review_task_result",
+            ])
+        }
+        report_schema = schemas["submit_task_report"]["function"]["parameters"]
+        task_report = report_schema["properties"]["task_report"]
+        self.assertEqual("object", task_report["type"])
+        self.assertIn("validation", task_report["properties"])
+        self.assertIn("next_step_suggestion", task_report["properties"])
+
+        review_schema = schemas["review_task_result"]["function"]["parameters"]
+        self.assertEqual(
+            [
+                "ACCEPT_REFINE",
+                "REJECT_REFINE",
+                "REVISE_CHILD",
+                "DESCEND_AGAIN",
+                "ASCEND",
+                "FAIL",
+            ],
+            review_schema["properties"]["decision"]["enum"],
+        )
+        self.assertEqual(
+            "object",
+            review_schema["properties"]["approved_next_task"]["type"],
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()