context_policy.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. """Recursive 任务上下文的规范化、版本引用与逐层授权策略。
  2. Runner、任务协议工具和 ``agent`` 都只调用这里的纯函数:根目标保持不变,
  3. TaskBrief 只继承硬约束,完整协议内容则通过当前 Trace 的不可变本地快照按需读取。
  4. """
  5. from __future__ import annotations
  6. from copy import deepcopy
  7. from hashlib import sha256
  8. import json
  9. from typing import Any, Literal
  10. from pydantic import BaseModel, ConfigDict, Field, ValidationError
  11. from cyber_agent.core.task_protocol import (
  12. ContextRef,
  13. RootTaskAnchor,
  14. TaskBrief,
  15. current_task_progress,
  16. )
  17. MAX_ROOT_TASK_ANCHOR_CHARS = 4_000
  18. MAX_TASK_BRIEF_CHARS = 16_000
  19. MAX_CONTEXT_REFS = 8
  20. MAX_CONTEXT_SNAPSHOT_CHARS = 16_000
  21. MAX_CONTEXT_SNAPSHOTS_CHARS = 64_000
  22. ROOT_TASK_ANCHOR_KEY = "root_task_anchor"
  23. ROOT_TASK_ANCHOR_HASH_KEY = "root_task_anchor_hash"
  24. CONTEXT_ACCESS_KEY = "context_access"
  25. ContextSnapshotKind = Literal[
  26. "task_brief",
  27. "reviewed_task_result",
  28. "application_resource",
  29. ]
  30. class ContextPolicyError(ValueError):
  31. """Recursive 上下文无法安全通过委托、审核或读取边界。"""
  32. class ContextSnapshot(BaseModel):
  33. """当前 Trace持有的一份框架生成、内容寻址的只读协议快照。"""
  34. model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
  35. ref_id: str = Field(pattern=r"^ctx_[0-9a-f]{24}$")
  36. version: str = Field(pattern=r"^[0-9a-f]{64}$")
  37. kind: ContextSnapshotKind
  38. summary: str = Field(min_length=1, max_length=500)
  39. source_trace_id: str = Field(min_length=1)
  40. root_trace_id: str = Field(min_length=1)
  41. uid: str | None = None
  42. content: dict[str, Any]
  43. granted_at_sequence: int = Field(ge=0)
  44. def to_ref(self) -> ContextRef:
  45. return ContextRef(ref_id=self.ref_id, version=self.version)
  46. def canonical_json(value: Any) -> str:
  47. """用唯一 JSON 形态计算大小、哈希和协议精确匹配。"""
  48. try:
  49. return json.dumps(
  50. value,
  51. ensure_ascii=False,
  52. sort_keys=True,
  53. separators=(",", ":"),
  54. allow_nan=False,
  55. )
  56. except (TypeError, ValueError) as exc:
  57. raise ContextPolicyError(f"Context must be JSON serializable: {exc}") from exc
  58. def context_chars(value: Any) -> int:
  59. return len(canonical_json(value))
  60. def _stable_unique(items: list[str]) -> list[str]:
  61. return list(dict.fromkeys(items))
  62. def _stable_unique_refs(items: list[ContextRef]) -> list[ContextRef]:
  63. return list({(item.ref_id, item.version): item for item in items}.values())
  64. def normalize_root_task_anchor(
  65. anchor: RootTaskAnchor | dict[str, Any] | str,
  66. *,
  67. max_chars: int = MAX_ROOT_TASK_ANCHOR_CHARS,
  68. ) -> RootTaskAnchor:
  69. """校验并规范化调用方提供的根目标锚点。"""
  70. if max_chars <= 0:
  71. raise ContextPolicyError("max_chars must be greater than zero")
  72. try:
  73. raw = json.loads(anchor) if isinstance(anchor, str) else anchor
  74. parsed = RootTaskAnchor.model_validate(raw)
  75. except (json.JSONDecodeError, TypeError, ValidationError) as exc:
  76. raise ContextPolicyError(f"Invalid RootTaskAnchor: {exc}") from exc
  77. normalized = parsed.model_copy(update={
  78. "completion_criteria": _stable_unique(parsed.completion_criteria),
  79. "constraints": _stable_unique(parsed.constraints),
  80. })
  81. try:
  82. encoded_chars = context_chars(normalized.model_dump(mode="json"))
  83. except Exception as exc:
  84. raise ContextPolicyError(
  85. f"RootTaskAnchor must be JSON serializable: {exc}"
  86. ) from exc
  87. if encoded_chars > max_chars:
  88. raise ContextPolicyError(
  89. f"RootTaskAnchor exceeds the {max_chars}-character context limit"
  90. )
  91. return normalized
  92. def root_task_anchor_hash(anchor: RootTaskAnchor | dict[str, Any]) -> str:
  93. normalized = normalize_root_task_anchor(anchor)
  94. return sha256(
  95. canonical_json(normalized.model_dump(mode="json")).encode("utf-8")
  96. ).hexdigest()
  97. def persist_root_task_anchor(
  98. context: dict[str, Any],
  99. anchor: RootTaskAnchor | dict[str, Any] | str,
  100. ) -> RootTaskAnchor:
  101. """把唯一规范化 Anchor和哈希写入 Trace context。"""
  102. normalized = normalize_root_task_anchor(anchor)
  103. context[ROOT_TASK_ANCHOR_KEY] = normalized.model_dump(mode="json")
  104. context[ROOT_TASK_ANCHOR_HASH_KEY] = root_task_anchor_hash(normalized)
  105. return normalized
  106. def require_root_task_anchor(context: dict[str, Any]) -> RootTaskAnchor:
  107. """读取并校验持久化 Anchor;缺失或被篡改时失败关闭。"""
  108. raw = context.get(ROOT_TASK_ANCHOR_KEY)
  109. if raw is None:
  110. raise ContextPolicyError(
  111. "This Recursive trace predates root_task_anchor; create a new trace"
  112. )
  113. normalized = normalize_root_task_anchor(raw)
  114. expected = root_task_anchor_hash(normalized)
  115. if context.get(ROOT_TASK_ANCHOR_HASH_KEY) != expected:
  116. raise ContextPolicyError("Persisted RootTaskAnchor hash does not match its content")
  117. return normalized
  118. def require_matching_root_task_anchor(
  119. root_context: dict[str, Any],
  120. current_context: dict[str, Any],
  121. ) -> RootTaskAnchor:
  122. """从根 Trace读取权威 Anchor,并确认当前节点副本完全一致。
  123. Runner续跑、Sub-Agent创建和父级审核共用,避免某一入口只验证
  124. 当前 Trace自身哈希,却接受与任务树根不同的自洽副本。
  125. """
  126. root = require_root_task_anchor(root_context)
  127. current = require_root_task_anchor(current_context)
  128. if (
  129. current_context.get(ROOT_TASK_ANCHOR_HASH_KEY)
  130. != root_context.get(ROOT_TASK_ANCHOR_HASH_KEY)
  131. or current.model_dump(mode="json") != root.model_dump(mode="json")
  132. ):
  133. raise ContextPolicyError(
  134. "Persisted RootTaskAnchor does not match the root Trace"
  135. )
  136. return root
  137. def normalize_task_brief(
  138. task_brief: TaskBrief | dict[str, Any] | str,
  139. *,
  140. parent_task_brief: TaskBrief | dict[str, Any] | None = None,
  141. root_task_anchor: RootTaskAnchor | dict[str, Any] | None = None,
  142. max_chars: int = MAX_TASK_BRIEF_CHARS,
  143. ) -> TaskBrief:
  144. """规范化 TaskBrief,只继承根与直接父级的硬约束。"""
  145. if max_chars <= 0:
  146. raise ContextPolicyError("max_chars must be greater than zero")
  147. try:
  148. raw_brief = json.loads(task_brief) if isinstance(task_brief, str) else task_brief
  149. brief = TaskBrief.model_validate(raw_brief)
  150. parent = (
  151. TaskBrief.model_validate(parent_task_brief)
  152. if parent_task_brief is not None
  153. else None
  154. )
  155. anchor = (
  156. normalize_root_task_anchor(root_task_anchor)
  157. if root_task_anchor is not None
  158. else None
  159. )
  160. except (json.JSONDecodeError, TypeError, ValidationError) as exc:
  161. raise ContextPolicyError(f"Invalid TaskBrief: {exc}") from exc
  162. normalized = brief.model_copy(update={
  163. "completion_criteria": _stable_unique(brief.completion_criteria),
  164. "expected_outputs": _stable_unique(brief.expected_outputs),
  165. "parent_findings": _stable_unique(brief.parent_findings),
  166. "constraints": _stable_unique([
  167. *(anchor.constraints if anchor else []),
  168. *(parent.constraints if parent else []),
  169. *brief.constraints,
  170. ]),
  171. "context_refs": _stable_unique_refs(brief.context_refs),
  172. "validation_scopes": [
  173. scope
  174. for scope in ("evidence", "hypothesis", "output", "task")
  175. if scope in set(brief.validation_scopes)
  176. ],
  177. })
  178. try:
  179. encoded_chars = context_chars(normalized.model_dump(mode="json"))
  180. except Exception as exc:
  181. raise ContextPolicyError(
  182. f"TaskBrief must be JSON serializable: {exc}"
  183. ) from exc
  184. if encoded_chars > max_chars:
  185. raise ContextPolicyError(
  186. f"TaskBrief exceeds the {max_chars}-character context limit"
  187. )
  188. return normalized
  189. def task_briefs_match(
  190. actual: TaskBrief | dict[str, Any] | str,
  191. approved: TaskBrief | dict[str, Any] | str,
  192. *,
  193. parent_task_brief: TaskBrief | dict[str, Any] | None = None,
  194. root_task_anchor: RootTaskAnchor | dict[str, Any] | None = None,
  195. ) -> bool:
  196. """以同一上下文策略规范化后精确比较实际和批准的 TaskBrief。"""
  197. actual_brief = normalize_task_brief(
  198. actual,
  199. parent_task_brief=parent_task_brief,
  200. root_task_anchor=root_task_anchor,
  201. )
  202. approved_brief = normalize_task_brief(
  203. approved,
  204. parent_task_brief=parent_task_brief,
  205. root_task_anchor=root_task_anchor,
  206. )
  207. return actual_brief.model_dump(mode="json") == approved_brief.model_dump(
  208. mode="json"
  209. )
  210. def _context_ref_id(
  211. kind: ContextSnapshotKind,
  212. source_trace_id: str,
  213. version: str,
  214. ) -> str:
  215. seed = f"{kind}\0{source_trace_id}\0{version}".encode("utf-8")
  216. return f"ctx_{sha256(seed).hexdigest()[:24]}"
  217. def create_context_snapshot(
  218. *,
  219. kind: ContextSnapshotKind,
  220. summary: str,
  221. source_trace_id: str,
  222. root_trace_id: str,
  223. uid: str | None,
  224. content: dict[str, Any],
  225. granted_at_sequence: int,
  226. ) -> ContextSnapshot:
  227. """为 TaskBrief或审核结论创建内容寻址的不可变快照。"""
  228. encoded = canonical_json(content)
  229. if len(encoded) > MAX_CONTEXT_SNAPSHOT_CHARS:
  230. raise ContextPolicyError(
  231. f"Context snapshot exceeds the {MAX_CONTEXT_SNAPSHOT_CHARS}-character limit"
  232. )
  233. version = sha256(encoded.encode("utf-8")).hexdigest()
  234. return ContextSnapshot(
  235. ref_id=_context_ref_id(kind, source_trace_id, version),
  236. version=version,
  237. kind=kind,
  238. summary=summary.strip()[:500] or kind,
  239. source_trace_id=source_trace_id,
  240. root_trace_id=root_trace_id,
  241. uid=uid,
  242. content=deepcopy(content),
  243. granted_at_sequence=granted_at_sequence,
  244. )
  245. def _validated_snapshot(raw: ContextSnapshot | dict[str, Any]) -> ContextSnapshot:
  246. try:
  247. snapshot = ContextSnapshot.model_validate(raw)
  248. except ValidationError as exc:
  249. raise ContextPolicyError(f"Invalid context snapshot: {exc}") from exc
  250. encoded = canonical_json(snapshot.content)
  251. if sha256(encoded.encode("utf-8")).hexdigest() != snapshot.version:
  252. raise ContextPolicyError("Context snapshot version does not match its content")
  253. if snapshot.ref_id != _context_ref_id(
  254. snapshot.kind,
  255. snapshot.source_trace_id,
  256. snapshot.version,
  257. ):
  258. raise ContextPolicyError("Context snapshot ref_id does not match its source")
  259. return snapshot
  260. def ensure_context_access(context: dict[str, Any]) -> dict[str, Any]:
  261. """幂等获取当前 Trace本地授权目录。
  262. 缺失时可初始化;已存在但结构被破坏时失败关闭,避免读取工具
  263. 把非法授权目录当成空目录静默修复。
  264. """
  265. access = context.get(CONTEXT_ACCESS_KEY)
  266. if access is None:
  267. access = {}
  268. context[CONTEXT_ACCESS_KEY] = access
  269. elif not isinstance(access, dict):
  270. raise ContextPolicyError("Invalid context_access directory")
  271. snapshots = access.setdefault("snapshots", {})
  272. metrics = access.setdefault("metrics", {})
  273. if not isinstance(snapshots, dict) or not isinstance(metrics, dict):
  274. raise ContextPolicyError("Invalid context_access directory")
  275. return access
  276. def _metrics(
  277. *,
  278. root_task_anchor: RootTaskAnchor | dict[str, Any],
  279. task_brief: TaskBrief | dict[str, Any] | None,
  280. snapshots: list[ContextSnapshot],
  281. ) -> dict[str, int]:
  282. return {
  283. "root_anchor_chars": context_chars(
  284. RootTaskAnchor.model_validate(root_task_anchor).model_dump(mode="json")
  285. ),
  286. "task_brief_chars": (
  287. context_chars(TaskBrief.model_validate(task_brief).model_dump(mode="json"))
  288. if task_brief is not None
  289. else 0
  290. ),
  291. "authorized_ref_count": len(snapshots),
  292. "snapshot_chars": sum(context_chars(item.content) for item in snapshots),
  293. }
  294. def replace_context_access(
  295. context: dict[str, Any],
  296. snapshots: list[ContextSnapshot | dict[str, Any]],
  297. *,
  298. root_task_anchor: RootTaskAnchor | dict[str, Any],
  299. task_brief: TaskBrief | dict[str, Any] | None,
  300. ) -> None:
  301. """原子替换当前 Trace的全部本地授权快照和静态用量。"""
  302. normalized = [_validated_snapshot(item) for item in snapshots]
  303. unique = {item.ref_id: item for item in normalized}
  304. normalized = list(unique.values())
  305. if len(normalized) > MAX_CONTEXT_REFS:
  306. raise ContextPolicyError(
  307. f"A Trace may authorize at most {MAX_CONTEXT_REFS} context references"
  308. )
  309. total_chars = sum(context_chars(item.content) for item in normalized)
  310. if total_chars > MAX_CONTEXT_SNAPSHOTS_CHARS:
  311. raise ContextPolicyError(
  312. f"Context snapshots exceed the {MAX_CONTEXT_SNAPSHOTS_CHARS}-character total limit"
  313. )
  314. context[CONTEXT_ACCESS_KEY] = {
  315. "snapshots": {
  316. item.ref_id: item.model_dump(mode="json") for item in normalized
  317. },
  318. "metrics": _metrics(
  319. root_task_anchor=root_task_anchor,
  320. task_brief=task_brief,
  321. snapshots=normalized,
  322. ),
  323. }
  324. def add_context_snapshot(
  325. context: dict[str, Any],
  326. snapshot: ContextSnapshot,
  327. *,
  328. root_task_anchor: RootTaskAnchor | dict[str, Any],
  329. task_brief: TaskBrief | dict[str, Any] | None,
  330. ) -> ContextRef:
  331. """向当前 Trace增加一个框架生成的本地快照。"""
  332. access = ensure_context_access(context)
  333. snapshots = list(access["snapshots"].values())
  334. snapshots.append(snapshot)
  335. replace_context_access(
  336. context,
  337. snapshots,
  338. root_task_anchor=root_task_anchor,
  339. task_brief=task_brief,
  340. )
  341. return snapshot.to_ref()
  342. def build_child_context_access(
  343. *,
  344. parent_context: dict[str, Any],
  345. parent_trace_id: str,
  346. root_trace_id: str,
  347. uid: str | None,
  348. parent_task_state: dict[str, Any],
  349. child_task_brief: TaskBrief,
  350. root_task_anchor: RootTaskAnchor,
  351. granted_at_sequence: int = 0,
  352. ) -> dict[str, Any]:
  353. """解析显式转授并为新孩子生成完整的本地授权目录。
  354. 直接父 TaskBrief自动占一个引用;其他祖先或审核结论必须由父级在
  355. ``child_task_brief.context_refs`` 中逐层明确转授。
  356. """
  357. snapshots: list[ContextSnapshot] = []
  358. parent_brief = parent_task_state.get("task_brief")
  359. if parent_brief is not None:
  360. parent_brief_model = TaskBrief.model_validate(parent_brief)
  361. snapshots.append(create_context_snapshot(
  362. kind="task_brief",
  363. summary=parent_brief_model.objective,
  364. source_trace_id=parent_trace_id,
  365. root_trace_id=root_trace_id,
  366. uid=uid,
  367. content=parent_brief_model.model_dump(mode="json"),
  368. granted_at_sequence=granted_at_sequence,
  369. ))
  370. parent_snapshots = ensure_context_access(parent_context)["snapshots"]
  371. for ref in child_task_brief.context_refs:
  372. raw = parent_snapshots.get(ref.ref_id)
  373. if raw is None:
  374. raise ContextPolicyError(
  375. f"Context reference is not authorized for the parent: {ref.ref_id}"
  376. )
  377. snapshot = _validated_snapshot(raw)
  378. if snapshot.ref_id != ref.ref_id:
  379. raise ContextPolicyError(
  380. f"Context reference directory entry does not match its key: {ref.ref_id}"
  381. )
  382. if snapshot.version != ref.version:
  383. raise ContextPolicyError(
  384. f"Context reference version is not authorized: {ref.ref_id}"
  385. )
  386. if snapshot.root_trace_id != root_trace_id or snapshot.uid != uid:
  387. raise ContextPolicyError("Context reference belongs to another tree or owner")
  388. resource_meta = (
  389. snapshot.content.get("application_resource")
  390. if snapshot.kind == "application_resource"
  391. and isinstance(snapshot.content, dict)
  392. else None
  393. )
  394. if (
  395. isinstance(resource_meta, dict)
  396. and resource_meta.get("inheritance") == "task_only"
  397. ):
  398. raise ContextPolicyError(
  399. f"Application context is task_only and cannot be delegated: {ref.ref_id}"
  400. )
  401. snapshots.append(snapshot.model_copy(
  402. update={"granted_at_sequence": granted_at_sequence}
  403. ))
  404. child_context: dict[str, Any] = {}
  405. replace_context_access(
  406. child_context,
  407. snapshots,
  408. root_task_anchor=root_task_anchor,
  409. task_brief=child_task_brief,
  410. )
  411. return child_context[CONTEXT_ACCESS_KEY]
  412. def get_authorized_context_snapshot(
  413. context: dict[str, Any],
  414. *,
  415. ref_id: str,
  416. version: str,
  417. root_trace_id: str,
  418. uid: str | None,
  419. ) -> ContextSnapshot:
  420. """只从当前 Trace本地目录读取并复核一个授权快照。"""
  421. raw = ensure_context_access(context)["snapshots"].get(ref_id)
  422. if raw is None:
  423. raise ContextPolicyError(f"Context reference is not authorized: {ref_id}")
  424. snapshot = _validated_snapshot(raw)
  425. if snapshot.ref_id != ref_id:
  426. raise ContextPolicyError(
  427. f"Context reference directory entry does not match its key: {ref_id}"
  428. )
  429. if snapshot.version != version:
  430. raise ContextPolicyError(f"Context reference version is not authorized: {ref_id}")
  431. if snapshot.root_trace_id != root_trace_id or snapshot.uid != uid:
  432. raise ContextPolicyError("Context reference belongs to another tree or owner")
  433. return snapshot
  434. def context_ref_descriptors(context: dict[str, Any]) -> list[dict[str, Any]]:
  435. """返回可展示但不展开内容的授权引用元数据。"""
  436. descriptors = []
  437. for raw in ensure_context_access(context)["snapshots"].values():
  438. snapshot = _validated_snapshot(raw)
  439. descriptors.append({
  440. "ref_id": snapshot.ref_id,
  441. "version": snapshot.version,
  442. "kind": snapshot.kind,
  443. "summary": snapshot.summary,
  444. "source_trace_id": snapshot.source_trace_id,
  445. })
  446. return descriptors
  447. def prune_context_access(context: dict[str, Any], cutoff_sequence: int) -> None:
  448. """回溯时删除当前 Trace在截断点之后新获得的本地引用。"""
  449. access = ensure_context_access(context)
  450. kept = [
  451. _validated_snapshot(raw)
  452. for raw in access["snapshots"].values()
  453. if int(raw.get("granted_at_sequence", 0)) <= cutoff_sequence
  454. ]
  455. anchor = require_root_task_anchor(context)
  456. state = context.get("task_protocol") or {}
  457. replace_context_access(
  458. context,
  459. kept,
  460. root_task_anchor=anchor,
  461. task_brief=state.get("task_brief"),
  462. )
  463. def render_recursive_context(context: dict[str, Any]) -> str:
  464. """为初始任务和周期刷新生成同一份 Recursive上下文摘要。"""
  465. anchor = require_root_task_anchor(context)
  466. state = context.get("task_protocol") or {}
  467. access = ensure_context_access(context)
  468. payload = {
  469. "root_task_anchor": anchor.model_dump(mode="json"),
  470. "task_brief_version": state.get("task_brief_version", 0),
  471. "task_progress": (
  472. current_task_progress(state).model_dump(mode="json")
  473. if state.get("task_progress_head_revision") is not None
  474. else None
  475. ),
  476. "available_context_refs": context_ref_descriptors(context),
  477. "context_usage": access.get("metrics", {}),
  478. }
  479. return "## Recursive Task Context\n\n" + canonical_json(payload)