feat(delegate): §8.11 最小子循环——上下文隔离的检索子助手(bump 0.58.30)

触发条件已满足(diag_tool_repeat.py 实测 document_search 122 次地毯搜 /
mp_search 562 次占主上下文大头)。形态 = 一个工具 delegate(instruction):
复用现成 AgentLoop 起空上下文子循环,只注只读/落盘检索白名单,硬轮数上限 20,
固定降 flash 档,跑完只把文字结论返主循环。主循环视角 = 一次普通 tool call
(零并行/零状态共享/深度 1 禁递归)。

对标 Claude Code / Agent SDK subagent:照抄 fresh-context / allowlist 工具 /
只返回最终消息 / maxTurns;有意砍掉并行与嵌套(§6 拒编排),采纳 per-subagent
模型 override 思路(降 flash)。

实现:
- core/delegate.py(新):DELEGATE_ALLOWED 白名单 + FilteredExecutor(包父
  executor 只暴露白名单∩已注册,backend 无关、delegate 不入表天然禁递归)+
  run_delegate(懒加载 AgentLoop 避 litellm 导入/循环 import)
- tools/delegate.py(新):DelegateTool schema 载体,execute 防御性 no-op
- core/loop.py:_run_delegate + _execute_tool_call 内建拦截(拦在 executor 前,
  要读活的 self.llm/caps/executor)+ usage_kind(记账 kind 区分)+
  delegate_model_factory(降档失败降级父模型)+ 内存态子循环禁折叠守卫
  (防 persist_fold 按主 task_id 污染主上下文状态)
- core/session.py:persist=False 内存态(复用主 task_id 计费归主 task、
  消息不入库不污染主流不撞 idx)
- core/agent_builder.py:注册工具 + _delegate_model 工厂(载 deepseek_v4.flash)

落地决策:① 白名单给"落盘检索"(document_download/mp_get_*)写口、不给
write/edit/run_python/shell;② 不引 Redis 抗重启(run 本进程绑定,值钱抓取数据
已落 FS,真要抗重启走 §8.12 bg-proc);③ 未收敛显式标注不当完成。

前端零结构改动(既有工具卡 spinner+跳秒覆盖不冻屏),只加 toolActivityLabel 的
delegate case。skills/brief 加 delegate 引导。tests/test_delegate.py +8
(FilteredExecutor 过滤/拒穿透/交集 + 白名单不变量 + Session persist);loop 全
路径要 litellm 本机跑不了靠生产手验(同 salvage)。DESIGN §8.11 标 。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-15 14:43:04 +08:00
parent d3bd40fad7
commit 7d6109b932
11 changed files with 383 additions and 12 deletions

View File

