test_application_runtime_integration.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. import asyncio
  2. import hashlib
  3. import json
  4. import tempfile
  5. import unittest
  6. from unittest.mock import patch
  7. from fastapi import HTTPException
  8. from pydantic import ValidationError
  9. from cyber_agent.application import (
  10. AgentApplication,
  11. AgentRole,
  12. ApplicationRegistry,
  13. ApplicationRuntime,
  14. ApplicationServices,
  15. ContextMaterial,
  16. ContextResourceDescriptor,
  17. ProviderRef,
  18. RoleRunLimits,
  19. RunLimits,
  20. ToolSet,
  21. ToolSpec,
  22. )
  23. from cyber_agent.application.models import canonical_json
  24. from cyber_agent.application.bootstrap import (
  25. APPLICATION_FACTORIES_ENV,
  26. build_runtime_from_environment,
  27. )
  28. from cyber_agent.core.context_policy import (
  29. ContextPolicyError,
  30. build_child_context_access,
  31. normalize_root_task_anchor,
  32. )
  33. from cyber_agent.core.run_snapshot import (
  34. RunConfigSnapshotV2,
  35. load_run_config_snapshot,
  36. persist_run_config_snapshot,
  37. )
  38. from cyber_agent.core.resource_budget import ResourceBudgetController
  39. from cyber_agent.core.task_protocol import ContextRef, TaskBrief, new_task_protocol
  40. from cyber_agent.tools import get_tool_registry
  41. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  42. from cyber_agent.tools.registry import ToolRegistry
  43. from cyber_agent.trace.models import Trace
  44. from cyber_agent.trace.run_api import (
  45. CreateRequest,
  46. _restore_runner_and_config,
  47. create_and_run,
  48. set_application_runtime,
  49. )
  50. from cyber_agent.trace.store import FileSystemTraceStore
  51. ROOT_ANCHOR = {
  52. "objective": "Produce an evidence-grounded article",
  53. "completion_criteria": ["Every claim is traceable"],
  54. "constraints": ["Do not invent evidence"],
  55. }
  56. class CountingContextProvider:
  57. def __init__(self, *, count=1, bad_hash=False):
  58. self.count = count
  59. self.bad_hash = bad_hash
  60. self.list_calls = 0
  61. self.resolve_calls = 0
  62. async def list_context(self, request):
  63. self.list_calls += 1
  64. descriptors = []
  65. for index in range(self.count):
  66. content = {"source": index, "fact": f"verified-{index}"}
  67. digest = hashlib.sha256(
  68. canonical_json(content).encode("utf-8")
  69. ).hexdigest()
  70. if self.bad_hash and index == 0:
  71. digest = "0" * 64
  72. descriptors.append(ContextResourceDescriptor(
  73. application_ref=request.application_ref,
  74. root_trace_id=request.root_trace_id,
  75. trace_id=request.trace_id,
  76. uid=request.uid,
  77. role_id=request.role_id,
  78. source_id=f"source-{index:02d}",
  79. source_version="1",
  80. content_hash=digest,
  81. kind="evidence",
  82. summary=f"Evidence {index}",
  83. inheritance="task_only" if index == 0 else "inheritable",
  84. priority=100 if index == 0 else index,
  85. estimated_chars=len(canonical_json(content)),
  86. ))
  87. return descriptors
  88. async def resolve_context(self, request, descriptor):
  89. del request
  90. self.resolve_calls += 1
  91. index = int(descriptor.source_id.rsplit("-", 1)[1])
  92. return ContextMaterial(
  93. descriptor=descriptor,
  94. content={"source": index, "fact": f"verified-{index}"},
  95. )
  96. async def unused_llm(**kwargs):
  97. raise AssertionError(f"LLM must not be called in this test: {kwargs}")
  98. def declaration(*, prompt="Bound role prompt", max_iterations=12):
  99. return AgentApplication(
  100. application_id="creative.reference",
  101. application_version="0.1.0",
  102. root_role="writer",
  103. roles=(AgentRole(
  104. role_id="writer",
  105. model="bound-model",
  106. temperature=0.2,
  107. system_prompt=prompt,
  108. allowed_child_roles=("writer",),
  109. run_limits=RoleRunLimits(max_iterations=max_iterations),
  110. ),),
  111. context_provider_ref=ProviderRef(
  112. provider_id="reference-context",
  113. provider_version="1",
  114. ),
  115. run_limits=RunLimits(max_iterations=20),
  116. )
  117. class ApplicationRuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
  118. async def asyncSetUp(self):
  119. self.temp = tempfile.TemporaryDirectory()
  120. self.store = FileSystemTraceStore(self.temp.name)
  121. self.provider = CountingContextProvider()
  122. self.registry = ApplicationRegistry()
  123. self.binding = self.registry.register(
  124. declaration(),
  125. ApplicationServices(
  126. tool_registry=ToolRegistry(),
  127. context_provider=self.provider,
  128. ),
  129. )
  130. self.runtime = ApplicationRuntime(
  131. registry=self.registry,
  132. trace_store=self.store,
  133. llm_call=unused_llm,
  134. )
  135. set_application_runtime(self.runtime)
  136. async def asyncTearDown(self):
  137. set_application_runtime(None)
  138. self.temp.cleanup()
  139. async def _create_trace(self, *, max_iterations=None):
  140. runner, config = self.runtime.new_run(
  141. "creative.reference",
  142. "0.1.0",
  143. uid="user-1",
  144. root_task_anchor=ROOT_ANCHOR,
  145. max_iterations=max_iterations,
  146. )
  147. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  148. trace, _goal, _sequence = await runner._prepare_new_trace(
  149. [{"role": "user", "content": "Start"}],
  150. config,
  151. )
  152. return trace, runner, config
  153. async def test_new_run_persists_v2_binding_and_resume_does_not_reload_context(self):
  154. trace, _runner, config = await self._create_trace(max_iterations=7)
  155. snapshot = load_run_config_snapshot(trace.context)
  156. self.assertIsInstance(snapshot, RunConfigSnapshotV2)
  157. self.assertEqual(self.binding.application_ref.model_dump(), snapshot.application_ref)
  158. self.assertEqual("writer", snapshot.role_id)
  159. self.assertEqual(7, snapshot.max_iterations)
  160. self.assertEqual(7, snapshot.effective_run_limits["max_iterations"])
  161. self.assertEqual(1, self.provider.list_calls)
  162. self.assertEqual(1, self.provider.resolve_calls)
  163. snapshots = trace.context["context_access"]["snapshots"]
  164. self.assertEqual(1, len(snapshots))
  165. self.assertEqual(
  166. "application_resource",
  167. next(iter(snapshots.values()))["kind"],
  168. )
  169. self.assertEqual("Bound role prompt", config.system_prompt)
  170. restored_runner, restored_config = await self.runtime.restore(trace.trace_id)
  171. self.assertIsNot(restored_runner, _runner)
  172. self.assertEqual("Bound role prompt", restored_config.system_prompt)
  173. self.assertEqual(1, self.provider.list_calls)
  174. self.assertEqual(1, self.provider.resolve_calls)
  175. async def test_application_http_handler_uses_the_bound_runtime(self):
  176. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  177. response = await create_and_run(CreateRequest(
  178. messages=[{"role": "user", "content": "Start through HTTP"}],
  179. application_id="creative.reference",
  180. application_version="0.1.0",
  181. uid="http-user",
  182. root_task_anchor=ROOT_ANCHOR,
  183. ))
  184. trace = await self.store.get_trace(response.trace_id)
  185. self.assertIsNotNone(trace)
  186. snapshot = load_run_config_snapshot(trace.context)
  187. self.assertIsInstance(snapshot, RunConfigSnapshotV2)
  188. self.assertEqual(
  189. self.binding.application_ref.model_dump(mode="json"),
  190. snapshot.application_ref,
  191. )
  192. await asyncio.sleep(0)
  193. async def test_restore_rejects_registry_hash_and_trace_binding_tampering(self):
  194. trace, base_runner, _config = await self._create_trace()
  195. changed_registry = ApplicationRegistry()
  196. changed_registry.register(
  197. declaration(prompt="Changed role prompt"),
  198. ApplicationServices(
  199. tool_registry=ToolRegistry(),
  200. context_provider=CountingContextProvider(),
  201. ),
  202. )
  203. changed_runtime = ApplicationRuntime(
  204. registry=changed_registry,
  205. trace_store=self.store,
  206. llm_call=unused_llm,
  207. )
  208. with self.assertRaisesRegex(ValueError, "config hash"):
  209. await changed_runtime.restore(trace.trace_id)
  210. persisted = await self.store.get_trace(trace.trace_id)
  211. persisted.context["application_role_hash"] = "f" * 64
  212. await self.store.update_trace(trace.trace_id, context=persisted.context)
  213. with self.assertRaisesRegex(ValueError, "Trace context"):
  214. await self.runtime.restore(trace.trace_id)
  215. with self.assertRaises(HTTPException) as raised:
  216. await _restore_runner_and_config(
  217. trace=await self.store.get_trace(trace.trace_id),
  218. base_runner=base_runner,
  219. )
  220. self.assertEqual(409, raised.exception.status_code)
  221. async def test_runtime_dependency_failure_happens_before_trace_creation(self):
  222. calls = 0
  223. def unavailable():
  224. nonlocal calls
  225. calls += 1
  226. raise RuntimeError("credential unavailable")
  227. registry = ApplicationRegistry()
  228. registry.register(
  229. declaration(),
  230. ApplicationServices(
  231. tool_registry=ToolRegistry(),
  232. context_provider=CountingContextProvider(),
  233. runtime_validator=unavailable,
  234. ),
  235. )
  236. runtime = ApplicationRuntime(
  237. registry=registry,
  238. trace_store=self.store,
  239. llm_call=unused_llm,
  240. )
  241. with self.assertRaisesRegex(RuntimeError, "credential unavailable"):
  242. runtime.new_run("creative.reference", "0.1.0")
  243. self.assertEqual(1, calls)
  244. self.assertEqual([], await self.store.list_traces())
  245. async def test_context_is_stably_capped_and_task_only_is_not_delegated(self):
  246. provider = CountingContextProvider(count=10)
  247. registry = ApplicationRegistry()
  248. binding = registry.register(
  249. declaration(),
  250. ApplicationServices(
  251. tool_registry=ToolRegistry(),
  252. context_provider=provider,
  253. ),
  254. )
  255. runtime = ApplicationRuntime(
  256. registry=registry,
  257. trace_store=self.store,
  258. llm_call=unused_llm,
  259. )
  260. self.runtime = runtime
  261. self.registry = registry
  262. self.binding = binding
  263. self.provider = provider
  264. set_application_runtime(runtime)
  265. trace, _runner, _config = await self._create_trace()
  266. snapshots = list(trace.context["context_access"]["snapshots"].values())
  267. self.assertEqual(8, len(snapshots))
  268. selected_sources = [
  269. item["content"]["application_resource"]["source_id"]
  270. for item in snapshots
  271. ]
  272. self.assertEqual(
  273. ["source-00", *[f"source-{index:02d}" for index in range(9, 2, -1)]],
  274. selected_sources,
  275. )
  276. refs = [
  277. ContextRef(ref_id=item["ref_id"], version=item["version"])
  278. for item in snapshots
  279. ]
  280. child_brief = TaskBrief(
  281. objective="Write the checked section",
  282. reason="The root needs this section",
  283. completion_criteria=["Use the supplied evidence"],
  284. expected_outputs=["One section"],
  285. context_refs=refs,
  286. )
  287. parent_state = new_task_protocol()
  288. with self.assertRaisesRegex(ContextPolicyError, "task_only"):
  289. build_child_context_access(
  290. parent_context=trace.context,
  291. parent_trace_id=trace.trace_id,
  292. root_trace_id=trace.trace_id,
  293. uid="user-1",
  294. parent_task_state=parent_state,
  295. child_task_brief=child_brief,
  296. root_task_anchor=normalize_root_task_anchor(ROOT_ANCHOR),
  297. )
  298. child_brief = child_brief.model_copy(update={
  299. "context_refs": refs[1:],
  300. })
  301. child_access = build_child_context_access(
  302. parent_context=trace.context,
  303. parent_trace_id=trace.trace_id,
  304. root_trace_id=trace.trace_id,
  305. uid="user-1",
  306. parent_task_state=parent_state,
  307. child_task_brief=child_brief,
  308. root_task_anchor=normalize_root_task_anchor(ROOT_ANCHOR),
  309. )
  310. delegated = list(child_access["snapshots"].values())
  311. self.assertEqual(7, len(delegated))
  312. self.assertTrue(all(
  313. item["content"]["application_resource"]["inheritance"]
  314. == "inheritable"
  315. for item in delegated
  316. if item["kind"] == "application_resource"
  317. ))
  318. async def test_context_hash_mismatch_leaves_no_trace(self):
  319. provider = CountingContextProvider(bad_hash=True)
  320. registry = ApplicationRegistry()
  321. registry.register(
  322. declaration(),
  323. ApplicationServices(
  324. tool_registry=ToolRegistry(),
  325. context_provider=provider,
  326. ),
  327. )
  328. runtime = ApplicationRuntime(
  329. registry=registry,
  330. trace_store=self.store,
  331. llm_call=unused_llm,
  332. )
  333. runner, config = runtime.new_run(
  334. "creative.reference",
  335. "0.1.0",
  336. root_task_anchor=ROOT_ANCHOR,
  337. )
  338. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  339. with self.assertRaisesRegex(ValueError, "hash mismatch"):
  340. await runner._prepare_new_trace([], config)
  341. self.assertEqual([], await self.store.list_traces())
  342. class ApplicationCreateRequestTest(unittest.TestCase):
  343. def test_application_pair_and_override_rules(self):
  344. with self.assertRaisesRegex(ValidationError, "provided together"):
  345. CreateRequest(
  346. messages=[{"role": "user", "content": "start"}],
  347. application_id="creative.reference",
  348. )
  349. with self.assertRaisesRegex(ValidationError, "mutually exclusive"):
  350. CreateRequest(
  351. messages=[{"role": "user", "content": "start"}],
  352. application_id="creative.reference",
  353. application_version="0.1.0",
  354. project_name="old_example",
  355. )
  356. for field, value in (
  357. ("model", "override"),
  358. ("temperature", 1.0),
  359. ("tools", []),
  360. ):
  361. with self.subTest(field=field):
  362. with self.assertRaisesRegex(ValidationError, "cannot override"):
  363. CreateRequest(
  364. messages=[{"role": "user", "content": "start"}],
  365. application_id="creative.reference",
  366. application_version="0.1.0",
  367. **{field: value},
  368. )
  369. with self.assertRaisesRegex(ValidationError, "system messages"):
  370. CreateRequest(
  371. messages=[{"role": "system", "content": "override"}],
  372. application_id="creative.reference",
  373. application_version="0.1.0",
  374. )
  375. def test_deployment_factory_builds_the_http_runtime_registry(self):
  376. with tempfile.TemporaryDirectory() as temp_dir:
  377. store = FileSystemTraceStore(temp_dir)
  378. runtime = build_runtime_from_environment(
  379. trace_store=store,
  380. llm_call=unused_llm,
  381. environ={
  382. APPLICATION_FACTORIES_ENV: (
  383. "examples.application_reference.application:"
  384. "build_reference_components"
  385. ),
  386. },
  387. )
  388. self.assertIsNotNone(runtime)
  389. binding = runtime.registry.resolve("application_reference", "1")
  390. self.assertEqual("editor", binding.application.root_role)
  391. self.assertIsNone(build_runtime_from_environment(
  392. trace_store=store,
  393. llm_call=unused_llm,
  394. environ={},
  395. ))
  396. def _tool_names(schemas):
  397. return {item["function"]["name"] for item in schemas or []}
  398. def _has_tool_result(messages, tool_name):
  399. return any(
  400. item.get("role") == "tool" and item.get("name") == tool_name
  401. for item in messages
  402. )
  403. def _tool_call(call_id, name, arguments):
  404. return {
  405. "content": "",
  406. "tool_calls": [{
  407. "id": call_id,
  408. "type": "function",
  409. "function": {
  410. "name": name,
  411. "arguments": json.dumps(arguments),
  412. },
  413. }],
  414. "finish_reason": "tool_calls",
  415. }
  416. def _ready_progress(call_id):
  417. return _tool_call(call_id, "update_task_progress", {
  418. "expected_revision": 1,
  419. "progress": {
  420. "phase": "ready_to_submit",
  421. "questions": [],
  422. "blockers": [],
  423. "findings": [],
  424. "hypotheses": [],
  425. "work_items": [],
  426. "decision_rationale": "The application role completed its contract.",
  427. },
  428. })
  429. class ApplicationChildPropagationTest(unittest.IsolatedAsyncioTestCase):
  430. async def test_child_inherits_application_but_uses_target_role_binding(self):
  431. with tempfile.TemporaryDirectory() as temp_dir:
  432. store = FileSystemTraceStore(temp_dir)
  433. declarations = tuple(
  434. ToolSpec(tool_name=name, implementation_version="1")
  435. for name in (
  436. "agent",
  437. "update_task_progress",
  438. "submit_task_report",
  439. "review_task_result",
  440. )
  441. )
  442. application = AgentApplication(
  443. application_id="creative.roles",
  444. application_version="0.1.0",
  445. root_role="director",
  446. roles=(
  447. AgentRole(
  448. role_id="director",
  449. model="root-model",
  450. system_prompt="ROOT_APPLICATION_PROMPT",
  451. tool_sets=("root-tools",),
  452. allowed_child_roles=("checker",),
  453. ),
  454. AgentRole(
  455. role_id="checker",
  456. model="child-model",
  457. system_prompt="CHILD_APPLICATION_PROMPT",
  458. tool_sets=("child-tools",),
  459. ),
  460. ),
  461. tool_sets=(
  462. ToolSet(
  463. tool_set_id="root-tools",
  464. tools=tuple(
  465. item
  466. for item in declarations
  467. if item.tool_name in {
  468. "agent",
  469. "update_task_progress",
  470. "review_task_result",
  471. }
  472. ),
  473. ),
  474. ToolSet(
  475. tool_set_id="child-tools",
  476. tools=tuple(
  477. item
  478. for item in declarations
  479. if item.tool_name in {
  480. "update_task_progress",
  481. "submit_task_report",
  482. }
  483. ),
  484. ),
  485. ),
  486. run_limits=RunLimits(max_iterations=20),
  487. )
  488. registry = ApplicationRegistry()
  489. binding = registry.register(
  490. application,
  491. ApplicationServices(tool_registry=get_tool_registry()),
  492. )
  493. prompts_by_model = {}
  494. delegated = False
  495. reviewed = False
  496. async def llm_call(**kwargs):
  497. nonlocal delegated, reviewed
  498. messages = kwargs["messages"]
  499. model = kwargs["model"]
  500. prompts_by_model.setdefault(model, set()).update(
  501. str(item.get("content", ""))
  502. for item in messages
  503. if item.get("role") == "system"
  504. )
  505. if any(
  506. "recursive_validation_protocol" in str(item.get("content", ""))
  507. for item in messages
  508. if item.get("role") == "system"
  509. ):
  510. packet = next(
  511. json.loads(item["content"])
  512. for item in messages
  513. if item.get("role") == "user"
  514. and "recursive_validation_protocol" in str(item.get("content", ""))
  515. )
  516. scope = packet["validation_scope"]
  517. return {
  518. "content": json.dumps({
  519. "outcome": "passed",
  520. "scope": scope,
  521. "checks": [
  522. {
  523. "check_id": check["check_id"],
  524. "status": "passed",
  525. "evidence_refs": [],
  526. "issue": None,
  527. }
  528. for check in packet["validation_plan"]["checks"]
  529. if check["scope"] == scope
  530. ],
  531. "reason": "bound application result is valid",
  532. "retry_from": None,
  533. }),
  534. "tool_calls": [],
  535. "finish_reason": "stop",
  536. }
  537. names = _tool_names(kwargs.get("tools"))
  538. if model == "child-model":
  539. if not _has_tool_result(messages, "update_task_progress"):
  540. return _ready_progress("child-progress")
  541. if not _has_tool_result(messages, "submit_task_report"):
  542. return _tool_call("child-report", "submit_task_report", {
  543. "task_report": {
  544. "summary": "The fact is checked.",
  545. "outcome": "satisfied",
  546. "validation": {
  547. "hard_passed": True,
  548. "open_issues": [],
  549. },
  550. "next_step_suggestion": {
  551. "direction": "NONE",
  552. "reason": "The local task is complete.",
  553. },
  554. "outputs": [{"kind": "fact", "value": "checked"}],
  555. "evidence": [],
  556. "remaining_issues": [],
  557. },
  558. })
  559. return {"content": "child submitted", "tool_calls": []}
  560. if names == {"review_task_result"}:
  561. root = next(
  562. item
  563. for item in await store.list_traces(limit=20)
  564. if item.parent_trace_id is None
  565. )
  566. child_id = next(iter(
  567. root.context["task_protocol"]["pending_reviews"]
  568. ))
  569. reviewed = True
  570. return _tool_call("review-child", "review_task_result", {
  571. "child_trace_id": child_id,
  572. "decision": "ASCEND",
  573. "reason": "The checked result satisfies the delegated task.",
  574. })
  575. if not delegated:
  576. delegated = True
  577. return _tool_call("delegate", "agent", {
  578. "agent_type": "checker",
  579. "task_brief": {
  580. "objective": "Check one fact",
  581. "reason": "The article needs verified evidence",
  582. "completion_criteria": ["Return one checked fact"],
  583. "expected_outputs": ["Checked fact"],
  584. },
  585. })
  586. if reviewed and not _has_tool_result(messages, "update_task_progress"):
  587. return _ready_progress("root-progress")
  588. return {"content": "root final", "tool_calls": []}
  589. runtime = ApplicationRuntime(
  590. registry=registry,
  591. trace_store=store,
  592. llm_call=llm_call,
  593. )
  594. runner, config = runtime.new_run(
  595. "creative.roles",
  596. "0.1.0",
  597. uid="user-1",
  598. root_task_anchor=ROOT_ANCHOR,
  599. )
  600. config.knowledge = KnowledgeConfig(
  601. enable_extraction=False,
  602. enable_completion_extraction=False,
  603. enable_injection=False,
  604. )
  605. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  606. result = await runner.run_result(
  607. [{"role": "user", "content": "Create the checked article"}],
  608. config,
  609. )
  610. self.assertEqual("completed", result["status"])
  611. self.assertTrue(reviewed)
  612. root = await store.get_trace(result["trace_id"])
  613. children = await store.list_traces(
  614. parent_trace_id=root.trace_id,
  615. created_by_tool="agent",
  616. limit=10,
  617. )
  618. self.assertEqual(1, len(children))
  619. child = children[0]
  620. root_snapshot = load_run_config_snapshot(root.context)
  621. child_snapshot = load_run_config_snapshot(child.context)
  622. self.assertIsInstance(child_snapshot, RunConfigSnapshotV2)
  623. self.assertEqual(root_snapshot.application_ref, child_snapshot.application_ref)
  624. self.assertEqual("director", root_snapshot.role_id)
  625. self.assertEqual("checker", child_snapshot.role_id)
  626. self.assertEqual(
  627. binding.role("checker").role_hash,
  628. child_snapshot.role_hash,
  629. )
  630. self.assertEqual("child-model", child.model)
  631. self.assertEqual(
  632. {"update_task_progress", "submit_task_report"},
  633. set(child_snapshot.tools),
  634. )
  635. self.assertTrue(any(
  636. "ROOT_APPLICATION_PROMPT" in prompt
  637. for prompt in prompts_by_model["root-model"]
  638. ))
  639. self.assertTrue(any(
  640. "CHILD_APPLICATION_PROMPT" in prompt
  641. for prompt in prompts_by_model["child-model"]
  642. ))
  643. usage = await ResourceBudgetController(store).get_usage(root.trace_id)
  644. self.assertEqual(2, usage.total_agents)
  645. if __name__ == "__main__":
  646. unittest.main()