"""§8.11 最小子循环 delegate:上下文隔离,而非多 agent 编排。 复用现成 `AgentLoop` 起一个**空上下文**子循环,只注只读 / 落盘检索白名单工具,硬轮数 上限,固定 flash 档(检索不需旗舰模型),跑完只把**文字结论**返主循环。主循环视角 = 一次普通 tool call:零并行、零状态共享、零 agent 间协议、**深度 1 禁递归**。 对标 Claude Code / Agent SDK subagent 的核心选择(fresh context / allowlist 工具 / 只返回最终消息 / maxTurns),但有意砍掉它的并行与嵌套(§6 拒编排,zcbot 无真实并行诉求)。 根因(§8.11):检索 / 扫文类工作"中间数据量大、最终只要结论",在主循环里跑,中间数据 必然流经主上下文——污染 + 膨胀是架构性的(实测 38 篇 abstract dump 烧 2.5M token、 document_search 122 次不收敛)。行为约束(RepeatGuard / context 压缩)劝不动结构性问题, 唯一根治是把检索挪进隔离上下文。 """ from __future__ import annotations from pathlib import Path from typing import Any, Callable, Dict, List, Optional from uuid import UUID from .executor import ExecCtx, Executor, ToolResult # 只读 + 落盘检索白名单(公测决策:给"落盘检索"工具写口,坚决不给 write/edit/run_python/ # shell)。落盘的是**抓取来的数据**(文献 / 结构 CIF / entries),非模型自撰内容; # read/glob/grep/look_at_image 纯读。delegate 自身不在表内 → 子循环无法递归(防自我繁殖, # 同 8.5 定时 run 禁 schedule_create)。实际可用集 = 本表 ∩ 父 executor 已注册(缺 key 的 # 工具本就没注册,FilteredExecutor 自动收窄)。 DELEGATE_ALLOWED = frozenset({ "document_search", "document_download", "web_search", "web_fetch", "mp_search_summary", "mp_get_structure", "mp_get_entries", "read", "glob", "grep", "look_at_image", }) DELEGATE_MAX_ITERS = 20 _DELEGATE_SYS = """你是一个检索子助手,在隔离的上下文里替主助手完成一段"查资料 / 读文件 / 收集数据"的工作。 工作目录(绝对路径):{working_dir} 规则: - 你只有只读 / 检索类工具(搜索、抓取、读文件、查 Materials Project);**没有**写文件 / 改文件 / 跑代码 / shell。 - 抓取到的数据用 document_download / mp_get_* 落到工作目录即可(这些工具会自动存文件)。 - 干完后**只返回一段简明的文字结论**给主助手:说清查到了什么,关键数值 / ID / 文件路径**原样保留**,不要把大段原始检索结果整个贴回来——主助手要的是结论,细节留在落盘文件里、给出路径即可。 - 查不到 / 不确定就如实说,不要编。 """ class FilteredExecutor(Executor): """包一层父 executor,只暴露白名单 ∩ 父已注册的工具。 backend 无关(host / docker 都适用),复用父工具的全部接线(working_dir / key / 容器), 零重接。非白名单工具的 schema 不暴露、call 直接拒——双重挡住子循环越权 / 递归。 """ def __init__(self, inner: Executor, allowed: frozenset) -> None: self._inner = inner self._allowed = frozenset(n for n in allowed if inner.has_tool(n)) def has_tool(self, name: str) -> bool: return name in self._allowed def schemas(self) -> List[Dict[str, Any]]: return [ s for s in self._inner.schemas() if (s.get("function") or {}).get("name") in self._allowed ] def call_tool(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult: if name not in self._allowed: return ToolResult( content=( f"[Error] 工具 {name} 在检索子任务里不可用。" f"可用(只读 / 检索):{sorted(self._allowed)}" ), exit_code=2, ) return self._inner.call_tool(name, args, ctx) def run_delegate( instruction: str, *, llm: Any, caps: Any, executor: Executor, user_id: UUID, working_dir: Path, parent_task_id: UUID, cancel_check: Optional[Callable[[], bool]] = None, ) -> str: """跑一个隔离子循环,返回其最终文字结论。 llm / caps 由调用方传入(已降 flash 档);session 纯内存(persist=False)复用 parent_task_id 让 usage_events 归属主 task,但消息不入库。子循环禁 SSE 直播 / 禁 skill 热切 / 禁再 delegate(FilteredExecutor 不含 delegate + AgentLoop delegate_enabled 默认 False)。 """ from .loop import AgentLoop # 懒加载:loop.py 顶层 import litellm,且避免循环 import from .session import Session sub_session = Session( task_id=parent_task_id, system_prompt=_DELEGATE_SYS.format(working_dir=working_dir), persist=False, ) sub = AgentLoop( llm, FilteredExecutor(executor, DELEGATE_ALLOWED), sub_session, caps, user_id=user_id, working_dir=working_dir, sink=None, # clause ⑤:子循环事件不直播 SSE max_iterations=DELEGATE_MAX_ITERS, cancel_check=cancel_check, # clause ④:主 run 的 cancel 传导进子循环 skill_model_switch=None, # 子循环禁 skill 定向模型热切 usage_kind="chat_delegate", # clause ③:计费归属主 task,kind 区分供 diag ) return sub.run(instruction)