| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652 |
- import asyncio
- import json
- import tempfile
- import unittest
- from cyber_agent.core.artifacts import (
- ArtifactRef,
- ValidationMaterial,
- canonical_material_content,
- material_content_hash,
- )
- from cyber_agent.core.context_policy import persist_root_task_anchor
- 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 (
- TaskReport,
- ensure_task_protocol,
- initialize_task_progress,
- new_task_protocol,
- pending_review_entry,
- replace_task_brief,
- )
- from cyber_agent.core.validation import (
- ValidationPolicy,
- ValidatorSettings,
- persist_validation_policy,
- )
- from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
- 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
- from cyber_agent.trace.models import Message, Trace
- from cyber_agent.trace.store import FileSystemTraceStore
- ANCHOR = {
- "objective": "发布只使用官方数字的完整建议",
- "completion_criteria": ["必须使用官方12%", "必须说明30%无官方支持"],
- "constraints": ["不得把转载当成官方来源"],
- }
- def brief(depth, *, evidence=False, version=1):
- value = "12%" if version == 2 else "30%"
- return {
- "objective": f"depth-{depth} 核实局部数字 {value}",
- "reason": f"depth-{depth - 1} 需要直属子任务的证据",
- "completion_criteria": [f"确认 {value} 是否有官方支持"],
- "expected_outputs": ["一条可追溯结论"],
- "constraints": ["不得猜测"],
- "validation_scopes": ["evidence"] if evidence else [],
- }
- def report(trace_id, summary):
- return TaskReport.model_validate({
- "child_trace_id": trace_id,
- "summary": summary,
- "outcome": "satisfied",
- "validation": {"hard_passed": True, "open_issues": []},
- "next_step_suggestion": {
- "direction": "ASCEND",
- "reason": "当前局部任务已完成",
- },
- "outputs": [{"conclusion": summary}],
- "evidence": [{"source": "validator-will-check"}],
- "remaining_issues": [],
- })
- def validation_packet(messages):
- return next(
- json.loads(message["content"])
- for message in messages
- if message.get("role") == "user"
- and "recursive_validation_protocol" in str(message.get("content", ""))
- )
- def check_response(packet, outcome="passed", issue=None, evidence_refs=None):
- scope = packet["validation_scope"]
- return {
- "content": json.dumps({
- "scope": scope,
- "outcome": outcome,
- "checks": [
- {
- "check_id": item["check_id"],
- "status": outcome,
- "evidence_refs": list(evidence_refs or []),
- "issue": None if outcome == "passed" else issue,
- }
- for item in packet["validation_plan"]["checks"]
- if item["scope"] == scope
- ],
- "reason": "验收通过" if outcome == "passed" else issue,
- "retry_from": None if outcome == "passed" else (
- "task_definition" if scope in {"task", "root"} else scope
- ),
- }, ensure_ascii=False),
- "tool_calls": [],
- "finish_reason": "stop",
- "prompt_tokens": 3,
- "completion_tokens": 2,
- "cost": 0.0001,
- }
- def tool_call(name, arguments):
- return {
- "content": "",
- "tool_calls": [{
- "id": f"{name}-call",
- "type": "function",
- "function": {
- "name": name,
- "arguments": json.dumps(arguments, ensure_ascii=False),
- },
- }],
- "finish_reason": "tool_calls",
- "prompt_tokens": 3,
- "completion_tokens": 2,
- "cost": 0.0001,
- }
- class SearchProvider:
- async def search(self, _query, max_results):
- return [
- {
- "title": "官方数字",
- "link": "https://official.example/12",
- "snippet": "12%",
- },
- {
- "title": "二次转载",
- "link": "https://repost.example/30",
- "snippet": "30%",
- },
- ][:max_results]
- async def page_fetcher(url, resolver=None):
- del resolver
- return {
- "source_id": "src-official-12",
- "url": url,
- "final_url": url,
- "title": "官方数字",
- "content_type": "text/plain",
- "retrieved_at": "2026-07-17T00:00:00+00:00",
- "content_sha256": "d" * 64,
- "text": "官方页面只支持12%,没有30%。",
- "truncated": False,
- "untrusted_material": True,
- }
- class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
- async def asyncSetUp(self):
- self.temp = tempfile.TemporaryDirectory()
- self.store = FileSystemTraceStore(self.temp.name)
- self.root_id = "complex-root"
- self.ids = [self.root_id, *[f"depth-{depth}" for depth in range(1, 6)]]
- budget = ResourceBudget()
- root_context = {
- "agent_mode": "recursive",
- "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(),
- ValidatorSettings(search_provider="serper"),
- )
- await self.store.create_trace(Trace(
- trace_id=self.root_id,
- mode="agent",
- task="root",
- uid="user-1",
- model="fake",
- context=root_context,
- ))
- await self.store.add_message(Message.create(
- trace_id=self.root_id,
- role="user",
- sequence=1,
- content="生成可发布建议",
- ))
- await self.store.update_trace(self.root_id, head_sequence=1)
- for depth in range(1, 6):
- context = {
- "agent_mode": "recursive",
- "agent_mode_revision": 3,
- "agent_depth": depth,
- "root_trace_id": self.root_id,
- "created_by_tool": "agent",
- "task_protocol": new_task_protocol(
- brief(depth, evidence=(depth == 5))
- ),
- }
- 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",
- task=f"depth-{depth}",
- parent_trace_id=self.ids[depth - 1],
- parent_goal_id="1",
- uid="user-1",
- model="fake",
- context=context,
- ))
- await self.store.add_message(Message.create(
- trace_id=self.ids[depth],
- role="user",
- sequence=1,
- content=f"execute depth-{depth}",
- ))
- await self.store.update_trace(self.ids[depth], head_sequence=1)
- await ResourceBudgetController(self.store).initialize(
- self.root_id,
- budget,
- initial_agents=6,
- )
- async def asyncTearDown(self):
- self.temp.cleanup()
- async def test_deepest_evidence_failure_revises_only_direct_child(self):
- async def llm_call(**kwargs):
- messages = kwargs["messages"]
- packet = validation_packet(messages)
- scope = packet["validation_scope"]
- if scope == "evidence":
- tool_names = [
- message.get("name")
- for message in messages
- if message.get("role") == "tool"
- ]
- if not tool_names:
- return tool_call(
- "validator_web_search",
- {"query": "official 30% 12%", "max_results": 5},
- )
- if tool_names[-1] == "validator_web_search":
- return tool_call(
- "validator_open_url",
- {"url": "https://official.example/12"},
- )
- summary = packet["task_report"]["summary"]
- if "30%" in summary:
- return check_response(
- packet,
- outcome="failed",
- issue="官方来源只支持12%,不支持30%",
- evidence_refs=["src-official-12"],
- )
- return check_response(
- packet,
- evidence_refs=["src-official-12"],
- )
- return check_response(packet)
- runner = AgentRunner(
- trace_store=self.store,
- llm_call=llm_call,
- validator_search_provider=SearchProvider(),
- validator_page_fetcher=page_fetcher,
- )
- child_id = self.ids[5]
- parent_id = self.ids[4]
- first_report = report(child_id, "官方支持30%")
- child = await self.store.get_trace(child_id)
- 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(
- child_id,
- task_report=first_report.model_dump(mode="json"),
- )
- self.assertEqual("failed", first.result.outcome)
- self.assertEqual(
- ["failed", "passed"],
- [item.outcome for item in first.result.scope_results],
- )
- parent = await self.store.get_trace(parent_id)
- parent_state = ensure_task_protocol(parent.context)
- parent_state["pending_reviews"][child_id] = pending_review_entry(
- goal_id="1",
- report=first_report,
- validation_result=first.result.model_dump(mode="json"),
- received_at_sequence=10,
- )
- await self.store.update_trace(parent_id, context=parent.context)
- tree = GoalTree(
- mission="depth-4",
- goals=[Goal(id="1", description="verify depth-5", status="pending_review")],
- current_id=None,
- )
- await self.store.update_goal_tree(parent_id, tree)
- reviewed = await review_task_result(
- child_trace_id=child_id,
- decision="REVISE_CHILD",
- reason="改为官方可支持的12%",
- approved_next_task=brief(5, evidence=True, version=2),
- context={
- "store": self.store,
- "trace_id": parent_id,
- "goal_id": "1",
- "goal_tree": tree,
- "sequence": 11,
- },
- )
- self.assertEqual("completed", reviewed["status"])
- child = await self.store.get_trace(child_id)
- child_state = ensure_task_protocol(child.context)
- self.assertIsNone(child_state["task_report"])
- self.assertIsNone(child_state["task_report_validation"])
- self.assertEqual(1, len(child_state["report_history"]))
- parent = await self.store.get_trace(parent_id)
- parent_state = ensure_task_protocol(parent.context)
- self.assertEqual("REVISE_CHILD", parent_state["next_actions"][0]["decision"])
- revised_brief = parent_state["next_actions"][0]["task_brief"]
- 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)
- second = await runner.validate_recursive_trace(
- child_id,
- task_report=second_report.model_dump(mode="json"),
- )
- self.assertEqual("passed", second.result.outcome)
- self.assertNotEqual(first.result.plan_hash, second.result.plan_hash)
- self.assertEqual(
- 4,
- len(await self.store.list_traces(
- parent_trace_id=child_id,
- created_by_tool="validator",
- limit=10,
- )),
- )
- usage = await ResourceBudgetController(self.store).get_usage(self.root_id)
- self.assertEqual(6, usage.total_agents)
- async def test_parent_stop_during_validator_page_fetch_discards_tool_result(self):
- page_started = asyncio.Event()
- release_page = asyncio.Event()
- page_text = "官方页面只支持12%,没有30%。"
- async def blocking_page_fetcher(url, resolver=None):
- del resolver
- page_started.set()
- await release_page.wait()
- return {
- "source_id": "src-official-12",
- "url": url,
- "final_url": url,
- "title": "官方数字",
- "content_type": "text/plain",
- "retrieved_at": "2026-07-17T00:00:00+00:00",
- "content_sha256": "d" * 64,
- "text": page_text,
- "truncated": False,
- "untrusted_material": True,
- }
- llm_calls = 0
- async def llm_call(**kwargs):
- nonlocal llm_calls
- llm_calls += 1
- packet = validation_packet(kwargs["messages"])
- self.assertEqual("evidence", packet["validation_scope"])
- return tool_call(
- "validator_open_url",
- {"url": "https://official.example/12"},
- )
- runner = AgentRunner(
- trace_store=self.store,
- llm_call=llm_call,
- validator_page_fetcher=blocking_page_fetcher,
- )
- root_event = asyncio.Event()
- runner._cancel_events[self.root_id] = root_event
- runner._recursive_active_traces[self.root_id] = root_event
- registered = []
- for parent_id, child_id in zip(self.ids, self.ids[1:]):
- registered.append((
- child_id,
- runner.register_recursive_child(parent_id, child_id),
- ))
- 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,
- ))
- await asyncio.wait_for(page_started.wait(), timeout=1)
- self.assertTrue(await runner.stop(self.root_id))
- release_page.set()
- run = await asyncio.wait_for(task, timeout=2)
- self.assertEqual("error", run.result.outcome)
- self.assertEqual(1, llm_calls)
- validators = await self.store.list_traces(
- parent_trace_id=child_id,
- created_by_tool="validator",
- limit=10,
- )
- evidence_trace = next(
- trace for trace in validators
- if trace.context["validation_scope"] == "evidence"
- )
- messages = await self.store.get_trace_messages(evidence_trace.trace_id)
- self.assertNotIn("tool", [message.role for message in messages])
- child = await self.store.get_trace(child_id)
- cache = ensure_task_protocol(child.context)["task_report_validation"]
- self.assertEqual("error", cache["aggregate_result"]["outcome"])
- usage = await ResourceBudgetController(self.store).get_usage(self.root_id)
- self.assertEqual(len(page_text), usage.validation_material_chars)
- for trace_id, event in reversed(registered):
- runner.release_recursive_trace(trace_id, event)
- runner.release_recursive_trace(self.root_id, root_event)
- async def test_all_children_pass_but_root_must_revise_its_own_output(self):
- root_validation_attempt = 0
- ordinary_calls = 0
- validated_versions = []
- scripts = {
- "v1": "建议使用官方12%。",
- "v2": "建议使用官方12%;30%只见于转载,无官方支持。",
- }
- refs = {
- version: ArtifactRef(
- artifact_id="script:final",
- version=version,
- content_hash=material_content_hash(content),
- kind="script",
- mime_type="text/plain",
- )
- for version, content in scripts.items()
- }
- class ScriptResolver:
- async def resolve(self, ref, root_trace_id, uid):
- return ValidationMaterial(
- **ref.model_dump(mode="json"),
- root_trace_id=root_trace_id,
- uid=uid,
- content=scripts[ref.version],
- )
- registry = ToolRegistry()
- async def publish_script(version: str):
- return ToolResult(
- title=f"script {version}",
- output=scripts[version],
- artifact_refs=[refs[version].model_dump(mode="json")],
- )
- 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
- messages = kwargs["messages"]
- if messages and "recursive_validation_protocol" in messages[0]["content"]:
- packet = validation_packet(messages)
- if packet["validation_scope"] == "root":
- root_validation_attempt += 1
- validated_versions.append([
- item["version"] for item in packet["materials"]
- ])
- if root_validation_attempt == 1:
- return check_response(
- packet,
- outcome="failed",
- issue="根输出遗漏了30%无官方支持的风险说明",
- )
- return check_response(packet)
- ordinary_calls += 1
- 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 == 3 else "v2"],
- "tool_calls": [],
- "finish_reason": "stop",
- "prompt_tokens": 3,
- "completion_tokens": 2,
- "cost": 0.0001,
- }
- runner = AgentRunner(
- trace_store=self.store,
- tool_registry=registry,
- llm_call=llm_call,
- artifact_resolver=ScriptResolver(),
- )
- for depth in range(1, 6):
- trace_id = self.ids[depth]
- child = await self.store.get_trace(trace_id)
- child_state = ensure_task_protocol(child.context)
- if depth == 5:
- replace_task_brief(
- child_state,
- brief(depth, evidence=False),
- effective_at_sequence=2,
- )
- 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,
- task_report=child_report.model_dump(mode="json"),
- )
- self.assertEqual("passed", run.result.outcome)
- resume_config = RunConfig(
- trace_id=self.root_id,
- model="fake",
- uid="user-1",
- tools=["publish_script", "update_task_progress"],
- tool_groups=[],
- enable_research_flow=False,
- knowledge=KnowledgeConfig(
- enable_extraction=False,
- enable_completion_extraction=False,
- enable_injection=False,
- ),
- )
- root = await self.store.get_trace(self.root_id)
- persist_run_config_snapshot(
- root.context,
- RunConfigSnapshotV1.from_run_config(
- resume_config,
- memory_identity=None,
- ),
- )
- await self.store.update_trace(self.root_id, context=root.context)
- result = await runner.run_result([], resume_config)
- self.assertEqual("completed", result["status"])
- root = await self.store.get_trace(self.root_id)
- state = ensure_task_protocol(root.context)
- self.assertEqual(2, state["root_validation_attempts"])
- self.assertEqual(
- ["failed", "passed"],
- [item["outcome"] for item in state["root_validation_history"]],
- )
- self.assertTrue(state["root_validation_passed"])
- 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)
- self.assertEqual(6, usage.total_agents)
- expected_material_chars = (
- 2 * len(canonical_material_content(scripts["v1"]))
- + len(canonical_material_content(scripts["v2"]))
- )
- self.assertEqual(expected_material_chars, usage.validation_material_chars)
- restarted_runner = AgentRunner(
- trace_store=self.store,
- tool_registry=registry,
- llm_call=llm_call,
- artifact_resolver=ScriptResolver(),
- )
- cached = await restarted_runner.validate_recursive_trace(
- self.root_id,
- scope="root",
- candidate_output=scripts["v2"],
- root_validator=True,
- )
- self.assertTrue(cached.cached)
- after_cache = await ResourceBudgetController(self.store).get_usage(self.root_id)
- self.assertEqual(
- expected_material_chars,
- after_cache.validation_material_chars,
- )
- if __name__ == "__main__":
- unittest.main()
|