@ -313,13 +313,17 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
**推翻 0.36.0 的渲图验收闭环**(svg_preview 渲 PNG → look_at_image 逐页过目 → accept_pages 标 pass → 导出 gate 校验 sha1)。三条根因:①**成本**——逐页 vision 26-42s/页、每 deck 8-25 次调用,烧 token 大头;②**环境脆弱**——沙箱 chromium "找到了但渲染崩"没有回退路径,硬门+死路逼模型即兴发挥(pip install cairosvg、手写渲染循环,正是 8.9 要防的行为);③**验收环节的"看"本身无法机检**——gate 只能强制"渲过",看没看/看得准不准全凭模型自觉,0.36.0 就写明了这个边界。**替代**:质检器 check 13/14 的精确几何检测(越界/压字/错位/网格漂移/图表退化)已覆盖"正确性"面,且在导出边界自动复跑;**主动放弃美学验收**(配色观感/页面空挤,单调门除外)——底线正确性机检可保,观感交给用户反馈迭代。svg_preview 保留为手动工具(chromium 失败自动回退 cairosvg)但 **SKILL 提示面零提及**——不给渲染入口模型就不会主动渲(同 8.9"禁令不带配方"逻辑,正面只写"导出唯一入口 svg_to_pptx");accept_pages/acceptance.json/--allow-unreviewed 整套删除。**回退信号**:若"几何全对但观感翻车"成为用户改稿主因,再考虑便宜的整本拼图单次 vision 抽检,而非逐页。
### 8.11 最小子循环 delegate:上下文隔离而非多 agent 编排(design,2026-07-08,按诊断数据触发)
### 8.11 最小子循环 delegate:上下文隔离而非多 agent 编排(✅ 2026-07-15)
**根因**:检索/扫文类工作(文献 brief、document_search、批量读文件)的形态是"中间数据量大、最终只要结论"——在主循环里跑,中间数据必然流经主上下文,污染 + 膨胀是**架构性的**。已踩实例:38 篇 abstract 反复 dump 烧 2.5M token、`document_search` 同参调 122 次不收敛。现有缓解(`_RepeatGuard` 熔断、§8.2 context 压缩、brief skill 的 context 纪律)全是**行为约束**——劝模型别乱来,不改变"中间数据必须过主上下文"这个结构;同 8.9 的教训,提示层纪律挡不住结构性问题。
**决策**:§6"不做 subagent"针对的是**编排型多 agent**(并行、状态共享、agent 间通信、任务分解),该结论不变。本条预留的是**一个工具**:`delegate(instruction) -> str 摘要`——复用现成 `AgentLoop` 起一个全新空上下文的子循环,只注只读工具(检索/读文件类白名单),硬轮数上限(~20),跑完只把文字摘要返回主循环。主循环视角就是一次普通 tool call,零状态共享、零并行、零 agent 间协议;实现量约一个文件。
**触发条件(无信号不实施)**:RepeatGuard + brief 纪律上线后,`scripts/diag_*.py` 数据仍显示检索型 task 是烧 token 大户 / 检索中间数据占上下文大头。数据收敛则本条永久搁置。
**触发条件(已满足 → 实施)**:`scripts/diag_tool_repeat.py` 全库扫实测:pymatgen task 里 `mp_search_summary` 占 562/687 次 tool_call(重复 91%)、coding task `document_search` 122 次(104 不同 query 地毯搜)——检索中间数据占上下文大头坐实。其中 mp_search 562 那类"已知清单批量扇出"另有更锋利解(工具批量入参,0.58.28 先落),delegate 主攻 document_search 那类真探索检索。
**实施要点(2026-07-15,0.58.30)**:`core/delegate.py`(§8.11 预言的"一个文件")= `DELEGATE_ALLOWED` 白名单 + `FilteredExecutor`(包父 executor 只暴露白名单∩已注册,backend 无关、复用父工具接线、delegate 不入白名单天然禁递归)+ `run_delegate`(懒加载 AgentLoop 起内存 Session 子循环)。loop 内建拦截(`_execute_tool_call` 见 `delegate``delegate_enabled``_run_delegate`,拦在 executor 前 —— 子循环要读活的 `self.llm/caps/executor`,不能预注入静态引用)。**对标 Claude Code / Agent SDK subagent**:fresh context / allowlist 工具 / 只返回最终消息 / maxTurns 四点照抄,**有意砍掉**它的并行与深度嵌套(§6 拒编排,zcbot 无真实并行诉求);模型侧采纳它的"per-subagent override"思路 —— **固定降 flash 档**(检索不需旗舰,§4)。
**几处落地决策**:① **白名单给"落盘检索"工具写口**(document_download / mp_get_* 落的是抓取数据非模型自撰内容),但坚决不给 write/edit/run_python/shell —— 这是对 Claude Explore"纯只读"的有意偏离,理由是负载不同(它读本地代码,我们抓外部数据需持久化);② **子 Session `persist=False` 纯内存**复用主 task_id:计费归主 task(usage kind=`chat_delegate` 供 diag 归因)、消息不入库(不污染主消息流 / 不撞 idx / web 翻不到)。**不引 Redis 抗重启**:delegate 同步跑在主 run 里,run 本身就进程绑定(§7.0),进程死整个 run 都没了、不只 delegate;且值钱的抓取数据已落 FS(bind-mount),丢的只是便宜的编排轨迹,重跑即可。真要抗重启的长检索走 §8.12 bg-proc,不是给 delegate 加第三个状态源;③ **未收敛显式标注**(命中 20 轮上限 / 空转 / 被取消都不当"完成"返回,§3.1 不静默)。
**不选**:
- 完整多 agent 编排:状态管理爆炸(§6),且 zcbot 无真实并行需求——用户没有"同时审 3 篇"诉求,职责隔离已由 skill 体系覆盖。

View File

