test_recursive_context_integration.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. import json
  2. import os
  3. import re
  4. import tempfile
  5. import unittest
  6. from unittest.mock import patch
  7. from cyber_agent.core.context_policy import (
  8. canonical_json,
  9. context_ref_descriptors,
  10. require_root_task_anchor,
  11. )
  12. from cyber_agent.core.resource_budget import ResourceBudgetController
  13. from cyber_agent.core.runner import AgentRunner, RunConfig
  14. from cyber_agent.core.task_protocol import ensure_task_protocol
  15. from cyber_agent.tools import get_tool_registry
  16. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  17. from cyber_agent.trace.store import FileSystemTraceStore
  18. ROOT_ANCHOR = {
  19. "objective": "发布只使用官方数字的建议",
  20. "completion_criteria": [
  21. "最终建议采用官方12%",
  22. "明确30%仅见于转载且无官方支持",
  23. ],
  24. "constraints": ["不得编造证据"],
  25. }
  26. def recursive_env():
  27. return patch.dict(os.environ, {
  28. "AGENT_MODE": "recursive",
  29. "AGENT_RESOURCE_BUDGET_ENABLED": "true",
  30. "AGENT_MAX_TOTAL_AGENTS": "10",
  31. "AGENT_MAX_LLM_CALLS": "80",
  32. "AGENT_MAX_TOTAL_TOKENS": "100000",
  33. "AGENT_MAX_TOTAL_COST_USD": "10",
  34. "AGENT_MAX_DURATION_SECONDS": "600",
  35. "AGENT_RESERVED_FINAL_CALLS": "8",
  36. "AGENT_VALIDATOR_SEARCH_PROVIDER": "serper",
  37. }, clear=False)
  38. def knowledge_disabled():
  39. return KnowledgeConfig(
  40. enable_extraction=False,
  41. enable_completion_extraction=False,
  42. enable_injection=False,
  43. )
  44. def schema_names(schemas):
  45. return {
  46. schema["function"]["name"]
  47. for schema in schemas or []
  48. }
  49. def message_text(messages):
  50. parts = []
  51. for message in messages:
  52. content = message.get("content", "")
  53. if isinstance(content, str):
  54. parts.append(content)
  55. elif isinstance(content, list):
  56. parts.extend(
  57. item.get("text", "")
  58. for item in content
  59. if isinstance(item, dict) and item.get("type") == "text"
  60. )
  61. return "\n".join(parts)
  62. def trace_depth(messages):
  63. match = re.search(r"## Objective\s*\n\s*depth-(\d+)", message_text(messages))
  64. return int(match.group(1)) if match else 0
  65. def last_tool_name(messages):
  66. for message in reversed(messages):
  67. if message.get("role") == "tool":
  68. return message.get("name")
  69. return None
  70. def has_tool_result(messages, name):
  71. return any(
  72. message.get("role") == "tool" and message.get("name") == name
  73. for message in messages
  74. )
  75. def ready_progress_call(call_index):
  76. return tool_call(
  77. "update_task_progress",
  78. {
  79. "expected_revision": 1,
  80. "progress": {
  81. "phase": "ready_to_submit",
  82. "questions": [],
  83. "blockers": [],
  84. "findings": [],
  85. "hypotheses": [],
  86. "work_items": [],
  87. "decision_rationale": "The delegated evidence chain is complete.",
  88. },
  89. },
  90. call_index,
  91. )
  92. def pending_child_id(messages):
  93. for message in reversed(messages):
  94. if message.get("role") != "tool":
  95. continue
  96. content = message.get("content", "")
  97. text = content if isinstance(content, str) else json.dumps(content)
  98. match = re.search(r'"child_trace_id"\s*:\s*"([^"]+)"', text)
  99. if match:
  100. return match.group(1)
  101. raise AssertionError("agent tool result did not contain child_trace_id")
  102. def available_ref(messages):
  103. text = message_text(messages)
  104. match = re.search(
  105. r'"ref_id":"([^"]+)","source_trace_id":"[^"]+",'
  106. r'"summary":"[^"]+","version":"([0-9a-f]{64})"',
  107. text,
  108. )
  109. if not match:
  110. # 字段按 sort_keys 排序,但摘要可能含有转义字符;用宽松回退只解析 ID/版本。
  111. ref_id = re.search(r'"ref_id":"([^"]+)"', text)
  112. version = re.search(r'"version":"([0-9a-f]{64})"', text)
  113. if not ref_id or not version:
  114. raise AssertionError("no authorized ContextRef in child task prompt")
  115. return ref_id.group(1), version.group(1)
  116. return match.group(1), match.group(2)
  117. def tool_call(name, arguments, call_index):
  118. return {
  119. "content": "",
  120. "tool_calls": [{
  121. "id": f"call-{call_index}-{name}",
  122. "type": "function",
  123. "function": {
  124. "name": name,
  125. "arguments": json.dumps(arguments, ensure_ascii=False),
  126. },
  127. }],
  128. "finish_reason": "tool_calls",
  129. "prompt_tokens": 3,
  130. "completion_tokens": 2,
  131. "cost": 0.0001,
  132. }
  133. class FiveLevelRecursiveContextTest(unittest.IsolatedAsyncioTestCase):
  134. async def test_real_runner_keeps_anchor_refs_permissions_and_reviews_through_depth_five(self):
  135. with tempfile.TemporaryDirectory() as directory:
  136. store = FileSystemTraceStore(directory)
  137. call_count = 0
  138. token_total = 0
  139. read_depths = []
  140. observed_tools = {}
  141. validator_packets = []
  142. validator_packet_keys = set()
  143. search_queries = []
  144. class SearchProvider:
  145. async def search(self, query, max_results):
  146. search_queries.append((query, max_results))
  147. return [
  148. {
  149. "title": "转载页面",
  150. "link": "https://repost.example/30-percent",
  151. "snippet": "未经核实的30%",
  152. },
  153. {
  154. "title": "官方页面",
  155. "link": "https://official.example/12-percent",
  156. "snippet": "官方数字12%",
  157. },
  158. ][:max_results]
  159. async def page_fetcher(url, resolver=None):
  160. del resolver
  161. return {
  162. "source_id": "src-official-12",
  163. "url": url,
  164. "final_url": url,
  165. "title": "官方页面",
  166. "content_type": "text/plain",
  167. "retrieved_at": "2026-07-17T00:00:00+00:00",
  168. "content_sha256": "c" * 64,
  169. "text": "官方数字是12%,不支持30%。",
  170. "truncated": False,
  171. "untrusted_material": True,
  172. }
  173. async def fake_llm(**kwargs):
  174. nonlocal call_count, token_total
  175. call_count += 1
  176. messages = kwargs["messages"]
  177. tools = schema_names(kwargs.get("tools"))
  178. depth = trace_depth(messages)
  179. observed_tools.setdefault(depth, []).append(tools)
  180. if any(
  181. message.get("role") == "system"
  182. and "recursive_validation_protocol" in str(message.get("content", ""))
  183. for message in messages
  184. ):
  185. packet = next(
  186. json.loads(message["content"])
  187. for message in messages
  188. if message.get("role") == "user"
  189. and "recursive_validation_protocol" in str(message.get("content", ""))
  190. )
  191. scope = packet["validation_scope"]
  192. packet_key = (packet["validation_plan"]["plan_hash"], scope)
  193. if packet_key not in validator_packet_keys:
  194. validator_packet_keys.add(packet_key)
  195. validator_packets.append(packet)
  196. if scope == "root":
  197. self.assertIn("官方12%", packet["candidate_output"])
  198. self.assertIn("30%仅见于转载", packet["candidate_output"])
  199. else:
  200. brief = packet["task_brief"]
  201. local_depth = brief["context"]["local_depth"]
  202. report_text = json.dumps(
  203. packet["task_report"], ensure_ascii=False,
  204. )
  205. self.assertIn(
  206. self._semantic_result(local_depth),
  207. report_text,
  208. )
  209. self.assertIn(
  210. scope,
  211. [*brief["validation_scopes"], "task"],
  212. )
  213. task_criteria = [
  214. item["criterion"]
  215. for item in packet["validation_plan"]["checks"]
  216. if item["check_id"].startswith("task.criterion.")
  217. ]
  218. self.assertEqual(
  219. brief["completion_criteria"],
  220. task_criteria,
  221. )
  222. self.assertTrue(
  223. set(ROOT_ANCHOR["completion_criteria"]).isdisjoint(
  224. task_criteria
  225. )
  226. )
  227. validator_last_tool = last_tool_name(messages)
  228. if scope == "evidence" and validator_last_tool is None:
  229. response = tool_call(
  230. "validator_web_search",
  231. {"query": "官方数字 12% 30%", "max_results": 5},
  232. call_count,
  233. )
  234. token_total += 5
  235. return response
  236. if (
  237. scope == "evidence"
  238. and validator_last_tool == "validator_web_search"
  239. ):
  240. response = tool_call(
  241. "validator_open_url",
  242. {"url": "https://official.example/12-percent"},
  243. call_count,
  244. )
  245. token_total += 5
  246. return response
  247. evidence_refs = (
  248. ["src-official-12"] if scope == "evidence" else []
  249. )
  250. response = {
  251. "content": json.dumps({
  252. "outcome": "passed",
  253. "scope": scope,
  254. "checks": [
  255. {
  256. "check_id": item["check_id"],
  257. "status": "passed",
  258. "evidence_refs": evidence_refs,
  259. "issue": None,
  260. }
  261. for item in packet["validation_plan"]["checks"]
  262. if item["scope"] == scope
  263. ],
  264. "reason": "已核对持久化轨迹、标准和输出",
  265. "retry_from": None,
  266. }, ensure_ascii=False),
  267. "tool_calls": [],
  268. "finish_reason": "stop",
  269. "prompt_tokens": 3,
  270. "completion_tokens": 2,
  271. "cost": 0.0001,
  272. }
  273. token_total += 5
  274. return response
  275. last_tool = last_tool_name(messages)
  276. if tools == {"review_task_result", "read_context_ref"}:
  277. response = tool_call(
  278. "review_task_result",
  279. {
  280. "child_trace_id": pending_child_id(messages),
  281. "decision": "ASCEND",
  282. "reason": f"depth-{depth + 1} 已通过独立验收",
  283. },
  284. call_count,
  285. )
  286. elif depth > 0 and "submit_task_report" not in tools:
  287. response = {
  288. "content": f"depth-{depth} TaskReport 已提交",
  289. "tool_calls": [],
  290. "finish_reason": "stop",
  291. "prompt_tokens": 3,
  292. "completion_tokens": 2,
  293. "cost": 0.0001,
  294. }
  295. elif last_tool == "review_task_result":
  296. response = ready_progress_call(call_count)
  297. elif last_tool == "update_task_progress":
  298. if depth == 0:
  299. response = {
  300. "content": (
  301. "最终建议采用官方12%;30%仅见于转载,"
  302. "没有官方支持,不能作为发布依据。"
  303. ),
  304. "tool_calls": [],
  305. "finish_reason": "stop",
  306. "prompt_tokens": 3,
  307. "completion_tokens": 2,
  308. "cost": 0.0001,
  309. }
  310. else:
  311. response = tool_call(
  312. "submit_task_report",
  313. {"task_report": self._report(depth)},
  314. call_count,
  315. )
  316. elif depth in {4, 5} and last_tool != "read_context_ref":
  317. ref_id, version = available_ref(messages)
  318. read_depths.append(depth)
  319. response = tool_call(
  320. "read_context_ref",
  321. {"ref_id": ref_id, "version": version},
  322. call_count,
  323. )
  324. elif depth == 5:
  325. if has_tool_result(messages, "update_task_progress"):
  326. response = tool_call(
  327. "submit_task_report",
  328. {"task_report": self._report(depth)},
  329. call_count,
  330. )
  331. else:
  332. response = ready_progress_call(call_count)
  333. else:
  334. next_depth = depth + 1
  335. response = tool_call(
  336. "agent",
  337. {"task_brief": self._brief(next_depth)},
  338. call_count,
  339. )
  340. token_total += 5
  341. return response
  342. runner = AgentRunner(
  343. trace_store=store,
  344. tool_registry=get_tool_registry(),
  345. llm_call=fake_llm,
  346. validator_search_provider=SearchProvider(),
  347. validator_page_fetcher=page_fetcher,
  348. )
  349. config = RunConfig(
  350. tools=[
  351. "agent",
  352. "submit_task_report",
  353. "review_task_result",
  354. "read_context_ref",
  355. "update_task_progress",
  356. ],
  357. tool_groups=[],
  358. enable_research_flow=False,
  359. root_task_anchor=ROOT_ANCHOR,
  360. knowledge=knowledge_disabled(),
  361. child_execution_mode="sequential",
  362. )
  363. read_stats_before = get_tool_registry().get_stats("read_context_ref")[
  364. "read_context_ref"
  365. ]["call_count"]
  366. with recursive_env():
  367. result = await runner.run_result(
  368. [{"role": "user", "content": "请逐层拆解并验收根任务"}],
  369. config,
  370. )
  371. self.assertEqual("completed", result["status"])
  372. traces = await store.list_traces(limit=100)
  373. business = [
  374. trace for trace in traces
  375. if trace.context.get("created_by_tool") == "agent"
  376. or trace.trace_id == result["trace_id"]
  377. ]
  378. business.sort(key=lambda trace: trace.context["agent_depth"])
  379. self.assertEqual(list(range(6)), [trace.context["agent_depth"] for trace in business])
  380. self.assertEqual(6, len(business))
  381. for index, trace in enumerate(business):
  382. self.assertEqual(result["trace_id"], trace.context["root_trace_id"])
  383. self.assertEqual(
  384. canonical_json(ROOT_ANCHOR),
  385. canonical_json(require_root_task_anchor(trace.context).model_dump(mode="json")),
  386. )
  387. messages = await store.get_trace_messages(trace.trace_id)
  388. first_user = next(message for message in messages if message.role == "user")
  389. first_user_text = (
  390. first_user.content
  391. if isinstance(first_user.content, str)
  392. else json.dumps(first_user.content, ensure_ascii=False)
  393. )
  394. self.assertEqual(1, first_user_text.count("# Root Task Anchor"))
  395. metrics = trace.context["context_access"]["metrics"]
  396. self.assertGreater(metrics["root_anchor_chars"], 0)
  397. self.assertEqual(
  398. len(context_ref_descriptors(trace.context)),
  399. metrics["authorized_ref_count"],
  400. )
  401. if index:
  402. self.assertEqual(business[index - 1].trace_id, trace.parent_trace_id)
  403. state = ensure_task_protocol(trace.context)
  404. self.assertEqual(1, state["task_brief_version"])
  405. self.assertEqual(
  406. ["不得编造证据", *[f"约束-{level}" for level in range(1, index + 1)]],
  407. state["task_brief"]["constraints"],
  408. )
  409. self.assertEqual(
  410. [f"直接父级结论 depth-{index - 1}"],
  411. state["task_brief"]["parent_findings"],
  412. )
  413. self.assertEqual(
  414. {"local_depth": index},
  415. state["task_brief"]["context"],
  416. )
  417. self.assertEqual([], state["task_brief"]["context_refs"])
  418. descriptors = context_ref_descriptors(trace.context)
  419. expected_kinds = (
  420. ["reviewed_task_result"]
  421. if index == 1
  422. else ["task_brief"]
  423. if index == 5
  424. else ["task_brief", "reviewed_task_result"]
  425. )
  426. self.assertEqual(
  427. expected_kinds,
  428. [item["kind"] for item in descriptors],
  429. )
  430. self.assertEqual([4, 5], read_depths)
  431. read_stats_after = get_tool_registry().get_stats("read_context_ref")[
  432. "read_context_ref"
  433. ]["call_count"]
  434. self.assertEqual(2, read_stats_after - read_stats_before)
  435. self.assertTrue(any("agent" in tools for tools in observed_tools[4]))
  436. self.assertTrue(all("evaluate" not in tools and "bash_command" not in tools for tools in observed_tools[5]))
  437. self.assertTrue(all("agent" not in tools for tools in observed_tools[5]))
  438. self.assertTrue(any("read_context_ref" in tools for tools in observed_tools[5]))
  439. validators = [
  440. trace for trace in traces
  441. if trace.context.get("created_by_tool") == "validator"
  442. ]
  443. self.assertEqual(11, len(validators))
  444. self.assertEqual(11, len(validator_packets))
  445. self.assertTrue(all(packet["root_task_anchor"] == ROOT_ANCHOR for packet in validator_packets))
  446. self.assertEqual(2, len(search_queries))
  447. self.assertEqual(
  448. ["evidence", "hypothesis", "output", "root", "task"],
  449. sorted({packet["validation_scope"] for packet in validator_packets}),
  450. )
  451. packets_with_real_ref_reads = [
  452. packet
  453. for packet in validator_packets
  454. if any(
  455. item.get("role") == "tool"
  456. and item.get("name") == "read_context_ref"
  457. for item in packet["trajectory"]
  458. )
  459. ]
  460. self.assertEqual(4, len(packets_with_real_ref_reads))
  461. self.assertTrue(ensure_task_protocol(business[0].context)["root_validation_passed"])
  462. usage = await ResourceBudgetController(store).get_usage(result["trace_id"])
  463. self.assertEqual(6, usage.total_agents)
  464. self.assertEqual(call_count, usage.llm_calls)
  465. self.assertEqual(token_total, usage.total_tokens)
  466. self.assertEqual(4, usage.validation_tool_calls)
  467. self.assertGreater(usage.validation_material_chars, 0)
  468. @staticmethod
  469. def _brief(depth):
  470. definitions = {
  471. 1: (
  472. "汇总最终发布建议,只采用通过验证的官方数字",
  473. "建议采用官方12%并说明30%没有官方支持",
  474. "可发布的最终建议",
  475. ["output"],
  476. ),
  477. 2: (
  478. "判断提升30%的假设是否获得证据支持",
  479. "区分30%转载说法与12%官方数字",
  480. "假设取舍结论",
  481. ["hypothesis"],
  482. ),
  483. 3: (
  484. "生成可用和禁用的数字表达",
  485. "可用表达只采用官方12%,禁用官方30%表达",
  486. "可用与禁用表达清单",
  487. ["output"],
  488. ),
  489. 4: (
  490. "建立数字与来源的对应关系",
  491. "明确官方来源对应12%、转载对应30%",
  492. "数字来源对应表",
  493. ["evidence"],
  494. ),
  495. 5: (
  496. "核实官方页面真实支持的数字",
  497. "确认官方只支持12%且不支持30%",
  498. "官方数字核查结论",
  499. ["evidence"],
  500. ),
  501. }
  502. objective, criterion, expected_output, validation_scopes = definitions[depth]
  503. return {
  504. "objective": f"depth-{depth} {objective}",
  505. "reason": f"depth-{depth - 1} 需要直属子任务结果",
  506. "completion_criteria": [criterion],
  507. "expected_outputs": [expected_output],
  508. "parent_findings": [f"直接父级结论 depth-{depth - 1}"],
  509. "context": {"local_depth": depth},
  510. "constraints": [f"约束-{depth}"],
  511. "validation_scopes": validation_scopes,
  512. }
  513. @staticmethod
  514. def _report(depth):
  515. result = FiveLevelRecursiveContextTest._semantic_result(depth)
  516. return {
  517. "summary": result,
  518. "outcome": "satisfied",
  519. "validation": {"hard_passed": True, "open_issues": []},
  520. "next_step_suggestion": {
  521. "direction": "ASCEND",
  522. "reason": "当前层已满足完成标准",
  523. },
  524. "outputs": [{"depth": depth, "result": result}],
  525. "evidence": [{"depth": depth, "source": "官方页面与转载页面"}],
  526. "remaining_issues": [],
  527. }
  528. @staticmethod
  529. def _semantic_result(depth):
  530. return {
  531. 1: "最终建议采用官方12%,并明确30%仅见于转载、无官方支持",
  532. 2: "30%假设无官方证据,采用12%有官方来源",
  533. 3: "可用表达:官方12%;禁用表达:官方30%",
  534. 4: "官方来源→12%;二次转载→30%,二者不可混用",
  535. 5: "官方页面只支持12%,不支持30%",
  536. }[depth]
  537. if __name__ == "__main__":
  538. unittest.main()