diff --git a/DESIGN.md b/DESIGN.md index 0cb725a..6def7f1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 体系覆盖。 diff --git a/PROGRESS.md b/PROGRESS.md index 5e434e0..7fdee7d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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(卡片显示「委派检索子任务: 」而非裸英文名)。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:模型把相对路径写成 `[]()`,`renderMd` 出 ``,但 `chat.js` 的 `#chat-stream` 点击委托无 `` 拦截 → 原生跳转打到 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_.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),对外无契约变化。 diff --git a/core/__init__.py b/core/__init__.py index 1bd0236..29798f1 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.29" +__version__ = "0.58.30" diff --git a/core/agent_builder.py b/core/agent_builder.py index 28a9c80..b36e174 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -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 diff --git a/core/delegate.py b/core/delegate.py new file mode 100644 index 0000000..76fabbe --- /dev/null +++ b/core/delegate.py @@ -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) diff --git a/core/loop.py b/core/loop.py index e5d8698..ca80378 100644 --- a/core/loop.py +++ b/core/loop.py @@ -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 diff --git a/core/session.py b/core/session.py index f8410e8..0ce9a28 100644 --- a/core/session.py +++ b/core/session.py @@ -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 diff --git a/skills/brief/SKILL.md b/skills/brief/SKILL.md index f4219d5..982de1c 100644 --- a/skills/brief/SKILL.md +++ b/skills/brief/SKILL.md @@ -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 补更近的非论文动向,**不脑补文献**。 ## 阶段三:列清单 + 内容总结(写 `/sections/*.md`) diff --git a/tests/test_delegate.py b/tests/test_delegate.py new file mode 100644 index 0000000..864309d --- /dev/null +++ b/tests/test_delegate.py @@ -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() diff --git a/tools/delegate.py b/tools/delegate.py new file mode 100644 index 0000000..5c01960 --- /dev/null +++ b/tools/delegate.py @@ -0,0 +1,38 @@ +"""delegate 工具:schema 载体。 + +真正的执行走 AgentLoop 内建拦截(`_run_delegate`,§8.11)—— 因为要读"活的" +llm/caps(skill 热切会在运行中改)、executor、cancel_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 拦截未启用)。" diff --git a/web/static/js/media.js b/web/static/js/media.js index b160220..b1f3a03 100644 --- a/web/static/js/media.js +++ b/web/static/js/media.js @@ -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)}`;