@ -23,6 +23,7 @@
### 2026-07
- **07-15 / 0.58.30**:**§8.11 最小子循环 `delegate`:上下文隔离的检索子助手(非多 agent 编排)**(触发条件已满足:`diag_tool_repeat.py` 全库扫实测 document_search 122 次地毯搜 / mp_search 562 次占主上下文大头)。形态 = **一个工具**:`delegate(instruction)` 复用现成 `AgentLoop` 起一个**空上下文**子循环,只注只读 / 落盘检索白名单工具,硬轮数上限 20,固定降 flash 档(检索不需旗舰,§4),跑完只把**文字结论**返主循环;主循环视角 = 一次普通 tool call(零并行 / 零状态共享 / 零 agent 间协议 / 深度 1 禁递归)。**对标 Claude Code / Agent SDK subagent** 照抄 fresh-context / allowlist 工具 / 只返回最终消息 / maxTurns 四点,有意砍掉其并行与嵌套(§6 拒编排),采纳其 per-subagent 模型 override 思路(降 flash)。**实现**:`core/delegate.py`(§8.11 预言的"一个文件")= `DELEGATE_ALLOWED` 白名单 + `FilteredExecutor`(包父 executor 只暴露白名单∩已注册,backend 无关、复用父工具接线、delegate 不入表天然禁递归)+ `run_delegate`(懒加载 AgentLoop 避 litellm 导入 + 循环 import);`core/loop.py` 加 `_run_delegate` + `_execute_tool_call` 内建拦截(`delegate_enabled` 主循环 True、子循环 False;拦在 executor 前因要读活的 self.llm/caps/executor)+ `usage_kind`(记账 kind 区分)+ `delegate_model_factory`(降档失败降级父模型 + warn);`core/session.py` 加 `persist=False`(内存态子 Session,复用主 task_id 让计费归主 task、消息不入库不污染主流不撞 idx);`tools/delegate.py`(schema 载体,execute 防御性 no-op);`agent_builder.py` 注册 + 接线 `_delegate_model` 工厂(载 `deepseek_v4.flash`)。**落地决策**:① 白名单给"落盘检索"(document_download/mp_get_*,落的是抓取数据)写口、坚决不给 write/edit/run_python/shell —— 对 Claude Explore 纯只读的有意偏离(负载不同);② **不引 Redis 抗重启**(用户问及):delegate 同步跑在主 run 里、run 本进程绑定(§7.0)进程死整个 run 都没了,值钱抓取数据已落 FS、丢的是便宜编排轨迹重跑即可;真要抗重启走 §8.12 bg-proc;③ 未收敛(命中上限/空转/取消)显式标注不当完成(§3.1 不静默)。`skills/brief/SKILL.md` context 纪律段加 delegate 引导(纪律是行为约束、delegate 是其结构性版本)。`tests/test_delegate.py` +8(FilteredExecutor 过滤/拒穿透/交集收窄 + 白名单不变量禁递归禁写 + Session persist 内存态);loop 拦截 + run_delegate 全路径要 import litellm 本机跑不了,靠生产/手验(同 salvage 拆分)。**前端零结构改动**:delegate 走既有工具卡渲染——运行态 spinner + 跳秒(覆盖"子循环几分钟不冻屏")、warn 单行、结论卡 + 下载文件进右侧文件树都是现成的;只给 `media.js::toolActivityLabel` 加一个 `delegate` case(卡片显示「委派检索子任务: <instruction>」而非裸英文名)。DESIGN §8.11 标 ✅。additive 内部能力,不碰对外 API/DB 契约。
- **07-15 / 0.58.29**:**修对话正文文件锚点两处回归(含 `·` 的 pptx 丢 chip + 正文文件链接点击整页 404)**。① chip 丢失:`media.js` 的 `_TAIL_CLS` 把间隔号 `·`(U+00B7)当尾部排除字符,而它是文件名合法分隔符(`AI赋能陶瓷产业·…-图标增强版.pptx`)→ 在 `·` 处截断成目录不挂 chip。去掉 `·`(尾部标点另有 strip 兜底);node 验证 OLD→`[]`、NEW→两路径全出。② 链接 404:模型把相对路径写成 `[<rel>](<rel>)`,`renderMd` 出 `<a>`,但 `chat.js``#chat-stream` 点击委托无 `<a>` 拦截 → 原生跳转打到 API 不存在路由返 FastAPI 默认 `{"detail":"Not Found"}`。加拦截 `.msg .body a[href]`:外链开新标签,内部相对路径 `openFilePreview(rel)`(复用预览 modal,pptx 转 PDF),`#` 锚点走默认。纯前端修复,不碰对外契约。
- **07-15 / 0.58.28**:**`mp_search_summary` 加批量入参 `formulas=[...]`(检索型烧 token 头号根因的定点解)**(诊断:`diag_tool_repeat.py` 全库扫,pymatgen task `fe2d8b73` 687 次 tool_call 里 `mp_search_summary`**562 次**、重复率 91%——逐化学式一 formula 一轮走 agent loop 建氧化物表,562 份中间 JSON 全流经主上下文且每轮重发)。根因不是"检索发散"而是**工具只收单 `formula` + host-side 持 MP key(§7.5 #7 禁在 run_python 里循环)→ 被迫一式一调**。**这类"已知清单批量扇出"的正解是工具批量化,不是 delegate 子循环**(delegate 只把 562 轮搬进子上下文、基础 token 一分不省;批量化直接把 562 次并成 1 次工具调用+全部中间数据落盘不进对话)。实现:`formulas: list[str]`(≤300/次,超则报错分批)触发 batch 分支,复用**单个 MPRester 会话**逐式查(`num_chunks=1` fair-use 护栏不变)、每式按 `energy_above_hull` 升序(稳定相优先)、**单式出错不连坐整批**(记 `{"formula","error"}` 继续);结果聚合写 `working_dir/materials/mp_search_batch_<hash8>.json`(内容寻址、同批幂等)、**只回紧凑 per-formula 摘要 + 路径**(N 个有结果/M 个空错 + 空错清单前 20),引导模型用 `read`/`run_python` 直读文件、勿逐条 dump。单查询路径(`formula`/`material_ids`/`elements`)**字节不变**保向后兼容;`working_dir` 设可选(裸构造 `MaterialsProjectSearchSummaryTool()` 不破,测试/smoke 无 working_dir 时降级内联返回)。`agent_builder` 注入 `working_dir_path`(同 `mp_get_structure`);pymatgen SKILL.md 加"多式用 `formulas` 批量"引导(WHY+WHAT,不写死步骤);`tests/test_secret_host_tools.py` +1(去重/落盘/稳定相排序/单式错误隔离/无 key 泄漏),原单查询测试补回 `chunk_size` 断言。additive 内部工具能力,不碰对外 API/DB 契约。**背景**:这是"检索型烧 token"三头(mp_search 562 批量扇出 / document_search 122 探索检索 / research code 试错抖动)里第一头的定点解;探索检索型留待 §8.11 delegate,代码试错归 RepeatGuard,不混为一谈。
- **07-15 / 0.58.27**:**对话卡片「复制」按钮(仿 GPT/DeepSeek 悬停操作栏)**(用户提议:卡片上加复制等按钮)。`renderMessages` 给每条 user 卡、以及有正文的 assistant 卡末尾挂 `.msg-actions` 栏(纯 tool_calls 轮 / error 卡不挂),默认 `opacity:0` 悬停/`focus-within` 才现、触屏(`hover:none`)常显淡态。复制取的是**正文原文**:点击经 `#chat-stream` 既有 click 委托据卡片 `data-idx``state.loadedMessages``payload.content`(Markdown 源,**不从渲染后的 `.body` HTML 反推**),`navigator.clipboard.writeText` 主路径 + `execCommand("copy")` 降级,按钮短暂反馈「✓ 已复制/复制失败」后复位。直播卡不带此栏——流结束后 `loadMessages` 整屏重渲成持久卡自然带上,省一处直播态特判。**范围收敛的理由**:后端是纯追加式会话,无「重跑最后一轮 / 截断历史 / 编辑分叉」语义,故本期只做零后端耦合的复制;重新生成 / 编辑重发若无后端截断支持会变成「复读上一句 / 历史里又追加一条」的语义欺骗,留待后端补重跑/截断能力再单独立项。只改 `chat.js`(生成+委托+剪贴板)+ `dev.html`(CSS),对外无契约变化。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.58.29"
__version__ = "0.58.30"

View File

@ -47,6 +47,7 @@ from tools.materials_project import (
)
from tools.look_at_image import LookAtImageTool
from tools.check_process import CheckProcessTool
from tools.delegate import DelegateTool
from tools.run_python import RunPythonTool
from tools.seedance import SeedanceTool
from tools.gpt_image import GptImageTool
@ -772,6 +773,11 @@ def build_agent(
ws = WebSearchTool(cfg=bocha_cfg)
tools[ws.name] = ws
# §8.11 delegate:隔离检索子循环工具。全局注册(schema 载体),loop 内建拦截执行;
# 子循环的 FilteredExecutor 不含它 → 无法递归。
dt = DelegateTool()
tools[dt.name] = dt
sink = ConsoleEventSink(console) if console else None
# §7.5 #5/#6 Executor 抽象:env `ZCBOT_SANDBOX_BACKEND=host|docker` 切 backend。
# host(默)= 全 in-process,本地 dogfood / Windows 走这条;docker = shell/run_python
@ -813,10 +819,20 @@ def build_agent(
)
return profile, new_caps, new_llm
def _delegate_model():
"""§8.11:delegate 子循环固定降 flash 档(检索不需旗舰模型,§4「99% 任务 flash 够用」)。
返回 (caps, llm); key/配置时 raise loop._run_delegate 捕获降级用父模型 + warn
档名走 config `delegate_model`,默认与 default_model 同为 flash, ops 覆盖"""
profile = cfg.get("delegate_model") or cfg.get("default_model") or "deepseek_v4.flash"
d_caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
return d_caps, LLM(d_caps)
agent = AgentLoop(
llm, executor, session, caps,
user_id=uid, working_dir=working_dir_path, sink=sink,
skill_model_switch=_skill_model_switch,
delegate_enabled=True,
delegate_model_factory=_delegate_model,
)
if cancel_check is not None:
agent.cancel_check = cancel_check

120
core/delegate.py Normal file
View File

@ -0,0 +1,120 @@
"""§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)

View File

@ -284,6 +284,9 @@ class AgentLoop:
max_iterations: Optional[int] = None,
cancel_check: Optional[Callable[[], bool]] = None,
skill_model_switch: Optional[Callable[[str, str], Optional[Tuple[str, Any, Any]]]] = None,
usage_kind: str = "chat",
delegate_enabled: bool = False,
delegate_model_factory: Optional[Callable[[], Tuple[Any, Any]]] = None,
) -> None:
self.llm = llm
self.executor = executor
@ -304,6 +307,14 @@ class AgentLoop:
# 调它,返回 (新 profile, caps, llm) 则热切 —— 本 run 内下一次 LLM 调用即生效。
# None(CLI 旧调用方 / 测试)= 不启用。loop 不碰 DB/config,切换判定与持久化全在闭包里。
self.skill_model_switch = skill_model_switch
# usage_events.kind:主循环 "chat";§8.11 delegate 子循环传 "chat_delegate",
# 计费仍归属主 task(子 session 复用主 task_id),kind 区分供 diag 归因。
self.usage_kind = usage_kind
# §8.11 delegate:只有主循环 delegate_enabled=True 才拦截 `delegate` tool_call
# 起子循环;子循环该标志默认 False(即便误暴露也不递归)。
self.delegate_enabled = delegate_enabled
# delegate 固定降 flash 档:返回 (caps, llm)。缺 key/配置抛异常 → 降级用父模型。
self.delegate_model_factory = delegate_model_factory
# 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。
self._repeat_guard = _RepeatGuard()
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
@ -372,6 +383,7 @@ class AgentLoop:
if k not in ("tokens_in", "tokens_out") and v
},
response=response,
kind=self.usage_kind,
)
except Exception as e:
self._emit({"type": "warn", "msg": f"record_usage failed: {type(e).__name__}: {e}"})
@ -471,6 +483,11 @@ class AgentLoop:
一档;对话是回合制,run 起点是自然缝隙,代价只是命中阈值那一回合首 token
几秒(每分段一两次)失败只 warn + 跳过,绝不阻塞本次 run(85% 距硬上限有垫)
"""
# 内存态子循环(§8.11 delegate,persist=False)禁折叠:persist_fold 会按
# session.task_id(=主 task_id)写 tasks.context_summary/context_base_idx,
# 折叠子循环上下文会污染主 task 的窗口状态。子循环有界(≤20 轮)本不需要折叠。
if not getattr(self.session, "_persist", True):
return
try:
maybe_fold(
self.session, self.llm, self.caps,
@ -708,6 +725,49 @@ class AgentLoop:
pass
return response
def _run_delegate(self, args: Dict[str, Any]) -> str:
"""§8.11:起一个隔离检索子循环,返回其文字结论。
固定降 flash (检索不需旗舰模型;delegate_model_factory key/配置 降级用
父模型 + warn)子循环 session 纯内存(persist=False)复用主 task_id:计费归主
task消息不入库未跑完(命中上限/空转/被取消)显式标注,不把截断当完成(§3.1
不静默clause )子循环事件不直播,主循环侧只发一条 warn 提示,免前端盯冻屏
"""
instruction = (args.get("instruction") or "").strip()
if not instruction:
return "[Error] delegate 需要 instruction(要检索子助手做什么)"
from .delegate import run_delegate
d_llm, d_caps = self.llm, self.caps
if self.delegate_model_factory is not None:
try:
d_caps, d_llm = self.delegate_model_factory()
except Exception as e:
self._emit({
"type": "warn",
"msg": f"delegate 降 flash 档失败,改用当前模型: {type(e).__name__}: {e}",
})
self._emit({"type": "warn", "msg": "正在委派检索子任务(隔离上下文,只回结论)…"})
try:
summary = run_delegate(
instruction,
llm=d_llm, caps=d_caps, executor=self.executor,
user_id=self.user_id, working_dir=self.working_dir,
parent_task_id=self.session.task_id,
cancel_check=self.cancel_check,
)
except Exception as e:
return f"[Error] delegate 子任务异常: {type(e).__name__}: {e}"
if summary in ("[reached max iterations]", "[stopped: no progress]"):
return (
f"[检索子任务未收敛:{summary}] 未能得出确定结论。可换更具体的 instruction "
"再试,或改由你自己直接检索。已抓取的数据(若有)仍在工作目录里。"
)
if summary == "[cancelled]":
return "[检索子任务已被取消]"
return summary or "[检索子任务无文字输出]"
def _execute_tool_call(self, tc: Any) -> Tuple[str, bool]:
"""执行一次 tool_call,返回 (结果文本, 本次是否有净产出)。
净产出供 run loop 的全局无进展熔断判定"""
@ -746,14 +806,20 @@ class AgentLoop:
})
return result, False
ctx = ExecCtx(
user_id=self.user_id,
task_id=self.session.task_id,
working_dir=self.working_dir,
cancel_check=self.cancel_check,
)
tool_started_at = time.time()
result = self.executor.call_tool(name, args, ctx).content
# §8.11 delegate:在隔离子循环里跑检索,只把结论收回来(拦在 executor 之前 —— 子
# 循环要读活的 self.llm/self.caps/self.executor,不能预注入静态引用)。下游截断 /
# repeat_guard / preview 全复用。
if name == "delegate" and self.delegate_enabled:
result = self._run_delegate(args)
else:
ctx = ExecCtx(
user_id=self.user_id,
task_id=self.session.task_id,
working_dir=self.working_dir,
cancel_check=self.cancel_check,
)
result = self.executor.call_tool(name, args, ctx).content
# 控制返回给模型的 tool 结果体量,避免炸 context
MAX_LEN = 16_000

View File

@ -61,10 +61,15 @@ class Session:
task_id: UUID,
system_prompt: str = "",
meta: Optional[dict] = None,
persist: bool = True,
) -> None:
self.task_id: UUID = task_id
self.messages: List[dict] = []
self.meta: Dict[str, Any] = dict(meta or {})
# persist=False:纯内存会话(§8.11 delegate 子循环用)—— append/reset 不碰 DB。
# 子循环复用主 task_id 让 usage_events 归属主 task,但消息只在内存流转:不污染主
# 消息流、不撞 messages.idx、web 翻不到。默认 True 保主循环行为不变。
self._persist: bool = persist
self._db_idx: int = 0 # 下一条要写 DB 的 idx
# 上下文窗口元数据(0019/0021):_base_idx = 窗口起点的 DB idx;_n_head = 内存
# 头部"不在 DB 里"的消息条数(system + 可选注入的前情摘要)。
@ -88,6 +93,9 @@ class Session:
self.messages.append(msg_dict)
if msg_dict.get("role") == "system":
return None
if not self._persist:
# 内存态子循环:不落 DB,无 message_id(usage 记账走 message_id=None 分支)。
return None
with session_scope() as s:
row = Message(
@ -139,8 +147,9 @@ class Session:
else:
self.messages = []
self._n_head = 0
with session_scope() as s:
s.execute(delete(Message).where(Message.task_id == self.task_id))
if self._persist:
with session_scope() as s:
s.execute(delete(Message).where(Message.task_id == self.task_id))
self._db_idx = 0
self._base_idx = 0

View File

@ -64,6 +64,8 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
> **context 纪律(省时省钱,务必遵守)**:检索结果(尤其全文 abstract)**落进 `evidence.md` / `selected_papers.json` 文件**,**不要在对话里反复 `run_python`/`print` 把整批 abstract 灌进上下文**。工具输出会永久留在 context 并每轮重发——同一批摘要 dump 三次,context 就滚成雪球(实测一次简报因此累计烧 2.5M 输入 token、跑满超时被掐断)。需要看某几篇时按需 `read` 文件片段,看完即弃,别整批重打。
> **大批量地毯搜 → 交给 `delegate`(结构性省 context)**:当"要搜十几二十个 query / 读几十篇 abstract 才能筛出选题"时,把这段检索**整体交给 `delegate(instruction=...)`**——它在隔离子上下文里搜、读、下载(结果照样落 `evidence.md`/文件),**只把筛选结论返回给你**,几十篇原始摘要一个字都不进你的上下文。这是上面 context 纪律的结构性版本:纪律靠自觉少 dump,delegate 直接让中间数据不经过主上下文。instruction 里写清楚:搜什么主题/期刊/窗口、落到哪个文件、要返回什么(选中论文清单 + 理由)。
> **窗口内 0 篇**:如实告知库内该窗口暂无收录(可能该刊本窗口尚未发文),可用 web 补更近的非论文动向,**不脑补文献**。
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)

