agent_factory.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  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 tools.demo_tools import PLAN_APPROVAL_TOOL
  18. logger = get_logger(__name__)
  19. # 沙箱目录:agent 唯一可写的虚拟根
  20. WORKSPACE_DIR = "workspace"
  21. SKILLS_DIR = "skills"
  22. # 框架摘要中间件把被淘汰的历史落盘到这里的 `<thread_id>.md`,
  23. # 并把路径写进摘要正文,agent 之后可以 read_file 把它们找回来
  24. CONVERSATION_HISTORY_DIR = "conversation_history"
  25. def _ensure_dirs() -> None:
  26. """确保沙箱、技能、历史落盘目录存在——backend 与技能扫描都要求目录真实存在。"""
  27. for name in (WORKSPACE_DIR, SKILLS_DIR, CONVERSATION_HISTORY_DIR):
  28. (config.ROOT / name).mkdir(parents=True, exist_ok=True)
  29. def _build_backend() -> Any:
  30. """文件系统后端:单一真实目录,虚拟路径锚定在项目根。
  31. ``virtual_mode=True`` 是硬要求——它为 False 时后端直接按真实绝对路径
  32. 访问磁盘,agent 传 ``/etc/passwd`` 就能读到。开启后 ``/skills/x`` 被解析
  33. 为 ``<项目根>/skills/x``,且禁止 ``..`` 穿越。
  34. """
  35. from deepagents.backends import FilesystemBackend
  36. _ensure_dirs()
  37. return FilesystemBackend(root_dir=str(config.ROOT), virtual_mode=True)
  38. def build_permissions() -> list[Any]:
  39. """文件访问权限规则。
  40. **顺序即语义**:框架逐条匹配、首条命中即返回(见 1.5 的实测记录
  41. ``tests/probe_w3a_fsglob.py``)。所以具体的 allow 必须排在宽泛的 deny
  42. 之前——反过来写的话 ``deny /**`` 会把后面的 allow 全部短路,agent
  43. 连技能文件都读不到。
  44. 规则集的设计目标是「最小可用」:
  45. - 可写:仅 ``/workspace/``(agent 的落盘沙箱)
  46. - 可读:``/workspace/``、``/skills/``(技能手册)、
  47. ``/conversation_history/``(被压缩淘汰的历史——不放开读的话,
  48. 摘要里那段「原文在此路径」对 agent 就是空头支票)
  49. - 其余一切(项目源码、.env、data/、系统路径)读写皆拒
  50. """
  51. from deepagents import FilesystemPermission
  52. workspace = f"/{WORKSPACE_DIR}"
  53. skills = f"/{SKILLS_DIR}"
  54. history = f"/{CONVERSATION_HISTORY_DIR}"
  55. return [
  56. FilesystemPermission(
  57. operations=["read", "write"],
  58. paths=[workspace, f"{workspace}/**"],
  59. mode="allow",
  60. ),
  61. FilesystemPermission(
  62. operations=["read"],
  63. paths=[skills, f"{skills}/**"],
  64. mode="allow",
  65. ),
  66. FilesystemPermission(
  67. operations=["read"],
  68. paths=[history, f"{history}/**"],
  69. mode="allow",
  70. ),
  71. # 兜底拒绝必须排在最后:它命中一切未被上面放行的路径
  72. FilesystemPermission(operations=["read", "write"], paths=["/**"], mode="deny"),
  73. ]
  74. def _apply_compaction_setting() -> None:
  75. """按配置决定是否保留框架的摘要中间件。
  76. 这里刻意**不自己构造 SummarizationMiddleware**。原因有二:
  77. 1. ``create_agent`` 会校验中间件 ``name`` 唯一,而框架已经放了一个
  78. 同名的摘要中间件;再传一个直接抛 "Please remove duplicate
  79. middleware instances"。1.5 时代的「同名原位替换」在 0.6.12 已不成立。
  80. 2. 更重要的是框架那个版本更强:它会把被淘汰的历史**追加落盘**到
  81. ``/conversation_history/{thread_id}.md``,并把该路径嵌进摘要正文,
  82. agent 事后能自己 read_file 找回来。自己塞一个 langchain 原生的
  83. 摘要中间件会把这份能力丢掉——被压缩的内容就真的没了。
  84. """
  85. if config.CTX_COMPACT_ENABLED:
  86. return
  87. from deepagents import HarnessProfile, register_harness_profile
  88. # 注册表按「provider 或具体模型」为键,且是叠加合并的
  89. register_harness_profile(
  90. config.MODEL,
  91. HarnessProfile(excluded_middleware=frozenset({"SummarizationMiddleware"})),
  92. )
  93. logger.warning(
  94. "自动压缩已关闭(CTX_COMPACT_ENABLED=0):"
  95. "被淘汰的历史不再落盘到 /%s/,长会话会在超出模型窗口后报错",
  96. CONVERSATION_HISTORY_DIR,
  97. )
  98. def _build_middleware() -> list[Any]:
  99. """用户中间件栈。
  100. 只放框架没有的能力(动态时间)。压缩交给框架内置的摘要中间件处理,
  101. 见 :func:`_apply_compaction_setting`。
  102. """
  103. _apply_compaction_setting()
  104. return [DynamicTimeMiddleware()]
  105. def _build_interrupt_on() -> dict[str, Any]:
  106. """需要人工审批的工具集合。
  107. 默认只有计划审批工具。``HITL_TOOLS`` 可追加(逗号分隔的工具名),
  108. 例如 ``HITL_TOOLS=calculate`` 会要求每次计算都经用户点头——
  109. 用来演示与自测中断闭环。
  110. """
  111. names = {PLAN_APPROVAL_TOOL}
  112. extra = config.get_str("HITL_TOOLS", "")
  113. if extra:
  114. names.update(n.strip() for n in extra.split(",") if n.strip())
  115. available = set(tool_registry.get_tool_names())
  116. missing = names - available
  117. if missing:
  118. logger.warning("interrupt_on 里配置了未注册的工具,已忽略:%s", sorted(missing))
  119. return {name: True for name in sorted(names & available)}
  120. def _build_subagents() -> list[dict[str, Any]]:
  121. """子智能体定义。
  122. 骨架给一个示例子智能体,演示「主 agent 只拿结论、原始材料留在子线程」
  123. 的用法。子智能体的 ``system_prompt`` **不继承**父级,必须自己写全。
  124. ``tools`` 必须是 ``BaseTool`` 列表(不是裸函数名,也不能是裸函数)——
  125. 这里用 ``registry.get_tools_by_name`` 转换,由注册表保证名字有效。
  126. """
  127. return [
  128. {
  129. "name": "demo-searcher",
  130. "description": (
  131. "资料检索专员。当任务需要在演示知识库里做多轮检索、比对多个来源、"
  132. "或需要把检索结果整理成摘要时委派给它。"
  133. "它只回结论与来源,不回原始文本。"
  134. ),
  135. "system_prompt": (
  136. "你是资料检索专员。职责是在演示知识库里检索并把结果整理成结论。\n\n"
  137. "工作方式:\n"
  138. "- 用 search_demo_knowledge 检索,可换多组关键词多轮检索。\n"
  139. "- 需要数值计算时用 calculate,不要心算。\n"
  140. "- 检索不到就如实说「未检索到」,不要用常识补写。\n\n"
  141. "输出格式:\n"
  142. "- 先给一段不超过 5 句的结论。\n"
  143. "- 再列来源编号与标题。\n"
  144. "- 不要把检索到的原文整段复制回来,主线程不需要原始数据。"
  145. ),
  146. "tools": tool_registry.get_tools_by_name(
  147. ["search_demo_knowledge", "calculate", "get_current_time"]
  148. ),
  149. }
  150. ]
  151. def build_agent() -> Any:
  152. """编译主 agent。由 agent_registry 调用,不直接对外暴露。"""
  153. from deepagents import create_deep_agent
  154. tool_registry.load_builtin_tools()
  155. tools = tool_registry.get_all_tools()
  156. subagents = _build_subagents()
  157. agent = create_deep_agent(
  158. model=model_factory.get_model(),
  159. tools=tools,
  160. system_prompt=prompts.build_system_prompt(subagents),
  161. middleware=_build_middleware(),
  162. subagents=subagents,
  163. # 技能目录:agent 启动时只读各技能的 frontmatter,按需才读全文
  164. skills=[f"/{SKILLS_DIR}/"],
  165. backend=_build_backend(),
  166. permissions=build_permissions(),
  167. interrupt_on=_build_interrupt_on(),
  168. name="deep-agent",
  169. checkpointer=agent_memory.get_checkpointer(),
  170. )
  171. logger.info(
  172. "agent 编译完成 tools=%d subagents=%d skills=%s checkpointer=%s",
  173. len(tools),
  174. len(subagents),
  175. f"/{SKILLS_DIR}/",
  176. agent_memory.saver_kind(),
  177. )
  178. return agent
  179. def describe() -> dict[str, Any]:
  180. """组装配置摘要,供 /health 与调试使用。"""
  181. return {
  182. "name": "deep-agent",
  183. "model": model_factory.describe(),
  184. "tools": tool_registry.describe(),
  185. "interrupt_on": sorted(_build_interrupt_on().keys()),
  186. "subagents": [s["name"] for s in _build_subagents()],
  187. "skills_path": f"/{SKILLS_DIR}/",
  188. "workspace_path": f"/{WORKSPACE_DIR}/",
  189. "sandbox_root": str(Path(config.ROOT)),
  190. "compaction": {
  191. "enabled": config.CTX_COMPACT_ENABLED,
  192. "trigger_fraction": config.FRAMEWORK_COMPACT_TRIGGER,
  193. "trigger_tokens": int(
  194. model_factory.context_window() * config.FRAMEWORK_COMPACT_TRIGGER
  195. ),
  196. "history_offload_path": f"/{CONVERSATION_HISTORY_DIR}/",
  197. "lever": "CONTEXT_LIMIT",
  198. },
  199. "checkpointer": agent_memory.saver_kind(),
  200. }