test_application_runtime_integration.py 24 KB

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