114
tests/test_delegate.py Normal file
View File

@ -0,0 +1,114 @@
"""§8.11 delegate 子循环的纯件测试。
只覆盖不依赖 litellm 的部分:FilteredExecutor(白名单过滤 / 递归防护)+ Session
persist=False(内存态不落 DB)full loop 拦截 + run_delegate 路径要 import core.loop
(顶层 import litellm,本机导入卡死),不在此单测,靠生产 / 手验 salvage 拆分策略
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from uuid import uuid4
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.delegate import DELEGATE_ALLOWED, FilteredExecutor # noqa: E402
from core.executor import ExecCtx, Executor, ToolResult # noqa: E402
class _FakeExecutor(Executor):
"""记录被调用的工具名;schemas 由构造时给的名字列表拼。"""
def __init__(self, names):
self._names = list(names)
self.calls = []
def has_tool(self, name):
return name in self._names
def schemas(self):
return [
{"type": "function", "function": {"name": n, "parameters": {}}}
for n in self._names
]
def call_tool(self, name, args, ctx):
self.calls.append(name)
return ToolResult(content=f"ran:{name}", exit_code=0)
def _ctx():
return ExecCtx(user_id=uuid4(), task_id=uuid4(), working_dir=Path("."))
class TestFilteredExecutor(unittest.TestCase):
def test_only_whitelisted_and_available_tools_exposed(self):
# 父有 read(白名单)、write(非白名单)、run_python(非白名单)、document_search(白名单)
inner = _FakeExecutor(["read", "write", "run_python", "document_search"])
fe = FilteredExecutor(inner, DELEGATE_ALLOWED)
exposed = {s["function"]["name"] for s in fe.schemas()}
self.assertEqual(exposed, {"read", "document_search"})
self.assertTrue(fe.has_tool("read"))
self.assertFalse(fe.has_tool("write"))
self.assertFalse(fe.has_tool("run_python"))
def test_whitelisted_call_passes_through(self):
inner = _FakeExecutor(["read", "document_search"])
fe = FilteredExecutor(inner, DELEGATE_ALLOWED)
out = fe.call_tool("read", {"path": "x"}, _ctx())
self.assertEqual(out.content, "ran:read")
self.assertEqual(inner.calls, ["read"])
def test_non_whitelisted_call_rejected_without_reaching_inner(self):
inner = _FakeExecutor(["read", "write", "shell"])
fe = FilteredExecutor(inner, DELEGATE_ALLOWED)
for banned in ("write", "shell"):
out = fe.call_tool(banned, {}, _ctx())
self.assertTrue(out.content.startswith("[Error]"), out.content)
self.assertEqual(out.exit_code, 2)
self.assertEqual(inner.calls, []) # 拒在 FilteredExecutor,没穿到父
def test_available_narrows_to_intersection(self):
# 白名单里有 mp_*,但父没注册(缺 key)→ 不暴露、不可调
inner = _FakeExecutor(["read"]) # 只有 read
fe = FilteredExecutor(inner, DELEGATE_ALLOWED)
self.assertFalse(fe.has_tool("mp_search_summary"))
exposed = {s["function"]["name"] for s in fe.schemas()}
self.assertEqual(exposed, {"read"})
class TestDelegateWhitelistInvariants(unittest.TestCase):
def test_no_write_or_exec_tools_in_whitelist(self):
# clause ②:不给 write/edit/run_python/shell
for banned in ("write", "edit", "run_python", "shell"):
self.assertNotIn(banned, DELEGATE_ALLOWED)
def test_delegate_not_in_whitelist_prevents_recursion(self):
# clause ①:子循环无法再 delegate(schema 不暴露)
self.assertNotIn("delegate", DELEGATE_ALLOWED)
def test_fetch_to_disk_retrieval_tools_allowed(self):
# 公测决策:给"落盘检索"工具写口(落的是抓取数据非模型内容)
for t in ("document_download", "mp_get_structure", "mp_get_entries"):
self.assertIn(t, DELEGATE_ALLOWED)
class TestSessionPersistFlag(unittest.TestCase):
def test_in_memory_session_appends_without_db(self):
# persist=False 的 Session.append 不碰 DB(不需要配置连接)
from core.session import Session
s = Session(task_id=uuid4(), system_prompt="SYS", persist=False)
self.assertEqual(len(s.messages), 1) # system
mid = s.append({"role": "user", "content": "hi"})
self.assertIsNone(mid) # 内存态无 message_id
s.append({"role": "assistant", "content": "yo"})
roles = [m["role"] for m in s.messages]
self.assertEqual(roles, ["system", "user", "assistant"])
self.assertEqual(s.n_user_msgs(), 1)
if __name__ == "__main__":
unittest.main()

38
tools/delegate.py Normal file
View File

@ -0,0 +1,38 @@
"""delegate 工具:schema 载体。
真正的执行走 AgentLoop 内建拦截(`_run_delegate`,§8.11) 因为要读"活的"
llm/caps(skill 热切会在运行中改)executorcancel_check,这些都在 loop 实例上
本类只负责把 schema 暴露给 LLM;`execute` 是防御性 no-op(loop 拦截后正常到不了)
子循环里 FilteredExecutor 不含本工具 schema 不暴露无法递归调用
"""
from __future__ import annotations
from .base import Tool
class DelegateTool(Tool):
name = "delegate"
description = (
"把一段『查资料 / 读文件 / 收集数据』的检索工作委派给一个隔离的子助手:它在全新的"
"空上下文里用只读 / 检索工具(搜索、抓取、读文件、查 Materials Project)干活,"
"只把简明结论返回给你,大量中间检索数据不会灌进你的上下文。"
"适用:文献综述式地毯搜索、批量读多个文件后只要结论、扫一批数据得汇总。"
"不适用:需要写文件 / 改文件 / 跑代码 / shell 的工作(子助手没有这些工具)。"
"instruction 要写清目标 + 必要的路径 / 关键词 / 约束——子助手看不到你当前对话,全靠这段说明。"
)
parameters = {
"type": "object",
"properties": {
"instruction": {
"type": "string",
"description": (
"交给检索子助手的完整任务说明:要查什么、已知的文件路径 / 关键词 / 约束、"
"期望的结论形态。子助手是空上下文,需要的信息都写进来。"
),
},
},
"required": ["instruction"],
}
def execute(self, instruction: str = "") -> str:
return "[Error] delegate 只能在 agent 主循环内运行(loop 拦截未启用)。"

View File

@ -32,6 +32,7 @@ export function toolActivityLabel(name, args) {
case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`;
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
case "delegate": return `委派检索子任务: ${clip(a.instruction, 70)}`;
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`;
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;