From eb135a90412241e921ba252a40beb71e0b1da8a9 Mon Sep 17 00:00:00 2001 From: lili Date: Sat, 4 Jul 2026 00:49:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(tier2):=20=E8=BD=AF=E5=81=9C=E5=90=8E=20fi?= =?UTF-8?q?nish=20=E9=80=BC=E8=BF=91=E9=80=92=E8=BF=9B=E5=8D=87=E5=8E=8B(?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=20c)=E2=80=94=E2=80=94=E8=BF=9E=E7=BB=AD=20N?= =?UTF-8?q?=20=E8=BD=AE=E4=B8=8D=20finish=20=E7=BB=8F=20on=5Facting=20?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E5=8D=87=E5=8E=8B=E6=94=B6=E5=B0=BE=E6=8C=87?= =?UTF-8?q?=E4=BB=A4=E9=80=BC=E8=BD=AF=E7=9D=80=E9=99=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 软停(budget_soft_tripped)已有 on_system_prompt 强提醒 + on_acting 拦生成类工具,但那是被动系统提示, M3 可能烧推理不理会(80011 实录:软停触发后仍烧到 step_cap/硬地板才停)。本次在共享 CircuitBreakerMiddleware 加「finish 逼近升压」:on_reply 用现有事件计数面数软停后连续无-finish 的 acting 轮(ToolCallStartEvent +1; 一推理轮无 tool call=finish 尝试则轮边界复位),达 N=3 轮升第一档(提醒收尾)、达 2N=6 轮升第二档(强制立即 finish、禁新增大额生成);升压指令经 on_acting 注入工具结果(拦截语并入 / 收尾类结果尾部附加),跨档只注一次。 只改观察/注入路径与状态跟踪:不动任何熔断阈值 / finish 语义 / 九门;无新增配置键(_softstop_rounds 派生); _softstop_rounds=0 时 on_acting 与升压前逐字节等价(兼容既有 cheap 共享面)。cheap_budget 同源共享,不改 cheap 文件。 新增 tests/test_softstop_escalation.py(5 用例):升压逐档升级 / 透传附加 / finish 尝试复位 / 不足 N 轮零升压 / 硬地板仍兜底。 验证:tier2/gen-worker 102 passed;cheap-worker 共享软停面 29 passed(test_budget_two_stage 等)。 Co-Authored-By: Claude Fable 5 --- .../tests/test_softstop_escalation.py | 214 ++++++++++++++++++ tier2/gen-worker/worker/middleware.py | 120 +++++++++- 2 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 tier2/gen-worker/tests/test_softstop_escalation.py diff --git a/tier2/gen-worker/tests/test_softstop_escalation.py b/tier2/gen-worker/tests/test_softstop_escalation.py new file mode 100644 index 00000000..d7e23f86 --- /dev/null +++ b/tier2/gen-worker/tests/test_softstop_escalation.py @@ -0,0 +1,214 @@ +"""软停后「finish 逼近」递进升压单测(工单 c)。 + +守的行为:软停(budget_soft_tripped)后,agent 连续 N 轮(SOFT_STOP_ESCALATE_ROUNDS=3)仍在动手而非 +finish 时,on_acting 主动把递进升压的收尾指令注入工具结果——第一档提醒收尾、第二档强制立即 finish 且禁 +新增大额生成——让软停线软着陆,而非像 80011 实录那样软停后仍烧到 step_cap/硬地板才停。 + +计数面复用现有 on_reply 事件计数(ModelCallStartEvent 起一轮 / ToolCallStartEvent = 本轮动手); +一推理轮无 tool call = finish 尝试,轮边界复位升压计数。硬地板等失控保护阈值不受升压影响(升压是加法)。 + +跑:PYTHONPATH=tier2/gen-worker cheap-worker/.venv/bin/python -m pytest tier2/gen-worker/tests/test_softstop_escalation.py -v +""" +import asyncio + +import pytest + +from agentscope.message import TextBlock, ToolResultState +from agentscope.tool import ToolResponse + +from worker.middleware import ( + CircuitBreakerMiddleware, Tier2CircuitBreak, SOFT_STOP_ESCALATE_ROUNDS, +) + +_N = SOFT_STOP_ESCALATE_ROUNDS # 3 + + +# ── 假事件:on_reply 按 type(evt).__name__ 分派,类名必须与 AgentScope 事件同名 ── +class ModelCallStartEvent: + """一次模型推理(=一轮)的起点。""" + + +class ToolCallStartEvent: + """本轮产了 tool call(= 未 finish);推理相位产出,早于 on_acting。""" + + def __init__(self, tcid: str = "tc", name: str = "build") -> None: + self.tool_call_id = tcid + self.tool_call_name = name + + +class ToolResultEndEvent: + """工具结果终态(success 不触 stuck;用于把一轮凑完整)。""" + + def __init__(self, tcid: str = "tc", state: str = "success") -> None: + self.tool_call_id = tcid + self.state = state + + +class _FakeModel: + model = "MiniMax-M3" + + +class _ToolCall: + def __init__(self, name: str) -> None: + self.name = name + self.id = "id-" + name + + +async def _passthrough(**_kwargs): + class _Resp: + usage = None # 无 usage → 不折算,不干扰软停/升压断言 + return _Resp() + + +async def _sentinel_handler(**_kwargs): + yield "TOOL_RAN" # 透传哨兵(字符串,非 ToolResponse → 不会被升压附加) + + +async def _toolresponse_handler(**_kwargs): + # 真实工具终态 ToolResponse(升压走「附加到工具结果尾部」路时用它验证附加)。 + yield ToolResponse(content=[TextBlock(text="原始工具输出")], state=ToolResultState.SUCCESS) + + +def _soft_tripped_breaker(**kw) -> CircuitBreakerMiddleware: + """已软停的 soft 档 breaker;其余闸放宽到打不着,专注测升压(pricing_params 传入避免测试内打网关取价)。""" + b = CircuitBreakerMiddleware( + soft_budget=True, rmb_hard_limit=10.0, # 软停线 ¥10 → 硬地板 ¥15(×1.5) + max_tool_calls=999, max_model_calls=999, + wall_timeout_s=9999.0, step_timeout_s=9999.0, + pricing_params={"pricing": {}, "qpu": 1, "usd_rate": 0}, + **kw, + ) + b._rmb_gate_active = True + b.budget_soft_tripped = True # 直接置软停态(等价越软停线后);本组用例专注升压逻辑 + return b + + +def _drive(b: CircuitBreakerMiddleware, events: list) -> None: + """把一串假事件喂过 on_reply(消费完毕),驱动其轮计数/复位。""" + async def handler(**_kwargs): + for e in events: + yield e + + async def run(): + async for _ in b.on_reply(None, {}, handler): + pass + + asyncio.run(run()) + + +def _drive_tool_rounds(b: CircuitBreakerMiddleware, n: int) -> None: + """驱动 n 个「动手轮」(每轮 = 模型推理 + 一次 tool call + 成功结果),软停后每轮使 _softstop_rounds +1。""" + events: list = [] + for i in range(n): + tcid = f"tc-{id(events)}-{i}" + events += [ModelCallStartEvent(), ToolCallStartEvent(tcid, "build"), + ToolResultEndEvent(tcid, "success")] + _drive(b, events) + + +def _drive_finish_attempt(b: CircuitBreakerMiddleware) -> None: + """驱动一次「finish 尝试」:一推理轮全程无 tool call,再起下一轮 ModelCallStart 触发轮边界结算 → 复位。""" + _drive(b, [ModelCallStartEvent(), ModelCallStartEvent()]) + + +def _acting_collect(b: CircuitBreakerMiddleware, name: str, handler=None) -> list: + """同步跑一次 on_acting,返回产出列表。""" + async def run(): + out = [] + async for item in b.on_acting(None, {"tool_call": _ToolCall(name)}, + handler or _sentinel_handler): + out.append(item) + return out + + return asyncio.run(run()) + + +def _acting_text(b: CircuitBreakerMiddleware, name: str, handler=None) -> str: + """取 on_acting 产出的文本(拦截路 = ERROR ToolResponse 文本;透传路 = ToolResponse/哨兵串拼接)。""" + out = _acting_collect(b, name, handler) + for item in out: + if isinstance(item, ToolResponse): + return "".join(getattr(bk, "text", "") for bk in item.content) + return "".join(x for x in out if isinstance(x, str)) + + +# ── 门①:软停后连续 N 轮无 finish → 升压文案注入且逐档升级 ── +def test_escalation_injects_and_upgrades_after_n_rounds(): + b = _soft_tripped_breaker() + # 连续 N 轮动手不 finish → 第一档(提醒尽快收尾)。 + _drive_tool_rounds(b, _N) + assert b._softstop_rounds == _N, "软停后 N 个动手轮应累计 N" + out1 = _acting_text(b, "write_file") # 被拦生成类:文本 = 拦截 base + 第一档升压 + assert "软停线" in out1 and "finish" in out1, "应保留原拦截语义" + assert "尽快" in out1 and "收尾" in out1, "第一档应注入『提醒收尾』" + assert "禁止" not in out1, "第一档还不到『禁新增生成』的强度" + assert b._softstop_level == 1 + + # 再连续 N 轮仍不 finish(累计 2N)→ 升到第二档(强制立即 finish、禁新增生成)。 + _drive_tool_rounds(b, _N) + assert b._softstop_rounds == 2 * _N + out2 = _acting_text(b, "write_file") + assert "立即" in out2 and "禁止" in out2 and "新增" in out2, "第二档应强制立即 finish、禁新增大额生成" + assert b._softstop_level == 2 + assert out1 != out2, "两档文案应逐档升级(第二档更硬)" + + +# ── 门①补充:收尾类工具透传时,升压指令附加到其真实工具结果尾部(on_acting 注入路验证)── +def test_escalation_augments_passthrough_tool_result(): + b = _soft_tripped_breaker() + _drive_tool_rounds(b, _N) # 跨第一档 + out = _acting_collect(b, "read_file", _toolresponse_handler) # read_file 非生成类 → 放行 + resp = [x for x in out if isinstance(x, ToolResponse)][-1] + joined = "".join(getattr(bk, "text", "") for bk in resp.content) + assert "原始工具输出" in joined, "原工具结果必须保留" + assert "尽快" in joined and "收尾" in joined, "升档瞬间应把第一档升压指令附加到工具结果尾部" + + +# ── 门②:期间出现 finish 尝试 → 不注入 / 复位 ── +def test_finish_attempt_resets_escalation(): + b = _soft_tripped_breaker() + _drive_tool_rounds(b, _N) # rounds=N + _acting_text(b, "write_file") # 触第一档,level=1 + assert b._softstop_rounds == _N and b._softstop_level == 1 + + # 一推理轮无 tool call = finish 尝试 → 轮边界复位升压计数与档位。 + _drive_finish_attempt(b) + assert b._softstop_rounds == 0, "finish 尝试后应复位轮计数" + assert b._softstop_level == 0, "finish 尝试后应复位升压档" + + # 复位后不足 N 轮 → on_acting 不再注入升压(回到升压前:只拦生成类、不升压)。 + # 注:拦截 base 文案本身含「立即调用 finish」,故用升压独有标记判断——「尽快」仅第一档、「禁止」仅第二档。 + out = _acting_text(b, "write_file") + assert "软停线" in out and "finish" in out, "拦截语义仍在" + assert "尽快" not in out and "禁止" not in out, "复位后未达 N 轮不应再注入升压文案" + assert b._softstop_level == 0 + + +# ── 边界 / 兼容:软停后不足 N 轮 → 零升压(与升压前逐字节等价)── +def test_no_escalation_below_n_rounds_backward_compat(): + b = _soft_tripped_breaker() + _drive_tool_rounds(b, _N - 1) # rounds=2 < 3 + assert b._softstop_rounds == _N - 1 + # 生成类:仍拦截,文本 = base(无升压)。用升压独有标记判断(base 本身含「立即调用 finish」): + # 「尽快」仅第一档、「禁止」仅第二档,两者皆不在 base。 + txt = _acting_text(b, "write_file") + assert "软停线" in txt and "finish" in txt + assert "尽快" not in txt and "禁止" not in txt, "未达 N 轮不注入升压" + assert b._softstop_level == 0 + # 收尾类:透传哨兵原样(未附加)。 + assert _acting_collect(b, "read_file", _sentinel_handler) == ["TOOL_RAN"] + + +# ── 门③:硬地板行为不变,升压不禁用失控保护(仍 fail-closed 兜底)── +def test_hard_floor_still_fail_closed_under_escalation(): + b = _soft_tripped_breaker() # rmb_hard_limit=10 → 硬地板 15 + _drive_tool_rounds(b, 2 * _N) # 升到第二档 + _acting_text(b, "write_file") + assert b._softstop_level == 2, "前置:已升到第二档" + + # 已花越硬地板(¥15)→ 下一次模型调用仍必 fail-closed 抛熔断(升压不改熔断判定阈值)。 + b.spent_rmb = 20.0 + with pytest.raises(Tier2CircuitBreak) as ei: + asyncio.run(b.on_model_call(None, {"current_model": _FakeModel()}, _passthrough)) + assert ei.value.kind == "budget", "硬地板越线必须 fail-closed(budget)" + assert "硬地板" in ei.value.reason diff --git a/tier2/gen-worker/worker/middleware.py b/tier2/gen-worker/worker/middleware.py index 851c02ee..42b07753 100644 --- a/tier2/gen-worker/worker/middleware.py +++ b/tier2/gen-worker/worker/middleware.py @@ -177,6 +177,27 @@ DEFAULT_RMB_HARD_LIMIT = genconfig.get("budget", "rmb_hard_limit", 50.0) # 富 # finish 是收尾链本体,均放行。 DEFAULT_SOFT_STOP_BLOCKED_TOOLS = frozenset({"write_file", "write_source", "scaffold_init"}) +# ── 软停后「finish 逼近」递进升压(工单 c)── +# 软停(budget_soft_tripped)已由 on_system_prompt 注入「立即 finish」强提醒 + on_acting 拦新增生成类工具, +# 但这是被动系统提示;A1 T8 实测 M3 可能烧推理不理会,80011 生产实录也是「软停触发后仍烧到 step_cap/硬地板才停」。 +# 故软停后再数「连续多少轮仍不 finish」,达阈值即经 on_acting 主动把递进升压的收尾指令注进工具结果,逼软着陆。 +# N=3 的依据:软停后一次合法收尾序列很短——「读一眼当前工程 → 跑一次门/构建自查 → finish」约 2~3 个工具调用; +# 故给 3 轮宽限覆盖正常收尾,第 3 轮后仍在动手(而非 finish)就升压。3 远小于 step_cap(genconfig 现值 100 步), +# 能在烧穿硬地板/步顶前把 agent 逼向 finish,又不误伤正常的最后自查。第二档在 2×N=6 轮触发(更硬)。 +SOFT_STOP_ESCALATE_ROUNDS = 3 + +# 两档收尾文案(第一档提醒收尾、第二档强制立即 finish 且禁新增大额生成;经 on_acting 注入进工具结果/拦截语)。 +# 索引 = 升压档位:0=不升压(占位 None)、1=第一档、2=第二档。文案措辞逐档加硬,便于 agent(与测试)区分档位。 +_SOFT_STOP_ESCALATE_L1 = ( + "提醒:本次生成的 ¥ 预算已过软停线,且已连续多轮未收尾。请尽快基于当前工程的最好状态收敛、" + "补齐能过门的最小可玩闭环后调用 finish,不要再展开新的探索或大改。" +) +_SOFT_STOP_ESCALATE_L2 = ( + "预算已严重逼近上限:请【立即】调用 finish 交付当前工程,禁止再新增任何大额生成调用(写源码 / 重开工程)。" + "再不收尾将触发硬熔断、本次作废——现在就 finish。" +) +_SOFT_STOP_ESCALATE_TEXTS = (None, _SOFT_STOP_ESCALATE_L1, _SOFT_STOP_ESCALATE_L2) + # stuck 失败签名归一化:长 hex(≥8 位,如 tool_call_id/session id)与数字串 → "#", # 使「同类错误、仅路径/id/行号不同」仍判为同签名(设计意图=「反复同错」死圈;80009 事故实证: # 旧实现签名恒为状态值 "error",4 次异类临时错也误熔断,且不留工具名/错误文本 → 生产盲区)。 @@ -308,6 +329,15 @@ class CircuitBreakerMiddleware(MiddlewareBase): self._rmb_gate_active = self._pricing_params is not None # 有计费参数才按金额拦,否则降级次数闸 # ¥ 软预算越限标记(soft 档:越限设 True + 放行本次,agent 收 on_system_prompt 强提醒后尽快 finish)。 self.budget_soft_tripped = False + # (工单 c)软停后 finish 逼近升压:连续无-finish 的 acting 轮计数 + 已注入的最高升压档(防同档重复注入)。 + # 计数在 on_reply 维护(软停后每个 ToolCallStartEvent +1;一推理轮无 tool call=finish 尝试则复位); + # 注入在 on_acting(按轮数派生档位,升档时把收尾指令附加到工具结果/并进拦截语)。 + self._softstop_rounds = 0 + self._softstop_level = 0 + # on_reply 轮内状态(判「一轮无 finish」用):本推理轮是否已产 tool call + 是否已见过首个 ModelCallStart + # (首轮无「上一轮」可结算,避免起手误判 finish)。 + self._sr_round_had_tool = False + self._sr_seen_model_call = False # 触发记录(供编排器/调试读)。 self.tripped: dict | None = None @@ -353,14 +383,34 @@ class CircuitBreakerMiddleware(MiddlewareBase): evt_type = type(evt).__name__ - # ── ② budget:每次模型推理计一次 ── + # ── ② budget:每次模型推理计一次(+ 工单 c:软停后 finish 逼近升压的轮边界结算)── if evt_type == "ModelCallStartEvent": + # (工单 c)轮边界:新推理轮开始前先结算上一轮——软停后若上一轮全程无 tool call,即一次 finish + # 尝试(被续修压制或真放行都算「agent 试图收尾」),复位升压计数,给它这轮 finish 后重新计的宽限 + # (续修上限 / 硬地板兜底,复位不会让 ¥ 无界)。仅 soft 档软停后生效,不碰正常路。 + if self.soft_budget and self.budget_soft_tripped and self._sr_seen_model_call \ + and not self._sr_round_had_tool: + if self._softstop_rounds or self._softstop_level: + print( + f"[tier2-circuit] 软停后检出 finish 尝试(上一推理轮无 tool call)→ 复位 finish 逼近升压" + f"(原 rounds={self._softstop_rounds} level={self._softstop_level})", + flush=True, + ) + self._softstop_rounds = 0 + self._softstop_level = 0 + self._sr_seen_model_call = True + self._sr_round_had_tool = False self.model_calls += 1 if self.model_calls > self.max_model_calls: self._trip("budget", f"模型推理次数超预算闸(>{self.max_model_calls})") - # ── ① step_cap:每次工具调用计一步 ── + # ── ① step_cap:每次工具调用计一步(+ 工单 c:记本轮已动手 + 软停后累计无-finish acting 轮)── if evt_type == "ToolCallStartEvent": + self._sr_round_had_tool = True # 本推理轮产了 tool call = 未 finish(轮边界据此判 finish 复位) + if self.soft_budget and self.budget_soft_tripped: + # 软停后又一个 acting 轮(agent 选择继续动手而非 finish);逼近升压的计数面(on_acting 据此派生档位)。 + # 注:批量并发工具会各计一次——烧得越快即升压越早,可接受(与「防继续烧」目标一致)。 + self._softstop_rounds += 1 self.tool_calls += 1 if self.tool_calls > self.max_tool_calls: self._trip("step_cap", f"工具调用步数超硬顶(>{self.max_tool_calls})") @@ -501,32 +551,88 @@ class CircuitBreakerMiddleware(MiddlewareBase): return _wrap() - # ── 软停后工具面收窄:on_acting 拦「新增生成面」工具(裁决:越软停线只许收尾类动作)── + # ── 软停后工具面收窄 + finish 逼近升压:on_acting 拦「新增生成面」工具 + 连续无-finish 递进升压(工单 c)── async def on_acting(self, agent, input_kwargs, next_handler): - """soft 档且已软停时,拦截生成类工具调用(write_file/write_source/scaffold_init),其余透传。 + """soft 档且已软停时:拦截生成类工具(write_file/write_source/scaffold_init)+ 软停后连续 N 轮 + 仍不 finish 则把递进升压的收尾指令注入工具结果,其余透传。 拦截方式(AgentScope 2.0.2 源码核验):on_acting 是单工具 I/O 洋葱钩子(_agent.py:1585), 不调 next_handler、直接 yield 一个 state=ERROR 的 ToolResponse 即为该工具的最终结果——消费方 _execute_tool_call(:1452)据 tool_call.id 组 ToolResultBlock,不依赖 ToolResponse 自身 id。 agent 收到错误文本会转向收尾(finish/check/build 恒放行);若它反复重试同一被拦工具, stuck 闸(连续同签名失败)会兜底熔断。hard 档 / 未软停 → 恒透传,零行为变化。 + + finish 逼近升压(工单 c):软停已由 on_system_prompt 强提醒 + 拦生成类工具,但那是被动系统提示; + A1 T8 实测 M3 可能烧推理不理会(80011 实录=软停触发后仍烧到 step_cap/硬地板才停)。故软停后再数 + 「连续多少个 acting 轮仍不 finish」(_softstop_rounds,on_reply 维护、finish 尝试即复位):达 N 轮升第一档 + (提醒尽快收尾),达 2N 轮升第二档(强制立即 finish、禁新增大额生成)。升压指令经本钩子主动注进工具结果 + (被拦生成类 → 并进拦截语;收尾类透传 → 附加到结果尾部),比被动系统提示更贴脸,逼软停线软着陆。 + 升压只在跨档瞬间注一次(_softstop_escalation_pending 记录已注档,防每轮重复刷屏);_softstop_rounds=0 + (未升档 / 复位后)时本钩子行为与升压前逐字节等价——只拦生成类、收尾类原样透传。 """ if self.soft_budget and self.budget_soft_tripped: + # 按 on_reply 维护的无-finish acting 轮数派生升压档;升档瞬间取一次收尾文案(否则 None,不注入)。 + escalate = self._softstop_escalation_pending() tool_call = input_kwargs.get("tool_call") if isinstance(input_kwargs, dict) else None name = getattr(tool_call, "name", None) if name in self._soft_stop_blocked_tools: + # 生成类工具:拦截(只许收尾);升档时把升压收尾指令并进拦截文本,催得更硬。 + base = ( + "本次生成的 ¥ 预算已过软停线,只允许收尾类动作(finish / check / build / 跑门 / 只读)," + f"不再接受新增生成调用({name} 已拦截)。请基于当前工程的最好状态立即调用 finish 交付。") + text = base if not escalate else base + "\n" + escalate print(f"[tier2-circuit] ¥ 软停后拦截生成类工具 {name}(只许收尾:finish/构建/跑门/只读)。", flush=True) yield ToolResponse( - content=[TextBlock(text=( - "本次生成的 ¥ 预算已过软停线,只允许收尾类动作(finish / check / build / 跑门 / 只读)," - f"不再接受新增生成调用({name} 已拦截)。请基于当前工程的最好状态立即调用 finish 交付。"))], + content=[TextBlock(text=text)], state=ToolResultState.ERROR, ) return + # 收尾类工具:放行;若本轮升档,把升压收尾指令附加到工具结果尾部(agent 下一轮读得到,主动催 finish)。 + async for item in next_handler(**input_kwargs): + if escalate and isinstance(item, ToolResponse): + self._augment_toolresponse(item, escalate) + escalate = None # _acting 仅一个终态 ToolResponse;附加一次即可,防御性置空避免重复 + yield item + return async for item in next_handler(**input_kwargs): yield item + def _softstop_escalation_pending(self) -> Optional[str]: + """按软停后累计的无-finish acting 轮数派生当前升压档;高于已注入档 → 返回该档收尾文案并记录,否则 None。 + + 档位(SOFT_STOP_ESCALATE_ROUNDS=N):< N 轮不升压(0);[N, 2N) 第一档(提醒收尾);≥ 2N 第二档(强制立即 + finish)。只在跨档瞬间返回一次文案(记录 _softstop_level = 已注入的最高档),同档后续轮返回 None 不重复注入。 + """ + n = SOFT_STOP_ESCALATE_ROUNDS + desired = min(2, self._softstop_rounds // n) if n > 0 else 0 + if desired <= self._softstop_level: + return None + self._softstop_level = desired + print( + f"[tier2-circuit] 软停后连续 {self._softstop_rounds} 轮未 finish → 升压第 {desired} 档收尾指令注入。", + flush=True, + ) + return _SOFT_STOP_ESCALATE_TEXTS[desired] + + @staticmethod + def _augment_toolresponse(resp, extra_text: str) -> None: + """把升压收尾指令就地附加到工具结果 content 尾部(保留 state/metadata/id;best-effort,失败原样放行不抛)。 + + 就地改(而非重建 ToolResponse)以保全其余字段;AgentScope 本身也往工具输出追加 + (截断提醒,_agent.py 核验),此处同法追加一条收尾提醒,agent 读工具结果时一并看到。 + """ + try: + reminder = "\n" + extra_text + "" + content = getattr(resp, "content", None) + if isinstance(content, list): + content.append(TextBlock(text=reminder)) # 就地 append(pydantic 不对 in-place list 变更重校验) + elif isinstance(content, str): + resp.content = content + reminder + # 其它/None:不动,静默跳过(不因附加失败而中断收尾放行)。 + except Exception as exc: # noqa: BLE001 —— 附加失败原样放行工具结果,绝不中断生成 + print(f"[tier2-circuit] 升压收尾指令附加失败(原样放行工具结果):{exc}", flush=True) + def _extract_credential_key(self, current_model) -> Optional[str]: """best-effort 从 current_model 抽「本局 per-POST 凭据的 api_key」(治本取价口径,cutover 接缝 B)。