feat(tier2): 软停后 finish 逼近递进升压(工单 c)——连续 N 轮不 finish 经 on_acting 注入升压收尾指令逼软着陆

软停(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 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-04 00:49:35 -07:00
parent 2e4dd56b76
commit eb135a9041
2 changed files with 327 additions and 7 deletions

View File

@ -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

View File

@ -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 本身也往工具输出追加 <system-reminder>
(截断提醒,_agent.py 核验),此处同法追加一条收尾提醒,agent 读工具结果时一并看到
"""
try:
reminder = "\n<system-reminder>" + extra_text + "</system-reminder>"
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)。