diff --git a/harness/_llm_worker.py b/harness/_llm_worker.py index 3e54113..bb51752 100644 --- a/harness/_llm_worker.py +++ b/harness/_llm_worker.py @@ -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}) diff --git a/tests/test_tool_result_cap.py b/tests/test_tool_result_cap.py new file mode 100644 index 0000000..a634ee0 --- /dev/null +++ b/tests/test_tool_result_cap.py @@ -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("все проверки пройдены")