Compare commits
No commits in common. "1745e6c1c812c0d8d270782e6a6a69b08d10a272" and "d68b1c54b011dafbbc29bde58d4194bf045e1172" have entirely different histories.
1745e6c1c8
...
d68b1c54b0
4 changed files with 3 additions and 230 deletions
|
|
@ -151,77 +151,6 @@ def _wire_tool_budget(agent, max_calls: int = 30):
|
||||||
return agent
|
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 + ретрай висящего вызова ──────────
|
# ── e-261014163808-4da3ec: TTFT-watchdog + ретрай висящего вызова ──────────
|
||||||
#
|
#
|
||||||
# Диагноз (13:08 и 16:11 14-10-2026, павшие hour-тики): 9router принимает
|
# Диагноз (13:08 и 16:11 14-10-2026, павшие hour-тики): 9router принимает
|
||||||
|
|
@ -569,7 +498,6 @@ def main():
|
||||||
# Наш end_turn (AfterToolsEvent) кладёт в историю ассистентский
|
# Наш end_turn (AfterToolsEvent) кладёт в историю ассистентский
|
||||||
# текст с итогом — дайджест не пустеет.
|
# текст с итогом — дайджест не пустеет.
|
||||||
agent = _wire_tool_budget(agent, resolve_tool_budget(level))
|
agent = _wire_tool_budget(agent, resolve_tool_budget(level))
|
||||||
agent = _wire_tool_result_cap(agent)
|
|
||||||
result = agent(payload.get("prompt", ""))
|
result = agent(payload.get("prompt", ""))
|
||||||
usage = _extract_usage(agent)
|
usage = _extract_usage(agent)
|
||||||
_emit(payload, {"ok": True, "text": str(result), "usage": usage, "trace_id": trace_id})
|
_emit(payload, {"ok": True, "text": str(result), "usage": usage, "trace_id": trace_id})
|
||||||
|
|
|
||||||
|
|
@ -53,47 +53,6 @@ def _names_re():
|
||||||
re.IGNORECASE)
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
ACTIVE_MIN = 10 # бот писал в чат не позже N минут назад — с ним идёт разговор
|
|
||||||
|
|
||||||
|
|
||||||
def bot_idle_minutes(m: dict):
|
|
||||||
"""Сколько минут бот молчал в этом чате до сообщения (None — не писал вовсе).
|
|
||||||
|
|
||||||
Кейс 2026-10-17 18:40: «Ща обновлю тебе до премиум» (Alex — Vlad'у) →
|
|
||||||
учитель дал P(бот)=0.68 при пороге 0.5, хотя бот молчал в группе 2 часа:
|
|
||||||
«тебе» без разговора с ботом — почти всегда другому человеку."""
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime
|
|
||||||
chat, ts = m.get("chat_id"), m.get("ts") or ""
|
|
||||||
try:
|
|
||||||
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
|
|
||||||
try:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT MAX(timestamp) FROM events WHERE timestamp < ? AND ("
|
|
||||||
"(event_type='OutboundMessage' AND json_extract(data,'$.chat_id')=?) OR "
|
|
||||||
"(event_type IN ('ReplySent','FollowupSent') AND json_extract(data,'$.to_chat')=?))",
|
|
||||||
(ts or "9999", chat, chat)).fetchone()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if not row or not row[0]:
|
|
||||||
return None
|
|
||||||
last = datetime.fromisoformat(row[0])
|
|
||||||
now = datetime.fromisoformat(ts) if ts else datetime.now(last.tzinfo)
|
|
||||||
return max(0.0, (now - last).total_seconds() / 60)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _min_p_for(idle) -> float:
|
|
||||||
"""Порог P(бот): в разговоре с ботом — базовый, бот давно молчит — строже."""
|
|
||||||
if idle is not None and idle <= ACTIVE_MIN:
|
|
||||||
return _min_p()
|
|
||||||
try:
|
|
||||||
return float(os.environ.get("SIMBIOT_ADDRESSING_MIN_P_IDLE", "0.6"))
|
|
||||||
except ValueError:
|
|
||||||
return 0.6
|
|
||||||
|
|
||||||
|
|
||||||
def _min_p() -> float:
|
def _min_p() -> float:
|
||||||
try:
|
try:
|
||||||
return float(os.environ.get("SIMBIOT_ADDRESSING_MIN_P", "0.5"))
|
return float(os.environ.get("SIMBIOT_ADDRESSING_MIN_P", "0.5"))
|
||||||
|
|
@ -152,19 +111,11 @@ def model_verdict(m: dict, inbox: dict):
|
||||||
"sharing links, thinking aloud)")
|
"sharing links, thinking aloud)")
|
||||||
rt = (f" [in reply to: {(m.get('reply_to_text') or m.get('reply_to_desc') or '')[:150]}]"
|
rt = (f" [in reply to: {(m.get('reply_to_text') or m.get('reply_to_desc') or '')[:150]}]"
|
||||||
if m.get("reply_to_message_id") else "")
|
if m.get("reply_to_message_id") else "")
|
||||||
idle = bot_idle_minutes(m)
|
|
||||||
# подсказка — только когда бот давно молчит: в активном диалоге «люди, возможно,
|
|
||||||
# говорят с Коди» завышало P (реплей 17.10: вставленный текст от Claude → «боту»)
|
|
||||||
spoke = ("" if idle is not None and idle <= ACTIVE_MIN else
|
|
||||||
("Cody has not written in this chat yet; " if idle is None else
|
|
||||||
f"Cody last wrote in this chat {int(idle)} minutes ago; ")
|
|
||||||
+ "Cody is NOT part of the current conversation, so «ты/тебе» without "
|
|
||||||
"Cody's name most likely means another human.")
|
|
||||||
state = (f"Group chat '{m.get('chat_title') or ''}': humans talk to each other; Cody "
|
state = (f"Group chat '{m.get('chat_title') or ''}': humans talk to each other; Cody "
|
||||||
"(@juno42bot, called «Коди» in Russian) is an AI assistant bot that must "
|
"(@juno42bot, called «Коди» in Russian) is an AI assistant bot that must "
|
||||||
"speak ONLY when addressed. Talking ABOUT Cody in the third person "
|
"speak ONLY when addressed. Talking ABOUT Cody in the third person "
|
||||||
"(«Коди должен…», «он Коди», «Коди стал…») is people discussing the bot "
|
"(«Коди должен…», «он Коди», «Коди стал…») is people discussing the bot "
|
||||||
"with each other — NOT addressing Cody. " + spoke + "\n"
|
"with each other — NOT addressing Cody.\n"
|
||||||
"Recent messages:\n" + "\n".join(hist) +
|
"Recent messages:\n" + "\n".join(hist) +
|
||||||
f"\nLAST message — {who}{rt}: "
|
f"\nLAST message — {who}{rt}: "
|
||||||
f"{(m.get('text') or '[' + (m.get('kind') or '?') + ']')[:600]}")
|
f"{(m.get('text') or '[' + (m.get('kind') or '?') + ']')[:600]}")
|
||||||
|
|
@ -221,8 +172,8 @@ def triage(model: dict, log=None) -> int:
|
||||||
ok, rule = p >= _min_p(), "core"
|
ok, rule = p >= _min_p(), "core"
|
||||||
else:
|
else:
|
||||||
p = model_verdict(m, inbox)
|
p = model_verdict(m, inbox)
|
||||||
ok, rule = ((p >= _min_p_for(bot_idle_minutes(m)), "decision")
|
ok, rule = ((p >= _min_p(), "decision") if p is not None
|
||||||
if p is not None else (False, "no_decision"))
|
else (False, "no_decision"))
|
||||||
try:
|
try:
|
||||||
from . import kodi_core
|
from . import kodi_core
|
||||||
if kodi_core.mode() != "off":
|
if kodi_core.mode() != "off":
|
||||||
|
|
|
||||||
|
|
@ -154,18 +154,5 @@ check("приём: reply на бота", ib[mid2].get("reply_to_is_bot") is True
|
||||||
and ib[mid2].get("reply_to_user_id") == BOT_ID)
|
and ib[mid2].get("reply_to_user_id") == BOT_ID)
|
||||||
check("приём: обычный текст без упоминания", tg_inbox._mentions_bot({"text": "привет"}) is False)
|
check("приём: обычный текст без упоминания", tg_inbox._mentions_bot({"text": "привет"}) is False)
|
||||||
|
|
||||||
# ── бот молчит давно → строже (кейс 17.10: «Ща обновлю тебе до премиум» Vlad'у) ──
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
os.environ.pop("SIMBIOT_ADDRESSING_MIN_P_IDLE", None)
|
|
||||||
check("порог: в разговоре с ботом (≤10 мин) — базовый 0.5", addressing._min_p_for(3) == 0.5)
|
|
||||||
check("порог: бот молчит давно — 0.6", addressing._min_p_for(120) == 0.6
|
|
||||||
and addressing._min_p_for(None) == 0.6)
|
|
||||||
t0 = datetime.now(timezone.utc)
|
|
||||||
es.append_event("outbound", f"{G}:900", "OutboundMessage", {"chat_id": G, "message_id": 900, "text": "ответ"})
|
|
||||||
mm = {"chat_id": G, "ts": (t0 + timedelta(minutes=30)).isoformat()}
|
|
||||||
idle = addressing.bot_idle_minutes(mm)
|
|
||||||
check("молчание бота считается по журналу исходящих", idle is not None and 29 <= idle <= 31, idle)
|
|
||||||
check("в другом чате бот не писал → None", addressing.bot_idle_minutes({"chat_id": -5, "ts": mm["ts"]}) is None)
|
|
||||||
|
|
||||||
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
|
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
|
||||||
sys.exit(1 if fails else 0)
|
sys.exit(1 if fails else 0)
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
# -*- 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