test_task_protocol.py 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382
  1. import json
  2. import tempfile
  3. import unittest
  4. from unittest.mock import AsyncMock, patch
  5. from pydantic import ValidationError
  6. from cyber_agent.core.agent_mode import RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY
  7. from cyber_agent.core.resource_budget import (
  8. RESOURCE_BUDGET_CONTEXT_KEY,
  9. ResourceBudget,
  10. ResourceBudgetController,
  11. )
  12. from cyber_agent.core.runner import AgentRunner, RunConfig
  13. from cyber_agent.core.task_protocol import (
  14. TaskBrief,
  15. TaskReport,
  16. TaskReview,
  17. ensure_task_protocol,
  18. new_task_protocol,
  19. )
  20. from cyber_agent.tools import get_tool_registry
  21. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  22. from cyber_agent.tools.builtin.subagent import _record_pending_task_reports, agent
  23. from cyber_agent.tools.builtin.task_protocol import (
  24. review_task_result,
  25. submit_task_report,
  26. )
  27. from cyber_agent.trace.goal_models import Goal, GoalTree
  28. from cyber_agent.trace.goal_tool import goal_tool
  29. from cyber_agent.trace.models import Message, Trace
  30. from cyber_agent.trace.store import FileSystemTraceStore
  31. from cyber_agent.core.validation import ValidationResult, ValidationRun
  32. BRIEF = {
  33. "objective": "research one concrete question",
  34. "reason": "the parent needs one independently checked result",
  35. "completion_criteria": ["provide one checked conclusion"],
  36. "expected_outputs": ["one checked conclusion"],
  37. "constraints": ["do not guess"],
  38. }
  39. def report_payload(*, outcome="satisfied"):
  40. return {
  41. "summary": "checked result",
  42. "outcome": outcome,
  43. "validation": {
  44. "hard_passed": outcome == "satisfied",
  45. "open_issues": [] if outcome == "satisfied" else ["still failing"],
  46. },
  47. "next_step_suggestion": {
  48. "direction": "NONE",
  49. "reason": "No additional task is needed",
  50. },
  51. "outputs": [{"kind": "note", "value": "result"}],
  52. "evidence": [{"kind": "test", "value": "passed"}],
  53. "remaining_issues": [] if outcome == "satisfied" else ["still failing"],
  54. }
  55. def validation_payload(child_trace_id, *, outcome="passed"):
  56. if outcome == "passed":
  57. issues = []
  58. retry_from = None
  59. elif outcome == "failed":
  60. issues = ["task needs revision"]
  61. retry_from = "task_definition"
  62. else:
  63. issues = ["validator could not evaluate the task"]
  64. retry_from = None
  65. return ValidationResult(
  66. validator_trace_id=f"{child_trace_id}@validator-fixture",
  67. evaluated_trace_id=child_trace_id,
  68. outcome=outcome,
  69. scope="task",
  70. reason="fixture validation",
  71. issues=issues,
  72. retry_from=retry_from,
  73. ).model_dump()
  74. async def create_recursive_root(store, trace_id, *, state=None, uid="user-1"):
  75. budget = ResourceBudget()
  76. context = {
  77. "agent_mode": "recursive",
  78. "agent_mode_revision": 2,
  79. "agent_depth": 0,
  80. "root_trace_id": trace_id,
  81. "root_completion_criteria": ["finish the root task"],
  82. RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
  83. "task_protocol": state or new_task_protocol(),
  84. }
  85. await store.create_trace(Trace(
  86. trace_id=trace_id,
  87. mode="agent",
  88. task="root",
  89. uid=uid,
  90. model="fake-model",
  91. context=context,
  92. ))
  93. await ResourceBudgetController(store).initialize(
  94. trace_id,
  95. budget,
  96. initial_agents=1,
  97. )
  98. return await store.get_trace(trace_id)
  99. class FakeToolRegistry:
  100. def get_tool_names(self):
  101. return [
  102. "agent",
  103. "evaluate",
  104. "bash_command",
  105. "goal",
  106. "read_file",
  107. "submit_task_report",
  108. "review_task_result",
  109. ]
  110. class ReportingRunner:
  111. def __init__(self, store):
  112. self.store = store
  113. self.tools = FakeToolRegistry()
  114. self.debug = False
  115. self.stdin_check = None
  116. self._cancel_events = {}
  117. self.configs = []
  118. self.resource_budget = ResourceBudgetController(store)
  119. self.validation_count = 0
  120. async def _resource_budget_for_trace(self, trace_id):
  121. trace = await self.store.get_trace(trace_id)
  122. root_id = trace.context["root_trace_id"]
  123. root = trace if root_id == trace_id else await self.store.get_trace(root_id)
  124. return root_id, ResourceBudget.from_dict(
  125. root.context[RESOURCE_BUDGET_CONTEXT_KEY]
  126. )
  127. async def validate_recursive_trace(
  128. self,
  129. evaluated_trace_id,
  130. *,
  131. scope,
  132. deterministic_failure=None,
  133. **_kwargs,
  134. ):
  135. self.validation_count += 1
  136. evaluated = await self.store.get_trace(evaluated_trace_id)
  137. validator_trace_id = (
  138. f"{evaluated_trace_id}@validator-fixture-{self.validation_count}"
  139. )
  140. if deterministic_failure:
  141. outcome = deterministic_failure.get("outcome", "error")
  142. issues = deterministic_failure.get("issues") or [
  143. deterministic_failure.get("reason", "validation failed")
  144. ]
  145. retry_from = deterministic_failure.get("retry_from")
  146. else:
  147. outcome = "passed"
  148. issues = []
  149. retry_from = None
  150. result = ValidationResult(
  151. validator_trace_id=validator_trace_id,
  152. evaluated_trace_id=evaluated_trace_id,
  153. outcome=outcome,
  154. scope=scope,
  155. reason="fixture validation",
  156. issues=issues,
  157. retry_from=retry_from,
  158. )
  159. await self.store.create_trace(Trace(
  160. trace_id=validator_trace_id,
  161. mode="agent",
  162. task=f"validate {evaluated_trace_id}",
  163. parent_trace_id=evaluated_trace_id,
  164. uid=evaluated.uid,
  165. status="completed",
  166. tools=[],
  167. context={
  168. "created_by_tool": "validator",
  169. "root_trace_id": evaluated.context["root_trace_id"],
  170. "agent_depth": evaluated.context["agent_depth"],
  171. },
  172. ))
  173. return ValidationRun(result=result, trace_id=validator_trace_id)
  174. async def run_result(self, messages, config, on_event=None):
  175. self.configs.append(config)
  176. submitted = await submit_task_report(
  177. task_report=report_payload(),
  178. context={
  179. "store": self.store,
  180. "trace_id": config.trace_id,
  181. "sequence": 3,
  182. },
  183. )
  184. if submitted["status"] != "completed":
  185. raise AssertionError(submitted)
  186. await self.store.update_trace(config.trace_id, status="completed")
  187. return {
  188. "status": "completed",
  189. "summary": "child submitted TaskReport",
  190. "saved_knowledge_ids": [],
  191. "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
  192. }
  193. class FailedAfterReportingRunner(ReportingRunner):
  194. async def run_result(self, messages, config, on_event=None):
  195. result = await super().run_result(messages, config, on_event)
  196. await self.store.update_trace(
  197. config.trace_id,
  198. status="failed",
  199. error_message="runner failed after report submission",
  200. )
  201. return {
  202. **result,
  203. "status": "failed",
  204. "error": "runner failed after report submission",
  205. }
  206. class ForgedIdentityRunner(ReportingRunner):
  207. async def run_result(self, messages, config, on_event=None):
  208. trace = await self.store.get_trace(config.trace_id)
  209. state = ensure_task_protocol(trace.context)
  210. state["task_report"] = TaskReport(
  211. child_trace_id="someone-elses-trace",
  212. **report_payload(),
  213. ).model_dump()
  214. await self.store.update_trace(
  215. config.trace_id,
  216. context=trace.context,
  217. status="completed",
  218. )
  219. return {
  220. "status": "completed",
  221. "summary": "forged report identity",
  222. "saved_knowledge_ids": [],
  223. "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
  224. }
  225. class RecursiveTaskProtocolTest(unittest.IsolatedAsyncioTestCase):
  226. async def asyncSetUp(self):
  227. self.temp_dir = tempfile.TemporaryDirectory()
  228. self.store = FileSystemTraceStore(self.temp_dir.name)
  229. self.runner = ReportingRunner(self.store)
  230. self.root_id = "recursive-root"
  231. await create_recursive_root(self.store, self.root_id)
  232. self.tree = GoalTree(
  233. mission="root",
  234. goals=[Goal(id="1", description="delegate", status="in_progress")],
  235. current_id="1",
  236. )
  237. await self.store.update_goal_tree(self.root_id, self.tree)
  238. async def asyncTearDown(self):
  239. self.temp_dir.cleanup()
  240. def context(self, trace_id=None, goal_id="1", tree=None):
  241. return {
  242. "store": self.store,
  243. "trace_id": trace_id or self.root_id,
  244. "goal_id": goal_id,
  245. "runner": self.runner,
  246. "goal_tree": tree or self.tree,
  247. "sequence": 10,
  248. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: self.runner.tools.get_tool_names(),
  249. }
  250. async def children(self):
  251. return await self.store.list_traces(
  252. parent_trace_id=self.root_id,
  253. created_by_tool="agent",
  254. limit=20,
  255. )
  256. async def test_models_validate_and_round_trip(self):
  257. brief = TaskBrief.model_validate(BRIEF)
  258. self.assertEqual(
  259. {**BRIEF, "parent_findings": [], "context": {}},
  260. brief.model_dump(exclude_none=True),
  261. )
  262. with self.assertRaises(ValidationError):
  263. TaskBrief(objective="", completion_criteria=[])
  264. with self.assertRaises(ValidationError):
  265. TaskReview(
  266. parent_trace_id="p",
  267. child_trace_id="c",
  268. decision="ACCEPT_REFINE",
  269. reason="continue",
  270. )
  271. invalid_suggestion = report_payload()
  272. invalid_suggestion["next_step_suggestion"] = {
  273. "direction": "NONE",
  274. "reason": "No next task should be attached",
  275. "suggested_next_task": BRIEF,
  276. }
  277. with self.assertRaises(ValidationError):
  278. TaskReport(child_trace_id="child", **invalid_suggestion)
  279. root_submission = await submit_task_report(
  280. task_report=report_payload(),
  281. context={"store": self.store, "trace_id": self.root_id},
  282. )
  283. self.assertEqual("failed", root_submission["status"])
  284. self.assertIn("Root Agent", root_submission["error"])
  285. async def test_child_report_blocks_new_child_until_parent_review(self):
  286. result = await agent(task_brief=BRIEF, context=self.context())
  287. self.assertEqual("completed", result["status"])
  288. child_id = result["sub_trace_id"]
  289. self.assertEqual(child_id, result["task_report"]["child_trace_id"])
  290. parent = await self.store.get_trace(self.root_id)
  291. state = ensure_task_protocol(parent.context)
  292. self.assertEqual([child_id], list(state["pending_reviews"]))
  293. tree = await self.store.get_goal_tree(self.root_id)
  294. self.assertEqual("pending_review", tree.find("1").status)
  295. restarted_runner = AgentRunner(tool_registry=get_tool_registry())
  296. restored = await self.store.get_trace(self.root_id)
  297. restored_names = {
  298. schema["function"]["name"]
  299. for schema in restarted_runner._get_runtime_tool_schemas(
  300. RunConfig(), restored
  301. )
  302. }
  303. self.assertEqual({"review_task_result"}, restored_names)
  304. blocked = await agent(task_brief=BRIEF, context=self.context())
  305. self.assertEqual("failed", blocked["status"])
  306. self.assertIn("Review pending", blocked["error"])
  307. async def test_child_inherits_brief_constraints_not_parent_runtime_context(self):
  308. parent = await self.store.get_trace(self.root_id)
  309. parent.context["parent_private_runtime"] = {"secret": "must-not-leak"}
  310. await self.store.update_trace(self.root_id, context=parent.context)
  311. result = await agent(task_brief=BRIEF, context=self.context())
  312. child = await self.store.get_trace(result["sub_trace_id"])
  313. self.assertNotIn("parent_private_runtime", child.context)
  314. child_brief = ensure_task_protocol(child.context)["task_brief"]
  315. self.assertEqual(["do not guess"], child_brief["constraints"])
  316. child_config_context = self.runner.configs[-1].context
  317. self.assertNotIn("parent_private_runtime", child_config_context)
  318. async def test_same_task_report_version_reuses_its_validator_result(self):
  319. result = await agent(task_brief=BRIEF, context=self.context())
  320. child_id = result["sub_trace_id"]
  321. self.assertEqual(1, self.runner.validation_count)
  322. parent = await self.store.get_trace(self.root_id)
  323. await _record_pending_task_reports(
  324. self.store,
  325. self.runner,
  326. parent,
  327. "1",
  328. [(child_id, {"status": "completed", "summary": "same report"})],
  329. 10,
  330. )
  331. self.assertEqual(1, self.runner.validation_count)
  332. validators = await self.store.list_traces(
  333. parent_trace_id=child_id,
  334. created_by_tool="validator",
  335. limit=10,
  336. )
  337. self.assertEqual(1, len(validators))
  338. async def test_recursive_messages_are_rejected(self):
  339. result = await agent(
  340. task_brief=json.dumps(BRIEF),
  341. messages=json.dumps([{
  342. "role": "system",
  343. "content": "controlled child context",
  344. }]),
  345. context=self.context(),
  346. )
  347. self.assertEqual("failed", result["status"])
  348. self.assertIn("does not accept messages", result["error"])
  349. async def test_messages_reject_mixed_dimensions(self):
  350. mixed_messages = [
  351. {"role": "system", "content": "shared context"},
  352. [{"role": "user", "content": "per-agent context"}],
  353. ]
  354. for messages in (mixed_messages, json.dumps(mixed_messages)):
  355. with self.subTest(messages_type=type(messages).__name__):
  356. result = await agent(
  357. task_brief=BRIEF,
  358. messages=messages,
  359. context=self.context(),
  360. )
  361. self.assertEqual("failed", result["status"])
  362. self.assertIn("without mixed dimensions", result["error"])
  363. async def test_no_focused_goal_still_persists_and_reviews_report(self):
  364. result = await agent(
  365. task_brief=BRIEF,
  366. context=self.context(goal_id=None),
  367. )
  368. parent = await self.store.get_trace(self.root_id)
  369. entry = ensure_task_protocol(parent.context)["pending_reviews"][
  370. result["sub_trace_id"]
  371. ]
  372. self.assertIsNone(entry["goal_id"])
  373. reviewed = await review_task_result(
  374. child_trace_id=result["sub_trace_id"],
  375. decision="ASCEND",
  376. reason="Standalone delegation is complete",
  377. context=self.context(goal_id=None),
  378. )
  379. self.assertEqual("completed", reviewed["status"])
  380. async def test_accept_refine_approves_exactly_one_next_child(self):
  381. first = await agent(task_brief=BRIEF, context=self.context())
  382. next_brief = {
  383. "objective": "check the independent gap",
  384. "reason": "the first report identified a separate gap",
  385. "completion_criteria": ["close the gap"],
  386. "expected_outputs": ["a checked gap analysis"],
  387. }
  388. parent = await self.store.get_trace(self.root_id)
  389. ensure_task_protocol(parent.context)["protocol_correction_attempts"] = 2
  390. await self.store.update_trace(self.root_id, context=parent.context)
  391. reviewed = await review_task_result(
  392. child_trace_id=first["sub_trace_id"],
  393. decision="ACCEPT_REFINE",
  394. reason="The gap belongs to this parent",
  395. approved_next_task=json.dumps(next_brief),
  396. context=self.context(),
  397. )
  398. self.assertEqual("completed", reviewed["status"])
  399. parent = await self.store.get_trace(self.root_id)
  400. self.assertEqual(
  401. 0,
  402. ensure_task_protocol(parent.context)["protocol_correction_attempts"],
  403. )
  404. schemas = AgentRunner(
  405. tool_registry=get_tool_registry()
  406. )._get_runtime_tool_schemas(RunConfig(), parent)
  407. self.assertEqual(
  408. {"agent"},
  409. {schema["function"]["name"] for schema in schemas},
  410. )
  411. wrong = await agent(task_brief=BRIEF, context=self.context())
  412. self.assertEqual("failed", wrong["status"])
  413. self.assertIn("approved next task", wrong["error"])
  414. second = await agent(task_brief=next_brief, context=self.context())
  415. self.assertEqual("completed", second["status"])
  416. self.assertEqual(2, len(await self.children()))
  417. parent = await self.store.get_trace(self.root_id)
  418. self.assertEqual([], ensure_task_protocol(parent.context)["next_actions"])
  419. async def test_batch_reports_are_reviewed_one_by_one(self):
  420. briefs = [
  421. BRIEF,
  422. {
  423. "objective": "research second question",
  424. "reason": "the batch needs a second independent result",
  425. "completion_criteria": ["provide second conclusion"],
  426. "expected_outputs": ["a second conclusion"],
  427. },
  428. ]
  429. result = await agent(task_brief=briefs, context=self.context())
  430. child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
  431. self.assertEqual(2, len(result["task_reports"]))
  432. premature = await review_task_result(
  433. child_trace_id=child_ids[0],
  434. decision="ASCEND",
  435. reason="Cannot finish while another report is pending",
  436. context=self.context(),
  437. )
  438. self.assertEqual("failed", premature["status"])
  439. self.assertIn("final pending report", premature["error"])
  440. first_review = await review_task_result(
  441. child_trace_id=child_ids[0],
  442. decision="REJECT_REFINE",
  443. reason="No extra task needed for this report",
  444. context=self.context(),
  445. )
  446. self.assertEqual(1, first_review["pending_review_count"])
  447. tree = await self.store.get_goal_tree(self.root_id)
  448. self.assertEqual("pending_review", tree.find("1").status)
  449. final_review = await review_task_result(
  450. child_trace_id=child_ids[1],
  451. decision="ASCEND",
  452. reason="All completion criteria are satisfied",
  453. context=self.context(),
  454. )
  455. self.assertEqual(0, final_review["pending_review_count"])
  456. tree = await self.store.get_goal_tree(self.root_id)
  457. self.assertEqual("completed", tree.find("1").status)
  458. duplicate = await review_task_result(
  459. child_trace_id=child_ids[1],
  460. decision="ASCEND",
  461. reason="Duplicate review must fail",
  462. context=self.context(),
  463. )
  464. self.assertEqual("failed", duplicate["status"])
  465. self.assertIn("no pending TaskReport", duplicate["error"])
  466. async def test_terminal_review_waits_for_queued_next_action(self):
  467. result = await agent(
  468. task_brief=[
  469. BRIEF,
  470. {
  471. "objective": "second batch child",
  472. "reason": "exercise queued batch behavior",
  473. "completion_criteria": ["finish the second child"],
  474. "expected_outputs": ["the second child result"],
  475. },
  476. ],
  477. context=self.context(),
  478. )
  479. child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
  480. next_brief = {
  481. "objective": "approved follow-up",
  482. "reason": "the parent approved one follow-up",
  483. "completion_criteria": ["finish the approved follow-up"],
  484. "expected_outputs": ["the approved follow-up result"],
  485. }
  486. accepted = await review_task_result(
  487. child_trace_id=child_ids[0],
  488. decision="ACCEPT_REFINE",
  489. reason="A separate follow-up is required",
  490. approved_next_task=next_brief,
  491. context=self.context(),
  492. )
  493. self.assertEqual("completed", accepted["status"])
  494. blocked = await review_task_result(
  495. child_trace_id=child_ids[1],
  496. decision="ASCEND",
  497. reason="Cannot ascend before the accepted follow-up",
  498. context=self.context(),
  499. )
  500. self.assertEqual("failed", blocked["status"])
  501. self.assertIn("approved next action", blocked["error"])
  502. tree = await self.store.get_goal_tree(self.root_id)
  503. self.assertEqual("pending_review", tree.find("1").status)
  504. final_non_terminal = await review_task_result(
  505. child_trace_id=child_ids[1],
  506. decision="REJECT_REFINE",
  507. reason="This report adds no other follow-up",
  508. context=self.context(),
  509. )
  510. self.assertEqual("completed", final_non_terminal["status"])
  511. tree = await self.store.get_goal_tree(self.root_id)
  512. self.assertEqual("in_progress", tree.find("1").status)
  513. async def test_runner_failure_overrides_stale_satisfied_report(self):
  514. self.runner = FailedAfterReportingRunner(self.store)
  515. result = await agent(task_brief=BRIEF, context=self.context())
  516. self.assertEqual("failed", result["status"])
  517. self.assertEqual("protocol_error", result["task_report"]["outcome"])
  518. child = await self.store.get_trace(result["sub_trace_id"])
  519. child_state = ensure_task_protocol(child.context)
  520. self.assertEqual(1, len(child_state["report_history"]))
  521. self.assertEqual("satisfied", child_state["report_history"][0]["outcome"])
  522. invalid_ascend = await review_task_result(
  523. child_trace_id=result["sub_trace_id"],
  524. decision="ASCEND",
  525. reason="A failed execution cannot ascend",
  526. context=self.context(),
  527. )
  528. self.assertEqual("failed", invalid_ascend["status"])
  529. self.assertIn("only allow", invalid_ascend["error"])
  530. async def test_review_rejects_wrong_owner_even_if_report_is_injected(self):
  531. first = await agent(task_brief=BRIEF, context=self.context())
  532. child_id = first["sub_trace_id"]
  533. await self.store.update_trace(child_id, uid="another-user")
  534. rejected = await review_task_result(
  535. child_trace_id=child_id,
  536. decision="ASCEND",
  537. reason="Owner mismatch must be rejected",
  538. context=self.context(),
  539. )
  540. self.assertEqual("failed", rejected["status"])
  541. self.assertIn("same owner", rejected["error"])
  542. async def test_report_identity_must_match_direct_child(self):
  543. self.runner = ForgedIdentityRunner(self.store)
  544. result = await agent(task_brief=BRIEF, context=self.context())
  545. child_id = result["sub_trace_id"]
  546. self.assertEqual("protocol_error", result["task_report"]["outcome"])
  547. self.assertEqual(child_id, result["task_report"]["child_trace_id"])
  548. parent = await self.store.get_trace(self.root_id)
  549. pending_report = ensure_task_protocol(parent.context)["pending_reviews"][
  550. child_id
  551. ]["task_report"]
  552. pending_report["child_trace_id"] = "corrupted-after-recording"
  553. await self.store.update_trace(self.root_id, context=parent.context)
  554. rejected = await review_task_result(
  555. child_trace_id=child_id,
  556. decision="FAIL",
  557. reason="Corrupted identity cannot be reviewed",
  558. context=self.context(),
  559. )
  560. self.assertEqual("failed", rejected["status"])
  561. self.assertIn("does not match", rejected["error"])
  562. async def test_remote_agent_keeps_legacy_task_interface(self):
  563. remote_result = {
  564. "status": "completed",
  565. "summary": "remote result",
  566. }
  567. with patch(
  568. "cyber_agent.tools.builtin.subagent._run_remote_agent",
  569. new=AsyncMock(return_value=remote_result),
  570. ) as remote_call:
  571. result = await agent(
  572. task="remote free text",
  573. agent_type="remote_librarian",
  574. )
  575. self.assertEqual(remote_result, result)
  576. self.assertEqual("remote free text", remote_call.await_args.kwargs["task"])
  577. async def test_revise_child_reuses_trace_and_preserves_report_history(self):
  578. first = await agent(task_brief=BRIEF, context=self.context())
  579. child_id = first["sub_trace_id"]
  580. reviewed = await review_task_result(
  581. child_trace_id=child_id,
  582. decision="REVISE_CHILD",
  583. reason="Please strengthen the evidence",
  584. context=self.context(),
  585. )
  586. self.assertEqual("completed", reviewed["status"])
  587. wrong = await agent(
  588. task_brief={
  589. "objective": "silently replace the reviewed task",
  590. "reason": "attempt a mismatched revision",
  591. "completion_criteria": ["this must be rejected"],
  592. "expected_outputs": ["a rejected replacement"],
  593. },
  594. continue_from=child_id,
  595. context=self.context(),
  596. )
  597. self.assertEqual("failed", wrong["status"])
  598. self.assertIn("approved revision", wrong["error"])
  599. revised = await agent(
  600. task_brief=BRIEF,
  601. continue_from=child_id,
  602. context=self.context(),
  603. )
  604. self.assertEqual(child_id, revised["sub_trace_id"])
  605. self.assertTrue(revised["continue_from"])
  606. self.assertEqual(1, len(await self.children()))
  607. child = await self.store.get_trace(child_id)
  608. self.assertEqual(1, len(ensure_task_protocol(child.context)["report_history"]))
  609. self.assertEqual(
  610. TaskBrief.model_validate(BRIEF).model_dump(),
  611. ensure_task_protocol(child.context)["task_brief"],
  612. )
  613. async def test_review_cannot_reserve_more_children_than_quota(self):
  614. briefs = [
  615. {
  616. "objective": f"batch child {index}",
  617. "reason": "fill the local child quota",
  618. "completion_criteria": [f"finish child {index}"],
  619. "expected_outputs": [f"result for child {index}"],
  620. }
  621. for index in range(5)
  622. ]
  623. result = await agent(task_brief=briefs, context=self.context())
  624. child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
  625. next_brief = {
  626. "objective": "one allowed reserved child",
  627. "reason": "use the final available child slot",
  628. "completion_criteria": ["use the final available slot"],
  629. "expected_outputs": ["one final child result"],
  630. }
  631. first = await review_task_result(
  632. child_trace_id=child_ids[0],
  633. decision="ACCEPT_REFINE",
  634. reason="Reserve the sixth and final child slot",
  635. approved_next_task=next_brief,
  636. context=self.context(),
  637. )
  638. self.assertEqual("completed", first["status"])
  639. overflow = await review_task_result(
  640. child_trace_id=child_ids[1],
  641. decision="DESCEND_AGAIN",
  642. reason="A seventh child must not be approved",
  643. approved_next_task={
  644. "objective": "overflow child",
  645. "reason": "exercise quota rejection",
  646. "completion_criteria": ["must never be scheduled"],
  647. "expected_outputs": ["no output because creation is rejected"],
  648. },
  649. context=self.context(),
  650. )
  651. self.assertEqual("failed", overflow["status"])
  652. self.assertIn("reserved=1", overflow["error"])
  653. parent = await self.store.get_trace(self.root_id)
  654. state = ensure_task_protocol(parent.context)
  655. self.assertEqual(1, len(state["next_actions"]))
  656. self.assertIn(child_ids[1], state["pending_reviews"])
  657. async def test_recursive_goal_completion_does_not_cascade_or_select_sibling(self):
  658. tree = GoalTree(mission="recursive")
  659. parent = tree.add_goals(["parent"])[0]
  660. first, second = tree.add_goals(["first", "second"], parent_id=parent.id)
  661. tree.focus(parent.id)
  662. tree.focus(first.id)
  663. tree.complete(
  664. first.id,
  665. "done",
  666. auto_advance=False,
  667. cascade_parent=False,
  668. )
  669. self.assertEqual(parent.id, tree.current_id)
  670. self.assertEqual("pending", second.status)
  671. self.assertNotEqual("completed", parent.status)
  672. async def test_rewind_before_review_restores_report_and_review_gate(self):
  673. delegated = await agent(task_brief=BRIEF, context=self.context())
  674. child_id = delegated["sub_trace_id"]
  675. reviewed = await review_task_result(
  676. child_trace_id=child_id,
  677. decision="ASCEND",
  678. reason="Initially accepted before rewind",
  679. context={**self.context(), "sequence": 20},
  680. )
  681. self.assertEqual("completed", reviewed["status"])
  682. for sequence in (1, 15):
  683. await self.store.add_message(Message.create(
  684. trace_id=self.root_id,
  685. role="user",
  686. sequence=sequence,
  687. content=f"message-{sequence}",
  688. ))
  689. tree = await self.store.get_goal_tree(self.root_id)
  690. runner = AgentRunner(
  691. trace_store=self.store,
  692. tool_registry=get_tool_registry(),
  693. )
  694. await runner._rewind(self.root_id, 15, tree)
  695. parent = await self.store.get_trace(self.root_id)
  696. state = ensure_task_protocol(parent.context)
  697. self.assertIn(child_id, state["pending_reviews"])
  698. self.assertEqual([], state["reviews"])
  699. restored_tree = await self.store.get_goal_tree(self.root_id)
  700. self.assertEqual("pending_review", restored_tree.find("1").status)
  701. schemas = runner._get_runtime_tool_schemas(RunConfig(), parent)
  702. self.assertEqual(
  703. {"review_task_result"},
  704. {schema["function"]["name"] for schema in schemas},
  705. )
  706. async def test_grandchild_report_is_reviewed_only_by_direct_child(self):
  707. child_id = "manual-child"
  708. child_context = {
  709. "agent_mode": "recursive",
  710. "agent_mode_revision": 2,
  711. "agent_depth": 1,
  712. "root_trace_id": self.root_id,
  713. "created_by_tool": "agent",
  714. "task_protocol": new_task_protocol(BRIEF),
  715. }
  716. await self.store.create_trace(Trace(
  717. trace_id=child_id,
  718. mode="agent",
  719. task="child",
  720. parent_trace_id=self.root_id,
  721. uid="user-1",
  722. model="fake-model",
  723. context=child_context,
  724. ))
  725. child_tree = GoalTree(
  726. mission="child",
  727. goals=[Goal(id="1", description="delegate grandchild", status="in_progress")],
  728. current_id="1",
  729. )
  730. await self.store.update_goal_tree(child_id, child_tree)
  731. grandchild = await agent(
  732. task_brief=BRIEF,
  733. context=self.context(trace_id=child_id, tree=child_tree),
  734. )
  735. child = await self.store.get_trace(child_id)
  736. child_state = ensure_task_protocol(child.context)
  737. self.assertIn(grandchild["sub_trace_id"], child_state["pending_reviews"])
  738. root = await self.store.get_trace(self.root_id)
  739. self.assertEqual({}, ensure_task_protocol(root.context)["pending_reviews"])
  740. blocked = await agent(
  741. task_brief=BRIEF,
  742. context=self.context(trace_id=child_id, tree=child_tree),
  743. )
  744. self.assertEqual("failed", blocked["status"])
  745. reviewed = await review_task_result(
  746. child_trace_id=grandchild["sub_trace_id"],
  747. decision="ASCEND",
  748. reason="Grandchild satisfied the child criteria",
  749. context=self.context(trace_id=child_id, tree=child_tree),
  750. )
  751. self.assertEqual("completed", reviewed["status"])
  752. submitted = await submit_task_report(
  753. task_report=json.dumps(report_payload()),
  754. context={"store": self.store, "trace_id": child_id, "sequence": 20},
  755. )
  756. self.assertEqual("completed", submitted["status"])
  757. async def test_child_must_execute_approved_action_before_own_report(self):
  758. child_id = "child-with-approved-action"
  759. child_context = {
  760. "agent_mode": "recursive",
  761. "agent_mode_revision": 2,
  762. "agent_depth": 1,
  763. "root_trace_id": self.root_id,
  764. "created_by_tool": "agent",
  765. "task_protocol": new_task_protocol(BRIEF),
  766. }
  767. await self.store.create_trace(Trace(
  768. trace_id=child_id,
  769. mode="agent",
  770. task="child",
  771. parent_trace_id=self.root_id,
  772. uid="user-1",
  773. model="fake-model",
  774. context=child_context,
  775. ))
  776. child_tree = GoalTree(
  777. mission="child",
  778. goals=[Goal(id="1", description="delegate", status="in_progress")],
  779. current_id="1",
  780. )
  781. await self.store.update_goal_tree(child_id, child_tree)
  782. child_tool_context = self.context(trace_id=child_id, tree=child_tree)
  783. first = await agent(task_brief=BRIEF, context=child_tool_context)
  784. next_brief = {
  785. "objective": "check a second grandchild task",
  786. "reason": "the child approved another direct task",
  787. "completion_criteria": ["provide a second checked conclusion"],
  788. "expected_outputs": ["a second checked conclusion"],
  789. }
  790. reviewed = await review_task_result(
  791. child_trace_id=first["sub_trace_id"],
  792. decision="ACCEPT_REFINE",
  793. reason="A separate gap remains",
  794. approved_next_task=next_brief,
  795. context=child_tool_context,
  796. )
  797. self.assertEqual("completed", reviewed["status"])
  798. blocked_report = await submit_task_report(
  799. task_report=report_payload(),
  800. context={"store": self.store, "trace_id": child_id, "sequence": 20},
  801. )
  802. self.assertEqual("failed", blocked_report["status"])
  803. self.assertIn("approved next action", blocked_report["error"])
  804. second = await agent(task_brief=next_brief, context=child_tool_context)
  805. self.assertEqual("completed", second["status"])
  806. child = await self.store.get_trace(child_id)
  807. state = ensure_task_protocol(child.context)
  808. self.assertEqual([], state["next_actions"])
  809. self.assertIn(second["sub_trace_id"], state["pending_reviews"])
  810. async def test_goal_tool_cannot_bypass_queued_next_action(self):
  811. root = await self.store.get_trace(self.root_id)
  812. state = ensure_task_protocol(root.context)
  813. state["next_actions"] = [{
  814. "decision": "ACCEPT_REFINE",
  815. "child_trace_id": "reviewed-child",
  816. "goal_id": "1",
  817. "task_brief": BRIEF,
  818. "created_at_sequence": 5,
  819. }]
  820. await self.store.update_trace(self.root_id, context=root.context)
  821. result = await goal_tool(
  822. tree=self.tree,
  823. store=self.store,
  824. trace_id=self.root_id,
  825. done="attempt to bypass the approved action",
  826. )
  827. self.assertIn("已批准的下一步待执行", result)
  828. persisted_tree = await self.store.get_goal_tree(self.root_id)
  829. self.assertEqual("in_progress", persisted_tree.find("1").status)
  830. async def test_failed_report_allows_only_revise_or_fail(self):
  831. child_id = "failed-child"
  832. failed_report = TaskReport(
  833. child_trace_id=child_id,
  834. **report_payload(outcome="failed"),
  835. )
  836. child_context = {
  837. "agent_mode": "recursive",
  838. "agent_mode_revision": 2,
  839. "agent_depth": 1,
  840. "root_trace_id": self.root_id,
  841. "created_by_tool": "agent",
  842. "task_protocol": new_task_protocol(BRIEF),
  843. }
  844. child_context["task_protocol"]["task_report"] = failed_report.model_dump()
  845. await self.store.create_trace(Trace(
  846. trace_id=child_id,
  847. mode="agent",
  848. task="failed",
  849. parent_trace_id=self.root_id,
  850. parent_goal_id="1",
  851. uid="user-1",
  852. context=child_context,
  853. ))
  854. root = await self.store.get_trace(self.root_id)
  855. state = ensure_task_protocol(root.context)
  856. state["pending_reviews"][child_id] = {
  857. "goal_id": "1",
  858. "task_report": failed_report.model_dump(),
  859. "validation_result": validation_payload(child_id, outcome="failed"),
  860. "received_at_sequence": 2,
  861. }
  862. await self.store.update_trace(self.root_id, context=root.context)
  863. rejected = await review_task_result(
  864. child_trace_id=child_id,
  865. decision="ASCEND",
  866. reason="incorrect",
  867. context=self.context(),
  868. )
  869. self.assertEqual("failed", rejected["status"])
  870. self.assertIn("only allow", rejected["error"])
  871. class RunnerCompletionGateTest(unittest.IsolatedAsyncioTestCase):
  872. async def asyncSetUp(self):
  873. self.temp_dir = tempfile.TemporaryDirectory()
  874. self.store = FileSystemTraceStore(self.temp_dir.name)
  875. self.trace_id = "gate-child"
  876. await create_recursive_root(self.store, "root")
  877. controller = ResourceBudgetController(self.store)
  878. budget = ResourceBudget()
  879. await controller.reserve_agents("root", budget, 1)
  880. await self.store.create_trace(Trace(
  881. trace_id=self.trace_id,
  882. mode="agent",
  883. task="must report",
  884. parent_trace_id="root",
  885. uid="user-1",
  886. context={
  887. "agent_mode": "recursive",
  888. "agent_mode_revision": 2,
  889. "agent_depth": 1,
  890. "root_trace_id": "root",
  891. "created_by_tool": "agent",
  892. "task_protocol": new_task_protocol(BRIEF),
  893. },
  894. ))
  895. await self.store.update_goal_tree(self.trace_id, GoalTree(mission="must report"))
  896. async def asyncTearDown(self):
  897. self.temp_dir.cleanup()
  898. async def test_missing_report_becomes_protocol_error_after_two_corrections(self):
  899. calls = 0
  900. async def llm_call(**kwargs):
  901. nonlocal calls
  902. calls += 1
  903. return {
  904. "content": "I am done",
  905. "tool_calls": [],
  906. "finish_reason": "stop",
  907. }
  908. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  909. result = await runner.run_result(
  910. messages=[],
  911. config=RunConfig(
  912. trace_id=self.trace_id,
  913. max_iterations=8,
  914. knowledge=KnowledgeConfig(
  915. enable_extraction=False,
  916. enable_completion_extraction=False,
  917. enable_injection=False,
  918. ),
  919. ),
  920. )
  921. self.assertEqual("failed", result["status"])
  922. trace = await self.store.get_trace(self.trace_id)
  923. report = ensure_task_protocol(trace.context)["task_report"]
  924. self.assertEqual("protocol_error", report["outcome"])
  925. self.assertGreaterEqual(calls, 3)
  926. async def test_valid_report_allows_child_to_finish(self):
  927. calls = 0
  928. async def llm_call(**kwargs):
  929. nonlocal calls
  930. calls += 1
  931. if kwargs.get("tools") == []:
  932. return {
  933. "content": json.dumps({
  934. "outcome": "passed",
  935. "scope": "root",
  936. "reason": "the corrected behavior meets the criterion",
  937. "issues": [],
  938. "retry_from": None,
  939. }),
  940. "tool_calls": [],
  941. "finish_reason": "stop",
  942. }
  943. if calls == 1:
  944. return {
  945. "content": "",
  946. "tool_calls": [{
  947. "id": "submit-1",
  948. "type": "function",
  949. "function": {
  950. "name": "submit_task_report",
  951. "arguments": json.dumps({
  952. "task_report": report_payload(),
  953. }),
  954. },
  955. }],
  956. "finish_reason": "tool_calls",
  957. }
  958. return {
  959. "content": "Structured report submitted.",
  960. "tool_calls": [],
  961. "finish_reason": "stop",
  962. }
  963. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  964. result = await runner.run_result(
  965. messages=[],
  966. config=RunConfig(
  967. trace_id=self.trace_id,
  968. max_iterations=4,
  969. knowledge=KnowledgeConfig(
  970. enable_extraction=False,
  971. enable_completion_extraction=False,
  972. enable_injection=False,
  973. ),
  974. ),
  975. )
  976. self.assertEqual("completed", result["status"])
  977. trace = await self.store.get_trace(self.trace_id)
  978. self.assertEqual(
  979. "satisfied",
  980. ensure_task_protocol(trace.context)["task_report"]["outcome"],
  981. )
  982. async def test_lifecycle_tool_batch_is_rejected_before_execution(self):
  983. root_id = "batch-gate-root"
  984. await create_recursive_root(self.store, root_id)
  985. await self.store.update_goal_tree(root_id, GoalTree(mission="root"))
  986. calls = 0
  987. async def llm_call(**kwargs):
  988. nonlocal calls
  989. calls += 1
  990. if kwargs.get("tools") == []:
  991. return {
  992. "content": json.dumps({
  993. "outcome": "passed",
  994. "scope": "root",
  995. "reason": "the corrected lifecycle behavior is valid",
  996. "issues": [],
  997. "retry_from": None,
  998. }),
  999. "tool_calls": [],
  1000. "finish_reason": "stop",
  1001. }
  1002. if calls == 1:
  1003. return {
  1004. "content": "",
  1005. "tool_calls": [
  1006. {
  1007. "id": "agent-1",
  1008. "type": "function",
  1009. "function": {
  1010. "name": "agent",
  1011. "arguments": json.dumps({
  1012. "task_brief": BRIEF,
  1013. }),
  1014. },
  1015. },
  1016. {
  1017. "id": "context-1",
  1018. "type": "function",
  1019. "function": {
  1020. "name": "get_current_context",
  1021. "arguments": "{}",
  1022. },
  1023. },
  1024. ],
  1025. "finish_reason": "tool_calls",
  1026. }
  1027. return {
  1028. "content": "Corrected the invalid batch.",
  1029. "tool_calls": [],
  1030. "finish_reason": "stop",
  1031. }
  1032. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  1033. result = await runner.run_result(
  1034. messages=[],
  1035. config=RunConfig(
  1036. trace_id=root_id,
  1037. max_iterations=3,
  1038. knowledge=KnowledgeConfig(
  1039. enable_extraction=False,
  1040. enable_completion_extraction=False,
  1041. enable_injection=False,
  1042. ),
  1043. ),
  1044. )
  1045. self.assertEqual("completed", result["status"])
  1046. children = await self.store.list_traces(
  1047. parent_trace_id=root_id,
  1048. created_by_tool="agent",
  1049. limit=10,
  1050. )
  1051. self.assertEqual([], children)
  1052. tool_messages = [
  1053. message
  1054. for message in await self.store.get_trace_messages(root_id)
  1055. if message.role == "tool"
  1056. ]
  1057. self.assertEqual(2, len(tool_messages))
  1058. self.assertTrue(all(
  1059. "only tool call" in str(message.content)
  1060. for message in tool_messages
  1061. ))
  1062. async def test_rewind_clears_protocol_state_after_cutoff(self):
  1063. trace = await self.store.get_trace(self.trace_id)
  1064. state = ensure_task_protocol(trace.context)
  1065. report = TaskReport(
  1066. child_trace_id=self.trace_id,
  1067. **report_payload(),
  1068. ).model_dump()
  1069. state["task_report"] = report
  1070. state["task_report_submitted_at_sequence"] = 5
  1071. state["pending_reviews"] = {
  1072. "grandchild": {
  1073. "goal_id": "1",
  1074. "task_report": report,
  1075. "validation_result": validation_payload("grandchild"),
  1076. "received_at_sequence": 6,
  1077. }
  1078. }
  1079. state["reviews"] = [{"reviewed_at_sequence": 7}]
  1080. state["next_actions"] = [{"created_at_sequence": 8}]
  1081. await self.store.update_trace(self.trace_id, context=trace.context)
  1082. tree = GoalTree(
  1083. mission="rewind",
  1084. goals=[Goal(id="1", description="active", status="pending_review")],
  1085. current_id="1",
  1086. )
  1087. await self.store.update_goal_tree(self.trace_id, tree)
  1088. for sequence in (1, 2):
  1089. await self.store.add_message(Message.create(
  1090. trace_id=self.trace_id,
  1091. role="user",
  1092. sequence=sequence,
  1093. content=f"message-{sequence}",
  1094. ))
  1095. runner = AgentRunner(trace_store=self.store)
  1096. cutoff = await runner._rewind(self.trace_id, 2, tree)
  1097. self.assertEqual(3, cutoff)
  1098. rewound = await self.store.get_trace(self.trace_id)
  1099. state = ensure_task_protocol(rewound.context)
  1100. self.assertIsNone(state["task_report"])
  1101. self.assertEqual(1, len(state["report_history"]))
  1102. self.assertEqual({}, state["pending_reviews"])
  1103. self.assertEqual([], state["reviews"])
  1104. self.assertEqual([], state["next_actions"])
  1105. rewound_tree = await self.store.get_goal_tree(self.trace_id)
  1106. self.assertEqual("pending", rewound_tree.find("1").status)
  1107. async def test_rewind_restores_preserved_pending_review_projection(self):
  1108. trace = await self.store.get_trace(self.trace_id)
  1109. state = ensure_task_protocol(trace.context)
  1110. report = TaskReport(
  1111. child_trace_id="preserved-grandchild",
  1112. **report_payload(),
  1113. ).model_dump()
  1114. state["pending_reviews"] = {
  1115. "preserved-grandchild": {
  1116. "goal_id": "1",
  1117. "task_report": report,
  1118. "validation_result": validation_payload(
  1119. "preserved-grandchild"
  1120. ),
  1121. "received_at_sequence": 2,
  1122. }
  1123. }
  1124. await self.store.update_trace(self.trace_id, context=trace.context)
  1125. tree = GoalTree(
  1126. mission="rewind",
  1127. goals=[Goal(id="1", description="review", status="pending_review")],
  1128. current_id="1",
  1129. )
  1130. await self.store.update_goal_tree(self.trace_id, tree)
  1131. for sequence in (1, 2):
  1132. await self.store.add_message(Message.create(
  1133. trace_id=self.trace_id,
  1134. role="user",
  1135. sequence=sequence,
  1136. content=f"message-{sequence}",
  1137. ))
  1138. runner = AgentRunner(trace_store=self.store)
  1139. await runner._rewind(self.trace_id, 2, tree)
  1140. rewound = await self.store.get_trace(self.trace_id)
  1141. self.assertIn(
  1142. "preserved-grandchild",
  1143. ensure_task_protocol(rewound.context)["pending_reviews"],
  1144. )
  1145. rewound_tree = await self.store.get_goal_tree(self.trace_id)
  1146. self.assertEqual("pending_review", rewound_tree.find("1").status)
  1147. async def test_root_cannot_finish_with_unconsumed_next_action(self):
  1148. root_id = "next-action-gate-root"
  1149. state = new_task_protocol()
  1150. state["next_actions"] = [{
  1151. "decision": "ACCEPT_REFINE",
  1152. "child_trace_id": "reviewed-child",
  1153. "goal_id": None,
  1154. "task_brief": BRIEF,
  1155. "created_at_sequence": 1,
  1156. }]
  1157. await create_recursive_root(self.store, root_id, state=state)
  1158. await self.store.update_goal_tree(root_id, GoalTree(mission="root"))
  1159. calls = 0
  1160. async def llm_call(**kwargs):
  1161. nonlocal calls
  1162. calls += 1
  1163. return {
  1164. "content": "Trying to finish without the approved action.",
  1165. "tool_calls": [],
  1166. "finish_reason": "stop",
  1167. }
  1168. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  1169. result = await runner.run_result(
  1170. messages=[],
  1171. config=RunConfig(
  1172. trace_id=root_id,
  1173. max_iterations=5,
  1174. knowledge=KnowledgeConfig(
  1175. enable_extraction=False,
  1176. enable_completion_extraction=False,
  1177. enable_injection=False,
  1178. ),
  1179. ),
  1180. )
  1181. self.assertEqual("failed", result["status"])
  1182. self.assertEqual(3, calls)
  1183. root = await self.store.get_trace(root_id)
  1184. self.assertEqual(1, len(ensure_task_protocol(root.context)["next_actions"]))
  1185. class RuntimeProtocolSchemaTest(unittest.TestCase):
  1186. def setUp(self):
  1187. self.runner = AgentRunner(tool_registry=get_tool_registry())
  1188. def names(self, schemas):
  1189. return {schema["function"]["name"] for schema in schemas}
  1190. def test_legacy_and_recursive_runtime_schemas_are_isolated(self):
  1191. legacy = Trace(
  1192. trace_id="legacy",
  1193. mode="agent",
  1194. context={"agent_mode": "legacy", "agent_mode_revision": 1},
  1195. )
  1196. legacy_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), legacy)
  1197. self.assertNotIn("submit_task_report", self.names(legacy_schemas))
  1198. self.assertNotIn("review_task_result", self.names(legacy_schemas))
  1199. legacy_agent = next(
  1200. item for item in legacy_schemas if item["function"]["name"] == "agent"
  1201. )
  1202. properties = legacy_agent["function"]["parameters"]["properties"]
  1203. self.assertIn("task", properties)
  1204. self.assertNotIn("task_brief", properties)
  1205. child = Trace(
  1206. trace_id="child",
  1207. mode="agent",
  1208. parent_trace_id="root",
  1209. context={
  1210. "agent_mode": "recursive",
  1211. "agent_mode_revision": 2,
  1212. "task_protocol": new_task_protocol(BRIEF),
  1213. },
  1214. )
  1215. child_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), child)
  1216. self.assertIn("submit_task_report", self.names(child_schemas))
  1217. self.assertNotIn("review_task_result", self.names(child_schemas))
  1218. self.assertNotIn("evaluate", self.names(child_schemas))
  1219. recursive_agent = next(
  1220. item for item in child_schemas if item["function"]["name"] == "agent"
  1221. )
  1222. recursive_properties = recursive_agent["function"]["parameters"]["properties"]
  1223. self.assertNotIn("messages", recursive_properties)
  1224. brief_object = recursive_properties["task_brief"]["oneOf"][0]
  1225. self.assertEqual(
  1226. {
  1227. "objective",
  1228. "reason",
  1229. "completion_criteria",
  1230. "expected_outputs",
  1231. },
  1232. set(brief_object["required"]),
  1233. )
  1234. state = ensure_task_protocol(child.context)
  1235. state["pending_reviews"]["grandchild"] = {"task_report": {}}
  1236. review_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), child)
  1237. self.assertEqual({"review_task_result"}, self.names(review_schemas))
  1238. def test_protocol_tools_publish_nested_object_schemas(self):
  1239. schemas = {
  1240. schema["function"]["name"]: schema
  1241. for schema in get_tool_registry().get_schemas([
  1242. "submit_task_report",
  1243. "review_task_result",
  1244. ])
  1245. }
  1246. report_schema = schemas["submit_task_report"]["function"]["parameters"]
  1247. task_report = report_schema["properties"]["task_report"]
  1248. self.assertEqual("object", task_report["type"])
  1249. self.assertIn("validation", task_report["properties"])
  1250. self.assertIn("next_step_suggestion", task_report["properties"])
  1251. review_schema = schemas["review_task_result"]["function"]["parameters"]
  1252. self.assertEqual(
  1253. [
  1254. "ACCEPT_REFINE",
  1255. "REJECT_REFINE",
  1256. "REVISE_CHILD",
  1257. "DESCEND_AGAIN",
  1258. "REPLAN_CURRENT",
  1259. "ASCEND",
  1260. "FAIL",
  1261. ],
  1262. review_schema["properties"]["decision"]["enum"],
  1263. )
  1264. self.assertEqual(
  1265. "object",
  1266. review_schema["properties"]["approved_next_task"]["type"],
  1267. )
  1268. if __name__ == "__main__":
  1269. unittest.main()