Просмотр исходного кода

test(m2): 增加 TaskProgress 复杂场景和 revision 3 协议链路

覆盖严格模型、全局 item_id、状态关联字段、并发 stale revision、Brief 换版空快照、分支回溯、崩溃恢复、孤立报告绑定与 ContextRef 所有权门禁。

升级五层递归、根验证、父子审核、重规划、预算、停止传播和工具伪造场景,使所有真实 Recursive 测试显式执行 planning 到 ready_to_submit 的 revision 3 合同。

验证 ValidationPlan 对精确 Progress revision 的哈希绑定,并保留历史 revision 作为审计记录而不复活已回溯分支。
SamLee 12 часов назад
Родитель
Сommit
96ed1fe88c

+ 37 - 5
tests/test_recursive_context_integration.py

@@ -84,6 +84,32 @@ def last_tool_name(messages):
     return None
 
 
+def has_tool_result(messages, name):
+    return any(
+        message.get("role") == "tool" and message.get("name") == name
+        for message in messages
+    )
+
+
+def ready_progress_call(call_index):
+    return tool_call(
+        "update_task_progress",
+        {
+            "expected_revision": 1,
+            "progress": {
+                "phase": "ready_to_submit",
+                "questions": [],
+                "blockers": [],
+                "findings": [],
+                "hypotheses": [],
+                "work_items": [],
+                "decision_rationale": "The delegated evidence chain is complete.",
+            },
+        },
+        call_index,
+    )
+
+
 def pending_child_id(messages):
     for message in reversed(messages):
         if message.get("role") != "tool":
@@ -299,6 +325,8 @@ class FiveLevelRecursiveContextTest(unittest.IsolatedAsyncioTestCase):
                         "cost": 0.0001,
                     }
                 elif last_tool == "review_task_result":
