test_recursive_context_policy.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. import json
  2. import tempfile
  3. import unittest
  4. from pydantic import ValidationError
  5. from cyber_agent.core.agent_mode import (
  6. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY,
  7. policy_from_context,
  8. )
  9. from cyber_agent.core.runner import AgentRunner, RunConfig
  10. from cyber_agent.core.context_policy import (
  11. MAX_CONTEXT_REFS,
  12. MAX_CONTEXT_SNAPSHOTS_CHARS,
  13. MAX_ROOT_TASK_ANCHOR_CHARS,
  14. MAX_TASK_BRIEF_CHARS,
  15. ContextPolicyError,
  16. add_context_snapshot,
  17. build_child_context_access,
  18. canonical_json,
  19. context_chars,
  20. create_context_snapshot,
  21. get_authorized_context_snapshot,
  22. normalize_root_task_anchor,
  23. normalize_task_brief,
  24. persist_root_task_anchor,
  25. prune_context_access,
  26. replace_context_access,
  27. require_matching_root_task_anchor,
  28. require_root_task_anchor,
  29. root_task_anchor_hash,
  30. )
  31. from cyber_agent.core.task_protocol import (
  32. ContextRef,
  33. TaskBrief,
  34. new_task_protocol,
  35. replace_task_brief,
  36. )
  37. from cyber_agent.tools.builtin.context import read_context_ref
  38. from cyber_agent.tools.builtin.subagent import _get_allowed_tools
  39. from cyber_agent.trace.models import Trace
  40. from cyber_agent.trace.run_api import CreateRequest
  41. from cyber_agent.trace.store import FileSystemTraceStore
  42. ROOT_ANCHOR = {
  43. "objective": "五层委托仍围绕同一个根任务",
  44. "completion_criteria": ["所有局部结论可追溯", "根结果通过独立验收"],
  45. "constraints": ["不得编造证据"],
  46. }
  47. def brief(level: int, **updates) -> TaskBrief:
  48. values = {
  49. "objective": f"完成第 {level} 层局部任务",
  50. "reason": f"第 {level - 1} 层需要该结果",
  51. "completion_criteria": [f"第 {level} 层结论可验收"],
  52. "expected_outputs": [f"第 {level} 层结论"],
  53. "parent_findings": [f"直接父级结论 {level - 1}"],
  54. "context": {"local_level": level},
  55. "constraints": [f"约束-{level}"],
  56. }
  57. values.update(updates)
  58. return TaskBrief.model_validate(values)
  59. def anchored_context(task_brief: TaskBrief | None = None) -> dict:
  60. context = {
  61. "agent_mode": "recursive",
  62. "agent_mode_revision": 2,
  63. "root_trace_id": "root",
  64. "agent_depth": 0,
  65. "task_protocol": new_task_protocol(task_brief),
  66. }
  67. anchor = persist_root_task_anchor(context, ROOT_ANCHOR)
  68. replace_context_access(
  69. context,
  70. [],
  71. root_task_anchor=anchor,
  72. task_brief=task_brief,
  73. )
  74. return context
  75. class RootAnchorAndTaskBriefTest(unittest.TestCase):
  76. def test_anchor_is_canonical_and_hash_stays_identical_for_five_levels(self):
  77. anchor = normalize_root_task_anchor({
  78. "constraints": ["不得编造证据", "不得编造证据"],
  79. "completion_criteria": ["所有局部结论可追溯", "根结果通过独立验收"],
  80. "objective": "五层委托仍围绕同一个根任务",
  81. })
  82. expected_json = canonical_json(anchor.model_dump(mode="json"))
  83. expected_hash = root_task_anchor_hash(anchor)
  84. for _ in range(6):
  85. context = {}
  86. persisted = persist_root_task_anchor(context, anchor)
  87. self.assertEqual(expected_json, canonical_json(persisted.model_dump(mode="json")))
  88. self.assertEqual(expected_hash, context["root_task_anchor_hash"])
  89. self.assertEqual(expected_json, canonical_json(require_root_task_anchor(context).model_dump(mode="json")))
  90. def test_anchor_rejects_tampering_and_enforces_real_4000_char_limit(self):
  91. context = {}
  92. persist_root_task_anchor(context, ROOT_ANCHOR)
  93. context["root_task_anchor"]["objective"] = "被篡改"
  94. with self.assertRaisesRegex(ContextPolicyError, "hash"):
  95. require_root_task_anchor(context)
  96. low, high = 1, MAX_ROOT_TASK_ANCHOR_CHARS * 2
  97. while low < high:
  98. middle = (low + high + 1) // 2
  99. candidate = {**ROOT_ANCHOR, "objective": "根" * middle}
  100. try:
  101. normalize_root_task_anchor(candidate)
  102. low = middle
  103. except ContextPolicyError:
  104. high = middle - 1
  105. largest = normalize_root_task_anchor({**ROOT_ANCHOR, "objective": "根" * low})
  106. self.assertLessEqual(
  107. context_chars(largest.model_dump(mode="json")),
  108. MAX_ROOT_TASK_ANCHOR_CHARS,
  109. )
  110. with self.assertRaisesRegex(ContextPolicyError, "4000"):
  111. normalize_root_task_anchor({**ROOT_ANCHOR, "objective": "根" * (low + 1)})
  112. def test_self_consistent_child_anchor_must_still_match_authoritative_root(self):
  113. root_context = {}
  114. child_context = {}
  115. persist_root_task_anchor(root_context, ROOT_ANCHOR)
  116. persist_root_task_anchor(child_context, {
  117. **ROOT_ANCHOR,
  118. "objective": "另一份自洽但非权威的根目标",
  119. })
  120. with self.assertRaisesRegex(ContextPolicyError, "does not match"):
  121. require_matching_root_task_anchor(root_context, child_context)
  122. def test_five_level_constraints_accumulate_without_other_context_drift(self):
  123. parent = None
  124. inherited_constraints = ROOT_ANCHOR["constraints"][:]
  125. for level in range(1, 6):
  126. current = normalize_task_brief(
  127. brief(level),
  128. parent_task_brief=parent,
  129. root_task_anchor=ROOT_ANCHOR,
  130. )
  131. inherited_constraints.append(f"约束-{level}")
  132. self.assertEqual(inherited_constraints, current.constraints)
  133. self.assertEqual([f"直接父级结论 {level - 1}"], current.parent_findings)
  134. self.assertEqual({"local_level": level}, current.context)
  135. self.assertEqual([], current.context_refs)
  136. parent = current
  137. def test_task_brief_version_preserves_old_normalized_content(self):
  138. first = normalize_task_brief(brief(1), root_task_anchor=ROOT_ANCHOR)
  139. state = new_task_protocol(first)
  140. second = normalize_task_brief(
  141. brief(1, objective="修订后的局部任务"),
  142. root_task_anchor=ROOT_ANCHOR,
  143. )
  144. replace_task_brief(state, second, effective_at_sequence=9)
  145. self.assertEqual(2, state["task_brief_version"])
  146. self.assertEqual(9, state["task_brief_effective_at_sequence"])
  147. self.assertEqual(first.model_dump(), state["task_brief_history"][0]["task_brief"])
  148. self.assertEqual(second.model_dump(), state["task_brief"])
  149. second.objective = "外部再次修改"
  150. self.assertEqual("修订后的局部任务", state["task_brief"]["objective"])
  151. def test_task_brief_uses_stable_json_and_enforces_16000_chars(self):
  152. escaped = normalize_task_brief(
  153. brief(1, context={"quote": "\"中文\\n", "ordered": {"b": 2, "a": 1}}),
  154. root_task_anchor=ROOT_ANCHOR,
  155. )
  156. encoded = canonical_json(escaped.model_dump(mode="json"))
  157. self.assertEqual(encoded, canonical_json(json.loads(encoded)))
  158. with self.assertRaisesRegex(ContextPolicyError, str(MAX_TASK_BRIEF_CHARS)):
  159. normalize_task_brief(
  160. brief(1, context={"payload": "资料" * MAX_TASK_BRIEF_CHARS}),
  161. root_task_anchor=ROOT_ANCHOR,
  162. )
  163. def test_create_request_uses_new_anchor_and_rejects_removed_field(self):
  164. request = CreateRequest(
  165. messages=[{"role": "user", "content": "开始"}],
  166. root_task_anchor=ROOT_ANCHOR,
  167. )
  168. self.assertEqual(ROOT_ANCHOR["objective"], request.root_task_anchor.objective)
  169. with self.assertRaisesRegex(ValidationError, "root_task_anchor"):
  170. CreateRequest(
  171. messages=[{"role": "user", "content": "开始"}],
  172. root_completion_criteria=["完成"],
  173. )
  174. class ContextReferencePolicyTest(unittest.TestCase):
  175. def test_root_child_only_gets_anchor_and_deeper_child_gets_parent_brief(self):
  176. root_context = anchored_context()
  177. level1 = normalize_task_brief(brief(1), root_task_anchor=ROOT_ANCHOR)
  178. child_access = build_child_context_access(
  179. parent_context=root_context,
  180. parent_trace_id="root",
  181. root_trace_id="root",
  182. uid="user-1",
  183. parent_task_state=root_context["task_protocol"],
  184. child_task_brief=level1,
  185. root_task_anchor=normalize_root_task_anchor(ROOT_ANCHOR),
  186. )
  187. self.assertEqual({}, child_access["snapshots"])
  188. level1_context = anchored_context(level1)
  189. level2 = normalize_task_brief(
  190. brief(2),
  191. parent_task_brief=level1,
  192. root_task_anchor=ROOT_ANCHOR,
  193. )
  194. grandchild_access = build_child_context_access(
  195. parent_context=level1_context,
  196. parent_trace_id="depth-1",
  197. root_trace_id="root",
  198. uid="user-1",
  199. parent_task_state=level1_context["task_protocol"],
  200. child_task_brief=level2,
  201. root_task_anchor=normalize_root_task_anchor(ROOT_ANCHOR),
  202. )
  203. snapshots = list(grandchild_access["snapshots"].values())
  204. self.assertEqual(1, len(snapshots))
  205. self.assertEqual("task_brief", snapshots[0]["kind"])
  206. self.assertEqual(level1.model_dump(mode="json"), snapshots[0]["content"])
  207. def test_reference_requires_exact_local_grant_version_tree_and_owner(self):
  208. context = anchored_context(brief(1))
  209. snapshot = create_context_snapshot(
  210. kind="reviewed_task_result",
  211. summary="已审核结果",
  212. source_trace_id="child",
  213. root_trace_id="root",
  214. uid="user-1",
  215. content={"task_report": {"summary": "真实结论"}},
  216. granted_at_sequence=7,
  217. )
  218. ref = add_context_snapshot(
  219. context,
  220. snapshot,
  221. root_task_anchor=ROOT_ANCHOR,
  222. task_brief=brief(1),
  223. )
  224. loaded = get_authorized_context_snapshot(
  225. context,
  226. ref_id=ref.ref_id,
  227. version=ref.version,
  228. root_trace_id="root",
  229. uid="user-1",
  230. )
  231. self.assertEqual("真实结论", loaded.content["task_report"]["summary"])
  232. for changes, message in (
  233. ({"version": "0" * 64}, "version"),
  234. ({"root_trace_id": "other-root"}, "another tree"),
  235. ({"uid": "user-2"}, "another tree"),
  236. ):
  237. values = {
  238. "ref_id": ref.ref_id,
  239. "version": ref.version,
  240. "root_trace_id": "root",
  241. "uid": "user-1",
  242. **changes,
  243. }
  244. with self.assertRaisesRegex(ContextPolicyError, message):
  245. get_authorized_context_snapshot(context, **values)
  246. with self.assertRaisesRegex(ContextPolicyError, "not authorized"):
  247. get_authorized_context_snapshot(
  248. context,
  249. ref_id="ctx_unknown",
  250. version="0" * 64,
  251. root_trace_id="root",
  252. uid="user-1",
  253. )
  254. context["context_access"]["snapshots"][ref.ref_id]["content"] = {
  255. "task_report": {"summary": "伪造结论"}
  256. }
  257. with self.assertRaisesRegex(ContextPolicyError, "version"):
  258. get_authorized_context_snapshot(
  259. context,
  260. ref_id=ref.ref_id,
  261. version=ref.version,
  262. root_trace_id="root",
  263. uid="user-1",
  264. )
  265. context = anchored_context(brief(1))
  266. ref = add_context_snapshot(
  267. context,
  268. snapshot,
  269. root_task_anchor=ROOT_ANCHOR,
  270. task_brief=brief(1),
  271. )
  272. context["context_access"]["snapshots"][ref.ref_id]["source_trace_id"] = "sibling"
  273. with self.assertRaisesRegex(ContextPolicyError, "ref_id"):
  274. get_authorized_context_snapshot(
  275. context,
  276. ref_id=ref.ref_id,
  277. version=ref.version,
  278. root_trace_id="root",
  279. uid="user-1",
  280. )
  281. context = anchored_context(brief(1))
  282. ref = add_context_snapshot(
  283. context,
  284. snapshot,
  285. root_task_anchor=ROOT_ANCHOR,
  286. task_brief=brief(1),
  287. )
  288. raw = context["context_access"]["snapshots"].pop(ref.ref_id)
  289. mismatched_key = "ctx_000000000000000000000000"
  290. context["context_access"]["snapshots"][mismatched_key] = raw
  291. with self.assertRaisesRegex(ContextPolicyError, "does not match its key"):
  292. get_authorized_context_snapshot(
  293. context,
  294. ref_id=mismatched_key,
  295. version=ref.version,
  296. root_trace_id="root",
  297. uid="user-1",
  298. )
  299. forwarded = brief(
  300. 2,
  301. context_refs=[ContextRef(ref_id=mismatched_key, version=ref.version)],
  302. )
  303. with self.assertRaisesRegex(ContextPolicyError, "does not match its key"):
  304. build_child_context_access(
  305. parent_context=context,
  306. parent_trace_id="depth-1",
  307. root_trace_id="root",
  308. uid="user-1",
  309. parent_task_state=context["task_protocol"],
  310. child_task_brief=forwarded,
  311. root_task_anchor=normalize_root_task_anchor(ROOT_ANCHOR),
  312. )
  313. def test_reference_must_be_forwarded_at_every_edge(self):
  314. parent_context = anchored_context(brief(1))
  315. ancestor_snapshot = create_context_snapshot(
  316. kind="reviewed_task_result",
  317. summary="祖先已审核结果",
  318. source_trace_id="ancestor-child",
  319. root_trace_id="root",
  320. uid="user-1",
  321. content={"task_review": {"reason": "已确认的完整结论"}},
  322. granted_at_sequence=4,
  323. )
  324. ancestor_ref = add_context_snapshot(
  325. parent_context,
  326. ancestor_snapshot,
  327. root_task_anchor=ROOT_ANCHOR,
  328. task_brief=brief(1),
  329. )
  330. forwarded_brief = normalize_task_brief(
  331. brief(2, context_refs=[ancestor_ref]),
  332. parent_task_brief=brief(1),
  333. root_task_anchor=ROOT_ANCHOR,
  334. )
  335. forwarded_access = build_child_context_access(
  336. parent_context=parent_context,
  337. parent_trace_id="depth-1",
  338. root_trace_id="root",
  339. uid="user-1",
  340. parent_task_state=parent_context["task_protocol"],
  341. child_task_brief=forwarded_brief,
  342. root_task_anchor=normalize_root_task_anchor(ROOT_ANCHOR),
  343. )
  344. self.assertIn(ancestor_ref.ref_id, forwarded_access["snapshots"])
  345. child_context = anchored_context(forwarded_brief)
  346. child_context["context_access"] = forwarded_access
  347. not_forwarded = normalize_task_brief(
  348. brief(3),
  349. parent_task_brief=forwarded_brief,
  350. root_task_anchor=ROOT_ANCHOR,
  351. )
  352. deeper_access = build_child_context_access(
  353. parent_context=child_context,
  354. parent_trace_id="depth-2",
  355. root_trace_id="root",
  356. uid="user-1",
  357. parent_task_state=child_context["task_protocol"],
  358. child_task_brief=not_forwarded,
  359. root_task_anchor=normalize_root_task_anchor(ROOT_ANCHOR),
  360. )
  361. self.assertNotIn(ancestor_ref.ref_id, deeper_access["snapshots"])
  362. def test_reference_limits_and_rewind_cleanup_are_enforced(self):
  363. context = anchored_context(brief(1))
  364. snapshots = [
  365. create_context_snapshot(
  366. kind="reviewed_task_result",
  367. summary=f"结果 {index}",
  368. source_trace_id=f"child-{index}",
  369. root_trace_id="root",
  370. uid="user-1",
  371. content={"index": index},
  372. granted_at_sequence=index,
  373. )
  374. for index in range(MAX_CONTEXT_REFS + 1)
  375. ]
  376. replace_context_access(
  377. context,
  378. snapshots[:MAX_CONTEXT_REFS],
  379. root_task_anchor=ROOT_ANCHOR,
  380. task_brief=brief(1),
  381. )
  382. self.assertEqual(
  383. MAX_CONTEXT_REFS,
  384. context["context_access"]["metrics"]["authorized_ref_count"],
  385. )
  386. with self.assertRaisesRegex(ContextPolicyError, str(MAX_CONTEXT_REFS)):
  387. replace_context_access(
  388. context,
  389. snapshots,
  390. root_task_anchor=ROOT_ANCHOR,
  391. task_brief=brief(1),
  392. )
  393. with self.assertRaisesRegex(ContextPolicyError, "16000"):
  394. create_context_snapshot(
  395. kind="reviewed_task_result",
  396. summary="超大结果",
  397. source_trace_id="oversized",
  398. root_trace_id="root",
  399. uid="user-1",
  400. content={"payload": "x" * 16_000},
  401. granted_at_sequence=1,
  402. )
  403. prune_context_access(context, 3)
  404. kept = context["context_access"]["snapshots"].values()
  405. self.assertTrue(all(item["granted_at_sequence"] <= 3 for item in kept))
  406. oversized = [
  407. create_context_snapshot(
  408. kind="reviewed_task_result",
  409. summary=f"大结果 {index}",
  410. source_trace_id=f"large-{index}",
  411. root_trace_id="root",
  412. uid="user-1",
  413. content={"payload": "x" * 13_000, "index": index},
  414. granted_at_sequence=index,
  415. )
  416. for index in range(5)
  417. ]
  418. self.assertGreater(
  419. sum(context_chars(item.content) for item in oversized),
  420. MAX_CONTEXT_SNAPSHOTS_CHARS,
  421. )
  422. with self.assertRaisesRegex(ContextPolicyError, str(MAX_CONTEXT_SNAPSHOTS_CHARS)):
  423. replace_context_access(
  424. context,
  425. oversized,
  426. root_task_anchor=ROOT_ANCHOR,
  427. task_brief=brief(1),
  428. )
  429. class ContextReferenceToolTest(unittest.IsolatedAsyncioTestCase):
  430. def test_legacy_and_recursive_revision_one_never_expose_context_tool(self):
  431. class Tools:
  432. names = [
  433. "agent",
  434. "read_context_ref",
  435. "submit_task_report",
  436. "review_task_result",
  437. ]
  438. def get_tool_names(self, current_url=None, groups=None):
  439. return list(self.names)
  440. def get_schemas(self, tool_names=None):
  441. selected = self.names if tool_names is None else tool_names
  442. return [{"type": "function", "function": {"name": name}}
  443. for name in selected]
  444. runner = AgentRunner(tool_registry=Tools(), llm_call=lambda **_kwargs: None)
  445. config = RunConfig()
  446. for context in (
  447. {"agent_mode": "legacy", "agent_mode_revision": 1},
  448. {"agent_mode": "recursive", "agent_mode_revision": 1},
  449. ):
  450. trace = Trace(trace_id="trace", mode="agent", task="task", context=context)
  451. names = {
  452. item["function"]["name"]
  453. for item in runner._get_runtime_tool_schemas(config, trace)
  454. }
  455. self.assertNotIn("read_context_ref", names)
  456. self.assertNotIn("submit_task_report", names)
  457. self.assertNotIn("review_task_result", names)
  458. async def test_read_tool_returns_only_local_authorized_snapshot(self):
  459. with tempfile.TemporaryDirectory() as directory:
  460. store = FileSystemTraceStore(directory)
  461. context = anchored_context(brief(1))
  462. snapshot = create_context_snapshot(
  463. kind="reviewed_task_result",
  464. summary="已审核证据",
  465. source_trace_id="child",
  466. root_trace_id="root",
  467. uid="user-1",
  468. content={"evidence": [{"fact": "授权原文"}]},
  469. granted_at_sequence=5,
  470. )
  471. ref = add_context_snapshot(
  472. context,
  473. snapshot,
  474. root_task_anchor=ROOT_ANCHOR,
  475. task_brief=brief(1),
  476. )
  477. await store.create_trace(Trace(
  478. trace_id="root",
  479. mode="agent",
  480. task="root",
  481. uid="user-1",
  482. context=context,
  483. ))
  484. result = await read_context_ref(
  485. ref_id=ref.ref_id,
  486. version=ref.version,
  487. context={"store": store, "trace_id": "root"},
  488. )
  489. payload = json.loads(result.output)
  490. self.assertEqual("授权原文", payload["content"]["evidence"][0]["fact"])
  491. denied = await read_context_ref(
  492. ref_id="ctx_unknown",
  493. version="0" * 64,
  494. context={"store": store, "trace_id": "root"},
  495. )
  496. self.assertIn("not authorized", denied.error)
  497. async def test_read_and_validator_reject_child_anchor_that_differs_from_root(self):
  498. with tempfile.TemporaryDirectory() as directory:
  499. store = FileSystemTraceStore(directory)
  500. root_context = anchored_context()
  501. child_context = anchored_context(brief(1))
  502. persist_root_task_anchor(child_context, {
  503. **ROOT_ANCHOR,
  504. "objective": "非权威但自洽的根目标",
  505. })
  506. child_context["root_trace_id"] = "root"
  507. child_context["agent_depth"] = 1
  508. await store.create_trace(Trace(
  509. trace_id="root",
  510. mode="agent",
  511. task="root",
  512. uid="user-1",
  513. context=root_context,
  514. ))
  515. await store.create_trace(Trace(
  516. trace_id="child",
  517. mode="agent",
  518. task="child",
  519. parent_trace_id="root",
  520. uid="user-1",
  521. context=child_context,
  522. ))
  523. denied = await read_context_ref(
  524. ref_id="ctx_000000000000000000000000",
  525. version="0" * 64,
  526. context={"store": store, "trace_id": "child"},
  527. )
  528. self.assertIn("does not match", denied.error)
  529. async def must_not_call(**_kwargs):
  530. raise AssertionError("mismatched child must fail before Validator LLM")
  531. runner = AgentRunner(trace_store=store, llm_call=must_not_call)
  532. with self.assertRaisesRegex(ContextPolicyError, "does not match"):
  533. await runner.validate_recursive_trace(
  534. "child",
  535. scope="task",
  536. )
  537. def test_depth_five_keeps_read_permission_but_loses_agent(self):
  538. class Tools:
  539. @staticmethod
  540. def get_tool_names():
  541. return [
  542. "agent",
  543. "read_context_ref",
  544. "submit_task_report",
  545. "evaluate",
  546. "bash_command",
  547. ]
  548. class Runner:
  549. tools = Tools()
  550. trace_context = {
  551. "agent_mode": "recursive",
  552. "agent_mode_revision": 2,
  553. }
  554. policy = policy_from_context(trace_context)
  555. context = {
  556. "runner": Runner(),
  557. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: [
  558. "agent",
  559. "read_context_ref",
  560. "submit_task_report",
  561. ],
  562. }
  563. depth_four = _get_allowed_tools(context, 4, policy)
  564. depth_five = _get_allowed_tools(context, 5, policy)
  565. self.assertIn("agent", depth_four)
  566. self.assertIn("read_context_ref", depth_four)
  567. self.assertNotIn("agent", depth_five)
  568. self.assertIn("read_context_ref", depth_five)
  569. self.assertNotIn("evaluate", depth_five)
  570. self.assertNotIn("bash_command", depth_five)
  571. context[RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY].remove("read_context_ref")
  572. self.assertNotIn("read_context_ref", _get_allowed_tools(context, 4, policy))
  573. self.assertNotIn("read_context_ref", _get_allowed_tools(context, 5, policy))
  574. if __name__ == "__main__":
  575. unittest.main()