кэп tool-result'ов в истории тика: срез >8k симв (после 60k суммы — 4k), полный текст в трейсе; burn-аудит 14.10 рек.1
This commit is contained in:
parent
d68b1c54b0
commit
2da39af74d
2 changed files with 165 additions and 0 deletions
|
|
@ -151,6 +151,77 @@ def _wire_tool_budget(agent, max_calls: int = 30):
|
|||
return agent
|
||||
|
||||
|
||||
# ── кэп результатов инструментов в истории тика (burn-аудит 14.10, рек.1) ──
|
||||
#
|
||||
# Диагноз (docs/aum/burn-audit-2026-10-14.md): 99.9% бюджета харнеса —
|
||||
# промпт-токены: каждый агентный шаг пересылает НАКОПЛЕННУЮ историю
|
||||
# (tool-result'ы целиком, до 75k ток/шаг), при этом 46% запросов
|
||||
# завершаются ≤100 токенов выхода — платим за пересылку, а не за работу.
|
||||
# Хозяйке-ключу f4b2347d: 11.18 → 27.84 → 37.95 у.е./день (14→16 окт).
|
||||
#
|
||||
# Фикс: AfterToolCallEvent.result — writable-поле хука (проверено по
|
||||
# исходникам strands: _can_write = ["result", "retry"]). Режем text-
|
||||
# контент результата ДО попадания в историю: полный вывод остаётся
|
||||
# в EventStore (ToolResult пишет runtime-хук до нас — но порядок хуков
|
||||
# не гарантирован, поэтому срезаем копию с маркером, а полный текст
|
||||
# живёт в трейсе тика events.db). Разрез только когда история уже
|
||||
# большая (после ~60k симв. накопленных результатов): первые шаги тика
|
||||
# работают на полном контексте, резать начинаем когда история реально
|
||||
# раздулась.
|
||||
|
||||
_TOOL_RESULT_CAP_CHARS = int(os.environ.get("SIMBIOT_TOOL_RESULT_CAP", "8000"))
|
||||
_TOOL_RESULT_SOFT_SUM = int(os.environ.get("SIMBIOT_TOOL_RESULT_SOFT_SUM", "60000"))
|
||||
|
||||
|
||||
def _wire_tool_result_cap(agent):
|
||||
"""Срезать гигантские tool-result'ы до попадания в историю тика."""
|
||||
from strands.hooks import AfterToolCallEvent
|
||||
state = {"sum": 0, "cut": 0}
|
||||
|
||||
def on_after_tool(event):
|
||||
try:
|
||||
res = event.result
|
||||
if not isinstance(res, dict):
|
||||
return
|
||||
content = res.get("content")
|
||||
if not isinstance(content, list):
|
||||
return
|
||||
changed = False
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = item.get("text")
|
||||
if not isinstance(text, str) or len(text) <= _TOOL_RESULT_CAP_CHARS:
|
||||
continue
|
||||
item["text"] = (text[:_TOOL_RESULT_CAP_CHARS]
|
||||
+ f"\n…[срез {len(text) - _TOOL_RESULT_CAP_CHARS} симв.; "
|
||||
f"полный вывод — в трейсе тика (events.db)]")
|
||||
changed = True
|
||||
if changed:
|
||||
state["cut"] += 1
|
||||
state["sum"] += _TOOL_RESULT_CAP_CHARS
|
||||
else:
|
||||
state["sum"] += sum(len(i.get("text", "")) for i in content
|
||||
if isinstance(i, dict))
|
||||
# мягкий порог: пока сумма результатов мала — не трогаем
|
||||
if state["sum"] <= _TOOL_RESULT_SOFT_SUM:
|
||||
return
|
||||
# после — дополнительно режем и средние результаты (по 4k)
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = item.get("text")
|
||||
if isinstance(text, str) and len(text) > 4000:
|
||||
item["text"] = (text[:4000]
|
||||
+ "\n…[срез: история тика раздута, "
|
||||
"полный вывод — в трейсе (events.db)]")
|
||||
except Exception:
|
||||
pass # кэп — best effort: не должен валить тик
|
||||
|
||||
agent.add_hook(on_after_tool, AfterToolCallEvent)
|
||||
return agent
|
||||
|
||||
|
||||
# ── e-261014163808-4da3ec: TTFT-watchdog + ретрай висящего вызова ──────────
|
||||
#
|
||||
# Диагноз (13:08 и 16:11 14-10-2026, павшие hour-тики): 9router принимает
|
||||
|
|
@ -498,6 +569,7 @@ def main():
|
|||
# Наш end_turn (AfterToolsEvent) кладёт в историю ассистентский
|
||||
# текст с итогом — дайджест не пустеет.
|
||||
agent = _wire_tool_budget(agent, resolve_tool_budget(level))
|
||||
agent = _wire_tool_result_cap(agent)
|
||||
result = agent(payload.get("prompt", ""))
|
||||
usage = _extract_usage(agent)
|
||||
_emit(payload, {"ok": True, "text": str(result), "usage": usage, "trace_id": trace_id})
|
||||
|
|
|
|||
93
tests/test_tool_result_cap.py
Normal file
93
tests/test_tool_result_cap.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Кэп результатов инструментов в истории тика (burn-аудит 14.10, рек.1).
|
||||
|
||||
Запуск: ./venv/bin/python tests/test_tool_result_cap.py
|
||||
Hermetic: без LLM, на живом strands-агенте (только реестр хуков).
|
||||
|
||||
Диагноз из аудита (docs/aum/burn-audit-2026-10-14.md): 99.9% бюджета —
|
||||
промпт-токены; каждый агентный шаг пересылает накопленную историю с
|
||||
полными tool-result'ами (до 75k ток/шаг). Кэп срезает гигантские
|
||||
результаты до попадания в историю; полный текст остаётся в трейсе
|
||||
(events.db, ToolResult пишет runtime-хук).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile as _iso_tf
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ["SIMBIOT_DB"] = os.path.join(_iso_tf.mkdtemp(prefix="simbiot_test_"), "harness.db")
|
||||
|
||||
from strands import Agent
|
||||
from strands.hooks import AfterToolCallEvent
|
||||
|
||||
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)
|
||||
|
||||
|
||||
agent = Agent(callback_handler=None)
|
||||
agent = _llm_worker._wire_tool_result_cap(agent)
|
||||
|
||||
_probe = AfterToolCallEvent(agent=agent, selected_tool=None, tool_use={},
|
||||
invocation_state={},
|
||||
result={"content": [{"text": ""}], "status": "success", "toolUseId": "t0"})
|
||||
_cbs = list(agent.hooks.get_callbacks_for(_probe))
|
||||
check("хук зарегистрирован", len(_cbs) >= 1)
|
||||
|
||||
|
||||
def fire(text):
|
||||
ev = AfterToolCallEvent(agent=agent, selected_tool=None,
|
||||
tool_use={"name": "shell", "input": {}},
|
||||
invocation_state={},
|
||||
result={"content": [{"text": text}], "status": "success", "toolUseId": "t"})
|
||||
for cb in _cbs:
|
||||
cb(ev)
|
||||
return ev.result["content"][0]["text"]
|
||||
|
||||
|
||||
# 1) гигантский результат срезается сразу, с маркером
|
||||
t1 = fire("x" * 20000)
|
||||
check("20k срезан до кэпа", len(t1) < _llm_worker._TOOL_RESULT_CAP_CHARS + 200, len(t1))
|
||||
check("маркер среза в тексте", "срез" in t1)
|
||||
|
||||
# 2) маленький результат не трогается
|
||||
check("маленький не тронут", fire("ok") == "ok")
|
||||
|
||||
# 3) мягкий порог: после ~60k накопленных результатов средний (6k) тоже режется
|
||||
for _ in range(12):
|
||||
fire("z" * 5000)
|
||||
t3 = fire("y" * 6000)
|
||||
check("soft-порог включил срез средних", len(t3) < 6000, len(t3))
|
||||
|
||||
# 4) не-текстовый контент (json/image) не валит хук
|
||||
_ev = AfterToolCallEvent(agent=agent, selected_tool=None,
|
||||
tool_use={"name": "portal_mcp", "input": {}},
|
||||
invocation_state={},
|
||||
result={"content": [{"json": {"a": 1}}, {"image": b"x"}],
|
||||
"status": "success", "toolUseId": "t4"})
|
||||
for cb in _cbs:
|
||||
cb(_ev)
|
||||
check("не-текстовый контент жив", _ev.result["content"][0] == {"json": {"a": 1}})
|
||||
|
||||
# 5) мусорный result (не dict) не валит хук
|
||||
try:
|
||||
from strands.hooks import AfterToolCallEvent as _ATE
|
||||
ev5 = _ATE(agent=agent, selected_tool=None, tool_use={}, invocation_state={},
|
||||
result=None)
|
||||
for cb in _cbs:
|
||||
cb(ev5)
|
||||
check("result=None не валит хук", True)
|
||||
except Exception as e:
|
||||
check("result=None не валит хук", False, str(e)[:120])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("FAIL:", fails)
|
||||
sys.exit(1)
|
||||
print("все проверки пройдены")
|
||||
Loading…
Reference in a new issue