+                    response = ready_progress_call(call_count)
+                elif last_tool == "update_task_progress":
                     if depth == 0:
                         response = {
                             "content": (
@@ -326,11 +354,14 @@ class FiveLevelRecursiveContextTest(unittest.IsolatedAsyncioTestCase):
                         call_count,
                     )
                 elif depth == 5:
-                    response = tool_call(
-                        "submit_task_report",
-                        {"task_report": self._report(depth)},
-                        call_count,
-                    )
+                    if has_tool_result(messages, "update_task_progress"):
+                        response = tool_call(
+                            "submit_task_report",
+                            {"task_report": self._report(depth)},
+                            call_count,
+                        )
+                    else:
+                        response = ready_progress_call(call_count)
                 else:
                     next_depth = depth + 1
                     response = tool_call(
@@ -354,6 +385,7 @@ class FiveLevelRecursiveContextTest(unittest.IsolatedAsyncioTestCase):
                     "submit_task_report",
                     "review_task_result",
                     "read_context_ref",
+                    "update_task_progress",
                 ],
                 tool_groups=[],
                 enable_research_flow=False,

+ 6 - 1
tests/test_recursive_context_policy.py

@@ -33,6 +33,7 @@ from cyber_agent.core.context_policy import (
 from cyber_agent.core.task_protocol import (
     ContextRef,
     TaskBrief,
+    initialize_task_progress,
     new_task_protocol,
     replace_task_brief,
 )
@@ -67,12 +68,16 @@ def brief(level: int, **updates) -> TaskBrief:
 def anchored_context(task_brief: TaskBrief | None = None) -> dict:
     context = {
         "agent_mode": "recursive",
-        "agent_mode_revision": 2,
+        "agent_mode_revision": 3,
         "root_trace_id": "root",
         "agent_depth": 0,
         "task_protocol": new_task_protocol(task_brief),
     }
     anchor = persist_root_task_anchor(context, ROOT_ANCHOR)
+    initialize_task_progress(
+        context["task_protocol"],
+        root_task_anchor_hash=context.get("root_task_anchor_hash"),
+    )
     replace_context_access(
         context,
         [],

+ 10 - 3
tests/test_recursive_replan_context.py

@@ -16,6 +16,7 @@ from cyber_agent.core.task_protocol import (
     Validation,
     NextStepSuggestion,
     ensure_task_protocol,
+    initialize_task_progress,
     new_task_protocol,
     pending_review_entry,
     rebuild_pending_replans,
@@ -49,6 +50,12 @@ ROOT_ANCHOR = {
 
 def with_root_anchor(context):
     persist_root_task_anchor(context, ROOT_ANCHOR)
+    state = ensure_task_protocol(context)
+    if state.get("task_progress_head_revision") is None:
+        initialize_task_progress(
+            state,
+            root_task_anchor_hash=context["root_task_anchor_hash"],
+        )
     return context
 
 
@@ -159,7 +166,7 @@ class ReplanCurrentTest(unittest.IsolatedAsyncioTestCase):
             model="fake",
             context=with_root_anchor({
                 "agent_mode": "recursive",
-                "agent_mode_revision": 2,
+                "agent_mode_revision": 3,
                 "agent_depth": 1,
                 "root_trace_id": self.parent_id,
                 "task_protocol": new_task_protocol(BRIEF),
@@ -192,7 +199,7 @@ class ReplanCurrentTest(unittest.IsolatedAsyncioTestCase):
             model="fake",
             context=with_root_anchor({
                 "agent_mode": "recursive",
-                "agent_mode_revision": 2,
+                "agent_mode_revision": 3,
                 "agent_depth": 2,
                 "root_trace_id": self.parent_id,
                 "task_protocol": new_task_protocol(BRIEF),
@@ -315,7 +322,7 @@ class ReplanCurrentTest(unittest.IsolatedAsyncioTestCase):
             model="fake",
             context={
                 "agent_mode": "recursive",
-                "agent_mode_revision": 2,
+                "agent_mode_revision": 3,
             },
         ))
         parent = await self.store.get_trace(self.parent_id)

+ 83 - 11
tests/test_recursive_runtime_extensions.py

@@ -12,11 +12,16 @@ from cyber_agent.core.resource_budget import (
     ResourceBudgetController,
 )
 from cyber_agent.core.runner import AgentRunner, RunConfig
-from cyber_agent.core.task_protocol import ensure_task_protocol, new_task_protocol
+from cyber_agent.core.task_protocol import (
+    ensure_task_protocol,
+    initialize_task_progress,
+    new_task_protocol,
+)
 from cyber_agent.core.context_policy import persist_root_task_anchor
 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 update_task_progress
 from cyber_agent.tools.registry import ToolRegistry
 from cyber_agent.trace.models import Trace
 from cyber_agent.trace.store import FileSystemTraceStore
@@ -52,6 +57,12 @@ def knowledge_disabled():
 
 def with_root_anchor(context):
     persist_root_task_anchor(context, ROOT_ANCHOR)
+    state = ensure_task_protocol(context)
+    if state.get("task_progress_head_revision") is None:
+        initialize_task_progress(
+            state,
+            root_task_anchor_hash=context["root_task_anchor_hash"],
+        )
     return context
 
 
@@ -85,6 +96,39 @@ def is_validator_call(messages):
     )
 
 
+def has_tool_result(messages, name):
+    return any(
+        message.get("role") == "tool" and message.get("name") == name
+        for message in messages
+    )
+
+
+def ready_progress_response(call_id="progress-ready"):
+    return {
+        "content": "",
+        "tool_calls": [{
+            "id": call_id,
+            "type": "function",
+            "function": {
+                "name": "update_task_progress",
+                "arguments": json.dumps({
+                    "expected_revision": 1,
+                    "progress": {
+                        "phase": "ready_to_submit",
+                        "questions": [],
+                        "blockers": [],
+                        "findings": [],
+                        "hypotheses": [],
+                        "work_items": [],
+                        "decision_rationale": "The fixture completed the checked work.",
+                    },
+                }),
+            },
+        }],
+        "finish_reason": "tool_calls",
+    }
+
+
 def validator_response(messages, outcome="passed"):
     packet = next(
         json.loads(message["content"])
@@ -132,7 +176,7 @@ class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
 
     def config(self, **overrides):
         values = {
-            "tools": [],
+            "tools": ["update_task_progress"],
             "tool_groups": [],
             "enable_research_flow": False,
             "root_task_anchor": ROOT_ANCHOR,
@@ -148,6 +192,8 @@ class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
             calls.append(kwargs)
             if is_validator_call(kwargs["messages"]):
                 return validator_response(kwargs["messages"])
+            if not has_tool_result(kwargs["messages"], "update_task_progress"):
+                return ready_progress_response()
             return {
                 "content": "checked final answer",
                 "tool_calls": [],
@@ -182,9 +228,9 @@ class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
         )
         usage = await ResourceBudgetController(self.store).get_usage(root.trace_id)
         self.assertEqual(1, usage.total_agents)
-        self.assertEqual(2, usage.llm_calls)
+        self.assertEqual(3, usage.llm_calls)
         self.assertEqual(14, usage.total_tokens)
-        self.assertEqual(2, len(calls))
+        self.assertEqual(3, len(calls))
 
     async def test_root_gets_one_revision_then_fails_on_second_rejection(self):
         ordinary_calls = 0
@@ -195,6 +241,8 @@ class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
             if is_validator_call(kwargs["messages"]):
                 validation_calls += 1
                 return validator_response(kwargs["messages"], outcome="failed")
+            if not has_tool_result(kwargs["messages"], "update_task_progress"):
+                return ready_progress_response()
             ordinary_calls += 1
             return {
                 "content": f"candidate-{ordinary_calls}",
@@ -233,6 +281,8 @@ class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
                     kwargs["messages"],
                     outcome=next(validation_outcomes),
                 )
+            if not has_tool_result(kwargs["messages"], "update_task_progress"):
+                return ready_progress_response()
             ordinary_calls += 1
             if ordinary_calls == 2:
                 second_candidate_messages = kwargs["messages"]
@@ -292,6 +342,11 @@ class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
             return {"verified": "persisted-tool-result"}
 
         registry.register(evidence_tool, groups=["test"])
+        registry.register(
+            update_task_progress,
+            hidden_params=["context"],
+            groups=["core"],
+        )
         ordinary_calls = 0
         validator_packet = None
 
@@ -319,6 +374,8 @@ class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
                     }],
                     "finish_reason": "tool_calls",
                 }
+            if not has_tool_result(kwargs["messages"], "update_task_progress"):
+                return ready_progress_response()
             return {
                 "content": "candidate-from-real-tool-result",
                 "tool_calls": [],
@@ -333,7 +390,7 @@ class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
         with recursive_env():
             result = await runner.run_result(
                 [{"role": "user", "content": "use the evidence tool"}],
-                self.config(tools=["evidence_tool"]),
+                self.config(tools=["evidence_tool", "update_task_progress"]),
             )
 
         self.assertEqual("completed", result["status"])
@@ -498,6 +555,8 @@ class RecursiveBudgetIntegrationTest(unittest.IsolatedAsyncioTestCase):
                     }],
                     "finish_reason": "tool_calls",
                 }
+            if not has_tool_result(kwargs["messages"], "update_task_progress"):
+                return ready_progress_response()
             return {
                 "content": "root can still finish without a child",
                 "tool_calls": [],
@@ -510,7 +569,7 @@ class RecursiveBudgetIntegrationTest(unittest.IsolatedAsyncioTestCase):
             llm_call=llm_call,
         )
         config = RunConfig(
-            tools=["agent"],
+            tools=["agent", "update_task_progress"],
             tool_groups=[],
             enable_research_flow=False,
             root_task_anchor=ROOT_ANCHOR,
@@ -545,7 +604,7 @@ class RecursiveBudgetIntegrationTest(unittest.IsolatedAsyncioTestCase):
             model="fake",
             context=with_root_anchor({
                 "agent_mode": "recursive",
-                "agent_mode_revision": 2,
+                "agent_mode_revision": 3,
                 "agent_depth": 0,
                 "root_trace_id": root_id,
                 RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
@@ -599,7 +658,7 @@ class RecursiveBudgetIntegrationTest(unittest.IsolatedAsyncioTestCase):
             model="fake",
             context=with_root_anchor({
                 "agent_mode": "recursive",
-                "agent_mode_revision": 2,
+                "agent_mode_revision": 3,
                 "agent_depth": 0,
                 "root_trace_id": root_id,
                 RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
@@ -646,7 +705,7 @@ class RecursiveBudgetIntegrationTest(unittest.IsolatedAsyncioTestCase):
         budget = ResourceBudget()
         context = with_root_anchor({
             "agent_mode": "recursive",
-            "agent_mode_revision": 2,
+            "agent_mode_revision": 3,
             "agent_depth": 0,
             "root_trace_id": root_id,
             RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
@@ -730,6 +789,14 @@ class RecursiveLifecycleIntegrationTest(unittest.IsolatedAsyncioTestCase):
                 for message in messages
                 if message.get("role") == "user"
             )
+            if (
+                "update_task_progress" in names
+                and not has_tool_result(messages, "update_task_progress")
+                and ("# Task Brief" in user_text or root_reviewed)
+            ):
+                return ready_progress_response(
+                    "child-progress" if "# Task Brief" in user_text else "root-progress"
+                )
             if "submit_task_report" in names and not child_submitted:
                 child_submitted = True
                 return {
@@ -818,7 +885,12 @@ class RecursiveLifecycleIntegrationTest(unittest.IsolatedAsyncioTestCase):
             llm_call=llm_call,
         )
         config = RunConfig(
-            tools=["agent", "submit_task_report", "review_task_result"],
+            tools=[
+                "agent",
+                "update_task_progress",
+                "submit_task_report",
+                "review_task_result",
+            ],
             tool_groups=[],
             enable_research_flow=False,
             root_task_anchor=ROOT_ANCHOR,
@@ -867,7 +939,7 @@ class RecursiveLifecycleIntegrationTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual(child.trace_id, root_state["reviews"][0]["child_trace_id"])
         usage = await ResourceBudgetController(self.store).get_usage(root.trace_id)
         self.assertEqual(2, usage.total_agents)
-        self.assertEqual(7, usage.llm_calls)
+        self.assertEqual(9, usage.llm_calls)
 
     async def test_parent_stop_reaches_validator_after_child_runner_finished(self):
         root_id = "validator-cancel-root"

+ 1 - 0
tests/test_recursive_subtree_cancellation.py

@@ -291,6 +291,7 @@ class RecursiveSubtreeCancellationTest(unittest.IsolatedAsyncioTestCase):
     async def test_stop_api_uses_the_runner_that_started_the_root(self):
         trace_id = "example-root"
         actual_runner = AsyncMock()
+        actual_runner.trace_store = None
         actual_runner.stop.return_value = True
         run_api._running_runners[trace_id] = actual_runner
         try:

+ 37 - 1
tests/test_recursive_tool_capabilities.py

@@ -9,6 +9,7 @@ from cyber_agent.core.runner import AgentRunner, RunConfig
 from cyber_agent.core.task_protocol import ensure_task_protocol
 from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
 from cyber_agent.tools.builtin.subagent import agent
+from cyber_agent.tools.builtin.task_protocol import update_task_progress
 from cyber_agent.tools.registry import ToolRegistry
 from cyber_agent.trace.goal_models import GoalTree
 from cyber_agent.trace.models import Trace
@@ -234,6 +235,11 @@ class RecursiveRuntimeDispatchTest(unittest.IsolatedAsyncioTestCase):
 
                 registry.register(safe, groups=["test"])
                 registry.register(dangerous, groups=["test"])
+                registry.register(
+                    update_task_progress,
+                    hidden_params=["context"],
+                    groups=["core"],
+                )
                 llm_calls = 0
 
                 async def fake_llm(**kwargs):
@@ -273,6 +279,36 @@ class RecursiveRuntimeDispatchTest(unittest.IsolatedAsyncioTestCase):
                             }],
                             "finish_reason": "tool_calls",
                         }
+                    if not any(
+                        message.get("role") == "tool"
+                        and message.get("name") == "update_task_progress"
+                        for message in messages
+                    ):
+                        return {
+                            "content": "",
+                            "tool_calls": [{
+                                "id": "progress-ready",
+                                "type": "function",
+                                "function": {
+                                    "name": "update_task_progress",
+                                    "arguments": json.dumps({
+                                        "expected_revision": 1,
+                                        "progress": {
+                                            "phase": "ready_to_submit",
+                                            "questions": [],
+                                            "blockers": [],
+                                            "findings": [],
+                                            "hypotheses": [],
+                                            "work_items": [],
+                                            "decision_rationale": (
+                                                "The forged tool was rejected."
+                                            ),
+                                        },
+                                    }),
+                                },
+                            }],
+                            "finish_reason": "tool_calls",
+                        }
                     return {"content": "done", "finish_reason": "stop"}
 
                 runner = AgentRunner(
@@ -284,7 +320,7 @@ class RecursiveRuntimeDispatchTest(unittest.IsolatedAsyncioTestCase):
                     result = await runner.run_result(
                         messages=[{"role": "user", "content": "test"}],
                         config=RunConfig(
-                            tools=["safe"],
+                            tools=["safe", "update_task_progress"],
                             tool_groups=[],
                             parallel_tool_execution=parallel,
                             enable_research_flow=False,

+ 57 - 8
tests/test_recursive_validator_lifecycle_complex.py

@@ -23,6 +23,7 @@ from cyber_agent.core.runner import AgentRunner, RunConfig
 from cyber_agent.core.task_protocol import (
     TaskReport,
     ensure_task_protocol,
+    initialize_task_progress,
     new_task_protocol,
     pending_review_entry,
     replace_task_brief,
@@ -33,7 +34,10 @@ from cyber_agent.core.validation import (
     persist_validation_policy,
 )
 from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
-from cyber_agent.tools.builtin.task_protocol import review_task_result
+from cyber_agent.tools.builtin.task_protocol import (
+    review_task_result,
+    update_task_progress,
+)
 from cyber_agent.tools.models import ToolResult
 from cyber_agent.tools.registry import ToolRegistry
 from cyber_agent.trace.goal_models import Goal, GoalTree
@@ -173,13 +177,17 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
         budget = ResourceBudget()
         root_context = {
             "agent_mode": "recursive",
-            "agent_mode_revision": 2,
+            "agent_mode_revision": 3,
             "agent_depth": 0,
             "root_trace_id": self.root_id,
             RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
             "task_protocol": new_task_protocol(),
         }
         persist_root_task_anchor(root_context, ANCHOR)
+        initialize_task_progress(
+            ensure_task_protocol(root_context),
+            root_task_anchor_hash=root_context["root_task_anchor_hash"],
+        )
         persist_validation_policy(
             root_context,
             ValidationPolicy(),
@@ -203,7 +211,7 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
         for depth in range(1, 6):
             context = {
                 "agent_mode": "recursive",
-                "agent_mode_revision": 2,
+                "agent_mode_revision": 3,
                 "agent_depth": depth,
                 "root_trace_id": self.root_id,
                 "created_by_tool": "agent",
@@ -212,6 +220,10 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
                 ),
             }
             persist_root_task_anchor(context, ANCHOR)
+            initialize_task_progress(
+                ensure_task_protocol(context),
+                root_task_anchor_hash=context["root_task_anchor_hash"],
+            )
             await self.store.create_trace(Trace(
                 trace_id=self.ids[depth],
                 mode="agent",
@@ -283,7 +295,11 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
         parent_id = self.ids[4]
         first_report = report(child_id, "官方支持30%")
         child = await self.store.get_trace(child_id)
-        ensure_task_protocol(child.context)["task_report"] = first_report.model_dump()
+        child_state = ensure_task_protocol(child.context)
+        child_state["task_report"] = first_report.model_dump()
+        child_state["task_report_progress_revision"] = child_state[
+            "task_progress_head_revision"
+        ]
         await self.store.update_trace(child_id, context=child.context)
 
         first = await runner.validate_recursive_trace(
@@ -338,6 +354,9 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
         replace_task_brief(child_state, revised_brief, effective_at_sequence=12)
         second_report = report(child_id, "官方只支持12%")
         child_state["task_report"] = second_report.model_dump()
+        child_state["task_report_progress_revision"] = child_state[
+            "task_progress_head_revision"
+        ]
         parent_state["next_actions"].clear()
         await self.store.update_trace(child_id, context=child.context)
         await self.store.update_trace(parent_id, context=parent.context)
@@ -410,6 +429,12 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
         child_id = self.ids[5]
         payload = report(child_id, "官方只支持12%").model_dump(mode="json")
         payload["source_urls"] = ["https://official.example/12"]
+        child = await self.store.get_trace(child_id)
+        child_state = ensure_task_protocol(child.context)
+        child_state["task_report_progress_revision"] = child_state[
+            "task_progress_head_revision"
+        ]
+        await self.store.update_trace(child_id, context=child.context)
         task = asyncio.create_task(runner.validate_recursive_trace(
             child_id,
             task_report=payload,
@@ -480,6 +505,11 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
             )
 
         registry.register(publish_script)
+        registry.register(
+            update_task_progress,
+            hidden_params=["context"],
+            groups=["core"],
+        )
 
         async def llm_call(**kwargs):
             nonlocal root_validation_attempt, ordinary_calls
@@ -499,11 +529,27 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
                         )
                 return check_response(packet)
             ordinary_calls += 1
-            if ordinary_calls in {1, 3}:
+            if ordinary_calls in {1, 4}:
                 version = "v1" if ordinary_calls == 1 else "v2"
                 return tool_call("publish_script", {"version": version})
+            if ordinary_calls == 2:
+                return tool_call(
+                    "update_task_progress",
+                    {
+                        "expected_revision": 1,
+                        "progress": {
+                            "phase": "ready_to_submit",
+                            "questions": [],
+                            "blockers": [],
+                            "findings": [],
+                            "hypotheses": [],
+                            "work_items": [],
+                            "decision_rationale": "The root output is ready for validation.",
+                        },
+                    },
+                )
             return {
-                "content": scripts["v1" if ordinary_calls == 2 else "v2"],
+                "content": scripts["v1" if ordinary_calls == 3 else "v2"],
                 "tool_calls": [],
                 "finish_reason": "stop",
                 "prompt_tokens": 3,
@@ -529,6 +575,9 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
                 )
             child_report = report(trace_id, f"depth-{depth} 局部交付准确")
             child_state["task_report"] = child_report.model_dump()
+            child_state["task_report_progress_revision"] = child_state[
+                "task_progress_head_revision"
+            ]
             await self.store.update_trace(trace_id, context=child.context)
             run = await runner.validate_recursive_trace(
                 trace_id,
@@ -540,7 +589,7 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
             trace_id=self.root_id,
             model="fake",
             uid="user-1",
-            tools=["publish_script"],
+            tools=["publish_script", "update_task_progress"],
             tool_groups=[],
             enable_research_flow=False,
             knowledge=KnowledgeConfig(
@@ -568,7 +617,7 @@ class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
             [item["outcome"] for item in state["root_validation_history"]],
         )
         self.assertTrue(state["root_validation_passed"])
-        self.assertEqual(4, ordinary_calls)
+        self.assertEqual(5, ordinary_calls)
         self.assertEqual(2, root_validation_attempt)
         self.assertEqual([["v1"], ["v1", "v2"]], validated_versions)
         usage = await ResourceBudgetController(self.store).get_usage(self.root_id)

+ 420 - 0
tests/test_task_progress.py

@@ -0,0 +1,420 @@
+import asyncio
+import tempfile
+import unittest
+
+from pydantic import ValidationError
+
+from cyber_agent.core.context_policy import (
+    add_context_snapshot,
+    create_context_snapshot,
+    normalize_root_task_anchor,
+    persist_root_task_anchor,
+    replace_context_access,
+)
+from cyber_agent.core.resource_budget import (
+    RESOURCE_BUDGET_CONTEXT_KEY,
+    ResourceBudget,
+    ResourceBudgetController,
+)
+from cyber_agent.core.run_snapshot import (
+    RunConfigSnapshotV1,
+    persist_run_config_snapshot,
+)
+from cyber_agent.core.runner import AgentRunner, RunConfig
+from cyber_agent.core.task_protocol import (
+    Blocker,
+    Finding,
+    Hypothesis,
+    Question,
+    TaskProgress,
+    TaskBrief,
+    WorkItem,
+    append_task_progress_revision,
+    current_task_progress,
+    initialize_task_progress,
+    new_task_protocol,
+    replace_task_brief,
+    rewind_task_progress,
+    task_contract_ref,
+    task_progress_readiness_error,
+)
+from cyber_agent.core.task_protocol_service import TaskProtocolService
+from cyber_agent.core.validation import (
+    ValidationPolicy,
+    ValidatorSettings,
+    persist_validation_policy,
+)
+from cyber_agent.trace.api import get_task_progress, set_trace_store
+from cyber_agent.trace.models import Message, Trace
+from cyber_agent.trace.store import FileSystemTraceStore
+
+
+ANCHOR = {
+    "objective": "produce a verified result",
+    "completion_criteria": ["the result is verified"],
+    "constraints": ["cite evidence"],
+}
+
+
+def ready_progress() -> TaskProgress:
+    return TaskProgress(
+        phase="ready_to_submit",
+        questions=[Question(item_id="q1", text="What is true?", state="answered", answer="12%")],
+        findings=[Finding(item_id="f1", statement="The value is 12%", basis="official evidence")],
+        hypotheses=[Hypothesis(item_id="h1", statement="The value is 12%", state="supported", rationale="official evidence")],
+        work_items=[WorkItem(item_id="w1", description="verify value", state="done", result_summary="verified")],
+        decision_rationale="Use the supported value.",
+    )
+
+
+class TaskProgressModelTest(unittest.TestCase):
+    def test_strict_models_reject_duplicate_ids_and_forbidden_fields(self):
+        with self.assertRaises(ValidationError):
+            TaskProgress.model_validate({"phase": "completed"})
+        with self.assertRaises(ValidationError):
+            TaskProgress.model_validate({"phase": "planning", "target": "copy"})
+        with self.assertRaisesRegex(ValidationError, "globally unique"):
+            TaskProgress(
+                phase="planning",
+                questions=[Question(item_id="same", text="q")],
+                blockers=[Blocker(item_id="same", text="b")],
+            )
+
+    def test_item_state_requires_its_structured_result(self):
+        with self.assertRaisesRegex(ValidationError, "requires answer"):
+            Question(item_id="q", text="q", state="answered")
+        with self.assertRaisesRegex(ValidationError, "requires resolution"):
+            Blocker(item_id="b", text="b", state="resolved")
+        with self.assertRaisesRegex(ValidationError, "requires rationale"):
+            Hypothesis(item_id="h", statement="h", state="supported")
+        with self.assertRaisesRegex(ValidationError, "requires result_summary"):
+            WorkItem(item_id="w", description="w", state="done")
+
+    def test_readiness_is_derived_without_copying_terminal_status(self):
+        state = new_task_protocol()
+        initialize_task_progress(state, root_task_anchor_hash="a" * 64)
+        self.assertIn("ready_to_submit", task_progress_readiness_error(state))
+        append_task_progress_revision(
+            state,
+            ready_progress(),
+            expected_revision=1,
+            effective_at_sequence=3,
+            contract_ref=task_contract_ref(
+                state,
+                root_task_anchor_hash="a" * 64,
+            ),
+        )
+        self.assertIsNone(task_progress_readiness_error(state))
+
+    def test_rewind_follows_ancestry_not_largest_sequence(self):
+        state = new_task_protocol()
+        initialize_task_progress(state, root_task_anchor_hash="a" * 64)
+        contract = task_contract_ref(state, root_task_anchor_hash="a" * 64)
+        r2 = append_task_progress_revision(
+            state,
+            TaskProgress(phase="investigating"),
+            expected_revision=1,
+            effective_at_sequence=6,
+            contract_ref=contract,
+        )
+        self.assertEqual(2, r2.revision)
+        state["task_report_progress_revision"] = r2.revision
+        rewind_task_progress(state, 4)
+        self.assertIsNone(state["task_report_progress_revision"])
+        r3 = append_task_progress_revision(
+            state,
+            TaskProgress(phase="executing"),
+            expected_revision=1,
+            effective_at_sequence=10,
+            contract_ref=contract,
+        )
+        self.assertEqual(3, r3.revision)
+        self.assertEqual(1, r3.parent_revision)
+        rewind_task_progress(state, 7)
+        self.assertEqual(1, current_task_progress(state).revision)
+        self.assertEqual(3, len(state["task_progress_revisions"]))
+
+    def test_new_brief_starts_empty_planning_revision_without_erasing_history(self):
+        first_brief = TaskBrief(
+            objective="check the first claim",
+            reason="the parent needs evidence",
+            completion_criteria=["reach a checked conclusion"],
+            expected_outputs=["an evidence note"],
+        )
+        state = new_task_protocol(first_brief)
+        initialize_task_progress(state, root_task_anchor_hash="a" * 64)
+        append_task_progress_revision(
+            state,
+            TaskProgress(
+                phase="investigating",
+                findings=[Finding(
+                    item_id="old-finding",
+                    statement="old claim",
+                    basis="old evidence",
+                )],
+            ),
+            expected_revision=1,
+            effective_at_sequence=4,
+            contract_ref=task_contract_ref(state),
+        )
+
+        replace_task_brief(
+            state,
+            first_brief.model_copy(update={
+                "objective": "check the corrected claim",
+            }),
+            effective_at_sequence=8,
+        )
+
+        current = current_task_progress(state)
+        self.assertEqual(3, current.revision)
+        self.assertEqual(2, current.parent_revision)
+        self.assertEqual("planning", current.phase)
+        self.assertEqual([], current.findings)
+        self.assertEqual(2, current.task_contract_ref.task_brief_version)
+        self.assertEqual(3, len(state["task_progress_revisions"]))
+
+    def test_validation_plan_hash_binds_exact_progress_revision(self):
+        state = new_task_protocol()
+        first = initialize_task_progress(
+            state,
+            root_task_anchor_hash="a" * 64,
+        )
+        second = append_task_progress_revision(
+            state,
+            TaskProgress(phase="investigating"),
+            expected_revision=1,
+            effective_at_sequence=2,
+            contract_ref=task_contract_ref(
+                state,
+                root_task_anchor_hash="a" * 64,
+            ),
+        )
+        policy = ValidationPolicy()
+        common = {
+            "task_brief": None,
+            "task_brief_version": 0,
+            "root_task_anchor": ANCHOR,
+            "task_report": None,
+            "candidate_output": "candidate",
+            "evaluated_head_sequence": 3,
+            "materials": [],
+            "material_issues": [],
+            "model_by_scope": {"root": "fake"},
+            "root": True,
+        }
+
+        first_plan = policy.compile_plan(task_progress=first, **common)
+        second_plan = policy.compile_plan(task_progress=second, **common)
+
+        self.assertNotEqual(first_plan.plan_hash, second_plan.plan_hash)
+        self.assertEqual(1, first_plan.task_progress_revision)
+        self.assertEqual(2, second_plan.task_progress_revision)
+
+
+class TaskProtocolServiceTest(unittest.IsolatedAsyncioTestCase):
+    async def asyncSetUp(self):
+        self.temp_dir = tempfile.TemporaryDirectory()
+        self.store = FileSystemTraceStore(self.temp_dir.name)
+        context = {
+            "agent_mode": "recursive",
+            "agent_mode_revision": 3,
+            "root_trace_id": "root",
+            "task_protocol": new_task_protocol(),
+        }
+        anchor = normalize_root_task_anchor(ANCHOR)
+        persist_root_task_anchor(context, anchor)
+        initialize_task_progress(
+            context["task_protocol"],
+            root_task_anchor_hash=context["root_task_anchor_hash"],
+        )
+        replace_context_access(
+            context,
+            [],
+            root_task_anchor=anchor,
+            task_brief=None,
+        )
+        await self.store.create_trace(Trace(
+            trace_id="root",
+            mode="agent",
+            task="test",
+            uid="user-1",
+            context=context,
+        ))
+        self.service = TaskProtocolService(self.store)
+
+    async def prepare_resume_contract(self) -> tuple[AgentRunner, RunConfig]:
+        trace = await self.store.get_trace("root")
+        budget = ResourceBudget()
+        trace.context[RESOURCE_BUDGET_CONTEXT_KEY] = budget.to_dict()
+        persist_validation_policy(
+            trace.context,
+            ValidationPolicy(),
+            ValidatorSettings(),
+        )
+        config = RunConfig(
+            trace_id="root",
+            model="fake",
+            uid="user-1",
+            tools=[],
+            tool_groups=[],
+            enable_research_flow=False,
+        )
+        persist_run_config_snapshot(
+            trace.context,
+            RunConfigSnapshotV1.from_run_config(
+                config,
+                memory_identity=None,
+            ),
+        )
+        await self.store.update_trace(
+            "root",
+            context=trace.context,
+            status="stopped",
+        )
+        await ResourceBudgetController(self.store).initialize("root", budget)
+        return (
+            AgentRunner(trace_store=self.store, llm_call=lambda **_: None),
+            config,
+        )
+
+    async def asyncTearDown(self):
+        self.temp_dir.cleanup()
+
+    async def test_concurrent_same_revision_has_one_winner(self):
+        results = await asyncio.gather(
+            self.service.update_progress(
+                "root",
+                expected_revision=1,
+                progress=TaskProgress(phase="investigating"),
+                effective_at_sequence=2,
+            ),
+            self.service.update_progress(
+                "root",
+                expected_revision=1,
+                progress=TaskProgress(phase="executing"),
+                effective_at_sequence=2,
+            ),
+            return_exceptions=True,
+        )
+        self.assertEqual(1, sum(not isinstance(item, Exception) for item in results))
+        self.assertEqual(1, sum("stale" in str(item) for item in results if isinstance(item, Exception)))
+        trace = await self.store.get_trace("root")
+        state = trace.context["task_protocol"]
+        self.assertEqual(2, len(state["task_progress_revisions"]))
+
+    async def test_read_projection_hides_other_protocol_state(self):
+        set_trace_store(self.store)
+        response = await get_task_progress("root", include_history=False)
+        self.assertEqual(3, response.protocol_revision)
+        self.assertEqual(1, response.revision_count)
+        self.assertIsNone(response.revisions)
+        self.assertNotIn("pending_reviews", response.model_dump())
+
+    async def test_progress_rejects_context_ref_from_another_owner(self):
+        trace = await self.store.get_trace("root")
+        anchor = normalize_root_task_anchor(ANCHOR)
+        snapshot = create_context_snapshot(
+            kind="task_brief",
+            summary="authorized evidence",
+            source_trace_id="source",
+            root_trace_id="root",
+            uid="user-1",
+            content={"claim": "12%"},
+            granted_at_sequence=1,
+        )
+        ref = add_context_snapshot(
+            trace.context,
+            snapshot,
+            root_task_anchor=anchor,
+            task_brief=None,
+        )
+        await self.store.update_trace(
+            "root",
+            context=trace.context,
+            uid="another-user",
+        )
+
+        with self.assertRaisesRegex(Exception, "another tree or owner"):
+            await self.service.update_progress(
+                "root",
+                expected_revision=1,
+                progress=TaskProgress(
+                    phase="investigating",
+                    findings=[Finding(
+                        item_id="finding",
+                        statement="The value is 12%",
+                        basis="authorized evidence",
+                        context_refs=[ref],
+                    )],
+                ),
+                effective_at_sequence=2,
+            )
+        persisted = await self.store.get_trace("root")
+        self.assertEqual(
+            1,
+            persisted.context["task_protocol"]["task_progress_head_revision"],
+        )
+
+    async def test_resume_rolls_back_progress_without_persisted_tool_result(self):
+        runner, config = await self.prepare_resume_contract()
+        await self.store.add_message(Message.create(
+            trace_id="root",
+            role="assistant",
+            sequence=1,
+            content={"tool_calls": [{"function": {"name": "update_task_progress"}}]},
+        ))
+        await self.service.update_progress(
+            "root",
+            expected_revision=1,
+            progress=TaskProgress(phase="investigating"),
+            effective_at_sequence=2,
+        )
+
+        await runner._prepare_existing_trace(config)
+
+        persisted = await self.store.get_trace("root")
+        state = persisted.context["task_protocol"]
+        self.assertEqual(1, state["task_progress_head_revision"])
+        self.assertEqual(2, len(state["task_progress_revisions"]))
+
+    async def test_resume_rejects_report_bound_to_abandoned_progress_branch(self):
+        runner, config = await self.prepare_resume_contract()
+        trace = await self.store.get_trace("root")
+        state = trace.context["task_protocol"]
+        contract = task_contract_ref(
+            state,
+            root_task_anchor_hash=trace.context["root_task_anchor_hash"],
+        )
+        abandoned = append_task_progress_revision(
+            state,
+            TaskProgress(phase="investigating"),
+            expected_revision=1,
+            effective_at_sequence=1,
+            contract_ref=contract,
+        )
+        rewind_task_progress(state, 0)
+        active = append_task_progress_revision(
+            state,
+            TaskProgress(phase="executing"),
+            expected_revision=1,
+            effective_at_sequence=1,
+            contract_ref=contract,
+        )
+        state["task_report"] = {"summary": "corrupt binding"}
+        state["task_report_progress_revision"] = abandoned.revision
+        await self.store.update_trace("root", context=trace.context)
+        await self.store.add_message(Message.create(
+            trace_id="root",
+            role="assistant",
+            sequence=1,
+            content="persisted active branch",
+        ))
+
+        with self.assertRaisesRegex(ValueError, "outside the current revision ancestry"):
+            await runner._prepare_existing_trace(config)
+        self.assertEqual(3, active.revision)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 57 - 8
tests/test_task_protocol.py

@@ -22,19 +22,25 @@ from cyber_agent.core.context_policy import (
     persist_root_task_anchor,
 )
 from cyber_agent.core.task_protocol import (
+    TaskProgress,
     TaskBrief,
     TaskReport,
     TaskReview,
+    append_task_progress_revision,
     ensure_task_protocol,
+    initialize_task_progress,
     new_task_protocol,
     replace_task_brief,
+    task_contract_ref,
 )
+from cyber_agent.core.task_protocol_service import TaskProtocolService
 from cyber_agent.tools import get_tool_registry
 from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
 from cyber_agent.tools.builtin.subagent import _record_pending_task_reports, agent
 from cyber_agent.tools.builtin.task_protocol import (
     review_task_result,
     submit_task_report,
+    update_task_progress,
 )
 from cyber_agent.trace.goal_models import Goal, GoalTree
 from cyber_agent.trace.goal_tool import goal_tool
@@ -67,6 +73,24 @@ ROOT_ANCHOR = {
 
 def with_root_anchor(context):
     persist_root_task_anchor(context, ROOT_ANCHOR)
+    state = ensure_task_protocol(context)
+    if state.get("task_progress_head_revision") is None:
+        initialize_task_progress(
+            state,
+            root_task_anchor_hash=context.get("root_task_anchor_hash"),
+        )
+    current = state.get("task_progress_head_revision")
+    if current == 1:
+        append_task_progress_revision(
+            state,
+            TaskProgress(phase="ready_to_submit"),
+            expected_revision=current,
+            effective_at_sequence=0,
+            contract_ref=task_contract_ref(
+                state,
+                root_task_anchor_hash=context.get("root_task_anchor_hash"),
+            ),
+        )
     return context
 
 
@@ -154,13 +178,13 @@ async def create_recursive_root(store, trace_id, *, state=None, uid="user-1"):
     budget = ResourceBudget()
     context = {
         "agent_mode": "recursive",
-        "agent_mode_revision": 2,
+        "agent_mode_revision": 3,
         "agent_depth": 0,
         "root_trace_id": trace_id,
         RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
         "task_protocol": state or new_task_protocol(),
     }
-    persist_root_task_anchor(context, ROOT_ANCHOR)
+    with_root_anchor(context)
     persist_validation_policy(
         context,
         ValidationPolicy(),
@@ -190,6 +214,7 @@ class FakeToolRegistry:
             "bash_command",
             "goal",
             "read_file",
+            "update_task_progress",
             "submit_task_report",
             "review_task_result",
             "read_context_ref",
@@ -283,6 +308,30 @@ class ReportingRunner:
 
     async def run_result(self, messages, config, on_event=None):
         self.configs.append(config)
+        trace = await self.store.get_trace(config.trace_id)
+        current_revision = ensure_task_protocol(trace.context)[
+            "task_progress_head_revision"
+        ]
+        progressed = await update_task_progress(
+            expected_revision=current_revision,
+            progress={
+                "phase": "ready_to_submit",
+                "questions": [],
+                "blockers": [],
+                "findings": [],
+                "hypotheses": [],
+                "work_items": [],
+                "decision_rationale": "The fixture completed its assigned work.",
+            },
+            context={
+                "store": self.store,
+                "task_protocol_service": TaskProtocolService(self.store),
+                "trace_id": config.trace_id,
+                "sequence": 2,
+            },
+        )
+        if progressed["status"] != "completed":
+            raise AssertionError(progressed)
         submitted = await submit_task_report(
             task_report=report_payload(),
             context={
@@ -764,7 +813,7 @@ class RecursiveTaskProtocolTest(unittest.IsolatedAsyncioTestCase):
     async def test_submit_and_review_reject_child_anchor_that_differs_from_root(self):
         child_context = with_root_anchor({
             "agent_mode": "recursive",
-            "agent_mode_revision": 2,
+            "agent_mode_revision": 3,
             "agent_depth": 1,
             "root_trace_id": self.root_id,
             "created_by_tool": "agent",
@@ -1024,7 +1073,7 @@ class RecursiveTaskProtocolTest(unittest.IsolatedAsyncioTestCase):
         child_id = "manual-child"
         child_context = with_root_anchor({
             "agent_mode": "recursive",
-            "agent_mode_revision": 2,
+            "agent_mode_revision": 3,
             "agent_depth": 1,
             "root_trace_id": self.root_id,
             "created_by_tool": "agent",
@@ -1079,7 +1128,7 @@ class RecursiveTaskProtocolTest(unittest.IsolatedAsyncioTestCase):
         child_id = "child-with-approved-action"
         child_context = with_root_anchor({
             "agent_mode": "recursive",
-            "agent_mode_revision": 2,
+            "agent_mode_revision": 3,
             "agent_depth": 1,
             "root_trace_id": self.root_id,
             "created_by_tool": "agent",
@@ -1165,7 +1214,7 @@ class RecursiveTaskProtocolTest(unittest.IsolatedAsyncioTestCase):
         )
         child_context = with_root_anchor({
             "agent_mode": "recursive",
-            "agent_mode_revision": 2,
+            "agent_mode_revision": 3,
             "agent_depth": 1,
             "root_trace_id": self.root_id,
             "created_by_tool": "agent",
@@ -1226,7 +1275,7 @@ class RunnerCompletionGateTest(unittest.IsolatedAsyncioTestCase):
             uid="user-1",
             context=with_root_anchor({
                 "agent_mode": "recursive",
-                "agent_mode_revision": 2,
+                "agent_mode_revision": 3,
                 "agent_depth": 1,
                 "root_trace_id": "root",
                 "created_by_tool": "agent",
@@ -1646,7 +1695,7 @@ class RuntimeProtocolSchemaTest(unittest.TestCase):
             parent_trace_id="root",
             context={
                 "agent_mode": "recursive",
-                "agent_mode_revision": 2,
+                "agent_mode_revision": 3,
                 "task_protocol": new_task_protocol(BRIEF),
             },
         )