agent_factory.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. """主 agent 的组装。
  2. 一个进程只编译一个 agent(见 agent_registry 的单例与失效重建)。组装时
  3. 刻意把「能用框架的」全部交给框架,只在两处自己动手:
  4. 1. ``DynamicTimeMiddleware`` —— 框架没有「每轮刷新系统提示词里的时间」的能力
  5. 2. 权限规则 —— 必须自己声明,否则默认对文件系统是全开的
  6. ``create_deep_agent`` 的参数只做加法,去不掉内置工具;要裁剪内置能力得改
  7. HarnessProfile(本项目用不到)。
  8. """
  9. from __future__ import annotations
  10. from pathlib import Path
  11. from typing import Any
  12. import config
  13. from agents import agent_memory, model_factory, prompts
  14. from agents.middleware import DynamicTimeMiddleware
  15. from obs import get_logger
  16. from tools import registry as tool_registry
  17. from extensions.registry import approval_names, load_extensions
  18. from extensions.services import service_view
  19. logger = get_logger(__name__)
  20. # 沙箱目录:agent 唯一可写的虚拟根
  21. WORKSPACE_DIR = "workspace"
  22. SKILLS_DIR = "skills"
  23. # 框架摘要中间件把被淘汰的历史落盘到这里的 `<thread_id>.md`,
  24. # 并把路径写进摘要正文,agent 之后可以 read_file 把它们找回来
  25. CONVERSATION_HISTORY_DIR = "conversation_history"
  26. def _ensure_dirs() -> None:
  27. """确保仓库级技能目录存在——技能扫描要求它真实存在。
  28. **刻意不创建仓库根下的 ``workspace/`` 与 ``conversation_history/``**:agent 看到的
  29. 这两个虚拟目录都由 ``backend_for_runtime`` 映射到会话沙箱
  30. (``data/workspaces/<用户哈希>/<会话哈希>/``),与仓库根的同名目录毫无关系。建了
  31. 它们只会让人误以为往那儿放文件 agent 就能读到——实测踩过:使用者把矿方资料放进
  32. 仓库的 ``workspace/矿井风量研判资料/``,agent 的 ``ls /workspace`` 返回空、转向在
  33. 文件系统里连查二十来个路径,全部 permission denied。
  34. 老部署里遗留的同名目录**不会被删除也不会被复制**(见「升级已有部署」),
  35. 这里只是不再凭空创建。
  36. """
  37. (config.ROOT / SKILLS_DIR).mkdir(parents=True, exist_ok=True)
  38. def _build_backend() -> Any:
  39. """延迟到每次执行,按服务端身份构造隔离文件后端。"""
  40. from agents.workspace import backend_for_runtime
  41. _ensure_dirs()
  42. return backend_for_runtime
  43. def build_permissions() -> list[Any]:
  44. """文件访问权限规则。
  45. **顺序即语义**:框架逐条匹配、首条命中即返回(见 1.5 的实测记录
  46. ``tests/probe_w3a_fsglob.py``)。所以具体的 allow 必须排在宽泛的 deny
  47. 之前——反过来写的话 ``deny /**`` 会把后面的 allow 全部短路,agent
  48. 连技能文件都读不到。
  49. 规则集的设计目标是「最小可用」:
  50. - 可写:仅 ``/workspace/``(agent 的落盘沙箱)
  51. - 可读:``/workspace/``、``/skills/``(技能手册)、
  52. ``/conversation_history/``(被压缩淘汰的历史——不放开读的话,
  53. 摘要里那段「原文在此路径」对 agent 就是空头支票)
  54. - 其余一切(项目源码、.env、data/、系统路径)读写皆拒
  55. """
  56. from deepagents import FilesystemPermission
  57. workspace = f"/{WORKSPACE_DIR}"
  58. skills = f"/{SKILLS_DIR}"
  59. history = f"/{CONVERSATION_HISTORY_DIR}"
  60. return [
  61. FilesystemPermission(
  62. operations=["read", "write"],
  63. paths=[workspace, f"{workspace}/**"],
  64. mode="allow",
  65. ),
  66. FilesystemPermission(
  67. operations=["read"],
  68. paths=[skills, f"{skills}/**"],
  69. mode="allow",
  70. ),
  71. FilesystemPermission(
  72. operations=["read"],
  73. paths=[history, f"{history}/**"],
  74. mode="allow",
  75. ),
  76. # 兜底拒绝必须排在最后:它命中一切未被上面放行的路径
  77. FilesystemPermission(operations=["read", "write"], paths=["/**"], mode="deny"),
  78. ]
  79. def _apply_compaction_setting() -> None:
  80. """按配置决定是否保留框架的摘要中间件。
  81. 这里刻意**不自己构造 SummarizationMiddleware**。原因有二:
  82. 1. ``create_agent`` 会校验中间件 ``name`` 唯一,而框架已经放了一个
  83. 同名的摘要中间件;再传一个直接抛 "Please remove duplicate
  84. middleware instances"。1.5 时代的「同名原位替换」在 0.6.12 已不成立。
  85. 2. 更重要的是框架那个版本更强:它会把被淘汰的历史**追加落盘**到
  86. ``/conversation_history/{thread_id}.md``,并把该路径嵌进摘要正文,
  87. agent 事后能自己 read_file 找回来。自己塞一个 langchain 原生的
  88. 摘要中间件会把这份能力丢掉——被压缩的内容就真的没了。
  89. """
  90. if config.CTX_COMPACT_ENABLED:
  91. return
  92. from deepagents import HarnessProfile, register_harness_profile
  93. # 注册表按「provider 或具体模型」为键,且是叠加合并的
  94. register_harness_profile(
  95. config.MODEL,
  96. HarnessProfile(excluded_middleware=frozenset({"SummarizationMiddleware"})),
  97. )
  98. logger.warning(
  99. "自动压缩已关闭(CTX_COMPACT_ENABLED=0):"
  100. "被淘汰的历史不再落盘到 /%s/,长会话会在超出模型窗口后报错",
  101. CONVERSATION_HISTORY_DIR,
  102. )
  103. def _build_middleware() -> list[Any]:
  104. """用户中间件栈。
  105. 动态时间后接入扩展声明的中间件。默认压缩交给框架内置的摘要中间件处理,
  106. 见 :func:`_apply_compaction_setting`。
  107. """
  108. _apply_compaction_setting()
  109. return [DynamicTimeMiddleware(), *load_extensions().build_middleware("main")]
  110. def _build_interrupt_on() -> dict[str, Any]:
  111. """审批来自工具声明、扩展声明与 HITL_TOOLS;未知工具在启动时拒绝。"""
  112. return {name: True for name in sorted(approval_names())}
  113. def _build_subagents() -> list[dict[str, Any]]:
  114. """从统一扩展目录组装子 Agent、系统提示词与工具审批。"""
  115. return load_extensions().subagents(approval_names())
  116. def build_agent() -> Any:
  117. """编译主 agent。由 agent_registry 调用,不直接对外暴露。"""
  118. from deepagents import create_deep_agent
  119. tool_registry.load_builtin_tools()
  120. tools = tool_registry.get_all_tools()
  121. subagents = _build_subagents()
  122. catalog = load_extensions()
  123. service_view(catalog.services) # 启用了外部服务时,必须先进入运行环境。
  124. for spec in subagents:
  125. spec["middleware"] = catalog.build_middleware(spec["name"])
  126. agent = create_deep_agent(
  127. model=model_factory.get_model(),
  128. tools=tools,
  129. system_prompt=prompts.build_system_prompt(subagents),
  130. middleware=_build_middleware(),
  131. subagents=subagents,
  132. # 技能目录:agent 启动时只读各技能的 frontmatter,按需才读全文
  133. skills=[f"/{SKILLS_DIR}/"],
  134. backend=_build_backend(),
  135. permissions=build_permissions(),
  136. interrupt_on=_build_interrupt_on(),
  137. name="deep-agent",
  138. checkpointer=agent_memory.get_checkpointer(),
  139. )
  140. logger.info(
  141. "agent 编译完成 tools=%d subagents=%d skills=%s checkpointer=%s",
  142. len(tools),
  143. len(subagents),
  144. f"/{SKILLS_DIR}/",
  145. agent_memory.saver_kind(),
  146. )
  147. return agent
  148. def describe() -> dict[str, Any]:
  149. """组装配置摘要,供 /health 与调试使用。"""
  150. return {
  151. "name": "deep-agent",
  152. "extensions": list(load_extensions().modules),
  153. "disabled_extensions": list(load_extensions().disabled_modules),
  154. "services": sorted(load_extensions().services),
  155. "middleware": [{"name": name, "targets": list(load_extensions().middleware[name].targets)}
  156. for name in load_extensions().middleware_order()],
  157. "model": model_factory.describe(),
  158. "tools": tool_registry.describe(),
  159. "interrupt_on": sorted(_build_interrupt_on().keys()),
  160. "subagents": [s["name"] for s in _build_subagents()],
  161. "skills_path": f"/{SKILLS_DIR}/",
  162. "workspace_path": f"/{WORKSPACE_DIR}/",
  163. "sandbox_root": str(Path(config.ROOT) / "data" / "workspaces"),
  164. "workspace_scope": "user/session",
  165. "compaction": {
  166. "enabled": config.CTX_COMPACT_ENABLED,
  167. "trigger_fraction": config.FRAMEWORK_COMPACT_TRIGGER,
  168. "trigger_tokens": int(
  169. model_factory.context_window() * config.FRAMEWORK_COMPACT_TRIGGER
  170. ),
  171. "history_offload_path": f"/{CONVERSATION_HISTORY_DIR}/",
  172. "lever": "CONTEXT_LIMIT",
  173. },
  174. "checkpointer": agent_memory.saver_kind(),
  175. }