test_recursive_runtime_extensions.py 35 KB

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