From c5ee8ef5e9e147e4d43fdda7b29447e25eaf126a Mon Sep 17 00:00:00 2001 From: lili Date: Sat, 4 Jul 2026 00:46:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(cheap):=20=E4=BE=BF=E5=AE=9C=E6=A1=A3=20Se?= =?UTF-8?q?rvice=20=E6=94=B6=E7=AA=84=E5=B7=A5=E5=85=B7=E9=9D=A2=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=85=B3=20Planning/Team/Schedule=20=E4=B8=89?= =?UTF-8?q?=E7=BB=84=E6=A1=86=E6=9E=B6=E5=B7=A5=E5=85=B7(=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20h)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.0.2 get_toolkit(app/_service/_toolkit.py)除 workspace 内建外,还无条件并入 Planning (Task* :109-110 "always on")/Team(Team*·AgentCreate :144-168),并按 session model 挂 Schedule(schedule_tools 组 :117-142)。便宜档单机单游戏用不到,纯工具面噪声(80011 flail: 12+ 工具语义重叠是根因之一;护城河 I1 六工具白名单邻域治理)。 修法:cheap Service 进程内 monkeypatch 包裹 _chat.get_toolkit(_chat.py:33 本地绑定、:240 唯一 装配调用者),调原函数拿到 Toolkit 后重建干净版:basic 组按 .name 剔 Planning+Team、丢整个 schedule_tools 组;workspace skills/MCP、六工具、后台控制 ToolStop 一律原样保留。钉 agentscope ==2.0.2、版本漂移响亮告警仍应用;作用域=cheap Service 进程(只跑便宜档 agent),tier2 独立进程 不受影响;绝不改 venv/site-packages 本体。惰性 import _chat(牵 apscheduler)保无 [service] 环境 可 import 本模块。 测试(tests/test_planning_team_tools_patch.py):4 filter/wrapper 单测(补丁后无三组 / 保六工具+ ToolStop / 过滤幂等 / 装配幂等)本机全绿 + 1 真 _chat 集成测 importorskip apscheduler(本机跳、 mini-desktop 全跑)。全套 pytest tests/ -q = 333 passed, 1 skipped。 Co-Authored-By: Claude Fable 5 --- cheap-worker/cheap_service_app.py | 7 + cheap-worker/planning_team_tools_patch.py | 138 +++++++++++++++++ .../tests/test_planning_team_tools_patch.py | 139 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 cheap-worker/planning_team_tools_patch.py create mode 100644 cheap-worker/tests/test_planning_team_tools_patch.py diff --git a/cheap-worker/cheap_service_app.py b/cheap-worker/cheap_service_app.py index 55946055..6aeb477d 100644 --- a/cheap-worker/cheap_service_app.py +++ b/cheap-worker/cheap_service_app.py @@ -372,6 +372,13 @@ def build_cheap_app(*, title: str = SERVICE_TITLE) -> "FastAPI": from ws_builtin_tools_patch import apply_ws_builtin_tools_patch # noqa: PLC0415 apply_ws_builtin_tools_patch() + # 工具面再封口(工单 h):2.0.2 get_toolkit 除内建外还无条件并入 Planning(Task*)/Team(Team*·AgentCreate), + # 并按 session model 挂 Schedule(schedule_tools 组)——便宜档单机单游戏用不到,纯工具面噪声(80011 flail: + # 12+ 工具语义重叠是根因之一)。本进程只跑便宜档 agent,进程内收窄作用域恰好;tier2 独立进程不受影响。 + # 【钉 agentscope==2.0.2,升级必须复核】纪律同 ws_builtin_tools_patch(不改 venv 本体)。 + from planning_team_tools_patch import apply_planning_team_tools_patch # noqa: PLC0415 + apply_planning_team_tools_patch() + storage_params = infra_config.redis_params(for_message_bus=False) bus_params = infra_config.redis_params(for_message_bus=True) # cheap 独立 workspace 根(与 tier2 的 _service-workspaces 分开;本线生成产物另落 game-runtime/games/amgen-)。 diff --git a/cheap-worker/planning_team_tools_patch.py b/cheap-worker/planning_team_tools_patch.py new file mode 100644 index 00000000..1598d4e5 --- /dev/null +++ b/cheap-worker/planning_team_tools_patch.py @@ -0,0 +1,138 @@ +"""planning_team_tools_patch.py — 便宜档 Service 收窄 agent 工具面:关 Planning / Team / Schedule 三组框架工具。 + +【坐实根因(2026-07-04 工单 h;接 ws_builtin_tools_patch 的收窄线)】agentscope 2.0.2 Service 路的工具面装配 +在 app/_service/_toolkit.py:get_toolkit 里,除了 workspace 内建(已由 ws_builtin_tools_patch 关)之外,还**无条件** +向 agent 工具面并入三组与便宜档无关的框架工具: + ① Planning —— TaskCreate / TaskList / TaskGet / TaskUpdate(:109-110 明文 "Planning tools — always on"); + ② Team —— agent_record.source=="team" 时挂 TeamSay,否则挂 TeamCreate / AgentCreate / TeamSay / TeamDelete + 全套(:144-168),便宜档是单机单游戏、无团队/无子 agent 编排; + ③ Schedule —— session 配了 chat_model_config 时,挂一个名为 "schedule_tools" 的 ToolGroup(cron 定时新起 chat, + :117-142),便宜档不做定时任务。 +后果:便宜档单机单游戏场景用不到这三组,纯属**工具面噪声**——80011 生产实录里 12+ 工具语义重叠正是 M3 flail +(反复空转/选错工具)的根因之一;工具面越窄,混乱税越低(护城河 I1「六工具白名单」的邻域治理)。CLI 路 +(cheap_run)从来只有六工具;Service 归并(配置控制面阶段一②)经框架 get_toolkit 把这三组带了进来。 + +【修法】cheap Service 进程内 monkeypatch,**包裹 get_toolkit 的唯一运行时调用者**—— +app/_service/_chat.py:33 以 `from ._toolkit import get_toolkit` 把 get_toolkit 绑成 _chat 模块的本地名,:240 是 +全仓唯一装配调用点;故替换 `_chat.get_toolkit`(替源模块 _toolkit 的属性对已绑定的 _chat 无效)。包装体调原 +函数拿到装配好的 Toolkit 后,**重建一个干净 Toolkit**:从 basic 组按 .name 剔除 Planning+Team 工具、丢掉整个 +schedule_tools 组;workspace skills / MCP / 其余工具(便宜档六工具、后台控制 ToolStop)一律原样保留。重建而非 +原地改,是为避开半构造对象的陈旧缓存(Toolkit.__init__ 会据 tool_groups 建 meta 工具 ResetTools)。作用域 +正确性:本进程(cheap_service_app)只跑便宜档 agent,进程内全局补丁 = 恰好全体便宜档 agent;tier2 服务是独立 +进程,不受影响。 + +【钉 agentscope==2.0.2,升级必须复核此补丁】运行时 monkeypatch,绝不改 venv/site-packages 文件本体。 +依赖三个 2.0.2 契约:① get_toolkit 由 _chat 模块 `from ._toolkit import get_toolkit` 本地绑定、且 _chat.py:240 是 +唯一装配调用者;② Planning/Team/Schedule 工具的 .name 为固定类名(见下方三张名单),且 Schedule 四工具装在名为 +"schedule_tools" 的 ToolGroup 里;③ Toolkit.tool_groups[0] 为 "basic" 组、其 .tools/.skills_or_loaders/.mcps +可用于重建。升级 agentscope 后必须复核这三点并重跑 tests/test_planning_team_tools_patch.py(版本漂移时 apply +响亮 warning、但仍应用——不应用等于把这三组噪声放回便宜档工具面)。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover —— 仅类型提示,运行时不 import(agentscope.tool 顶层牵重依赖) + from agentscope.tool import Toolkit + +# ── 便宜档要关掉的框架工具面(按 .name 精确匹配;钉 2.0.2 固定类名契约)── +# Planning:框架 TODO 计划板(get_toolkit "always on"),便宜档不需要 agent 自管任务板。 +_PLANNING_TOOL_NAMES = frozenset({"TaskCreate", "TaskList", "TaskGet", "TaskUpdate"}) +# Team:多 agent 团队 / 子 agent 编排,便宜档单机单游戏无团队。 +_TEAM_TOOL_NAMES = frozenset({"TeamCreate", "AgentCreate", "TeamSay", "TeamDelete"}) +# Schedule:cron 定时新起 chat 的四工具,正常装在名为 schedule_tools 的独立 ToolGroup(见 _SCHEDULE_GROUP_NAME)。 +_SCHEDULE_TOOL_NAMES = frozenset({"ScheduleCreate", "ScheduleView", "ScheduleDelete", "ScheduleList"}) +# Schedule 组名:整组移除即可;下方仍把 schedule 工具名并进黑名单,兜「未来某路径把它落进别的组」的漏。 +_SCHEDULE_GROUP_NAME = "schedule_tools" + +# 统一黑名单(按名从任意组剔除):Planning + Team + Schedule 三组工具名。 +_BLOCKED_TOOL_NAMES = _PLANNING_TOOL_NAMES | _TEAM_TOOL_NAMES | _SCHEDULE_TOOL_NAMES + + +def _filter_toolkit(toolkit: "Toolkit") -> "Toolkit": + """把框架 get_toolkit 产出的 Toolkit 重建成便宜档干净版:去 Planning/Team 工具 + 去 schedule_tools 组。 + + 只读 Toolkit 的公开结构(tool_groups / group.name / group.tools / group.skills_or_loaders / group.mcps), + 据此重建一个新 Toolkit(basic 组按名剔黑名单、其余组丢 schedule_tools、组内再兜底剔黑名单名), + skills / MCP 原样透传。任何结构异常(无 tool_groups 等)大声告警并**原样返回**——宁可多留几件工具, + 也不让 Service 装配崩(收窄是降噪、不是关键路径)。 + """ + from agentscope.tool import Toolkit # noqa: PLC0415 —— 惰性 import(顶层不牵重依赖,保 6c6g/无 [service] 环境可 import 本模块) + + groups = list(getattr(toolkit, "tool_groups", None) or []) + if not groups: + print("[planning-team-patch] WARNING: Toolkit 无 tool_groups,2.0.2 结构可能已变——原样返回、不收窄。", flush=True) + return toolkit + + def _keep(tool) -> bool: # noqa: ANN001 —— 只看 .name,替身/真工具通用 + return getattr(tool, "name", None) not in _BLOCKED_TOOL_NAMES + + # basic 组恒在 index 0(Toolkit.__init__ 保证);稳妥按名找,找不到再退 index 0。 + basic = next((g for g in groups if getattr(g, "name", None) == "basic"), groups[0]) + kept_basic_tools = [t for t in getattr(basic, "tools", None) or [] if _keep(t)] + + # 其余组:丢掉整个 schedule_tools 组;别的组(若有)保留,组内再兜底剔黑名单名(正常不会命中)。 + extra_groups = [] + for g in groups: + if g is basic or getattr(g, "name", None) == _SCHEDULE_GROUP_NAME: + continue + g.tools = [t for t in getattr(g, "tools", None) or [] if _keep(t)] + extra_groups.append(g) + + # 重建:basic 组的 skills/MCP 原样带回(工单不动 skills/MCP 装载);其余组带回。 + return Toolkit( + tools=kept_basic_tools, + skills_or_loaders=list(getattr(basic, "skills_or_loaders", None) or []), + mcps=list(getattr(basic, "mcps", None) or []), + tool_groups=extra_groups, + ) + + +def _install_get_toolkit_wrapper(chat_mod) -> None: # noqa: ANN001 —— 传 _chat 模块(或测试用假模块) + """把 chat 模块命名空间里的 get_toolkit 换成「调原函数 → _filter_toolkit」的包装(幂等)。 + + _chat.py:33 `from ._toolkit import get_toolkit` 使 get_toolkit 是 _chat 模块的**本地名**,:240 按该名调用; + 故必须替换 chat_mod.get_toolkit(替源模块 _toolkit 的属性对已绑定的 _chat 无效)。包装函数带 + `_cheap_ptl_patched` 标记位:重复 install 短路、不再包一层(与 ws_builtin_tools_patch 同款幂等纪律)。 + """ + orig = chat_mod.get_toolkit + if getattr(orig, "_cheap_ptl_patched", False): + return # 幂等:已包装,直接返回 + + async def _wrapped(*args, **kwargs): + # 与 2.0.2 get_toolkit 同签名(async;_chat.py:240 全 kwargs 调用,这里 *args/**kwargs 透传兜位)。 + toolkit = await orig(*args, **kwargs) + return _filter_toolkit(toolkit) + + _wrapped._cheap_ptl_patched = True # type: ignore[attr-defined] —— 幂等标记 + _wrapped.__name__ = "get_toolkit__cheap_ptl" # 便于日志/调试辨识 + chat_mod.get_toolkit = _wrapped + + +def apply_planning_team_tools_patch() -> None: + """便宜档 Service 进程内关 Planning/Team/Schedule 三组框架工具(幂等,钉 2.0.2)。 + + 惰性 import _chat:它经 agentscope.app 牵 apscheduler 等 [service] extra 重依赖,顶层 import 会污染无该 extra + 的环境(如本机 6c6g / 单测机);故只在 apply(运行时、Service 进程内)时 import。 + """ + import agentscope # noqa: PLC0415 + from agentscope.app._service import _chat as _chat_mod # noqa: PLC0415 —— 惰性:牵 apscheduler,只在运行时 import + + ver = getattr(agentscope, "__version__", "?") + if ver != "2.0.2": + # 版本漂移:响亮告警但仍应用(不应用 = 三组噪声放回便宜档工具面,与收窄意图相反)。 + print( + f"[planning-team-patch] WARNING: agentscope 版本 {ver} != 钉住的 2.0.2," + "get_toolkit 装配点 / 工具类名契约可能已变,升级复核未做——补丁仍应用,请尽快复核。", + flush=True, + ) + + already = getattr(_chat_mod.get_toolkit, "_cheap_ptl_patched", False) + _install_get_toolkit_wrapper(_chat_mod) + if not already: + print( + "[planning-team-patch] 已关 Planning(Task*)/Team(Team*·AgentCreate)/Schedule(schedule_tools 组)" + "——便宜档单机单游戏工具面收窄,只留六工具 + 后台控制 + skills/MCP(钉 2.0.2,升级必须复核)。", + flush=True, + ) diff --git a/cheap-worker/tests/test_planning_team_tools_patch.py b/cheap-worker/tests/test_planning_team_tools_patch.py new file mode 100644 index 00000000..136d5c37 --- /dev/null +++ b/cheap-worker/tests/test_planning_team_tools_patch.py @@ -0,0 +1,139 @@ +"""planning_team_tools_patch 单测:补丁后工具面无 Planning/Team/Schedule 三组、幂等、其余工具/skills 不受伤。 +跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_planning_team_tools_patch.py -v + +【为何不直接跑真 get_toolkit】get_toolkit / Team 工具类 / _chat 全在 agentscope.app 命名空间下,import 会牵 +apscheduler([service] extra);本机 venv 无 apscheduler(真门只在 mini-desktop)。故核心三测用**真 Toolkit + +真 Planning 工具类 + Team/Schedule 替身(按 2.0.2 固定类名造 .name)**验过滤逻辑——走的是与生产同一份 +_filter_toolkit / _install_get_toolkit_wrapper 代码路径;真 _chat.get_toolkit 的包装装配另有一枚 importorskip +守卫的集成测试(本机跳过、mini-desktop 全跑)。 +""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ +import _bootstrap # noqa: E402,F401 仅加 sys.path(import 时不取 key、不触网) +from agentscope.tool import ( # noqa: E402 + TaskCreate, + TaskGet, + TaskList, + TaskUpdate, + ToolGroup, + Toolkit, +) +from agentscope.tool._base import ToolBase # noqa: E402 +from planning_team_tools_patch import ( # noqa: E402 + _PLANNING_TOOL_NAMES, + _SCHEDULE_TOOL_NAMES, + _TEAM_TOOL_NAMES, + _filter_toolkit, + _install_get_toolkit_wrapper, +) + +# 便宜档六工具与后台控制工具的代表名(不在收窄范围,必须原样保留)。 +_KEEP_NAMES = ("read_source", "write_source", "check_and_build", "run_gates", "read_verdict", "finish", "ToolStop") + + +class _FakeTool(ToolBase): + """测试替身工具:过滤逻辑只看 .name,故替身只需一个 name(Team/Schedule/六工具在此仅取名验过滤)。""" + + def __init__(self, name: str) -> None: + self.name = name + self.description = f"fake {name}" + self.input_schema = {"type": "object", "properties": {}} + self.is_concurrency_safe = True + self.is_read_only = True + + async def check_permissions(self, tool_input, context): # noqa: ANN001 —— 替身不会被真调用 + raise NotImplementedError + + +def _build_framework_like_toolkit() -> Toolkit: + """仿 2.0.2 get_toolkit 产物结构:basic 组 = 真 Planning[4] + Team 替身[4] + ToolStop 替身 + 六工具替身; + 外挂一个 schedule_tools 组(Schedule 替身[4])。""" + planning = [TaskCreate(), TaskList(), TaskGet(), TaskUpdate()] # 真 Planning 类:坐实 .name 契约 + team = [_FakeTool(n) for n in _TEAM_TOOL_NAMES] + background = [_FakeTool("ToolStop")] # 后台控制,不在收窄范围 + cheap_six = [_FakeTool(n) for n in ("read_source", "write_source", "check_and_build", "run_gates", "read_verdict", "finish")] + schedule_group = ToolGroup( + name="schedule_tools", + description="cron schedule tools", + tools=[_FakeTool(n) for n in _SCHEDULE_TOOL_NAMES], + ) + return Toolkit(tools=planning + team + background + cheap_six, tool_groups=[schedule_group]) + + +def _surface_names(tk: Toolkit) -> set: + """取工具面全部工具名(遍历所有组)。""" + return {t.name for g in tk.tool_groups for t in g.tools} + + +def test_filter_removes_planning_team_schedule(): + # 补丁后工具面无 Planning/Team/Schedule 三组(既无其工具名,schedule 更是整组被移除)。 + tk = _filter_toolkit(_build_framework_like_toolkit()) + names = _surface_names(tk) + assert not (names & _PLANNING_TOOL_NAMES), f"Planning 应被剔除,残留:{names & _PLANNING_TOOL_NAMES}" + assert not (names & _TEAM_TOOL_NAMES), f"Team 应被剔除,残留:{names & _TEAM_TOOL_NAMES}" + assert not (names & _SCHEDULE_TOOL_NAMES), f"Schedule 应被剔除,残留:{names & _SCHEDULE_TOOL_NAMES}" + assert not any(g.name == "schedule_tools" for g in tk.tool_groups), "schedule_tools 组应整组移除" + + +def test_filter_preserves_other_tools_and_skills(): + # 工具面其余成员不受伤:六工具 + 后台 ToolStop 全在席,basic 组结构仍在。 + tk = _filter_toolkit(_build_framework_like_toolkit()) + names = _surface_names(tk) + for n in _KEEP_NAMES: + assert n in names, f"非收窄工具 {n} 不应被误伤,实际工具面:{sorted(names)}" + assert any(g.name == "basic" for g in tk.tool_groups), "重建后应仍保留 basic 组" + + +def test_filter_idempotent(): + # 幂等:对已收窄的 Toolkit 再过滤一次,工具面稳定不变(重复 apply 无害的过滤层证据)。 + once = _filter_toolkit(_build_framework_like_toolkit()) + twice = _filter_toolkit(once) + assert _surface_names(once) == _surface_names(twice), "对已收窄 Toolkit 再过滤应稳定不变" + assert not (_surface_names(twice) & (_PLANNING_TOOL_NAMES | _TEAM_TOOL_NAMES | _SCHEDULE_TOOL_NAMES)) + + +def test_install_wrapper_filters_and_is_idempotent(): + # 用假 chat 模块验:包装真的「调原 → 过滤」,且重复 install 不再包层(装配层幂等,与 ws patch 同款)。 + class _FakeChatMod: + pass + + fake = _FakeChatMod() + built = _build_framework_like_toolkit() + + async def _orig_get_toolkit(**kwargs): # 仿 get_toolkit:async、返回未过滤 Toolkit + return built + + fake.get_toolkit = _orig_get_toolkit + _install_get_toolkit_wrapper(fake) + wrapped = fake.get_toolkit + assert wrapped is not _orig_get_toolkit, "install 后 get_toolkit 应被替换为包装函数" + assert getattr(wrapped, "_cheap_ptl_patched", False), "包装函数应带幂等标记" + + # 包装确实过滤了三组 + out = asyncio.run(fake.get_toolkit()) + assert not (_surface_names(out) & (_PLANNING_TOOL_NAMES | _TEAM_TOOL_NAMES | _SCHEDULE_TOOL_NAMES)), "包装应过滤三组" + assert "finish" in _surface_names(out), "六工具应保留" + + # 幂等:第二次 install 短路,不再包一层 + _install_get_toolkit_wrapper(fake) + assert fake.get_toolkit is wrapped, "幂等:重复 install 不应替换包装函数" + + +def test_apply_on_real_chat_module_if_available(): + # 集成:真 _chat.get_toolkit 被包装 + 幂等。需 apscheduler(agentscope.app 牵),本机无则跳过(真跑在 mini-desktop)。 + import pytest # noqa: PLC0415 + + pytest.importorskip("apscheduler", reason="agentscope.app 需 [service] extra(apscheduler);真跑在 mini-desktop") + from agentscope.app._service import _chat as chat_mod # noqa: PLC0415 + + from planning_team_tools_patch import apply_planning_team_tools_patch # noqa: PLC0415 + + apply_planning_team_tools_patch() + assert getattr(chat_mod.get_toolkit, "_cheap_ptl_patched", False), "真 _chat.get_toolkit 应被包装" + wrapped = chat_mod.get_toolkit + apply_planning_team_tools_patch() # 再来一次 + assert chat_mod.get_toolkit is wrapped, "幂等:重复 apply 不换包装"