test_recursive_runtime_extensions.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907
  1. import asyncio
  2. import json
  3. import os
  4. import tempfile
  5. import unittest
  6. from unittest.mock import patch
  7. from cyber_agent.core.agent_mode import RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY
  8. from cyber_agent.core.resource_budget import (
  9. RESOURCE_BUDGET_CONTEXT_KEY,
  10. ResourceBudget,
  11. ResourceBudgetController,
  12. )
  13. from cyber_agent.core.runner import AgentRunner, RunConfig
  14. from cyber_agent.core.task_protocol import ensure_task_protocol, new_task_protocol
  15. from cyber_agent.tools import get_tool_registry
  16. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  17. from cyber_agent.tools.builtin.subagent import agent
  18. from cyber_agent.tools.registry import ToolRegistry
  19. from cyber_agent.trace.models import Trace
  20. from cyber_agent.trace.store import FileSystemTraceStore
  21. ROOT_CRITERIA = ["return a checked final answer"]
  22. CHILD_BRIEF = {
  23. "objective": "check one concrete fact",
  24. "reason": "the root needs independently checked evidence",
  25. "completion_criteria": ["return one supported conclusion"],
  26. "expected_outputs": ["one checked conclusion"],
  27. "constraints": ["do not guess"],
  28. }
  29. def knowledge_disabled():
  30. return KnowledgeConfig(
  31. enable_extraction=False,
  32. enable_completion_extraction=False,
  33. enable_injection=False,
  34. )
  35. def recursive_env(**overrides):
  36. values = {
  37. "AGENT_MODE": "recursive",
  38. "AGENT_RESOURCE_BUDGET_ENABLED": "true",
  39. "AGENT_MAX_TOTAL_AGENTS": "50",
  40. "AGENT_MAX_LLM_CALLS": "150",
  41. "AGENT_MAX_TOTAL_TOKENS": "1500000",
  42. "AGENT_MAX_TOTAL_COST_USD": "15",
  43. "AGENT_MAX_DURATION_SECONDS": "3600",
  44. "AGENT_RESERVED_FINAL_CALLS": "1",
  45. }
  46. values.update(overrides)
  47. return patch.dict(os.environ, values, clear=False)
  48. def tool_names(schemas):
  49. return {
  50. schema["function"]["name"]
  51. for schema in schemas or []
  52. }
  53. def is_validator_call(messages):
  54. return any(
  55. message.get("role") == "system"
  56. and "independent validator" in str(message.get("content", ""))
  57. for message in messages
  58. )
  59. def validator_response(messages, outcome="passed"):
  60. packet = json.loads(messages[-1]["content"])
  61. scope = packet["validation_scope"]
  62. passed = outcome == "passed"
  63. return {
  64. "content": json.dumps({
  65. "outcome": outcome,
  66. "scope": scope,
  67. "reason": "the persisted record was checked",
  68. "issues": [] if passed else ["the answer lacks required support"],
  69. "retry_from": None if passed else "evidence",
  70. }),
  71. "tool_calls": [],
  72. "finish_reason": "stop",
  73. "prompt_tokens": 3,
  74. "completion_tokens": 2,
  75. "cost": 0.001,
  76. }
  77. class RootValidationIntegrationTest(unittest.IsolatedAsyncioTestCase):
  78. async def asyncSetUp(self):
  79. self.temp_dir = tempfile.TemporaryDirectory()
  80. self.store = FileSystemTraceStore(self.temp_dir.name)
  81. async def asyncTearDown(self):
  82. self.temp_dir.cleanup()
  83. def config(self, **overrides):
  84. values = {
  85. "tools": [],
  86. "tool_groups": [],
  87. "enable_research_flow": False,
  88. "root_completion_criteria": ROOT_CRITERIA,
  89. "knowledge": knowledge_disabled(),
  90. }
  91. values.update(overrides)
  92. return RunConfig(**values)
  93. async def test_root_completes_only_after_independent_validator_passes(self):
  94. calls = []
  95. async def llm_call(**kwargs):
  96. calls.append(kwargs)
  97. if is_validator_call(kwargs["messages"]):
  98. return validator_response(kwargs["messages"])
  99. return {
  100. "content": "checked final answer",
  101. "tool_calls": [],
  102. "finish_reason": "stop",
  103. "prompt_tokens": 5,
  104. "completion_tokens": 4,
  105. "cost": 0.002,
  106. }
  107. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  108. with recursive_env():
  109. result = await runner.run_result(
  110. messages=[{"role": "user", "content": "answer carefully"}],
  111. config=self.config(),
  112. )
  113. self.assertEqual("completed", result["status"])
  114. root = await self.store.get_trace(result["trace_id"])
  115. state = ensure_task_protocol(root.context)
  116. self.assertTrue(state["root_validation_passed"])
  117. self.assertEqual(1, state["root_validation_attempts"])
  118. validators = await self.store.list_traces(
  119. parent_trace_id=root.trace_id,
  120. created_by_tool="validator",
  121. limit=10,
  122. )
  123. self.assertEqual(1, len(validators))
  124. self.assertEqual(root.trace_id, validators[0].context["root_trace_id"])
  125. self.assertEqual([], validators[0].tools)
  126. usage = await ResourceBudgetController(self.store).get_usage(root.trace_id)
  127. self.assertEqual(1, usage.total_agents)
  128. self.assertEqual(2, usage.llm_calls)
  129. self.assertEqual(14, usage.total_tokens)
  130. self.assertEqual(2, len(calls))
  131. async def test_root_gets_one_revision_then_fails_on_second_rejection(self):
  132. ordinary_calls = 0
  133. validation_calls = 0
  134. async def llm_call(**kwargs):
  135. nonlocal ordinary_calls, validation_calls
  136. if is_validator_call(kwargs["messages"]):
  137. validation_calls += 1
  138. return validator_response(kwargs["messages"], outcome="failed")
  139. ordinary_calls += 1
  140. return {
  141. "content": f"candidate-{ordinary_calls}",
  142. "tool_calls": [],
  143. "finish_reason": "stop",
  144. }
  145. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  146. with recursive_env():
  147. result = await runner.run_result(
  148. messages=[{"role": "user", "content": "answer carefully"}],
  149. config=self.config(),
  150. )
  151. self.assertEqual("failed", result["status"])
  152. self.assertEqual(2, ordinary_calls)
  153. self.assertEqual(2, validation_calls)
  154. root = await self.store.get_trace(result["trace_id"])
  155. state = ensure_task_protocol(root.context)
  156. self.assertEqual(2, state["root_validation_attempts"])
  157. self.assertEqual(
  158. ["failed", "failed"],
  159. [item["outcome"] for item in state["root_validation_history"]],
  160. )
  161. self.assertFalse(state["root_validation_passed"])
  162. async def test_root_can_complete_after_one_validator_requested_revision(self):
  163. ordinary_calls = 0
  164. validation_outcomes = iter(["failed", "passed"])
  165. second_candidate_messages = None
  166. async def llm_call(**kwargs):
  167. nonlocal ordinary_calls, second_candidate_messages
  168. if is_validator_call(kwargs["messages"]):
  169. return validator_response(
  170. kwargs["messages"],
  171. outcome=next(validation_outcomes),
  172. )
  173. ordinary_calls += 1
  174. if ordinary_calls == 2:
  175. second_candidate_messages = kwargs["messages"]
  176. return {
  177. "content": f"candidate-{ordinary_calls}",
  178. "tool_calls": [],
  179. "finish_reason": "stop",
  180. }
  181. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  182. with recursive_env():
  183. result = await runner.run_result(
  184. messages=[{"role": "user", "content": "revise if needed"}],
  185. config=self.config(),
  186. )
  187. self.assertEqual("completed", result["status"])
  188. root = await self.store.get_trace(result["trace_id"])
  189. state = ensure_task_protocol(root.context)
  190. self.assertEqual(2, state["root_validation_attempts"])
  191. self.assertEqual(
  192. ["failed", "passed"],
  193. [item["outcome"] for item in state["root_validation_history"]],
  194. )
  195. self.assertTrue(any(
  196. message.get("role") == "user"
  197. and "framework-owned ValidationResult" in str(message.get("content"))
  198. for message in second_candidate_messages
  199. ))
  200. persisted_messages = await self.store.get_trace_messages(root.trace_id)
  201. self.assertTrue(any(
  202. message.role == "user"
  203. and "framework-owned ValidationResult" in str(message.content)
  204. for message in persisted_messages
  205. ))
  206. calls_after_completion = 0
  207. async def must_not_call(**_kwargs):
  208. nonlocal calls_after_completion
  209. calls_after_completion += 1
  210. raise AssertionError("a third validation attempt must not start")
  211. resumed = AgentRunner(trace_store=self.store, llm_call=must_not_call)
  212. with recursive_env():
  213. with self.assertRaisesRegex(ValueError, "two independent"):
  214. await resumed.run_result(
  215. [],
  216. self.config(trace_id=root.trace_id),
  217. )
  218. self.assertEqual(0, calls_after_completion)
  219. async def test_root_validator_reads_current_tool_result_and_candidate(self):
  220. registry = ToolRegistry()
  221. async def evidence_tool():
  222. return {"verified": "persisted-tool-result"}
  223. registry.register(evidence_tool, groups=["test"])
  224. ordinary_calls = 0
  225. validator_packet = None
  226. async def llm_call(**kwargs):
  227. nonlocal ordinary_calls, validator_packet
  228. if is_validator_call(kwargs["messages"]):
  229. validator_packet = json.loads(kwargs["messages"][-1]["content"])
  230. return validator_response(kwargs["messages"])
  231. ordinary_calls += 1
  232. if ordinary_calls == 1:
  233. return {
  234. "content": "",
  235. "tool_calls": [{
  236. "id": "evidence-call",
  237. "type": "function",
  238. "function": {
  239. "name": "evidence_tool",
  240. "arguments": "{}",
  241. },
  242. }],
  243. "finish_reason": "tool_calls",
  244. }
  245. return {
  246. "content": "candidate-from-real-tool-result",
  247. "tool_calls": [],
  248. "finish_reason": "stop",
  249. }
  250. runner = AgentRunner(
  251. trace_store=self.store,
  252. tool_registry=registry,
  253. llm_call=llm_call,
  254. )
  255. with recursive_env():
  256. result = await runner.run_result(
  257. [{"role": "user", "content": "use the evidence tool"}],
  258. self.config(tools=["evidence_tool"]),
  259. )
  260. self.assertEqual("completed", result["status"])
  261. trajectory = validator_packet["trajectory"]
  262. self.assertTrue(any(
  263. item.get("role") == "tool"
  264. and "persisted-tool-result" in str(item.get("content"))
  265. for item in trajectory
  266. ))
  267. self.assertTrue(any(
  268. item.get("role") == "assistant"
  269. and "candidate-from-real-tool-result" in str(item.get("content"))
  270. for item in trajectory
  271. ))
  272. async def test_new_recursive_root_requires_explicit_completion_criteria(self):
  273. async def llm_call(**_kwargs):
  274. raise AssertionError("LLM must not be called")
  275. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  276. with recursive_env():
  277. with self.assertRaisesRegex(ValueError, "root_completion_criteria"):
  278. await runner.run_result(
  279. messages=[{"role": "user", "content": "missing criteria"}],
  280. config=self.config(root_completion_criteria=[]),
  281. )
  282. self.assertEqual([], await self.store.list_traces(limit=10))
  283. async def test_old_recursive_revision_two_trace_is_not_silently_migrated(self):
  284. trace_id = "old-recursive-experiment"
  285. await self.store.create_trace(Trace(
  286. trace_id=trace_id,
  287. mode="agent",
  288. context={
  289. "agent_mode": "recursive",
  290. "agent_mode_revision": 2,
  291. "agent_depth": 0,
  292. "root_trace_id": trace_id,
  293. "task_protocol": {},
  294. },
  295. ))
  296. runner = AgentRunner(trace_store=self.store, llm_call=lambda **_: None)
  297. with self.assertRaisesRegex(ValueError, "create a new trace"):
  298. await runner.run_result([], RunConfig(trace_id=trace_id))
  299. async def test_validator_trace_cannot_be_continued_as_an_agent(self):
  300. validator_id = "root@validator-fixture"
  301. await self.store.create_trace(Trace(
  302. trace_id=validator_id,
  303. mode="agent",
  304. agent_type="validator",
  305. tools=[],
  306. status="completed",
  307. context={
  308. "created_by_tool": "validator",
  309. "agent_mode": "recursive",
  310. "agent_mode_revision": 2,
  311. "root_trace_id": "root",
  312. },
  313. ))
  314. calls = 0
  315. async def llm_call(**_kwargs):
  316. nonlocal calls
  317. calls += 1
  318. return {"content": "must not run", "tool_calls": []}
  319. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  320. with self.assertRaisesRegex(ValueError, "cannot be continued"):
  321. await runner.run_result(
  322. [{"role": "user", "content": "gain tools"}],
  323. RunConfig(trace_id=validator_id),
  324. )
  325. self.assertEqual(0, calls)
  326. validator = await self.store.get_trace(validator_id)
  327. self.assertEqual("completed", validator.status)
  328. self.assertEqual([], validator.tools)
  329. class RecursiveBudgetIntegrationTest(unittest.IsolatedAsyncioTestCase):
  330. async def asyncSetUp(self):
  331. self.temp_dir = tempfile.TemporaryDirectory()
  332. self.store = FileSystemTraceStore(self.temp_dir.name)
  333. async def asyncTearDown(self):
  334. self.temp_dir.cleanup()
  335. async def test_response_over_token_budget_never_executes_its_tool_call(self):
  336. dangerous_calls = 0
  337. registry = ToolRegistry()
  338. async def dangerous():
  339. nonlocal dangerous_calls
  340. dangerous_calls += 1
  341. return "must not run"
  342. registry.register(dangerous, groups=["test"])
  343. async def llm_call(**_kwargs):
  344. return {
  345. "content": "over-budget response",
  346. "tool_calls": [{
  347. "id": "dangerous-call",
  348. "type": "function",
  349. "function": {"name": "dangerous", "arguments": "{}"},
  350. }],
  351. "finish_reason": "tool_calls",
  352. "prompt_tokens": 8,
  353. "completion_tokens": 8,
  354. }
  355. runner = AgentRunner(
  356. trace_store=self.store,
  357. tool_registry=registry,
  358. llm_call=llm_call,
  359. )
  360. config = RunConfig(
  361. tools=["dangerous"],
  362. tool_groups=[],
  363. enable_research_flow=False,
  364. root_completion_criteria=ROOT_CRITERIA,
  365. knowledge=knowledge_disabled(),
  366. )
  367. with recursive_env(AGENT_MAX_TOTAL_TOKENS="10"):
  368. result = await runner.run_result(
  369. [{"role": "user", "content": "do not overspend"}],
  370. config,
  371. )
  372. self.assertEqual("failed", result["status"])
  373. self.assertEqual(0, dangerous_calls)
  374. root = await self.store.get_trace(result["trace_id"])
  375. self.assertEqual("budget_exhausted:tokens", root.context["termination_reason"])
  376. usage = await ResourceBudgetController(self.store).get_usage(root.trace_id)
  377. self.assertEqual(16, usage.total_tokens)
  378. self.assertEqual("budget_exhausted:tokens", usage.exhausted_reason)
  379. assistant = [
  380. message
  381. for message in await self.store.get_trace_messages(root.trace_id)
  382. if message.role == "assistant"
  383. ]
  384. self.assertIsNone(assistant[-1].content["tool_calls"])
  385. async def test_agent_budget_denial_blocks_child_but_preserves_root_validator(self):
  386. ordinary_calls = 0
  387. async def llm_call(**kwargs):
  388. nonlocal ordinary_calls
  389. if is_validator_call(kwargs["messages"]):
  390. return validator_response(kwargs["messages"])
  391. ordinary_calls += 1
  392. if ordinary_calls == 1:
  393. return {
  394. "content": "",
  395. "tool_calls": [{
  396. "id": "delegate",
  397. "type": "function",
  398. "function": {
  399. "name": "agent",
  400. "arguments": json.dumps({"task_brief": CHILD_BRIEF}),
  401. },
  402. }],
  403. "finish_reason": "tool_calls",
  404. }
  405. return {
  406. "content": "root can still finish without a child",
  407. "tool_calls": [],
  408. "finish_reason": "stop",
  409. }
  410. runner = AgentRunner(
  411. trace_store=self.store,
  412. tool_registry=get_tool_registry(),
  413. llm_call=llm_call,
  414. )
  415. config = RunConfig(
  416. tools=["agent"],
  417. tool_groups=[],
  418. enable_research_flow=False,
  419. root_completion_criteria=ROOT_CRITERIA,
  420. knowledge=knowledge_disabled(),
  421. )
  422. with recursive_env(AGENT_MAX_TOTAL_AGENTS="1"):
  423. result = await runner.run_result(
  424. [{"role": "user", "content": "try one delegated check"}],
  425. config,
  426. )
  427. self.assertEqual("completed", result["status"])
  428. root = await self.store.get_trace(result["trace_id"])
  429. children = await self.store.list_traces(
  430. parent_trace_id=root.trace_id,
  431. created_by_tool="agent",
  432. limit=10,
  433. )
  434. self.assertEqual([], children)
  435. usage = await ResourceBudgetController(self.store).get_usage(root.trace_id)
  436. self.assertEqual(1, usage.total_agents)
  437. self.assertEqual("budget_exhausted:agents", usage.exhausted_reason)
  438. self.assertTrue(ensure_task_protocol(root.context)["root_validation_passed"])
  439. async def test_persisted_child_is_not_refunded_when_goal_write_fails(self):
  440. root_id = "partial-child-create-root"
  441. budget = ResourceBudget()
  442. await self.store.create_trace(Trace(
  443. trace_id=root_id,
  444. mode="agent",
  445. uid="user-1",
  446. model="fake",
  447. context={
  448. "agent_mode": "recursive",
  449. "agent_mode_revision": 2,
  450. "agent_depth": 0,
  451. "root_trace_id": root_id,
  452. "root_completion_criteria": ROOT_CRITERIA,
  453. RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
  454. "task_protocol": new_task_protocol(),
  455. },
  456. ))
  457. await ResourceBudgetController(self.store).initialize(root_id, budget)
  458. original_update_goal_tree = self.store.update_goal_tree
  459. async def fail_child_goal_write(trace_id, goal_tree):
  460. if trace_id != root_id:
  461. raise RuntimeError("simulated child GoalTree write failure")
  462. return await original_update_goal_tree(trace_id, goal_tree)
  463. self.store.update_goal_tree = fail_child_goal_write
  464. runner = AgentRunner(
  465. trace_store=self.store,
  466. tool_registry=get_tool_registry(),
  467. llm_call=lambda **_: None,
  468. )
  469. with self.assertRaisesRegex(RuntimeError, "GoalTree write failure"):
  470. await agent(
  471. task_brief=CHILD_BRIEF,
  472. context={
  473. "store": self.store,
  474. "trace_id": root_id,
  475. "runner": runner,
  476. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: ["agent"],
  477. },
  478. )
  479. children = await self.store.list_traces(
  480. parent_trace_id=root_id,
  481. created_by_tool="agent",
  482. limit=10,
  483. )
  484. self.assertEqual(1, len(children))
  485. self.assertEqual("failed", children[0].status)
  486. self.assertIn("Child initialization failed", children[0].error_message)
  487. usage = await ResourceBudgetController(self.store).get_usage(root_id)
  488. self.assertEqual(2, usage.total_agents)
  489. async def test_parent_goal_projection_failure_closes_precreated_child(self):
  490. root_id = "parent-goal-projection-root"
  491. budget = ResourceBudget()
  492. await self.store.create_trace(Trace(
  493. trace_id=root_id,
  494. mode="agent",
  495. uid="user-1",
  496. model="fake",
  497. context={
  498. "agent_mode": "recursive",
  499. "agent_mode_revision": 2,
  500. "agent_depth": 0,
  501. "root_trace_id": root_id,
  502. "root_completion_criteria": ROOT_CRITERIA,
  503. RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
  504. "task_protocol": new_task_protocol(),
  505. },
  506. ))
  507. await ResourceBudgetController(self.store).initialize(root_id, budget)
  508. runner = AgentRunner(
  509. trace_store=self.store,
  510. tool_registry=get_tool_registry(),
  511. llm_call=lambda **_: None,
  512. )
  513. async def fail_parent_projection(*_args, **_kwargs):
  514. raise RuntimeError("simulated parent Goal projection failure")
  515. with patch(
  516. "cyber_agent.tools.builtin.subagent._update_goal_start",
  517. new=fail_parent_projection,
  518. ):
  519. with self.assertRaisesRegex(RuntimeError, "projection failure"):
  520. await agent(
  521. task_brief=CHILD_BRIEF,
  522. context={
  523. "store": self.store,
  524. "trace_id": root_id,
  525. "runner": runner,
  526. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: ["agent"],
  527. },
  528. )
  529. children = await self.store.list_traces(
  530. parent_trace_id=root_id,
  531. created_by_tool="agent",
  532. limit=10,
  533. )
  534. self.assertEqual(1, len(children))
  535. self.assertEqual("failed", children[0].status)
  536. usage = await ResourceBudgetController(self.store).get_usage(root_id)
  537. self.assertEqual(2, usage.total_agents)
  538. async def test_approved_action_write_failure_closes_precreated_child(self):
  539. root_id = "approved-action-write-root"
  540. budget = ResourceBudget()
  541. context = {
  542. "agent_mode": "recursive",
  543. "agent_mode_revision": 2,
  544. "agent_depth": 0,
  545. "root_trace_id": root_id,
  546. "root_completion_criteria": ROOT_CRITERIA,
  547. RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
  548. "task_protocol": new_task_protocol(),
  549. }
  550. state = ensure_task_protocol(context)
  551. state["next_actions"] = [{
  552. "decision": "ACCEPT_REFINE",
  553. "child_trace_id": "reviewed-child",
  554. "goal_id": None,
  555. "task_brief": CHILD_BRIEF,
  556. "created_at_sequence": 1,
  557. }]
  558. await self.store.create_trace(Trace(
  559. trace_id=root_id,
  560. mode="agent",
  561. uid="user-1",
  562. model="fake",
  563. context=context,
  564. ))
  565. await ResourceBudgetController(self.store).initialize(root_id, budget)
  566. runner = AgentRunner(
  567. trace_store=self.store,
  568. tool_registry=get_tool_registry(),
  569. llm_call=lambda **_: None,
  570. )
  571. original_update_trace = self.store.update_trace
  572. rejected_parent_write = False
  573. async def fail_approved_action_write(trace_id, **updates):
  574. nonlocal rejected_parent_write
  575. if trace_id == root_id and "context" in updates and not rejected_parent_write:
  576. rejected_parent_write = True
  577. raise RuntimeError("simulated approved action write failure")
  578. return await original_update_trace(trace_id, **updates)
  579. self.store.update_trace = fail_approved_action_write
  580. with self.assertRaisesRegex(RuntimeError, "approved action write failure"):
  581. await agent(
  582. task_brief=CHILD_BRIEF,
  583. context={
  584. "store": self.store,
  585. "trace_id": root_id,
  586. "runner": runner,
  587. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: ["agent"],
  588. },
  589. )
  590. children = await self.store.list_traces(
  591. parent_trace_id=root_id,
  592. created_by_tool="agent",
  593. limit=10,
  594. )
  595. self.assertEqual(1, len(children))
  596. self.assertEqual("failed", children[0].status)
  597. usage = await ResourceBudgetController(self.store).get_usage(root_id)
  598. self.assertEqual(2, usage.total_agents)
  599. class RecursiveLifecycleIntegrationTest(unittest.IsolatedAsyncioTestCase):
  600. async def asyncSetUp(self):
  601. self.temp_dir = tempfile.TemporaryDirectory()
  602. self.store = FileSystemTraceStore(self.temp_dir.name)
  603. async def asyncTearDown(self):
  604. self.temp_dir.cleanup()
  605. async def test_child_report_is_validated_before_parent_review(self):
  606. child_submitted = False
  607. root_delegated = False
  608. root_reviewed = False
  609. async def llm_call(**kwargs):
  610. nonlocal child_submitted, root_delegated, root_reviewed
  611. messages = kwargs["messages"]
  612. if is_validator_call(messages):
  613. return validator_response(messages)
  614. names = tool_names(kwargs.get("tools"))
  615. user_text = "\n".join(
  616. str(message.get("content", ""))
  617. for message in messages
  618. if message.get("role") == "user"
  619. )
  620. if "submit_task_report" in names and not child_submitted:
  621. child_submitted = True
  622. return {
  623. "content": "",
  624. "tool_calls": [{
  625. "id": "submit-report",
  626. "type": "function",
  627. "function": {
  628. "name": "submit_task_report",
  629. "arguments": json.dumps({
  630. "task_report": {
  631. "summary": "one fact was checked",
  632. "outcome": "satisfied",
  633. "validation": {
  634. "hard_passed": True,
  635. "open_issues": [],
  636. },
  637. "next_step_suggestion": {
  638. "direction": "NONE",
  639. "reason": "the delegated check is complete",
  640. },
  641. "outputs": [{"kind": "answer", "value": "checked"}],
  642. "evidence": [{"kind": "tool", "value": "persisted"}],
  643. "remaining_issues": [],
  644. }
  645. }),
  646. },
  647. }],
  648. "finish_reason": "tool_calls",
  649. }
  650. if "# Task Brief" in user_text:
  651. return {
  652. "content": "structured child report submitted",
  653. "tool_calls": [],
  654. "finish_reason": "stop",
  655. }
  656. if names == {"review_task_result"}:
  657. roots = [
  658. trace
  659. for trace in await self.store.list_traces(limit=20)
  660. if trace.parent_trace_id is None
  661. ]
  662. root = roots[0]
  663. pending = ensure_task_protocol(root.context)["pending_reviews"]
  664. child_id = next(iter(pending))
  665. root_reviewed = True
  666. return {
  667. "content": "",
  668. "tool_calls": [{
  669. "id": "review-report",
  670. "type": "function",
  671. "function": {
  672. "name": "review_task_result",
  673. "arguments": json.dumps({
  674. "child_trace_id": child_id,
  675. "decision": "ASCEND",
  676. "reason": "the validated child satisfies this goal",
  677. }),
  678. },
  679. }],
  680. "finish_reason": "tool_calls",
  681. }
  682. if "agent" in names and not root_delegated:
  683. root_delegated = True
  684. return {
  685. "content": "",
  686. "tool_calls": [{
  687. "id": "delegate-child",
  688. "type": "function",
  689. "function": {
  690. "name": "agent",
  691. "arguments": json.dumps({"task_brief": CHILD_BRIEF}),
  692. },
  693. }],
  694. "finish_reason": "tool_calls",
  695. }
  696. return {
  697. "content": "final answer based on the validated child",
  698. "tool_calls": [],
  699. "finish_reason": "stop",
  700. }
  701. runner = AgentRunner(
  702. trace_store=self.store,
  703. tool_registry=get_tool_registry(),
  704. llm_call=llm_call,
  705. )
  706. config = RunConfig(
  707. tools=["agent", "submit_task_report", "review_task_result"],
  708. tool_groups=[],
  709. enable_research_flow=False,
  710. root_completion_criteria=ROOT_CRITERIA,
  711. knowledge=knowledge_disabled(),
  712. )
  713. with recursive_env():
  714. result = await runner.run_result(
  715. [{"role": "user", "content": "perform one delegated check"}],
  716. config,
  717. )
  718. self.assertEqual("completed", result["status"])
  719. self.assertTrue(root_reviewed)
  720. root = await self.store.get_trace(result["trace_id"])
  721. business_children = await self.store.list_traces(
  722. parent_trace_id=root.trace_id,
  723. created_by_tool="agent",
  724. limit=10,
  725. )
  726. self.assertEqual(1, len(business_children))
  727. child = business_children[0]
  728. child_validators = await self.store.list_traces(
  729. parent_trace_id=child.trace_id,
  730. created_by_tool="validator",
  731. limit=10,
  732. )
  733. root_validators = await self.store.list_traces(
  734. parent_trace_id=root.trace_id,
  735. created_by_tool="validator",
  736. limit=10,
  737. )
  738. self.assertEqual(1, len(child_validators))
  739. self.assertEqual(1, len(root_validators))
  740. self.assertEqual(
  741. child.trace_id,
  742. child_validators[0].context["evaluated_trace_id"],
  743. )
  744. child_state = ensure_task_protocol(child.context)
  745. self.assertEqual(
  746. child_validators[0].trace_id,
  747. child_state["task_report_validation"]["validation_result"][
  748. "validator_trace_id"
  749. ],
  750. )
  751. root_state = ensure_task_protocol(root.context)
  752. self.assertEqual({}, root_state["pending_reviews"])
  753. self.assertEqual(child.trace_id, root_state["reviews"][0]["child_trace_id"])
  754. usage = await ResourceBudgetController(self.store).get_usage(root.trace_id)
  755. self.assertEqual(2, usage.total_agents)
  756. self.assertEqual(7, usage.llm_calls)
  757. async def test_parent_stop_reaches_validator_after_child_runner_finished(self):
  758. root_id = "validator-cancel-root"
  759. child_id = "validator-cancel-child"
  760. budget = ResourceBudget()
  761. await self.store.create_trace(Trace(
  762. trace_id=root_id,
  763. mode="agent",
  764. model="fake-model",
  765. context={
  766. "agent_mode": "recursive",
  767. "agent_mode_revision": 2,
  768. "agent_depth": 0,
  769. "root_trace_id": root_id,
  770. "root_completion_criteria": ROOT_CRITERIA,
  771. RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
  772. "task_protocol": new_task_protocol(),
  773. },
  774. ))
  775. await self.store.create_trace(Trace(
  776. trace_id=child_id,
  777. mode="agent",
  778. model="fake-model",
  779. parent_trace_id=root_id,
  780. context={
  781. "agent_mode": "recursive",
  782. "agent_mode_revision": 2,
  783. "agent_depth": 1,
  784. "root_trace_id": root_id,
  785. "created_by_tool": "agent",
  786. "task_protocol": new_task_protocol(CHILD_BRIEF),
  787. },
  788. ))
  789. await ResourceBudgetController(self.store).initialize(
  790. root_id,
  791. budget,
  792. initial_agents=2,
  793. )
  794. started = asyncio.Event()
  795. release = asyncio.Event()
  796. async def llm_call(**kwargs):
  797. started.set()
  798. await release.wait()
  799. return validator_response(kwargs["messages"])
  800. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  801. root_event = asyncio.Event()
  802. runner._cancel_events[root_id] = root_event
  803. runner._recursive_active_traces[root_id] = root_event
  804. validation_task = asyncio.create_task(runner.validate_recursive_trace(
  805. child_id,
  806. scope="task",
  807. task_brief=CHILD_BRIEF,
  808. task_report={"summary": "candidate"},
  809. ))
  810. await asyncio.wait_for(started.wait(), timeout=1)
  811. self.assertIn(child_id, runner._active_children[root_id])
  812. self.assertTrue(await runner.stop(root_id))
  813. release.set()
  814. validation_run = await asyncio.wait_for(validation_task, timeout=1)
  815. self.assertEqual("error", validation_run.result.outcome)
  816. validator = await self.store.get_trace(validation_run.trace_id)
  817. self.assertEqual("failed", validator.status)
  818. self.assertEqual(child_id, validator.parent_trace_id)
  819. self.assertEqual(root_id, validator.context["root_trace_id"])
  820. self.assertNotIn(child_id, runner._recursive_active_traces)
  821. self.assertNotIn(validation_run.trace_id, runner._recursive_active_traces)
  822. if __name__ == "__main__":
  823. unittest.main()