121 lines
5.3 KiB
Python
121 lines
5.3 KiB
Python
"""§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)
|