test_recursive_runtime_extensions.py 34 KB

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