zcbot/core/delegate.py

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)