test_task_protocol.py 71 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865
  1. import asyncio
  2. import json
  3. import tempfile
  4. import unittest
  5. from unittest.mock import AsyncMock, patch
  6. from pydantic import ValidationError
  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.run_snapshot import (
  15. RunConfigSnapshotV1,
  16. persist_run_config_snapshot,
  17. )
  18. from cyber_agent.core.context_policy import (
  19. add_context_snapshot,
  20. create_context_snapshot,
  21. persist_root_task_anchor,
  22. )
  23. from cyber_agent.core.task_protocol import (
  24. TaskProgress,
  25. TaskBrief,
  26. TaskReport,
  27. TaskReview,
  28. append_task_progress_revision,
  29. ensure_task_protocol,
  30. initialize_task_progress,
  31. new_task_protocol,
  32. replace_task_brief,
  33. task_contract_ref,
  34. )
  35. from cyber_agent.core.task_protocol_service import TaskProtocolService
  36. from cyber_agent.tools import get_tool_registry
  37. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  38. from cyber_agent.tools.builtin.subagent import _record_pending_task_reports, agent
  39. from cyber_agent.tools.builtin.task_protocol import (
  40. review_task_result,
  41. submit_task_report,
  42. update_task_progress,
  43. )
  44. from cyber_agent.trace.goal_models import Goal, GoalTree
  45. from cyber_agent.trace.goal_tool import goal_tool
  46. from cyber_agent.trace.models import Message, Trace
  47. from cyber_agent.trace.store import FileSystemTraceStore
  48. from cyber_agent.core.validation import (
  49. ScopeValidationResult,
  50. ValidationCheck,
  51. ValidationPolicy,
  52. ValidationResult,
  53. ValidationRun,
  54. ValidatorSettings,
  55. persist_validation_policy,
  56. )
  57. BRIEF = {
  58. "objective": "research one concrete question",
  59. "reason": "the parent needs one independently checked result",
  60. "completion_criteria": ["provide one checked conclusion"],
  61. "expected_outputs": ["one checked conclusion"],
  62. "constraints": ["do not guess"],
  63. }
  64. ROOT_ANCHOR = {
  65. "objective": "finish the root task",
  66. "completion_criteria": ["finish the root task"],
  67. "constraints": ["do not guess"],
  68. }
  69. def with_root_anchor(context):
  70. persist_root_task_anchor(context, ROOT_ANCHOR)
  71. state = ensure_task_protocol(context)
  72. if state.get("task_progress_head_revision") is None:
  73. initialize_task_progress(
  74. state,
  75. root_task_anchor_hash=context.get("root_task_anchor_hash"),
  76. )
  77. current = state.get("task_progress_head_revision")
  78. if current == 1:
  79. append_task_progress_revision(
  80. state,
  81. TaskProgress(phase="ready_to_submit"),
  82. expected_revision=current,
  83. effective_at_sequence=0,
  84. contract_ref=task_contract_ref(
  85. state,
  86. root_task_anchor_hash=context.get("root_task_anchor_hash"),
  87. ),
  88. )
  89. return context
  90. def report_payload(*, outcome="satisfied"):
  91. return {
  92. "summary": "checked result",
  93. "outcome": outcome,
  94. "validation": {
  95. "hard_passed": outcome == "satisfied",
  96. "open_issues": [] if outcome == "satisfied" else ["still failing"],
  97. },
  98. "next_step_suggestion": {
  99. "direction": "NONE",
  100. "reason": "No additional task is needed",
  101. },
  102. "outputs": [{"kind": "note", "value": "result"}],
  103. "evidence": [{"kind": "test", "value": "passed"}],
  104. "remaining_issues": [] if outcome == "satisfied" else ["still failing"],
  105. }
  106. def validation_payload(child_trace_id, *, outcome="passed"):
  107. plan_hash = "a" * 64
  108. if outcome == "passed":
  109. issues = []
  110. retry_from = None
  111. elif outcome == "failed":
  112. issues = ["task needs revision"]
  113. retry_from = "task_definition"
  114. else:
  115. issues = ["validator could not evaluate the task"]
  116. retry_from = None
  117. scope_result = ScopeValidationResult(
  118. validator_trace_id=f"{child_trace_id}@validator-fixture",
  119. scope="task",
  120. outcome=outcome,
  121. checks=[ValidationCheck(
  122. check_id="task.fixture",
  123. status=("unknown" if outcome == "error" else outcome),
  124. issue=None if outcome == "passed" else issues[0],
  125. )],
  126. reason="fixture validation",
  127. retry_from=retry_from,
  128. plan_hash=plan_hash,
  129. )
  130. return ValidationResult(
  131. evaluated_trace_id=child_trace_id,
  132. outcome=outcome,
  133. scope_results=[scope_result],
  134. issues=issues,
  135. retry_from=retry_from,
  136. plan_hash=plan_hash,
  137. ).model_dump(mode="json")
  138. def passed_validator_response(kwargs):
  139. """按当前 ValidationPlan 返回完整的通过结果。"""
  140. messages = kwargs.get("messages") or []
  141. packet = json.loads(messages[1]["content"])
  142. scope = packet["validation_scope"]
  143. checks = [
  144. {
  145. "check_id": item["check_id"],
  146. "status": "passed",
  147. "evidence_refs": [],
  148. "issue": None,
  149. }
  150. for item in packet["validation_plan"]["checks"]
  151. if item["scope"] == scope
  152. ]
  153. return {
  154. "content": json.dumps({
  155. "outcome": "passed",
  156. "scope": scope,
  157. "checks": checks,
  158. "reason": "all planned checks passed",
  159. "retry_from": None,
  160. }),
  161. "tool_calls": [],
  162. "finish_reason": "stop",
  163. }
  164. async def create_recursive_root(store, trace_id, *, state=None, uid="user-1"):
  165. budget = ResourceBudget()
  166. context = {
  167. "agent_mode": "recursive",
  168. "agent_mode_revision": 3,
  169. "agent_depth": 0,
  170. "root_trace_id": trace_id,
  171. RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
  172. "task_protocol": state or new_task_protocol(),
  173. }
  174. with_root_anchor(context)
  175. persist_validation_policy(
  176. context,
  177. ValidationPolicy(),
  178. ValidatorSettings(),
  179. )
  180. await store.create_trace(Trace(
  181. trace_id=trace_id,
  182. mode="agent",
  183. task="root",
  184. uid=uid,
  185. model="fake-model",
  186. context=context,
  187. ))
  188. await ResourceBudgetController(store).initialize(
  189. trace_id,
  190. budget,
  191. initial_agents=1,
  192. )
  193. return await store.get_trace(trace_id)
  194. class FakeToolRegistry:
  195. def get_tool_names(self):
  196. return [
  197. "agent",
  198. "evaluate",
  199. "bash_command",
  200. "goal",
  201. "read_file",
  202. "update_task_progress",
  203. "submit_task_report",
  204. "review_task_result",
  205. "read_context_ref",
  206. ]
  207. class ReportingRunner:
  208. def __init__(self, store):
  209. self.store = store
  210. self.tools = FakeToolRegistry()
  211. self.debug = False
  212. self.stdin_check = None
  213. self._cancel_events = {}
  214. self.configs = []
  215. self.resource_budget = ResourceBudgetController(store)
  216. self.validation_count = 0
  217. async def _resource_budget_for_trace(self, trace_id):
  218. trace = await self.store.get_trace(trace_id)
  219. root_id = trace.context["root_trace_id"]
  220. root = trace if root_id == trace_id else await self.store.get_trace(root_id)
  221. return root_id, ResourceBudget.from_dict(
  222. root.context[RESOURCE_BUDGET_CONTEXT_KEY]
  223. )
  224. async def validate_recursive_trace(
  225. self,
  226. evaluated_trace_id,
  227. *,
  228. scope,
  229. deterministic_failure=None,
  230. **_kwargs,
  231. ):
  232. evaluated = await self.store.get_trace(evaluated_trace_id)
  233. child_state = ensure_task_protocol(evaluated.context)
  234. cached = child_state.get("task_report_validation")
  235. if isinstance(cached, dict) and cached.get("aggregate_result"):
  236. result = ValidationResult.model_validate(cached["aggregate_result"])
  237. return ValidationRun(
  238. result=result,
  239. trace_ids=[
  240. item.validator_trace_id for item in result.scope_results
  241. ],
  242. cached=True,
  243. )
  244. self.validation_count += 1
  245. validator_trace_id = (
  246. f"{evaluated_trace_id}@validator-fixture-{self.validation_count}"
  247. )
  248. if deterministic_failure:
  249. outcome = deterministic_failure.get("outcome", "error")
  250. issues = deterministic_failure.get("issues") or [
  251. deterministic_failure.get("reason", "validation failed")
  252. ]
  253. retry_from = deterministic_failure.get("retry_from")
  254. else:
  255. outcome = "passed"
  256. issues = []
  257. retry_from = None
  258. result = ValidationResult.model_validate(
  259. validation_payload(evaluated_trace_id, outcome=outcome)
  260. )
  261. child_state["task_report_validation"] = {
  262. "validation_plan": None,
  263. "plan_hash": result.plan_hash,
  264. "scope_results": [
  265. item.model_dump(mode="json") for item in result.scope_results
  266. ],
  267. "aggregate_result": result.model_dump(mode="json"),
  268. "validated_at_sequence": evaluated.head_sequence,
  269. }
  270. await self.store.update_trace(
  271. evaluated_trace_id,
  272. context=evaluated.context,
  273. )
  274. await self.store.create_trace(Trace(
  275. trace_id=validator_trace_id,
  276. mode="agent",
  277. task=f"validate {evaluated_trace_id}",
  278. parent_trace_id=evaluated_trace_id,
  279. uid=evaluated.uid,
  280. status="completed",
  281. tools=[],
  282. context=with_root_anchor({
  283. "created_by_tool": "validator",
  284. "root_trace_id": evaluated.context["root_trace_id"],
  285. "agent_depth": evaluated.context["agent_depth"],
  286. }),
  287. ))
  288. return ValidationRun(result=result, trace_ids=[validator_trace_id])
  289. async def run_result(self, messages, config, on_event=None):
  290. self.configs.append(config)
  291. trace = await self.store.get_trace(config.trace_id)
  292. current_revision = ensure_task_protocol(trace.context)[
  293. "task_progress_head_revision"
  294. ]
  295. progressed = await update_task_progress(
  296. expected_revision=current_revision,
  297. progress={
  298. "phase": "ready_to_submit",
  299. "questions": [],
  300. "blockers": [],
  301. "findings": [],
  302. "hypotheses": [],
  303. "work_items": [],
  304. "decision_rationale": "The fixture completed its assigned work.",
  305. },
  306. context={
  307. "store": self.store,
  308. "task_protocol_service": TaskProtocolService(self.store),
  309. "trace_id": config.trace_id,
  310. "sequence": 2,
  311. },
  312. )
  313. if progressed["status"] != "completed":
  314. raise AssertionError(progressed)
  315. submitted = await submit_task_report(
  316. task_report=report_payload(),
  317. context={
  318. "store": self.store,
  319. "trace_id": config.trace_id,
  320. "sequence": 3,
  321. },
  322. )
  323. if submitted["status"] != "completed":
  324. raise AssertionError(submitted)
  325. await self.store.update_trace(config.trace_id, status="completed")
  326. return {
  327. "status": "completed",
  328. "summary": "child submitted TaskReport",
  329. "saved_knowledge_ids": [],
  330. "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
  331. }
  332. class FailedAfterReportingRunner(ReportingRunner):
  333. async def run_result(self, messages, config, on_event=None):
  334. result = await super().run_result(messages, config, on_event)
  335. await self.store.update_trace(
  336. config.trace_id,
  337. status="failed",
  338. error_message="runner failed after report submission",
  339. )
  340. return {
  341. **result,
  342. "status": "failed",
  343. "error": "runner failed after report submission",
  344. }
  345. class ForgedIdentityRunner(ReportingRunner):
  346. async def run_result(self, messages, config, on_event=None):
  347. trace = await self.store.get_trace(config.trace_id)
  348. state = ensure_task_protocol(trace.context)
  349. state["task_report"] = TaskReport(
  350. child_trace_id="someone-elses-trace",
  351. **report_payload(),
  352. ).model_dump()
  353. await self.store.update_trace(
  354. config.trace_id,
  355. context=trace.context,
  356. status="completed",
  357. )
  358. return {
  359. "status": "completed",
  360. "summary": "forged report identity",
  361. "saved_knowledge_ids": [],
  362. "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
  363. }
  364. class RecursiveTaskProtocolTest(unittest.IsolatedAsyncioTestCase):
  365. async def asyncSetUp(self):
  366. self.temp_dir = tempfile.TemporaryDirectory()
  367. self.store = FileSystemTraceStore(self.temp_dir.name)
  368. self.runner = ReportingRunner(self.store)
  369. self.root_id = "recursive-root"
  370. await create_recursive_root(self.store, self.root_id)
  371. self.tree = GoalTree(
  372. mission="root",
  373. goals=[Goal(id="1", description="delegate", status="in_progress")],
  374. current_id="1",
  375. )
  376. await self.store.update_goal_tree(self.root_id, self.tree)
  377. async def asyncTearDown(self):
  378. self.temp_dir.cleanup()
  379. def context(self, trace_id=None, goal_id="1", tree=None):
  380. return {
  381. "store": self.store,
  382. "trace_id": trace_id or self.root_id,
  383. "goal_id": goal_id,
  384. "runner": self.runner,
  385. "goal_tree": tree or self.tree,
  386. "sequence": 10,
  387. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: self.runner.tools.get_tool_names(),
  388. }
  389. async def children(self):
  390. return await self.store.list_traces(
  391. parent_trace_id=self.root_id,
  392. created_by_tool="agent",
  393. limit=20,
  394. )
  395. async def test_models_validate_and_round_trip(self):
  396. brief = TaskBrief.model_validate(BRIEF)
  397. self.assertEqual(
  398. {
  399. **BRIEF,
  400. "parent_findings": [],
  401. "context": {},
  402. "context_refs": [],
  403. "validation_scopes": [],
  404. },
  405. brief.model_dump(exclude_none=True),
  406. )
  407. with self.assertRaises(ValidationError):
  408. TaskBrief(objective="", completion_criteria=[])
  409. with self.assertRaises(ValidationError):
  410. TaskReview(
  411. parent_trace_id="p",
  412. child_trace_id="c",
  413. decision="ACCEPT_REFINE",
  414. reason="continue",
  415. )
  416. invalid_suggestion = report_payload()
  417. invalid_suggestion["next_step_suggestion"] = {
  418. "direction": "NONE",
  419. "reason": "No next task should be attached",
  420. "suggested_next_task": BRIEF,
  421. }
  422. with self.assertRaises(ValidationError):
  423. TaskReport(child_trace_id="child", **invalid_suggestion)
  424. root_submission = await submit_task_report(
  425. task_report=report_payload(),
  426. context={"store": self.store, "trace_id": self.root_id},
  427. )
  428. self.assertEqual("failed", root_submission["status"])
  429. self.assertIn("Root Agent", root_submission["error"])
  430. async def test_child_report_blocks_new_child_until_parent_review(self):
  431. result = await agent(task_brief=BRIEF, context=self.context())
  432. self.assertEqual("completed", result["status"])
  433. child_id = result["sub_trace_id"]
  434. self.assertEqual(child_id, result["task_report"]["child_trace_id"])
  435. parent = await self.store.get_trace(self.root_id)
  436. state = ensure_task_protocol(parent.context)
  437. self.assertEqual([child_id], list(state["pending_reviews"]))
  438. tree = await self.store.get_goal_tree(self.root_id)
  439. self.assertEqual("pending_review", tree.find("1").status)
  440. restarted_runner = AgentRunner(tool_registry=get_tool_registry())
  441. restored = await self.store.get_trace(self.root_id)
  442. restored_names = {
  443. schema["function"]["name"]
  444. for schema in restarted_runner._get_runtime_tool_schemas(
  445. RunConfig(), restored
  446. )
  447. }
  448. self.assertEqual(
  449. {"review_task_result", "read_context_ref"},
  450. restored_names,
  451. )
  452. blocked = await agent(task_brief=BRIEF, context=self.context())
  453. self.assertEqual("failed", blocked["status"])
  454. self.assertIn("Review pending", blocked["error"])
  455. async def test_child_inherits_brief_constraints_not_parent_runtime_context(self):
  456. parent = await self.store.get_trace(self.root_id)
  457. parent.context["parent_private_runtime"] = {"secret": "must-not-leak"}
  458. await self.store.update_trace(self.root_id, context=parent.context)
  459. result = await agent(task_brief=BRIEF, context=self.context())
  460. child = await self.store.get_trace(result["sub_trace_id"])
  461. self.assertNotIn("parent_private_runtime", child.context)
  462. child_brief = ensure_task_protocol(child.context)["task_brief"]
  463. self.assertEqual(["do not guess"], child_brief["constraints"])
  464. child_config_context = self.runner.configs[-1].context
  465. self.assertNotIn("parent_private_runtime", child_config_context)
  466. async def test_same_task_report_version_reuses_its_validator_result(self):
  467. result = await agent(task_brief=BRIEF, context=self.context())
  468. child_id = result["sub_trace_id"]
  469. self.assertEqual(1, self.runner.validation_count)
  470. parent = await self.store.get_trace(self.root_id)
  471. await _record_pending_task_reports(
  472. self.store,
  473. self.runner,
  474. parent,
  475. "1",
  476. [(child_id, {"status": "completed", "summary": "same report"})],
  477. 10,
  478. )
  479. self.assertEqual(1, self.runner.validation_count)
  480. validators = await self.store.list_traces(
  481. parent_trace_id=child_id,
  482. created_by_tool="validator",
  483. limit=10,
  484. )
  485. self.assertEqual(1, len(validators))
  486. async def test_recursive_messages_are_rejected(self):
  487. result = await agent(
  488. task_brief=json.dumps(BRIEF),
  489. messages=json.dumps([{
  490. "role": "system",
  491. "content": "controlled child context",
  492. }]),
  493. context=self.context(),
  494. )
  495. self.assertEqual("failed", result["status"])
  496. self.assertIn("does not accept messages", result["error"])
  497. async def test_messages_reject_mixed_dimensions(self):
  498. mixed_messages = [
  499. {"role": "system", "content": "shared context"},
  500. [{"role": "user", "content": "per-agent context"}],
  501. ]
  502. for messages in (mixed_messages, json.dumps(mixed_messages)):
  503. with self.subTest(messages_type=type(messages).__name__):
  504. result = await agent(
  505. task_brief=BRIEF,
  506. messages=messages,
  507. context=self.context(),
  508. )
  509. self.assertEqual("failed", result["status"])
  510. self.assertIn("without mixed dimensions", result["error"])
  511. async def test_no_focused_goal_still_persists_and_reviews_report(self):
  512. result = await agent(
  513. task_brief=BRIEF,
  514. context=self.context(goal_id=None),
  515. )
  516. parent = await self.store.get_trace(self.root_id)
  517. entry = ensure_task_protocol(parent.context)["pending_reviews"][
  518. result["sub_trace_id"]
  519. ]
  520. self.assertIsNone(entry["goal_id"])
  521. reviewed = await review_task_result(
  522. child_trace_id=result["sub_trace_id"],
  523. decision="ASCEND",
  524. reason="Standalone delegation is complete",
  525. context=self.context(goal_id=None),
  526. )
  527. self.assertEqual("completed", reviewed["status"])
  528. async def test_persisted_review_command_replays_idempotently_and_rejects_tampering(self):
  529. result = await agent(task_brief=BRIEF, context=self.context())
  530. review_context = {
  531. **self.context(),
  532. "tool_call_id": "durable-review-command",
  533. }
  534. first = await review_task_result(
  535. child_trace_id=result["sub_trace_id"],
  536. decision="ASCEND",
  537. reason="The exact persisted report is accepted",
  538. context=review_context,
  539. )
  540. self.assertEqual("completed", first["status"])
  541. replayed = await review_task_result(
  542. child_trace_id=result["sub_trace_id"],
  543. decision="ASCEND",
  544. reason="The exact persisted report is accepted",
  545. context={**review_context, "sequence": 999},
  546. )
  547. self.assertEqual(first["task_review"], replayed["task_review"])
  548. parent = await self.store.get_trace(self.root_id)
  549. state = ensure_task_protocol(parent.context)
  550. self.assertEqual(1, len(state["reviews"]))
  551. self.assertEqual({}, state["pending_reviews"])
  552. tampered = await review_task_result(
  553. child_trace_id=result["sub_trace_id"],
  554. decision="ASCEND",
  555. reason="Changed after the durable command was committed",
  556. context={**review_context, "sequence": 1_000},
  557. )
  558. self.assertEqual("failed", tampered["status"])
  559. self.assertIn("payload changed", tampered["error"])
  560. async def test_revise_child_projection_recovers_after_parent_commit_crash(self):
  561. result = await agent(task_brief=BRIEF, context=self.context())
  562. child_id = result["sub_trace_id"]
  563. review_context = {
  564. **self.context(),
  565. "tool_call_id": "durable-revise-child",
  566. }
  567. original_update_trace = self.store.update_trace
  568. interrupted = False
  569. async def interrupt_child_projection(trace_id, **updates):
  570. nonlocal interrupted
  571. context = updates.get("context")
  572. child_state = (
  573. context.get("task_protocol", {})
  574. if isinstance(context, dict)
  575. else {}
  576. )
  577. if (
  578. not interrupted
  579. and trace_id == child_id
  580. and child_state.get("task_report") is None
  581. ):
  582. interrupted = True
  583. raise asyncio.CancelledError()
  584. return await original_update_trace(trace_id, **updates)
  585. with patch.object(
  586. self.store,
  587. "update_trace",
  588. side_effect=interrupt_child_projection,
  589. ):
  590. with self.assertRaises(asyncio.CancelledError):
  591. await review_task_result(
  592. child_trace_id=child_id,
  593. decision="REVISE_CHILD",
  594. reason="Revise only the child's current output",
  595. context=review_context,
  596. )
  597. self.assertTrue(interrupted)
  598. parent = await self.store.get_trace(self.root_id)
  599. parent_state = ensure_task_protocol(parent.context)
  600. self.assertEqual(1, len(parent_state["reviews"]))
  601. self.assertEqual(1, len(parent_state["next_actions"]))
  602. self.assertEqual({}, parent_state["pending_reviews"])
  603. child = await self.store.get_trace(child_id)
  604. self.assertIsNotNone(ensure_task_protocol(child.context)["task_report"])
  605. replayed = await review_task_result(
  606. child_trace_id=child_id,
  607. decision="REVISE_CHILD",
  608. reason="Revise only the child's current output",
  609. context={**review_context, "sequence": 999},
  610. )
  611. self.assertEqual("completed", replayed["status"])
  612. parent = await self.store.get_trace(self.root_id)
  613. parent_state = ensure_task_protocol(parent.context)
  614. self.assertEqual(1, len(parent_state["reviews"]))
  615. self.assertEqual(1, len(parent_state["next_actions"]))
  616. child = await self.store.get_trace(child_id)
  617. child_state = ensure_task_protocol(child.context)
  618. self.assertIsNone(child_state["task_report"])
  619. self.assertIsNone(child_state["task_report_validation"])
  620. self.assertEqual(1, len(child_state["report_history"]))
  621. async def test_accept_refine_approves_exactly_one_next_child(self):
  622. first = await agent(task_brief=BRIEF, context=self.context())
  623. next_brief = {
  624. "objective": "check the independent gap",
  625. "reason": "the first report identified a separate gap",
  626. "completion_criteria": ["close the gap"],
  627. "expected_outputs": ["a checked gap analysis"],
  628. }
  629. parent = await self.store.get_trace(self.root_id)
  630. ensure_task_protocol(parent.context)["protocol_correction_attempts"] = 2
  631. await self.store.update_trace(self.root_id, context=parent.context)
  632. reviewed = await review_task_result(
  633. child_trace_id=first["sub_trace_id"],
  634. decision="ACCEPT_REFINE",
  635. reason="The gap belongs to this parent",
  636. approved_next_task=json.dumps(next_brief),
  637. context=self.context(),
  638. )
  639. self.assertEqual("completed", reviewed["status"])
  640. self.assertIsNotNone(reviewed["context_ref"])
  641. self.assertIn(
  642. reviewed["context_ref"],
  643. reviewed["next_action_task_brief"]["context_refs"],
  644. )
  645. parent = await self.store.get_trace(self.root_id)
  646. self.assertEqual(
  647. 0,
  648. ensure_task_protocol(parent.context)["protocol_correction_attempts"],
  649. )
  650. schemas = AgentRunner(
  651. tool_registry=get_tool_registry()
  652. )._get_runtime_tool_schemas(RunConfig(), parent)
  653. self.assertEqual(
  654. {"agent", "read_context_ref"},
  655. {schema["function"]["name"] for schema in schemas},
  656. )
  657. wrong = await agent(task_brief=BRIEF, context=self.context())
  658. self.assertEqual("failed", wrong["status"])
  659. self.assertIn("approved next task", wrong["error"])
  660. second = await agent(
  661. task_brief=reviewed["next_action_task_brief"],
  662. context=self.context(),
  663. )
  664. self.assertEqual("completed", second["status"])
  665. self.assertEqual(2, len(await self.children()))
  666. second_child = await self.store.get_trace(second["sub_trace_id"])
  667. self.assertIn(
  668. reviewed["context_ref"]["ref_id"],
  669. second_child.context["context_access"]["snapshots"],
  670. )
  671. parent = await self.store.get_trace(self.root_id)
  672. self.assertEqual([], ensure_task_protocol(parent.context)["next_actions"])
  673. async def test_review_rejects_an_unusable_context_ref_without_consuming_report(self):
  674. first = await agent(task_brief=BRIEF, context=self.context())
  675. invalid_next = {
  676. "objective": "use an unauthorized context ref",
  677. "reason": "exercise approval validation",
  678. "completion_criteria": ["must not be approved"],
  679. "expected_outputs": ["nothing"],
  680. "context_refs": [{
  681. "ref_id": "ctx_000000000000000000000000",
  682. "version": "0" * 64,
  683. }],
  684. }
  685. reviewed = await review_task_result(
  686. child_trace_id=first["sub_trace_id"],
  687. decision="ACCEPT_REFINE",
  688. reason="try an invalid transfer",
  689. approved_next_task=invalid_next,
  690. context=self.context(),
  691. )
  692. self.assertEqual("failed", reviewed["status"])
  693. self.assertIn("not authorized", reviewed["error"])
  694. parent = await self.store.get_trace(self.root_id)
  695. state = ensure_task_protocol(parent.context)
  696. self.assertIn(first["sub_trace_id"], state["pending_reviews"])
  697. self.assertEqual([], state["reviews"])
  698. async def test_oversized_review_snapshot_warns_without_blocking_review(self):
  699. first = await agent(task_brief=BRIEF, context=self.context())
  700. parent = await self.store.get_trace(self.root_id)
  701. state = ensure_task_protocol(parent.context)
  702. state["pending_reviews"][first["sub_trace_id"]]["task_report"]["outputs"] = [
  703. {"payload": "x" * 16_000}
  704. ]
  705. await self.store.update_trace(self.root_id, context=parent.context)
  706. reviewed = await review_task_result(
  707. child_trace_id=first["sub_trace_id"],
  708. decision="ASCEND",
  709. reason="The task passed but its transferable snapshot is too large",
  710. context=self.context(),
  711. )
  712. self.assertEqual("completed", reviewed["status"])
  713. self.assertIsNone(reviewed["context_ref"])
  714. self.assertIn("16000", reviewed["context_ref_warning"])
  715. parent = await self.store.get_trace(self.root_id)
  716. self.assertEqual({}, ensure_task_protocol(parent.context)["pending_reviews"])
  717. async def test_batch_context_refs_are_validated_before_any_trace_is_created(self):
  718. invalid_ref_brief = {
  719. **BRIEF,
  720. "objective": "second child with an unauthorized reference",
  721. "context_refs": [{
  722. "ref_id": "ctx_000000000000000000000000",
  723. "version": "0" * 64,
  724. }],
  725. }
  726. result = await agent(
  727. task_brief=[BRIEF, invalid_ref_brief],
  728. context=self.context(),
  729. )
  730. self.assertEqual("failed", result["status"])
  731. self.assertIn("not authorized", result["error"])
  732. self.assertEqual([], await self.children())
  733. async def test_batch_reports_are_reviewed_one_by_one(self):
  734. briefs = [
  735. BRIEF,
  736. {
  737. "objective": "research second question",
  738. "reason": "the batch needs a second independent result",
  739. "completion_criteria": ["provide second conclusion"],
  740. "expected_outputs": ["a second conclusion"],
  741. },
  742. ]
  743. result = await agent(task_brief=briefs, context=self.context())
  744. child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
  745. self.assertEqual(2, len(result["task_reports"]))
  746. premature = await review_task_result(
  747. child_trace_id=child_ids[0],
  748. decision="ASCEND",
  749. reason="Cannot finish while another report is pending",
  750. context=self.context(),
  751. )
  752. self.assertEqual("failed", premature["status"])
  753. self.assertIn("final pending report", premature["error"])
  754. first_review = await review_task_result(
  755. child_trace_id=child_ids[0],
  756. decision="REJECT_REFINE",
  757. reason="No extra task needed for this report",
  758. context=self.context(),
  759. )
  760. self.assertEqual(1, first_review["pending_review_count"])
  761. tree = await self.store.get_goal_tree(self.root_id)
  762. self.assertEqual("pending_review", tree.find("1").status)
  763. final_review = await review_task_result(
  764. child_trace_id=child_ids[1],
  765. decision="ASCEND",
  766. reason="All completion criteria are satisfied",
  767. context=self.context(),
  768. )
  769. self.assertEqual(0, final_review["pending_review_count"])
  770. tree = await self.store.get_goal_tree(self.root_id)
  771. self.assertEqual("completed", tree.find("1").status)
  772. duplicate = await review_task_result(
  773. child_trace_id=child_ids[1],
  774. decision="ASCEND",
  775. reason="Duplicate review must fail",
  776. context=self.context(),
  777. )
  778. self.assertEqual("failed", duplicate["status"])
  779. self.assertIn("no pending TaskReport", duplicate["error"])
  780. async def test_terminal_review_waits_for_queued_next_action(self):
  781. result = await agent(
  782. task_brief=[
  783. BRIEF,
  784. {
  785. "objective": "second batch child",
  786. "reason": "exercise queued batch behavior",
  787. "completion_criteria": ["finish the second child"],
  788. "expected_outputs": ["the second child result"],
  789. },
  790. ],
  791. context=self.context(),
  792. )
  793. child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
  794. next_brief = {
  795. "objective": "approved follow-up",
  796. "reason": "the parent approved one follow-up",
  797. "completion_criteria": ["finish the approved follow-up"],
  798. "expected_outputs": ["the approved follow-up result"],
  799. }
  800. accepted = await review_task_result(
  801. child_trace_id=child_ids[0],
  802. decision="ACCEPT_REFINE",
  803. reason="A separate follow-up is required",
  804. approved_next_task=next_brief,
  805. context=self.context(),
  806. )
  807. self.assertEqual("completed", accepted["status"])
  808. blocked = await review_task_result(
  809. child_trace_id=child_ids[1],
  810. decision="ASCEND",
  811. reason="Cannot ascend before the accepted follow-up",
  812. context=self.context(),
  813. )
  814. self.assertEqual("failed", blocked["status"])
  815. self.assertIn("approved next action", blocked["error"])
  816. tree = await self.store.get_goal_tree(self.root_id)
  817. self.assertEqual("pending_review", tree.find("1").status)
  818. final_non_terminal = await review_task_result(
  819. child_trace_id=child_ids[1],
  820. decision="REJECT_REFINE",
  821. reason="This report adds no other follow-up",
  822. context=self.context(),
  823. )
  824. self.assertEqual("completed", final_non_terminal["status"])
  825. tree = await self.store.get_goal_tree(self.root_id)
  826. self.assertEqual("in_progress", tree.find("1").status)
  827. async def test_runner_failure_overrides_stale_satisfied_report(self):
  828. self.runner = FailedAfterReportingRunner(self.store)
  829. result = await agent(task_brief=BRIEF, context=self.context())
  830. self.assertEqual("failed", result["status"])
  831. self.assertEqual("protocol_error", result["task_report"]["outcome"])
  832. child = await self.store.get_trace(result["sub_trace_id"])
  833. child_state = ensure_task_protocol(child.context)
  834. self.assertEqual(1, len(child_state["report_history"]))
  835. self.assertEqual("satisfied", child_state["report_history"][0]["outcome"])
  836. invalid_ascend = await review_task_result(
  837. child_trace_id=result["sub_trace_id"],
  838. decision="ASCEND",
  839. reason="A failed execution cannot ascend",
  840. context=self.context(),
  841. )
  842. self.assertEqual("failed", invalid_ascend["status"])
  843. self.assertIn("only allow", invalid_ascend["error"])
  844. async def test_submit_and_review_reject_child_anchor_that_differs_from_root(self):
  845. child_context = with_root_anchor({
  846. "agent_mode": "recursive",
  847. "agent_mode_revision": 3,
  848. "agent_depth": 1,
  849. "root_trace_id": self.root_id,
  850. "created_by_tool": "agent",
  851. "task_protocol": new_task_protocol(BRIEF),
  852. })
  853. persist_root_task_anchor(child_context, {
  854. **ROOT_ANCHOR,
  855. "objective": "self-consistent but unauthorized root objective",
  856. })
  857. child_id = "mismatched-anchor-child"
  858. await self.store.create_trace(Trace(
  859. trace_id=child_id,
  860. mode="agent",
  861. task="child",
  862. parent_trace_id=self.root_id,
  863. parent_goal_id="1",
  864. uid="user-1",
  865. model="fake-model",
  866. context=child_context,
  867. ))
  868. submitted = await submit_task_report(
  869. task_report=report_payload(),
  870. context={"store": self.store, "trace_id": child_id},
  871. )
  872. self.assertEqual("failed", submitted["status"])
  873. self.assertIn("does not match", submitted["error"])
  874. parent = await self.store.get_trace(self.root_id)
  875. parent_state = ensure_task_protocol(parent.context)
  876. parent_state["pending_reviews"][child_id] = {
  877. "goal_id": "1",
  878. "task_report": TaskReport(
  879. child_trace_id=child_id,
  880. **report_payload(),
  881. ).model_dump(),
  882. "validation_result": validation_payload(child_id),
  883. "received_at_sequence": 1,
  884. }
  885. await self.store.update_trace(self.root_id, context=parent.context)
  886. reviewed = await review_task_result(
  887. child_trace_id=child_id,
  888. decision="ASCEND",
  889. reason="must not accept a different root anchor",
  890. context=self.context(),
  891. )
  892. self.assertEqual("failed", reviewed["status"])
  893. self.assertIn("does not match", reviewed["error"])
  894. parent = await self.store.get_trace(self.root_id)
  895. state = ensure_task_protocol(parent.context)
  896. self.assertIn(child_id, state["pending_reviews"])
  897. self.assertEqual([], state["reviews"])
  898. async def test_review_rejects_wrong_owner_even_if_report_is_injected(self):
  899. first = await agent(task_brief=BRIEF, context=self.context())
  900. child_id = first["sub_trace_id"]
  901. await self.store.update_trace(child_id, uid="another-user")
  902. rejected = await review_task_result(
  903. child_trace_id=child_id,
  904. decision="ASCEND",
  905. reason="Owner mismatch must be rejected",
  906. context=self.context(),
  907. )
  908. self.assertEqual("failed", rejected["status"])
  909. self.assertIn("same owner", rejected["error"])
  910. async def test_report_identity_must_match_direct_child(self):
  911. self.runner = ForgedIdentityRunner(self.store)
  912. result = await agent(task_brief=BRIEF, context=self.context())
  913. child_id = result["sub_trace_id"]
  914. self.assertEqual("protocol_error", result["task_report"]["outcome"])
  915. self.assertEqual(child_id, result["task_report"]["child_trace_id"])
  916. parent = await self.store.get_trace(self.root_id)
  917. pending_report = ensure_task_protocol(parent.context)["pending_reviews"][
  918. child_id
  919. ]["task_report"]
  920. pending_report["child_trace_id"] = "corrupted-after-recording"
  921. await self.store.update_trace(self.root_id, context=parent.context)
  922. rejected = await review_task_result(
  923. child_trace_id=child_id,
  924. decision="FAIL",
  925. reason="Corrupted identity cannot be reviewed",
  926. context=self.context(),
  927. )
  928. self.assertEqual("failed", rejected["status"])
  929. self.assertIn("does not match", rejected["error"])
  930. async def test_remote_agent_is_rejected_by_recursive_revision_2(self):
  931. remote_call = AsyncMock(return_value={
  932. "status": "completed",
  933. "summary": "remote result",
  934. })
  935. with patch(
  936. "cyber_agent.tools.builtin.subagent._run_remote_agent",
  937. new=remote_call,
  938. ):
  939. result = await agent(
  940. task="remote free text",
  941. agent_type="remote_librarian",
  942. context=self.context(),
  943. )
  944. self.assertEqual("failed", result["status"])
  945. self.assertIn("does not support remote agents", result["error"])
  946. remote_call.assert_not_awaited()
  947. async def test_revise_child_reuses_trace_and_preserves_report_history(self):
  948. first = await agent(task_brief=BRIEF, context=self.context())
  949. child_id = first["sub_trace_id"]
  950. reviewed = await review_task_result(
  951. child_trace_id=child_id,
  952. decision="REVISE_CHILD",
  953. reason="Please strengthen the evidence",
  954. context=self.context(),
  955. )
  956. self.assertEqual("completed", reviewed["status"])
  957. cleared_child = await self.store.get_trace(child_id)
  958. self.assertEqual({}, cleared_child.context["context_access"]["snapshots"])
  959. wrong = await agent(
  960. task_brief={
  961. "objective": "silently replace the reviewed task",
  962. "reason": "attempt a mismatched revision",
  963. "completion_criteria": ["this must be rejected"],
  964. "expected_outputs": ["a rejected replacement"],
  965. },
  966. continue_from=child_id,
  967. context=self.context(),
  968. )
  969. self.assertEqual("failed", wrong["status"])
  970. self.assertIn("approved revision", wrong["error"])
  971. revised_brief = reviewed["next_action_task_brief"]
  972. revised = await agent(
  973. task_brief=revised_brief,
  974. continue_from=child_id,
  975. context=self.context(),
  976. )
  977. self.assertEqual(child_id, revised["sub_trace_id"])
  978. self.assertTrue(revised["continue_from"])
  979. self.assertEqual(1, len(await self.children()))
  980. child = await self.store.get_trace(child_id)
  981. self.assertEqual(1, len(ensure_task_protocol(child.context)["report_history"]))
  982. self.assertEqual(
  983. TaskBrief.model_validate(revised_brief).model_dump(),
  984. ensure_task_protocol(child.context)["task_brief"],
  985. )
  986. self.assertEqual(2, ensure_task_protocol(child.context)["task_brief_version"])
  987. self.assertEqual(
  988. 1,
  989. len(ensure_task_protocol(child.context)["task_brief_history"]),
  990. )
  991. async def test_review_cannot_reserve_more_children_than_quota(self):
  992. briefs = [
  993. {
  994. "objective": f"batch child {index}",
  995. "reason": "fill the local child quota",
  996. "completion_criteria": [f"finish child {index}"],
  997. "expected_outputs": [f"result for child {index}"],
  998. }
  999. for index in range(5)
  1000. ]
  1001. result = await agent(task_brief=briefs, context=self.context())
  1002. child_ids = [item["trace_id"] for item in result["sub_trace_ids"]]
  1003. next_brief = {
  1004. "objective": "one allowed reserved child",
  1005. "reason": "use the final available child slot",
  1006. "completion_criteria": ["use the final available slot"],
  1007. "expected_outputs": ["one final child result"],
  1008. }
  1009. first = await review_task_result(
  1010. child_trace_id=child_ids[0],
  1011. decision="ACCEPT_REFINE",
  1012. reason="Reserve the sixth and final child slot",
  1013. approved_next_task=next_brief,
  1014. context=self.context(),
  1015. )
  1016. self.assertEqual("completed", first["status"])
  1017. overflow = await review_task_result(
  1018. child_trace_id=child_ids[1],
  1019. decision="DESCEND_AGAIN",
  1020. reason="A seventh child must not be approved",
  1021. approved_next_task={
  1022. "objective": "overflow child",
  1023. "reason": "exercise quota rejection",
  1024. "completion_criteria": ["must never be scheduled"],
  1025. "expected_outputs": ["no output because creation is rejected"],
  1026. },
  1027. context=self.context(),
  1028. )
  1029. self.assertEqual("failed", overflow["status"])
  1030. self.assertIn("reserved=1", overflow["error"])
  1031. parent = await self.store.get_trace(self.root_id)
  1032. state = ensure_task_protocol(parent.context)
  1033. self.assertEqual(1, len(state["next_actions"]))
  1034. self.assertIn(child_ids[1], state["pending_reviews"])
  1035. async def test_recursive_goal_completion_does_not_cascade_or_select_sibling(self):
  1036. tree = GoalTree(mission="recursive")
  1037. parent = tree.add_goals(["parent"])[0]
  1038. first, second = tree.add_goals(["first", "second"], parent_id=parent.id)
  1039. tree.focus(parent.id)
  1040. tree.focus(first.id)
  1041. tree.complete(
  1042. first.id,
  1043. "done",
  1044. auto_advance=False,
  1045. cascade_parent=False,
  1046. )
  1047. self.assertEqual(parent.id, tree.current_id)
  1048. self.assertEqual("pending", second.status)
  1049. self.assertNotEqual("completed", parent.status)
  1050. async def test_rewind_before_review_restores_report_and_review_gate(self):
  1051. delegated = await agent(task_brief=BRIEF, context=self.context())
  1052. child_id = delegated["sub_trace_id"]
  1053. reviewed = await review_task_result(
  1054. child_trace_id=child_id,
  1055. decision="ASCEND",
  1056. reason="Initially accepted before rewind",
  1057. context={**self.context(), "sequence": 20},
  1058. )
  1059. self.assertEqual("completed", reviewed["status"])
  1060. for sequence in (1, 15):
  1061. await self.store.add_message(Message.create(
  1062. trace_id=self.root_id,
  1063. role="user",
  1064. sequence=sequence,
  1065. content=f"message-{sequence}",
  1066. ))
  1067. tree = await self.store.get_goal_tree(self.root_id)
  1068. runner = AgentRunner(
  1069. trace_store=self.store,
  1070. tool_registry=get_tool_registry(),
  1071. )
  1072. await runner._rewind(self.root_id, 15, tree)
  1073. parent = await self.store.get_trace(self.root_id)
  1074. state = ensure_task_protocol(parent.context)
  1075. self.assertIn(child_id, state["pending_reviews"])
  1076. self.assertEqual([], state["reviews"])
  1077. restored_tree = await self.store.get_goal_tree(self.root_id)
  1078. self.assertEqual("pending_review", restored_tree.find("1").status)
  1079. schemas = runner._get_runtime_tool_schemas(RunConfig(), parent)
  1080. self.assertEqual(
  1081. {"review_task_result", "read_context_ref"},
  1082. {schema["function"]["name"] for schema in schemas},
  1083. )
  1084. async def test_grandchild_report_is_reviewed_only_by_direct_child(self):
  1085. child_id = "manual-child"
  1086. child_context = with_root_anchor({
  1087. "agent_mode": "recursive",
  1088. "agent_mode_revision": 3,
  1089. "agent_depth": 1,
  1090. "root_trace_id": self.root_id,
  1091. "created_by_tool": "agent",
  1092. "task_protocol": new_task_protocol(BRIEF),
  1093. })
  1094. await self.store.create_trace(Trace(
  1095. trace_id=child_id,
  1096. mode="agent",
  1097. task="child",
  1098. parent_trace_id=self.root_id,
  1099. uid="user-1",
  1100. model="fake-model",
  1101. context=child_context,
  1102. ))
  1103. child_tree = GoalTree(
  1104. mission="child",
  1105. goals=[Goal(id="1", description="delegate grandchild", status="in_progress")],
  1106. current_id="1",
  1107. )
  1108. await self.store.update_goal_tree(child_id, child_tree)
  1109. grandchild = await agent(
  1110. task_brief=BRIEF,
  1111. context=self.context(trace_id=child_id, tree=child_tree),
  1112. )
  1113. child = await self.store.get_trace(child_id)
  1114. child_state = ensure_task_protocol(child.context)
  1115. self.assertIn(grandchild["sub_trace_id"], child_state["pending_reviews"])
  1116. root = await self.store.get_trace(self.root_id)
  1117. self.assertEqual({}, ensure_task_protocol(root.context)["pending_reviews"])
  1118. blocked = await agent(
  1119. task_brief=BRIEF,
  1120. context=self.context(trace_id=child_id, tree=child_tree),
  1121. )
  1122. self.assertEqual("failed", blocked["status"])
  1123. reviewed = await review_task_result(
  1124. child_trace_id=grandchild["sub_trace_id"],
  1125. decision="ASCEND",
  1126. reason="Grandchild satisfied the child criteria",
  1127. context=self.context(trace_id=child_id, tree=child_tree),
  1128. )
  1129. self.assertEqual("completed", reviewed["status"])
  1130. submitted = await submit_task_report(
  1131. task_report=json.dumps(report_payload()),
  1132. context={"store": self.store, "trace_id": child_id, "sequence": 20},
  1133. )
  1134. self.assertEqual("completed", submitted["status"])
  1135. async def test_child_must_execute_approved_action_before_own_report(self):
  1136. child_id = "child-with-approved-action"
  1137. child_context = with_root_anchor({
  1138. "agent_mode": "recursive",
  1139. "agent_mode_revision": 3,
  1140. "agent_depth": 1,
  1141. "root_trace_id": self.root_id,
  1142. "created_by_tool": "agent",
  1143. "task_protocol": new_task_protocol(BRIEF),
  1144. })
  1145. await self.store.create_trace(Trace(
  1146. trace_id=child_id,
  1147. mode="agent",
  1148. task="child",
  1149. parent_trace_id=self.root_id,
  1150. uid="user-1",
  1151. model="fake-model",
  1152. context=child_context,
  1153. ))
  1154. child_tree = GoalTree(
  1155. mission="child",
  1156. goals=[Goal(id="1", description="delegate", status="in_progress")],
  1157. current_id="1",
  1158. )
  1159. await self.store.update_goal_tree(child_id, child_tree)
  1160. child_tool_context = self.context(trace_id=child_id, tree=child_tree)
  1161. first = await agent(task_brief=BRIEF, context=child_tool_context)
  1162. next_brief = {
  1163. "objective": "check a second grandchild task",
  1164. "reason": "the child approved another direct task",
  1165. "completion_criteria": ["provide a second checked conclusion"],
  1166. "expected_outputs": ["a second checked conclusion"],
  1167. }
  1168. reviewed = await review_task_result(
  1169. child_trace_id=first["sub_trace_id"],
  1170. decision="ACCEPT_REFINE",
  1171. reason="A separate gap remains",
  1172. approved_next_task=next_brief,
  1173. context=child_tool_context,
  1174. )
  1175. self.assertEqual("completed", reviewed["status"])
  1176. blocked_report = await submit_task_report(
  1177. task_report=report_payload(),
  1178. context={"store": self.store, "trace_id": child_id, "sequence": 20},
  1179. )
  1180. self.assertEqual("failed", blocked_report["status"])
  1181. self.assertIn("approved next action", blocked_report["error"])
  1182. second = await agent(
  1183. task_brief=reviewed["next_action_task_brief"],
  1184. context=child_tool_context,
  1185. )
  1186. self.assertEqual("completed", second["status"])
  1187. child = await self.store.get_trace(child_id)
  1188. state = ensure_task_protocol(child.context)
  1189. self.assertEqual([], state["next_actions"])
  1190. self.assertIn(second["sub_trace_id"], state["pending_reviews"])
  1191. async def test_goal_tool_cannot_bypass_queued_next_action(self):
  1192. root = await self.store.get_trace(self.root_id)
  1193. state = ensure_task_protocol(root.context)
  1194. state["next_actions"] = [{
  1195. "decision": "ACCEPT_REFINE",
  1196. "child_trace_id": "reviewed-child",
  1197. "goal_id": "1",
  1198. "task_brief": BRIEF,
  1199. "created_at_sequence": 5,
  1200. }]
  1201. await self.store.update_trace(self.root_id, context=root.context)
  1202. result = await goal_tool(
  1203. tree=self.tree,
  1204. store=self.store,
  1205. trace_id=self.root_id,
  1206. done="attempt to bypass the approved action",
  1207. )
  1208. self.assertIn("已批准的下一步待执行", result)
  1209. persisted_tree = await self.store.get_goal_tree(self.root_id)
  1210. self.assertEqual("in_progress", persisted_tree.find("1").status)
  1211. async def test_failed_report_allows_only_revise_or_fail(self):
  1212. child_id = "failed-child"
  1213. failed_report = TaskReport(
  1214. child_trace_id=child_id,
  1215. **report_payload(outcome="failed"),
  1216. )
  1217. child_context = with_root_anchor({
  1218. "agent_mode": "recursive",
  1219. "agent_mode_revision": 3,
  1220. "agent_depth": 1,
  1221. "root_trace_id": self.root_id,
  1222. "created_by_tool": "agent",
  1223. "task_protocol": new_task_protocol(BRIEF),
  1224. })
  1225. child_context["task_protocol"]["task_report"] = failed_report.model_dump()
  1226. failed_validation = validation_payload(child_id, outcome="failed")
  1227. child_context["task_protocol"]["task_report_validation"] = {
  1228. "validation_plan": None,
  1229. "plan_hash": failed_validation["plan_hash"],
  1230. "scope_results": failed_validation["scope_results"],
  1231. "aggregate_result": failed_validation,
  1232. "validated_at_sequence": 2,
  1233. }
  1234. await self.store.create_trace(Trace(
  1235. trace_id=child_id,
  1236. mode="agent",
  1237. task="failed",
  1238. parent_trace_id=self.root_id,
  1239. parent_goal_id="1",
  1240. uid="user-1",
  1241. context=child_context,
  1242. ))
  1243. root = await self.store.get_trace(self.root_id)
  1244. state = ensure_task_protocol(root.context)
  1245. state["pending_reviews"][child_id] = {
  1246. "goal_id": "1",
  1247. "task_report": failed_report.model_dump(),
  1248. "validation_result": failed_validation,
  1249. "received_at_sequence": 2,
  1250. }
  1251. await self.store.update_trace(self.root_id, context=root.context)
  1252. rejected = await review_task_result(
  1253. child_trace_id=child_id,
  1254. decision="ASCEND",
  1255. reason="incorrect",
  1256. context=self.context(),
  1257. )
  1258. self.assertEqual("failed", rejected["status"])
  1259. self.assertIn("only allow", rejected["error"])
  1260. class RunnerCompletionGateTest(unittest.IsolatedAsyncioTestCase):
  1261. async def asyncSetUp(self):
  1262. self.temp_dir = tempfile.TemporaryDirectory()
  1263. self.store = FileSystemTraceStore(self.temp_dir.name)
  1264. self.trace_id = "gate-child"
  1265. await create_recursive_root(self.store, "root")
  1266. controller = ResourceBudgetController(self.store)
  1267. budget = ResourceBudget()
  1268. await controller.reserve_agents("root", budget, 1)
  1269. await self.store.create_trace(Trace(
  1270. trace_id=self.trace_id,
  1271. mode="agent",
  1272. task="must report",
  1273. parent_trace_id="root",
  1274. uid="user-1",
  1275. context=with_root_anchor({
  1276. "agent_mode": "recursive",
  1277. "agent_mode_revision": 3,
  1278. "agent_depth": 1,
  1279. "root_trace_id": "root",
  1280. "created_by_tool": "agent",
  1281. "task_protocol": new_task_protocol(BRIEF),
  1282. }),
  1283. ))
  1284. await self.store.update_goal_tree(self.trace_id, GoalTree(mission="must report"))
  1285. async def asyncTearDown(self):
  1286. self.temp_dir.cleanup()
  1287. async def bind_snapshot(self, trace_id, config):
  1288. """Make manually-created Recursive fixtures equivalent to new traces."""
  1289. trace = await self.store.get_trace(trace_id)
  1290. config.model = trace.model or config.model
  1291. config.uid = trace.uid
  1292. config.agent_type = trace.agent_type or "default"
  1293. persist_run_config_snapshot(
  1294. trace.context,
  1295. RunConfigSnapshotV1.from_run_config(
  1296. config,
  1297. memory_identity=None,
  1298. ),
  1299. )
  1300. await self.store.update_trace(trace_id, context=trace.context)
  1301. return config
  1302. async def test_missing_report_becomes_protocol_error_after_two_corrections(self):
  1303. calls = 0
  1304. async def llm_call(**kwargs):
  1305. nonlocal calls
  1306. calls += 1
  1307. return {
  1308. "content": "I am done",
  1309. "tool_calls": [],
  1310. "finish_reason": "stop",
  1311. }
  1312. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  1313. config = await self.bind_snapshot(
  1314. self.trace_id,
  1315. RunConfig(
  1316. trace_id=self.trace_id,
  1317. max_iterations=8,
  1318. knowledge=KnowledgeConfig(
  1319. enable_extraction=False,
  1320. enable_completion_extraction=False,
  1321. enable_injection=False,
  1322. ),
  1323. ),
  1324. )
  1325. result = await runner.run_result(messages=[], config=config)
  1326. self.assertEqual("failed", result["status"])
  1327. trace = await self.store.get_trace(self.trace_id)
  1328. report = ensure_task_protocol(trace.context)["task_report"]
  1329. self.assertEqual("protocol_error", report["outcome"])
  1330. self.assertGreaterEqual(calls, 3)
  1331. async def test_valid_report_allows_child_to_finish(self):
  1332. calls = 0
  1333. async def llm_call(**kwargs):
  1334. nonlocal calls
  1335. calls += 1
  1336. messages = kwargs.get("messages") or []
  1337. if messages and "recursive_validation_protocol" in messages[0]["content"]:
  1338. return passed_validator_response(kwargs)
  1339. if calls == 1:
  1340. return {
  1341. "content": "",
  1342. "tool_calls": [{
  1343. "id": "submit-1",
  1344. "type": "function",
  1345. "function": {
  1346. "name": "submit_task_report",
  1347. "arguments": json.dumps({
  1348. "task_report": report_payload(),
  1349. }),
  1350. },
  1351. }],
  1352. "finish_reason": "tool_calls",
  1353. }
  1354. return {
  1355. "content": "Structured report submitted.",
  1356. "tool_calls": [],
  1357. "finish_reason": "stop",
  1358. }
  1359. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  1360. config = await self.bind_snapshot(
  1361. self.trace_id,
  1362. RunConfig(
  1363. trace_id=self.trace_id,
  1364. max_iterations=4,
  1365. knowledge=KnowledgeConfig(
  1366. enable_extraction=False,
  1367. enable_completion_extraction=False,
  1368. enable_injection=False,
  1369. ),
  1370. ),
  1371. )
  1372. result = await runner.run_result(messages=[], config=config)
  1373. self.assertEqual("completed", result["status"])
  1374. trace = await self.store.get_trace(self.trace_id)
  1375. self.assertEqual(
  1376. "satisfied",
  1377. ensure_task_protocol(trace.context)["task_report"]["outcome"],
  1378. )
  1379. async def test_lifecycle_tool_batch_is_rejected_before_execution(self):
  1380. root_id = "batch-gate-root"
  1381. await create_recursive_root(self.store, root_id)
  1382. await self.store.update_goal_tree(root_id, GoalTree(mission="root"))
  1383. calls = 0
  1384. async def llm_call(**kwargs):
  1385. nonlocal calls
  1386. calls += 1
  1387. messages = kwargs.get("messages") or []
  1388. if messages and "recursive_validation_protocol" in messages[0]["content"]:
  1389. return passed_validator_response(kwargs)
  1390. if calls == 1:
  1391. return {
  1392. "content": "",
  1393. "tool_calls": [
  1394. {
  1395. "id": "agent-1",
  1396. "type": "function",
  1397. "function": {
  1398. "name": "agent",
  1399. "arguments": json.dumps({
  1400. "task_brief": BRIEF,
  1401. }),
  1402. },
  1403. },
  1404. {
  1405. "id": "context-1",
  1406. "type": "function",
  1407. "function": {
  1408. "name": "get_current_context",
  1409. "arguments": "{}",
  1410. },
  1411. },
  1412. ],
  1413. "finish_reason": "tool_calls",
  1414. }
  1415. return {
  1416. "content": "Corrected the invalid batch.",
  1417. "tool_calls": [],
  1418. "finish_reason": "stop",
  1419. }
  1420. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  1421. config = await self.bind_snapshot(
  1422. root_id,
  1423. RunConfig(
  1424. trace_id=root_id,
  1425. max_iterations=3,
  1426. knowledge=KnowledgeConfig(
  1427. enable_extraction=False,
  1428. enable_completion_extraction=False,
  1429. enable_injection=False,
  1430. ),
  1431. ),
  1432. )
  1433. result = await runner.run_result(messages=[], config=config)
  1434. self.assertEqual("completed", result["status"])
  1435. children = await self.store.list_traces(
  1436. parent_trace_id=root_id,
  1437. created_by_tool="agent",
  1438. limit=10,
  1439. )
  1440. self.assertEqual([], children)
  1441. tool_messages = [
  1442. message
  1443. for message in await self.store.get_trace_messages(root_id)
  1444. if message.role == "tool"
  1445. ]
  1446. self.assertEqual(2, len(tool_messages))
  1447. self.assertTrue(all(
  1448. "only tool call" in str(message.content)
  1449. for message in tool_messages
  1450. ))
  1451. async def test_rewind_clears_protocol_state_after_cutoff(self):
  1452. trace = await self.store.get_trace(self.trace_id)
  1453. state = ensure_task_protocol(trace.context)
  1454. original_brief = TaskBrief.model_validate(state["task_brief"])
  1455. revised_brief = original_brief.model_copy(
  1456. update={"objective": "revised after the rewind cutoff"}
  1457. )
  1458. replace_task_brief(
  1459. state,
  1460. revised_brief,
  1461. effective_at_sequence=8,
  1462. )
  1463. late_snapshot = create_context_snapshot(
  1464. kind="reviewed_task_result",
  1465. summary="granted after cutoff",
  1466. source_trace_id="late-child",
  1467. root_trace_id="root",
  1468. uid="user-1",
  1469. content={"result": "must be removed by rewind"},
  1470. granted_at_sequence=8,
  1471. )
  1472. add_context_snapshot(
  1473. trace.context,
  1474. late_snapshot,
  1475. root_task_anchor=ROOT_ANCHOR,
  1476. task_brief=revised_brief,
  1477. )
  1478. report = TaskReport(
  1479. child_trace_id=self.trace_id,
  1480. **report_payload(),
  1481. ).model_dump()
  1482. state["task_report"] = report
  1483. state["task_report_submitted_at_sequence"] = 5
  1484. state["pending_reviews"] = {
  1485. "grandchild": {
  1486. "goal_id": "1",
  1487. "task_report": report,
  1488. "validation_result": validation_payload("grandchild"),
  1489. "received_at_sequence": 6,
  1490. }
  1491. }
  1492. state["reviews"] = [{"reviewed_at_sequence": 7}]
  1493. state["next_actions"] = [{"created_at_sequence": 8}]
  1494. await self.store.update_trace(self.trace_id, context=trace.context)
  1495. tree = GoalTree(
  1496. mission="rewind",
  1497. goals=[Goal(id="1", description="active", status="pending_review")],
  1498. current_id="1",
  1499. )
  1500. await self.store.update_goal_tree(self.trace_id, tree)
  1501. for sequence in (1, 2):
  1502. await self.store.add_message(Message.create(
  1503. trace_id=self.trace_id,
  1504. role="user",
  1505. sequence=sequence,
  1506. content=f"message-{sequence}",
  1507. ))
  1508. runner = AgentRunner(trace_store=self.store)
  1509. cutoff = await runner._rewind(self.trace_id, 2, tree)
  1510. self.assertEqual(3, cutoff)
  1511. rewound = await self.store.get_trace(self.trace_id)
  1512. state = ensure_task_protocol(rewound.context)
  1513. self.assertIsNone(state["task_report"])
  1514. self.assertEqual(1, len(state["report_history"]))
  1515. self.assertEqual({}, state["pending_reviews"])
  1516. self.assertEqual([], state["reviews"])
  1517. self.assertEqual([], state["next_actions"])
  1518. self.assertEqual(1, state["task_brief_version"])
  1519. self.assertEqual(original_brief.model_dump(), state["task_brief"])
  1520. self.assertEqual([], state["task_brief_history"])
  1521. self.assertEqual({}, rewound.context["context_access"]["snapshots"])
  1522. rewound_tree = await self.store.get_goal_tree(self.trace_id)
  1523. self.assertEqual("pending", rewound_tree.find("1").status)
  1524. async def test_rewind_restores_preserved_pending_review_projection(self):
  1525. trace = await self.store.get_trace(self.trace_id)
  1526. state = ensure_task_protocol(trace.context)
  1527. report = TaskReport(
  1528. child_trace_id="preserved-grandchild",
  1529. **report_payload(),
  1530. ).model_dump()
  1531. state["pending_reviews"] = {
  1532. "preserved-grandchild": {
  1533. "goal_id": "1",
  1534. "task_report": report,
  1535. "validation_result": validation_payload(
  1536. "preserved-grandchild"
  1537. ),
  1538. "received_at_sequence": 2,
  1539. }
  1540. }
  1541. await self.store.update_trace(self.trace_id, context=trace.context)
  1542. tree = GoalTree(
  1543. mission="rewind",
  1544. goals=[Goal(id="1", description="review", status="pending_review")],
  1545. current_id="1",
  1546. )
  1547. await self.store.update_goal_tree(self.trace_id, tree)
  1548. for sequence in (1, 2):
  1549. await self.store.add_message(Message.create(
  1550. trace_id=self.trace_id,
  1551. role="user",
  1552. sequence=sequence,
  1553. content=f"message-{sequence}",
  1554. ))
  1555. runner = AgentRunner(trace_store=self.store)
  1556. await runner._rewind(self.trace_id, 2, tree)
  1557. rewound = await self.store.get_trace(self.trace_id)
  1558. self.assertIn(
  1559. "preserved-grandchild",
  1560. ensure_task_protocol(rewound.context)["pending_reviews"],
  1561. )
  1562. rewound_tree = await self.store.get_goal_tree(self.trace_id)
  1563. self.assertEqual("pending_review", rewound_tree.find("1").status)
  1564. async def test_rewind_rebuilds_root_validation_history_and_cache(self):
  1565. root_id = "root-validation-rewind"
  1566. state = new_task_protocol()
  1567. passed = validation_payload(root_id, outcome="passed")
  1568. failed = validation_payload(root_id, outcome="failed")
  1569. state["root_validation_history"] = [
  1570. {**passed, "validated_at_sequence": 2},
  1571. {**failed, "validated_at_sequence": 5},
  1572. ]
  1573. state["root_validation_attempts"] = 2
  1574. state["root_validation_passed"] = False
  1575. state["task_report_validation"] = {
  1576. "plan_hash": failed["plan_hash"],
  1577. "aggregate_result": failed,
  1578. "scope_results": failed["scope_results"],
  1579. "validated_at_sequence": 5,
  1580. }
  1581. await create_recursive_root(self.store, root_id, state=state)
  1582. tree = GoalTree(
  1583. mission="root",
  1584. goals=[Goal(id="1", description="root", status="in_progress")],
  1585. current_id="1",
  1586. )
  1587. await self.store.update_goal_tree(root_id, tree)
  1588. for sequence in (1, 2, 3):
  1589. await self.store.add_message(Message.create(
  1590. trace_id=root_id,
  1591. role="user",
  1592. sequence=sequence,
  1593. content=f"message-{sequence}",
  1594. ))
  1595. await AgentRunner(trace_store=self.store)._rewind(root_id, 3, tree)
  1596. rewound = await self.store.get_trace(root_id)
  1597. state = ensure_task_protocol(rewound.context)
  1598. self.assertEqual(1, state["root_validation_attempts"])
  1599. self.assertTrue(state["root_validation_passed"])
  1600. self.assertEqual(
  1601. ["passed"],
  1602. [item["outcome"] for item in state["root_validation_history"]],
  1603. )
  1604. self.assertIsNone(state["task_report_validation"])
  1605. async def test_root_cannot_finish_with_unconsumed_next_action(self):
  1606. root_id = "next-action-gate-root"
  1607. state = new_task_protocol()
  1608. state["next_actions"] = [{
  1609. "decision": "ACCEPT_REFINE",
  1610. "child_trace_id": "reviewed-child",
  1611. "goal_id": None,
  1612. "task_brief": BRIEF,
  1613. "created_at_sequence": 1,
  1614. }]
  1615. await create_recursive_root(self.store, root_id, state=state)
  1616. await self.store.update_goal_tree(root_id, GoalTree(mission="root"))
  1617. calls = 0
  1618. async def llm_call(**kwargs):
  1619. nonlocal calls
  1620. calls += 1
  1621. return {
  1622. "content": "Trying to finish without the approved action.",
  1623. "tool_calls": [],
  1624. "finish_reason": "stop",
  1625. }
  1626. runner = AgentRunner(trace_store=self.store, llm_call=llm_call)
  1627. config = await self.bind_snapshot(
  1628. root_id,
  1629. RunConfig(
  1630. trace_id=root_id,
  1631. max_iterations=5,
  1632. knowledge=KnowledgeConfig(
  1633. enable_extraction=False,
  1634. enable_completion_extraction=False,
  1635. enable_injection=False,
  1636. ),
  1637. ),
  1638. )
  1639. result = await runner.run_result(messages=[], config=config)
  1640. self.assertEqual("failed", result["status"])
  1641. self.assertEqual(3, calls)
  1642. root = await self.store.get_trace(root_id)
  1643. self.assertEqual(1, len(ensure_task_protocol(root.context)["next_actions"]))
  1644. class RuntimeProtocolSchemaTest(unittest.TestCase):
  1645. def setUp(self):
  1646. self.runner = AgentRunner(tool_registry=get_tool_registry())
  1647. def names(self, schemas):
  1648. return {schema["function"]["name"] for schema in schemas}
  1649. def test_legacy_and_recursive_runtime_schemas_are_isolated(self):
  1650. legacy = Trace(
  1651. trace_id="legacy",
  1652. mode="agent",
  1653. context={"agent_mode": "legacy", "agent_mode_revision": 1},
  1654. )
  1655. legacy_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), legacy)
  1656. self.assertNotIn("submit_task_report", self.names(legacy_schemas))
  1657. self.assertNotIn("review_task_result", self.names(legacy_schemas))
  1658. legacy_agent = next(
  1659. item for item in legacy_schemas if item["function"]["name"] == "agent"
  1660. )
  1661. properties = legacy_agent["function"]["parameters"]["properties"]
  1662. self.assertIn("task", properties)
  1663. self.assertNotIn("task_brief", properties)
  1664. child = Trace(
  1665. trace_id="child",
  1666. mode="agent",
  1667. parent_trace_id="root",
  1668. context={
  1669. "agent_mode": "recursive",
  1670. "agent_mode_revision": 3,
  1671. "task_protocol": new_task_protocol(BRIEF),
  1672. },
  1673. )
  1674. child_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), child)
  1675. self.assertIn("submit_task_report", self.names(child_schemas))
  1676. self.assertNotIn("review_task_result", self.names(child_schemas))
  1677. self.assertNotIn("evaluate", self.names(child_schemas))
  1678. recursive_agent = next(
  1679. item for item in child_schemas if item["function"]["name"] == "agent"
  1680. )
  1681. recursive_properties = recursive_agent["function"]["parameters"]["properties"]
  1682. self.assertNotIn("messages", recursive_properties)
  1683. brief_object = recursive_properties["task_brief"]["oneOf"][0]
  1684. self.assertEqual(
  1685. {
  1686. "objective",
  1687. "reason",
  1688. "completion_criteria",
  1689. "expected_outputs",
  1690. },
  1691. set(brief_object["required"]),
  1692. )
  1693. state = ensure_task_protocol(child.context)
  1694. state["pending_reviews"]["grandchild"] = {"task_report": {}}
  1695. review_schemas = self.runner._get_runtime_tool_schemas(RunConfig(), child)
  1696. self.assertEqual(
  1697. {"review_task_result", "read_context_ref"},
  1698. self.names(review_schemas),
  1699. )
  1700. def test_protocol_tools_publish_nested_object_schemas(self):
  1701. schemas = {
  1702. schema["function"]["name"]: schema
  1703. for schema in get_tool_registry().get_schemas([
  1704. "submit_task_report",
  1705. "review_task_result",
  1706. ])
  1707. }
  1708. report_schema = schemas["submit_task_report"]["function"]["parameters"]
  1709. task_report = report_schema["properties"]["task_report"]
  1710. self.assertEqual("object", task_report["type"])
  1711. self.assertIn("validation", task_report["properties"])
  1712. self.assertIn("next_step_suggestion", task_report["properties"])
  1713. review_schema = schemas["review_task_result"]["function"]["parameters"]
  1714. self.assertEqual(
  1715. [
  1716. "ACCEPT_REFINE",
  1717. "REJECT_REFINE",
  1718. "REVISE_CHILD",
  1719. "DESCEND_AGAIN",
  1720. "REPLAN_CURRENT",
  1721. "ASCEND",
  1722. "FAIL",
  1723. ],
  1724. review_schema["properties"]["decision"]["enum"],
  1725. )
  1726. self.assertEqual(
  1727. "object",
  1728. review_schema["properties"]["approved_next_task"]["type"],
  1729. )
  1730. if __name__ == "__main__":
  1731. unittest.main()