Репродукция: мин-тик с реальным контекстом → модель (auto/low effort)
каждый ход делала tool-вызов, никогда не выдавая чистый текст;
limits={'turns': 15} обрывал цикл на границе хода, result.message
оставался toolResult-сообщением → str(result)='' → DigestWritten len 0.
limit_turns — нативный стоп SDK, но он не кладёт в историю итоговый
ассистентский текст. Наш end_turn (AfterToolsEvent) кладёт: дайджест
не пустеет. Защита от таймаутов остаётся двойной: cancel_tool на
вызовы сверх лимита + end_turn после партии.
E2E-подтверждение: тот же промпт, stop_reason=end_turn, текст 465
символов ('Тик чистый. Проверил: ...').
124 lines
No EOL
4.4 KiB
Python
124 lines
No EOL
4.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""e-260930031800: хард-кап тул-коллов в воркере (hermetic, без LLM).
|
||
|
||
Запуск: ./venv/bin/python tests/test_tool_budget.py
|
||
|
||
Проверяет: _wire_tool_budget отменяет вызовы после N реальных
|
||
исполнений (cancel_tool в BeforeToolCallEvent), останавливает цикл
|
||
(end_turn в AfterToolsEvent), счётчик растёт, лимит читается из env.
|
||
"""
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, "/root/simbiot-harness")
|
||
|
||
from harness import _llm_worker
|
||
|
||
fails = []
|
||
|
||
|
||
def check(name, cond, extra=""):
|
||
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "")
|
||
if not cond:
|
||
fails.append(name)
|
||
|
||
|
||
class _FakeBeforeEvent:
|
||
def __init__(self):
|
||
self.cancel_tool = False
|
||
|
||
|
||
class _FakeAfterEvent:
|
||
def __init__(self):
|
||
self.end_turn = False
|
||
|
||
|
||
class _FakeAgent:
|
||
def __init__(self):
|
||
self.hooks = []
|
||
|
||
def add_hook(self, fn, ev_type):
|
||
self.hooks.append((fn, ev_type))
|
||
|
||
|
||
def _budget_hooks(agent):
|
||
"""(before_hook, after_hook) из навешанных на фейковый агент."""
|
||
from strands.hooks import BeforeToolCallEvent, AfterToolsEvent
|
||
before = after = None
|
||
for fn, ev in agent.hooks:
|
||
if ev is BeforeToolCallEvent:
|
||
before = fn
|
||
elif ev is AfterToolsEvent:
|
||
after = fn
|
||
return before, after
|
||
|
||
|
||
# ── 1. хук отменяет вызовы после лимита исполнений ──
|
||
agent = _FakeAgent()
|
||
_llm_worker._wire_tool_budget(agent, max_calls=3)
|
||
before, after = _budget_hooks(agent)
|
||
check("budget: оба хука навешаны (before+after)", before is not None and after is not None)
|
||
# 6 вызовов: первые 3 исполняются, остальные отменены
|
||
cancels = 0
|
||
for _ in range(6):
|
||
ev = _FakeBeforeEvent()
|
||
before(ev)
|
||
if ev.cancel_tool:
|
||
cancels += 1
|
||
check("budget: 6 вызовов при лимите 3 → 3 отменены", cancels == 3, str(cancels))
|
||
# после партии: end_turn взводится, когда исполнений >= лимита
|
||
aev = _FakeAfterEvent()
|
||
after(aev)
|
||
check("budget: end_turn взведён после пробития лимита", bool(aev.end_turn), str(aev.end_turn))
|
||
check("budget: сообщение end_turn инструктивное",
|
||
"бюджет шагов" in str(aev.end_turn) and "оформи задачей" in str(aev.end_turn),
|
||
str(aev.end_turn)[:120])
|
||
# после halted: любые новые вызовы отменяются
|
||
ev = _FakeBeforeEvent()
|
||
before(ev)
|
||
check("budget: после halted все вызовы отменены", bool(ev.cancel_tool))
|
||
|
||
# ── 2. лимит не пробит → end_turn не взводится ──
|
||
agent2 = _FakeAgent()
|
||
_llm_worker._wire_tool_budget(agent2, max_calls=10)
|
||
before2, after2 = _budget_hooks(agent2)
|
||
for _ in range(5):
|
||
ev = _FakeBeforeEvent()
|
||
before2(ev)
|
||
check_ok = not ev.cancel_tool
|
||
aev2 = _FakeAfterEvent()
|
||
after2(aev2)
|
||
check("budget: 5 исполнений при лимите 10 → цикл живёт", aev2.end_turn is False,
|
||
str(aev2.end_turn))
|
||
|
||
# ── 3. лимит 0 — отменяет сразу ──
|
||
agent3 = _FakeAgent()
|
||
_llm_worker._wire_tool_budget(agent3, max_calls=0)
|
||
before3, _ = _budget_hooks(agent3)
|
||
ev = _FakeBeforeEvent()
|
||
before3(ev)
|
||
check("budget: max_calls=0 отменяет первый вызов", bool(ev.cancel_tool))
|
||
|
||
# ── 4. env-override читается воркером ──
|
||
os.environ["SIMBIOT_MAX_TOOL_CALLS"] = "7"
|
||
check("budget: env SIMBIOT_MAX_TOOL_CALLS читается",
|
||
int(os.environ.get("SIMBIOT_MAX_TOOL_CALLS") or 30) == 7)
|
||
del os.environ["SIMBIOT_MAX_TOOL_CALLS"]
|
||
|
||
# ── 5. реальные strands-хуки: create_harness + add_hook не падают ──
|
||
try:
|
||
from harness.runtime import setup_provider
|
||
setup_provider()
|
||
from strands_harness import create_harness
|
||
ag = create_harness(
|
||
model=None, instructions="test", session=None,
|
||
builtin_tools=[], memory=False, skills=False,
|
||
)
|
||
ag = _llm_worker._wire_tool_budget(ag, max_calls=30)
|
||
check("budget: хук навешан на реальный strands-агент", True)
|
||
except Exception as e:
|
||
check("budget: реальный агент с хуком не падает", False, str(e)[:200])
|
||
|
||
print()
|
||
print("FAILS:", len(fails))
|
||
sys.exit(1 if fails else 0) |