test_recursive_context_integration.py 22 KB

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