test_recursive_context_integration.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  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. "constraints": ["不得编造证据"],
  22. }
  23. def recursive_env():
  24. return patch.dict(os.environ, {
  25. "AGENT_MODE": "recursive",
  26. "AGENT_RESOURCE_BUDGET_ENABLED": "true",
  27. "AGENT_MAX_TOTAL_AGENTS": "10",
  28. "AGENT_MAX_LLM_CALLS": "80",
  29. "AGENT_MAX_TOTAL_TOKENS": "100000",
  30. "AGENT_MAX_TOTAL_COST_USD": "10",
  31. "AGENT_MAX_DURATION_SECONDS": "600",
  32. "AGENT_RESERVED_FINAL_CALLS": "1",
  33. }, clear=False)
  34. def knowledge_disabled():
  35. return KnowledgeConfig(
  36. enable_extraction=False,
  37. enable_completion_extraction=False,
  38. enable_injection=False,
  39. )
  40. def schema_names(schemas):
  41. return {
  42. schema["function"]["name"]
  43. for schema in schemas or []
  44. }
  45. def message_text(messages):
  46. parts = []
  47. for message in messages:
  48. content = message.get("content", "")
  49. if isinstance(content, str):
  50. parts.append(content)
  51. elif isinstance(content, list):
  52. parts.extend(
  53. item.get("text", "")
  54. for item in content
  55. if isinstance(item, dict) and item.get("type") == "text"
  56. )
  57. return "\n".join(parts)
  58. def trace_depth(messages):
  59. match = re.search(r"## Objective\s*\n\s*depth-(\d+)", message_text(messages))
  60. return int(match.group(1)) if match else 0
  61. def last_tool_name(messages):
  62. for message in reversed(messages):
  63. if message.get("role") == "tool":
  64. return message.get("name")
  65. return None
  66. def pending_child_id(messages):
  67. for message in reversed(messages):
  68. if message.get("role") != "tool":
  69. continue
  70. content = message.get("content", "")
  71. text = content if isinstance(content, str) else json.dumps(content)
  72. match = re.search(r'"child_trace_id"\s*:\s*"([^"]+)"', text)
  73. if match:
  74. return match.group(1)
  75. raise AssertionError("agent tool result did not contain child_trace_id")
  76. def available_ref(messages):
  77. text = message_text(messages)
  78. match = re.search(
  79. r'"ref_id":"([^"]+)","source_trace_id":"[^"]+",'
  80. r'"summary":"[^"]+","version":"([0-9a-f]{64})"',
  81. text,
  82. )
  83. if not match:
  84. # 字段按 sort_keys 排序,但摘要可能含有转义字符;用宽松回退只解析 ID/版本。
  85. ref_id = re.search(r'"ref_id":"([^"]+)"', text)
  86. version = re.search(r'"version":"([0-9a-f]{64})"', text)
  87. if not ref_id or not version:
  88. raise AssertionError("no authorized ContextRef in child task prompt")
  89. return ref_id.group(1), version.group(1)
  90. return match.group(1), match.group(2)
  91. def tool_call(name, arguments, call_index):
  92. return {
  93. "content": "",
  94. "tool_calls": [{
  95. "id": f"call-{call_index}-{name}",
  96. "type": "function",
  97. "function": {
  98. "name": name,
  99. "arguments": json.dumps(arguments, ensure_ascii=False),
  100. },
  101. }],
  102. "finish_reason": "tool_calls",
  103. "prompt_tokens": 3,
  104. "completion_tokens": 2,
  105. "cost": 0.0001,
  106. }
  107. class FiveLevelRecursiveContextTest(unittest.IsolatedAsyncioTestCase):
  108. async def test_real_runner_keeps_anchor_refs_permissions_and_reviews_through_depth_five(self):
  109. with tempfile.TemporaryDirectory() as directory:
  110. store = FileSystemTraceStore(directory)
  111. call_count = 0
  112. token_total = 0
  113. read_depths = []
  114. observed_tools = {}
  115. validator_packets = []
  116. async def fake_llm(**kwargs):
  117. nonlocal call_count, token_total
  118. call_count += 1
  119. messages = kwargs["messages"]
  120. tools = schema_names(kwargs.get("tools"))
  121. depth = trace_depth(messages)
  122. observed_tools.setdefault(depth, []).append(tools)
  123. if any(
  124. message.get("role") == "system"
  125. and "independent validator" in str(message.get("content", ""))
  126. for message in messages
  127. ):
  128. packet = json.loads(messages[-1]["content"])
  129. validator_packets.append(packet)
  130. response = {
  131. "content": json.dumps({
  132. "outcome": "passed",
  133. "scope": packet["validation_scope"],
  134. "reason": "已核对持久化轨迹、标准和输出",
  135. "issues": [],
  136. "retry_from": None,
  137. }, ensure_ascii=False),
  138. "tool_calls": [],
  139. "finish_reason": "stop",
  140. "prompt_tokens": 3,
  141. "completion_tokens": 2,
  142. "cost": 0.0001,
  143. }
  144. token_total += 5
  145. return response
  146. last_tool = last_tool_name(messages)
  147. if tools == {"review_task_result", "read_context_ref"}:
  148. response = tool_call(
  149. "review_task_result",
  150. {
  151. "child_trace_id": pending_child_id(messages),
  152. "decision": "ASCEND",
  153. "reason": f"depth-{depth + 1} 已通过独立验收",
  154. },
  155. call_count,
  156. )
  157. elif depth > 0 and "submit_task_report" not in tools:
  158. response = {
  159. "content": f"depth-{depth} TaskReport 已提交",
  160. "tool_calls": [],
  161. "finish_reason": "stop",
  162. "prompt_tokens": 3,
  163. "completion_tokens": 2,
  164. "cost": 0.0001,
  165. }
  166. elif last_tool == "review_task_result":
  167. if depth == 0:
  168. response = {
  169. "content": "根任务的五层结果已逐层审核完成",
  170. "tool_calls": [],
  171. "finish_reason": "stop",
  172. "prompt_tokens": 3,
  173. "completion_tokens": 2,
  174. "cost": 0.0001,
  175. }
  176. else:
  177. response = tool_call(
  178. "submit_task_report",
  179. {"task_report": self._report(depth)},
  180. call_count,
  181. )
  182. elif depth in {4, 5} and last_tool != "read_context_ref":
  183. ref_id, version = available_ref(messages)
  184. read_depths.append(depth)
  185. response = tool_call(
  186. "read_context_ref",
  187. {"ref_id": ref_id, "version": version},
  188. call_count,
  189. )
  190. elif depth == 5:
  191. response = tool_call(
  192. "submit_task_report",
  193. {"task_report": self._report(depth)},
  194. call_count,
  195. )
  196. else:
  197. next_depth = depth + 1
  198. response = tool_call(
  199. "agent",
  200. {"task_brief": self._brief(next_depth)},
  201. call_count,
  202. )
  203. token_total += 5
  204. return response
  205. runner = AgentRunner(
  206. trace_store=store,
  207. tool_registry=get_tool_registry(),
  208. llm_call=fake_llm,
  209. )
  210. config = RunConfig(
  211. tools=[
  212. "agent",
  213. "submit_task_report",
  214. "review_task_result",
  215. "read_context_ref",
  216. ],
  217. tool_groups=[],
  218. enable_research_flow=False,
  219. root_task_anchor=ROOT_ANCHOR,
  220. knowledge=knowledge_disabled(),
  221. child_execution_mode="sequential",
  222. )
  223. read_stats_before = get_tool_registry().get_stats("read_context_ref")[
  224. "read_context_ref"
  225. ]["call_count"]
  226. with recursive_env():
  227. result = await runner.run_result(
  228. [{"role": "user", "content": "请逐层拆解并验收根任务"}],
  229. config,
  230. )
  231. self.assertEqual("completed", result["status"])
  232. traces = await store.list_traces(limit=100)
  233. business = [
  234. trace for trace in traces
  235. if trace.context.get("created_by_tool") == "agent"
  236. or trace.trace_id == result["trace_id"]
  237. ]
  238. business.sort(key=lambda trace: trace.context["agent_depth"])
  239. self.assertEqual(list(range(6)), [trace.context["agent_depth"] for trace in business])
  240. self.assertEqual(6, len(business))
  241. for index, trace in enumerate(business):
  242. self.assertEqual(result["trace_id"], trace.context["root_trace_id"])
  243. self.assertEqual(
  244. canonical_json(ROOT_ANCHOR),
  245. canonical_json(require_root_task_anchor(trace.context).model_dump(mode="json")),
  246. )
  247. messages = await store.get_trace_messages(trace.trace_id)
  248. first_user = next(message for message in messages if message.role == "user")
  249. first_user_text = (
  250. first_user.content
  251. if isinstance(first_user.content, str)
  252. else json.dumps(first_user.content, ensure_ascii=False)
  253. )
  254. self.assertEqual(1, first_user_text.count("# Root Task Anchor"))
  255. metrics = trace.context["context_access"]["metrics"]
  256. self.assertGreater(metrics["root_anchor_chars"], 0)
  257. self.assertEqual(
  258. len(context_ref_descriptors(trace.context)),
  259. metrics["authorized_ref_count"],
  260. )
  261. if index:
  262. self.assertEqual(business[index - 1].trace_id, trace.parent_trace_id)
  263. state = ensure_task_protocol(trace.context)
  264. self.assertEqual(1, state["task_brief_version"])
  265. self.assertEqual(
  266. ["不得编造证据", *[f"约束-{level}" for level in range(1, index + 1)]],
  267. state["task_brief"]["constraints"],
  268. )
  269. self.assertEqual(
  270. [f"直接父级结论 depth-{index - 1}"],
  271. state["task_brief"]["parent_findings"],
  272. )
  273. self.assertEqual(
  274. {"local_depth": index},
  275. state["task_brief"]["context"],
  276. )
  277. self.assertEqual([], state["task_brief"]["context_refs"])
  278. descriptors = context_ref_descriptors(trace.context)
  279. expected_kinds = (
  280. ["reviewed_task_result"]
  281. if index == 1
  282. else ["task_brief"]
  283. if index == 5
  284. else ["task_brief", "reviewed_task_result"]
  285. )
  286. self.assertEqual(
  287. expected_kinds,
  288. [item["kind"] for item in descriptors],
  289. )
  290. self.assertEqual([4, 5], read_depths)
  291. read_stats_after = get_tool_registry().get_stats("read_context_ref")[
  292. "read_context_ref"
  293. ]["call_count"]
  294. self.assertEqual(2, read_stats_after - read_stats_before)
  295. self.assertTrue(any("agent" in tools for tools in observed_tools[4]))
  296. self.assertTrue(all("evaluate" not in tools and "bash_command" not in tools for tools in observed_tools[5]))
  297. self.assertTrue(all("agent" not in tools for tools in observed_tools[5]))
  298. self.assertTrue(any("read_context_ref" in tools for tools in observed_tools[5]))
  299. validators = [
  300. trace for trace in traces
  301. if trace.context.get("created_by_tool") == "validator"
  302. ]
  303. self.assertEqual(6, len(validators))
  304. self.assertEqual(6, len(validator_packets))
  305. self.assertTrue(all(packet["root_task_anchor"] == ROOT_ANCHOR for packet in validator_packets))
  306. packets_with_real_ref_reads = [
  307. packet
  308. for packet in validator_packets
  309. if any(
  310. item.get("role") == "tool"
  311. and item.get("name") == "read_context_ref"
  312. for item in packet["trajectory"]
  313. )
  314. ]
  315. self.assertEqual(2, len(packets_with_real_ref_reads))
  316. self.assertTrue(ensure_task_protocol(business[0].context)["root_validation_passed"])
  317. usage = await ResourceBudgetController(store).get_usage(result["trace_id"])
  318. self.assertEqual(6, usage.total_agents)
  319. self.assertEqual(call_count, usage.llm_calls)
  320. self.assertEqual(token_total, usage.total_tokens)
  321. @staticmethod
  322. def _brief(depth):
  323. return {
  324. "objective": f"depth-{depth}",
  325. "reason": f"depth-{depth - 1} 需要直属子任务结果",
  326. "completion_criteria": [f"depth-{depth} 结果通过验收"],
  327. "expected_outputs": [f"depth-{depth} 可追溯结论"],
  328. "parent_findings": [f"直接父级结论 depth-{depth - 1}"],
  329. "context": {"local_depth": depth},
  330. "constraints": [f"约束-{depth}"],
  331. }
  332. @staticmethod
  333. def _report(depth):
  334. return {
  335. "summary": f"depth-{depth} 局部任务完成",
  336. "outcome": "satisfied",
  337. "validation": {"hard_passed": True, "open_issues": []},
  338. "next_step_suggestion": {
  339. "direction": "ASCEND",
  340. "reason": "当前层已满足完成标准",
  341. },
  342. "outputs": [{"depth": depth, "result": f"结论-{depth}"}],
  343. "evidence": [{"depth": depth, "source": "fake-persisted-trace"}],
  344. "remaining_issues": [],
  345. }
  346. if __name__ == "__main__":
  347. unittest.main()