agent_factory.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. """主 agent 的组装。
  2. 每个 Agent 名称缓存一张图(见 agent_registry 的单飞编译与失效重建)。组装时
  3. 刻意把「能用框架的」全部交给框架,额外声明:
  4. 1. ``DynamicTimeMiddleware`` —— 框架没有「每轮刷新系统提示词里的时间」的能力
  5. 2. ``SkillCompletionMiddleware`` —— 提交正式报告后结束探索,失败时继续修正
  6. 3. 权限规则 —— 必须自己声明,否则默认对文件系统是全开的
  7. 所有 Agent 独立执行,不挂载扩展子 Agent;通过 HarnessProfile 关闭框架自动
  8. 添加的 general-purpose,确保不暴露 task 委派工具。
  9. """
  10. from __future__ import annotations
  11. from pathlib import Path
  12. from typing import Any
  13. import config
  14. from agents import agent_memory, model_factory, prompts
  15. from agents.middleware import DynamicTimeMiddleware, SkillCompletionMiddleware
  16. from obs import get_logger
  17. from tools import registry as tool_registry
  18. from extensions.registry import approval_names, load_extensions
  19. from extensions.services import service_view
  20. logger = get_logger(__name__)
  21. # 沙箱目录:agent 唯一可写的虚拟根
  22. WORKSPACE_DIR = "workspace"
  23. SKILLS_DIR = "skills"
  24. # 框架摘要中间件把被淘汰的历史落盘到这里的 `<thread_id>.md`,
  25. # 并把路径写进摘要正文,agent 之后可以 read_file 把它们找回来
  26. CONVERSATION_HISTORY_DIR = "conversation_history"
  27. def _ensure_dirs() -> None:
  28. """技能只读,实际工作区和历史按用户/会话存于可写的数据卷。"""
  29. if not (config.ROOT / SKILLS_DIR).is_dir():
  30. raise ValueError('缺少 skills 目录,请检查发布包或镜像内容')
  31. # backend_for_runtime 创建各会话的 workspace/conversation_history;
  32. # 不再向源码根目录创建旧的共享目录,兼容非 root 容器和只读源码。
  33. (config.ROOT / 'data' / 'workspaces').mkdir(parents=True, exist_ok=True)
  34. def _build_backend() -> Any:
  35. """延迟到每次执行,按服务端身份构造隔离文件后端。"""
  36. from agents.workspace import backend_for_runtime
  37. _ensure_dirs()
  38. return backend_for_runtime
  39. def build_permissions() -> list[Any]:
  40. """文件访问权限规则。
  41. **顺序即语义**:框架逐条匹配、首条命中即返回(见 1.5 的实测记录
  42. ``tests/probe_w3a_fsglob.py``)。所以具体的 allow 必须排在宽泛的 deny
  43. 之前——反过来写的话 ``deny /**`` 会把后面的 allow 全部短路,agent
  44. 连技能文件都读不到。
  45. 规则集的设计目标是「最小可用」:
  46. - 可写:仅 ``/workspace/``(agent 的落盘沙箱)
  47. - 可读:``/workspace/``、``/skills/``(技能手册)、
  48. ``/conversation_history/``(被压缩淘汰的历史——不放开读的话,
  49. 摘要里那段「原文在此路径」对 agent 就是空头支票)
  50. - 其余一切(项目源码、.env、data/、系统路径)读写皆拒
  51. """
  52. from deepagents import FilesystemPermission
  53. workspace = f"/{WORKSPACE_DIR}"
  54. skills = f"/{SKILLS_DIR}"
  55. history = f"/{CONVERSATION_HISTORY_DIR}"
  56. return [
  57. FilesystemPermission(
  58. operations=["read", "write"],
  59. paths=[workspace, f"{workspace}/**"],
  60. mode="allow",
  61. ),
  62. FilesystemPermission(
  63. operations=["read"],
  64. paths=[skills, f"{skills}/**"],
  65. mode="allow",
  66. ),
  67. FilesystemPermission(
  68. operations=["read"],
  69. paths=[history, f"{history}/**"],
  70. mode="allow",
  71. ),
  72. # 兜底拒绝必须排在最后:它命中一切未被上面放行的路径
  73. FilesystemPermission(operations=["read", "write"], paths=["/**"], mode="deny"),
  74. ]
  75. def _apply_compaction_setting() -> None:
  76. """按配置决定是否保留框架的摘要中间件。
  77. 这里刻意**不自己构造 SummarizationMiddleware**。原因有二:
  78. 1. ``create_agent`` 会校验中间件 ``name`` 唯一,而框架已经放了一个
  79. 同名的摘要中间件;再传一个直接抛 "Please remove duplicate
  80. middleware instances"。1.5 时代的「同名原位替换」在 0.6.12 已不成立。
  81. 2. 更重要的是框架那个版本更强:它会把被淘汰的历史**追加落盘**到
  82. ``/conversation_history/{thread_id}.md``,并把该路径嵌进摘要正文,
  83. agent 事后能自己 read_file 找回来。自己塞一个 langchain 原生的
  84. 摘要中间件会把这份能力丢掉——被压缩的内容就真的没了。
  85. """
  86. if config.CTX_COMPACT_ENABLED:
  87. return
  88. from deepagents import HarnessProfile, register_harness_profile
  89. # 注册表按「provider 或具体模型」为键,且是叠加合并的
  90. register_harness_profile(
  91. config.MODEL,
  92. HarnessProfile(excluded_middleware=frozenset({"SummarizationMiddleware"})),
  93. )
  94. logger.warning(
  95. "自动压缩已关闭(CTX_COMPACT_ENABLED=0):"
  96. "被淘汰的历史不再落盘到 /%s/,长会话会在超出模型窗口后报错",
  97. CONVERSATION_HISTORY_DIR,
  98. )
  99. def _build_middleware() -> list[Any]:
  100. """用户中间件栈。
  101. 动态时间后接入扩展声明的中间件。默认压缩交给框架内置的摘要中间件处理,
  102. 见 :func:`_apply_compaction_setting`。
  103. """
  104. _apply_compaction_setting()
  105. return [DynamicTimeMiddleware(), *load_extensions().build_middleware("main")]
  106. def _build_interrupt_on(tool_names=None) -> dict[str, Any]:
  107. """审批来自工具声明、扩展声明与 HITL_TOOLS;未知工具在启动时拒绝。"""
  108. return {name: True for name in sorted(approval_names())
  109. if tool_names is None or name in tool_names}
  110. def _build_subagents() -> list[dict[str, Any]]:
  111. """全局禁用子 Agent;业务定义继续供独立业务入口使用。"""
  112. return []
  113. def _disable_subagents(model: Any) -> None:
  114. """关闭实际模型所用配置中的默认子 Agent,避免空列表仍被自动补齐。"""
  115. from deepagents import GeneralPurposeSubagentProfile, HarnessProfile, register_harness_profile
  116. from deepagents._models import get_model_identifier, get_model_provider
  117. provider = get_model_provider(model)
  118. identifier = get_model_identifier(model)
  119. if not provider or not identifier:
  120. raise ValueError('无法识别模型的 provider/model,不能确保子 Agent 已禁用')
  121. # DeepAgents 对预构建模型按 provider:model 查找;裸模型名不会命中。
  122. key = f'{provider}:{identifier}' if ':' not in identifier else identifier
  123. register_harness_profile(key, HarnessProfile(
  124. general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False),
  125. ))
  126. def _coordinator_tools(catalog):
  127. # 专业业务由独立业务入口执行,通用 Agent 保留现有通用和示例工具。
  128. business_tools = {n for b in catalog.business_agents.values() for n in catalog.agents[b.name].tools}
  129. shared = {n for a in catalog.agents.values() if a.name not in catalog.business_agents for n in a.tools}
  130. return [t for t in tool_registry.get_all_tools() if t.name not in business_tools or t.name in shared]
  131. def build_agent(agent_name: str = "main") -> Any:
  132. """编译独立执行的通用或业务 Agent,不提供子 Agent 委派。"""
  133. from deepagents import create_deep_agent
  134. tool_registry.load_builtin_tools()
  135. catalog = load_extensions()
  136. service_view(catalog.services)
  137. if agent_name == "main":
  138. tools = _coordinator_tools(catalog)
  139. subagents = _build_subagents()
  140. prompt = prompts.build_system_prompt()
  141. middleware = _build_middleware()
  142. result_schema = None
  143. graph_name = "deep-agent"
  144. else:
  145. business = catalog.business(agent_name)
  146. spec = catalog.agents[agent_name]
  147. from runtime.business import BusinessGateMiddleware
  148. _apply_compaction_setting()
  149. tools = tool_registry.get_tools_by_name((*spec.tools, "ask_user", "request_plan_approval"))
  150. subagents = []
  151. prompt = spec.prompt
  152. middleware = [BusinessGateMiddleware(agent_name), DynamicTimeMiddleware(),
  153. *catalog.build_middleware(agent_name)]
  154. if business.skill_path:
  155. middleware.append(SkillCompletionMiddleware())
  156. result_schema = None if business.skill_path else business.result_schema
  157. graph_name = agent_name
  158. model = model_factory.get_model()
  159. _disable_subagents(model)
  160. agent = create_deep_agent(
  161. model=model,
  162. tools=tools,
  163. system_prompt=prompt,
  164. middleware=middleware,
  165. subagents=subagents,
  166. # 技能目录:agent 启动时只读各技能的 frontmatter,按需才读全文
  167. skills=[f"/{SKILLS_DIR}/"],
  168. backend=_build_backend(),
  169. permissions=build_permissions(),
  170. interrupt_on=_build_interrupt_on({t.name for t in tools}),
  171. response_format=result_schema,
  172. name=graph_name,
  173. checkpointer=agent_memory.get_checkpointer(),
  174. )
  175. logger.info(
  176. "agent 编译完成 tools=%d subagents=%d skills=%s checkpointer=%s",
  177. len(tools),
  178. len(subagents),
  179. f"/{SKILLS_DIR}/",
  180. agent_memory.saver_kind(),
  181. )
  182. return agent
  183. def describe() -> dict[str, Any]:
  184. """组装配置摘要,供 /health 与调试使用。"""
  185. return {
  186. "name": "deep-agent",
  187. "extensions": list(load_extensions().modules),
  188. "disabled_extensions": list(load_extensions().disabled_modules),
  189. "services": sorted(load_extensions().services),
  190. "middleware": [{"name": name, "targets": list(load_extensions().middleware[name].targets)}
  191. for name in load_extensions().middleware_order()],
  192. "model": model_factory.describe(),
  193. "tools": tool_registry.describe(),
  194. "interrupt_on": sorted(_build_interrupt_on().keys()),
  195. "subagents": [],
  196. "subagents_enabled": False,
  197. "business_agents": [{"name": b.name, "display_name": b.display_name,
  198. "tools": list(load_extensions().agents[b.name].tools),
  199. "execution_mode": "skill_exploration" if b.skill_path else "structured_agent",
  200. "skill_path": b.skill_path,
  201. "max_concurrency": b.max_concurrency,
  202. "gateway_configured": bool(b.gateway.agent_code),
  203. "result_platform_configured": bool(b.result_binding.agent_code) and config.get_bool("RESULT_PLATFORM_ENABLED", False)}
  204. for b in load_extensions().business_agents.values()],
  205. "skills_path": f"/{SKILLS_DIR}/",
  206. "workspace_path": f"/{WORKSPACE_DIR}/",
  207. "sandbox_root": str(Path(config.ROOT) / "data" / "workspaces"),
  208. "workspace_scope": "user/session",
  209. "compaction": {
  210. "enabled": config.CTX_COMPACT_ENABLED,
  211. "trigger_fraction": config.FRAMEWORK_COMPACT_TRIGGER,
  212. "trigger_tokens": int(
  213. model_factory.context_window() * config.FRAMEWORK_COMPACT_TRIGGER
  214. ),
  215. "history_offload_path": f"/{CONVERSATION_HISTORY_DIR}/",
  216. "lever": "CONTEXT_LIMIT",
  217. },
  218. "checkpointer": agent_memory.saver_kind(),
  219. }