diff --git a/README.md b/README.md index b119e05..ea10491 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,6 @@ journalctl -u simbiot-harness -f Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`. ## Без-LLM команды бота (t-260929210755) - `/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model: их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`), минутный LLM-контекст на команды не тратится. Алиасы (латиница/транслит, @@ -106,6 +105,28 @@ tg-поллера или вручную: `build_context` не показывает команды мин-уровню — даже без ответа (гонка на границе тика / лежащий TG не заставят минуту тратить LLM). +## RAG в тике (t-260929212316) + +Каждый LLM-тик получает инструмент `rag_lookup` — семантический поиск +по базе знаний кооператива (вендор `/root/rag`: fastembed bge-small + +sqlite, rag.db ~12.5k чанков по контексту/навыкам, hermes.db ~54k). +Лимиты зашиты в инструмент, не в промпт: **1 запрос на тик**, k<=5, +таймаут 20с, сабпроцесс `rag-venv` (ONNX-модель не грузится в процесс +воркера), упавший вендор не валит тик. Трейс — `RagQueried` в сторе. +Ручной вызов: `cli.py rag-query "вопрос" -k 5 [--db ...] [--json]`. +Выключить: `RAG_DISABLED=1`. + +## Хард-кап шагов тика (e-260930031800) + +Мин-воркер уходил в исследовательские цепочки (90+ tool-вызовов) и умирал +по таймауту 300с — CycleError, эскалации min→hour каждую ~50 минут. +Защита в `_llm_worker`: `BeforeToolCallEvent.cancel_tool` отменяет +вызова сверх лимита, `AfterToolsEvent.end_turn` останавливает цикл +агента ПОСЛЕ партии инструментов (без него модель шлёт шаги снова — +e2e: 20 отмен подряд), `limits={"turns": N}` — нативный кап ходов SDK. +Лимиты: `SIMBIOT_MAX_TOOL_CALLS` (по умолчанию 30, счёт по реальным +исполнениям), `SIMBIOT_MAX_TURNS` (по умолчанию 15). + ## Дашборд задач Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов + diff --git a/cli.py b/cli.py index 45a62e2..a7f67aa 100644 --- a/cli.py +++ b/cli.py @@ -80,6 +80,12 @@ def main(): p_init = sub.add_parser("init", help="инициализация стора") p_init.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива") + p_rag = sub.add_parser("rag-query", help="семантический поиск по базе кооператива (вендор /root/rag)") + p_rag.add_argument("question") + p_rag.add_argument("-k", type=int, default=5, help="сколько чанков (<=5)") + p_rag.add_argument("--db", default=None, help="база RAG (по умолчанию rag.db; hermes.db — общая)") + p_rag.add_argument("--json", action="store_true", help="вывести машинно-читаемо") + args = ap.parse_args() if args.cmd == "init": @@ -169,6 +175,17 @@ def main(): print("(у бота нет зарегистрированных команд)") for name, desc in cmds: print(f"/{name} — {desc}") + elif args.cmd == "rag-query": + # без init_db: RAG не пишет в read model, только трейс по уровню env + from harness import rag_lookup + chunks = rag_lookup.rag_query(args.question, k=args.k, db=args.db) + if args.json: + print(json.dumps(chunks, ensure_ascii=False)) + elif not chunks: + print("(ничего не найдено или вендор недоступен)") + for c in chunks: + print(f"\n═══ {c['score']:.3f} | {c['source']} ═══") + print(c["text"][:rag_lookup.TIMEOUT * 100 or 900]) elif args.cmd == "escalate": es.init_db() eid, to_level = commands.raise_escalation( diff --git a/harness/_llm_worker.py b/harness/_llm_worker.py index 85d0d12..8a7318a 100644 --- a/harness/_llm_worker.py +++ b/harness/_llm_worker.py @@ -12,6 +12,80 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +def _rag_tool_for(level: str, trace_id: str): + """RAG-инструмент тика (t-260929212316): семантический поиск по базе + кооператива (вендор /root/rag, fastembed bge-small + sqlite). + + Ограничения зашиты в сам инструмент (не в промпт): + 1 запрос на тик, k<=5, таймаут 20с, сабпроцесс rag-venv — упавший + вендор не валит тик. RAG_DISABLED=1 выключает. + """ + from strands import tool as strands_tool + from harness import rag_lookup as _rag + state = {"calls": 0} + + @strands_tool + def rag_lookup(question: str) -> str: + """Семантический поиск по базе знаний кооператива: контекст, проекты, + порталы, встречи, документы. Возвращает релевантные фрагменты. + Один вызов на тик — формулируй запрос целиком.""" + if state["calls"] >= 1: + return "лимит: 1 RAG-запрос на тик (использован)" + state["calls"] += 1 + chunks = _rag.rag_query(question, k=_rag.MAX_K, + level=level, trace_id=trace_id) + if not chunks: + return "(поиск ничего не вернул — отвечай из контекста тика)" + return _rag.format_chunks(chunks) + + return rag_lookup + + +def _wire_tool_budget(agent, max_calls: int = 30): + """Хард-кап тул-коллов на тик (e-260930031800: мин-воркер уходил в + 90+ шагов исследования и умирал по таймауту 300с). + + Механизм (e2e-проверено): BeforeToolCallEvent.cancel_tool отменяет + каждый вызов сверх лимита; AfterToolsEvent.end_turn останавливает + цикл агента ПОСЛЕ партии инструментов — модель больше не + вызывается, финальный текст тика — наш отчёт о недовыполненном. + Без end_turn модель «настаивает»: в тесте слала 20 шагов подряд, + получая отказы (дёшево, но цикл не кончается). + + Партия: 20 параллельных tool-вызовов за один ход. Лимит считается + по РЕАЛЬНЫМ исполнениям (отменённые не съедают бюджет — модель + не может заблокировать тик потоком отмен), поэтому первые + max_calls вызова партии выполняются, хвост отменяется, затем + цикл останавливается с итоговым текстом. + """ + from strands.hooks import AfterToolsEvent, BeforeToolCallEvent + state = {"executed": 0, "halted": False} + + def on_before_tool(event): + if state["halted"] or state["executed"] >= max_calls: + event.cancel_tool = ( + f"тиковый бюджет шагов исчерпан ({max_calls} tool-вызовов): " + f"вызов отменён; новые вызовы инструментов отключены до конца тика" + ) + return + state["executed"] += 1 + + def on_after_tools(event): + # партия инструментов закончилась; лимит пробит → стоп-цикл + # (end_turn: цикл агента не зовёт модель повторно) + if not state["halted"] and state["executed"] >= max_calls: + state["halted"] = True + event.end_turn = ( + f"тик завершён: бюджет шагов исчерпан ({max_calls} tool-вызовов). " + f"Выше — результат того, что успело выполниться. Не успевшее " + f"оформи задачей (cli.py task) или продолжи в следующем тике." + ) + + agent.add_hook(on_before_tool, BeforeToolCallEvent) + agent.add_hook(on_after_tools, AfterToolsEvent) + return agent + + def build_level_model_from_payload(payload: dict): """Модель для воркера — та же сборка, что и в runtime.build_level_model: 9router понимает chat/completions (usage там же), Responses API — шим без usage.""" @@ -138,18 +212,36 @@ def main(): trace_id = payload.get("trace_prefix") or "tick" level = payload.get("level") or "?" try: + # RAG-инструмент тика (t-260929212316): у LLM появляется rag_lookup — + # поиск по базе кооператива; лимиты (1 запрос/tick, k<=5, таймаут) + # зашиты в инструмент. Уровень без shell получает только его. + tools = [] + try: + tools.append(_rag_tool_for(level, trace_id)) + except Exception: + pass # RAG — best effort: вендор не должен валить тик agent = create_harness( model=build_level_model_from_payload(payload), effort="auto", # effort уже в параметрах модели instructions=payload.get("instructions", ""), session=None, + tools=tools, builtin_tools=("read", "write", "edit", "shell") if payload.get("can_shell") else [], memory=False, skills=False, ) agent = _wire_trace_hooks(agent, es, trace_id) agent = _wire_budget_hooks(agent, level, trace_id) - result = agent(payload.get("prompt", "")) + # хард-кап шагов (e-260930031800): двойная защита. + # 1) limits={"turns": N} — нативный механизм SDK: цикл агента + # останавливается на границе хода (stop_reason="limit_turns"), + # модель не может уйти в бесконечную цепочку tool-вызовов. + # 2) хук cancel_tool — страховка на случай параллельного блока + # (20 вызовов одним блоком: turns их не режет, хук отменяет). + max_turns = int(os.environ.get("SIMBIOT_MAX_TURNS") or 15) + agent = _wire_tool_budget( + agent, int(os.environ.get("SIMBIOT_MAX_TOOL_CALLS") or 30)) + result = agent(payload.get("prompt", ""), limits={"turns": max_turns}) usage = _extract_usage(agent) _emit(payload, {"ok": True, "text": str(result), "usage": usage, "trace_id": trace_id}) except Exception as e: diff --git a/harness/levels.py b/harness/levels.py index 85723cb..d454255 100644 --- a/harness/levels.py +++ b/harness/levels.py @@ -49,6 +49,10 @@ LEVELS = { "cli.py tg-reply \"текст\" (тон: кратко, по-русски, тёпло; можно " "выполнить просьбу инструментами и ответить результатом). Быстрые " "мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. " + "У тебя есть инструмент rag_lookup — семантический поиск по базе " + "знаний кооператива (контекст, проекты, порталы, встречи, документы). " + "Если вопрос про кооператив/продукты/документы — сначала rag_lookup " + "(1 запрос за тик, формулируй целиком), потом отвечай по найденному. " "ВАЖНО: НЕ исследуй исходники харнеса (scheduler.py, commands.py, es.py, " "isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. " "Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. " diff --git a/harness/rag_lookup.py b/harness/rag_lookup.py new file mode 100644 index 0000000..811da78 --- /dev/null +++ b/harness/rag_lookup.py @@ -0,0 +1,123 @@ +"""Семантический поиск по базе кооператива для тиков харнеса (t-260929212316). + +Вендор: /root/rag (fastembed bge-small-en-v1.5 + sqlite), базы: +- rag.db — /root/coop/context + agent-skills + PORTAL_API.md (12.5k чанков) +- hermes.db — 54k чанков общего корпуса (продукты, код, история) +Каждый запрос — отдельный сабпроцесс /root/rag-venv/bin/python rag.py query: +модель embedding (ONNX, ~130МБ) тяжёлая для импорта в процесс воркера, +изолированный сабпроцесс не раздувает память LLM-тика и не ломает +глобальный рантайм Strands. + +Лимиты (по постановке Алекса): 1 запрос на тик, k<=5, таймаут 20с, +RAG_DISABLED=1 выключает. Трейс запроса пишется в event store +(RagQueried) — часовой уровень видит в событиях, что мин-пульс +ходил в знания кооператива. +""" +import json +import os +import subprocess + +_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_RAG_VENV_PY = "/root/rag-venv/bin/python" +_RAG_SCRIPT = "/root/rag/rag.py" + +# База по умолчанию: корпус кооператива (12.5k чанков) — то, что просил Алекс +# для мин-пульса; hermes.db (54k) — опцией RAG_DB для верхних уровней. +DEFAULT_DB = "/root/rag/rag.db" + +# Лимиты на тик: 1 запрос, k<=5, таймаут 20с (эмбеддинг ~2.7с + запас) +MAX_K = 5 +TIMEOUT = 20 + + +def _parse_output(out: str) -> list: + """Разбор вывода rag.py: чанки, разделённые '═══ score | file#N ═══'.""" + chunks, cur = [], None + for line in out.splitlines(): + if line.startswith("═══"): + if cur is not None: + chunks.append(cur) + head = line.strip("═ ").strip() + try: + score, src = head.split("|", 1) + score = float(score.strip()) + except ValueError: + score, src = 0.0, head + cur = {"score": score, "source": src.strip(), "text": ""} + elif cur is not None: + cur["text"] += line + "\n" + if cur is not None: + chunks.append(cur) + for c in chunks: + c["text"] = c["text"].strip() + return chunks + + +def _trace(level, trace_id, payload): + """Трейс RAG-запроса в event store — spend/observability едины с LLM-тиками.""" + if not (level and trace_id): + return + try: + from . import es + seq = 1 + rows = [] # считаем версию по aggregate_id — append_event сам валиден + es.append_event("trace", f"{trace_id}:rag{seq}", "RagQueried", { + **payload, "level": level, + }) + except Exception: + pass # трейс — best effort, не валить тик + + +def rag_query(question: str, k: int = MAX_K, level: str = None, + trace_id: str = None, db: str = None, timeout: int = TIMEOUT) -> list: + """Семантический поиск по базе кооператива. Возвращает список чанков. + + [{score, source, text}] — отсортировано по релевантности. Пустой список — + ничего не найдено или вендор упал; текст ошибки не поднимается наружу: + мин-тик не должен падать из-за RAG. + """ + if os.environ.get("RAG_DISABLED"): + return [] + question = (question or "").strip() + if not question: + return [] + k = max(1, min(int(k or MAX_K), MAX_K)) + env = dict(os.environ) + env["RAG_DB"] = db or DEFAULT_DB + if not os.access(_RAG_VENV_PY, os.X_OK) or not os.path.exists(_RAG_SCRIPT): + return [] + try: + proc = subprocess.run( + [_RAG_VENV_PY, _RAG_SCRIPT, "query", question, "-k", str(k)], + capture_output=True, text=True, timeout=timeout or TIMEOUT, + cwd=_HARNESS_DIR, env=env, + ) + chunks = _parse_output(proc.stdout) + except subprocess.TimeoutExpired: + _trace(level, trace_id, {"query": question[:300], "k": k, + "error": f"timeout after {timeout}s"}) + return [] + except Exception as e: + _trace(level, trace_id, {"query": question[:300], "k": k, + "error": str(e)[:200]}) + return [] + _trace(level, trace_id, {"query": question[:300], "k": k, + "results": len(chunks), + "top": [c["source"] for c in chunks[:3]]}) + return chunks + + +def format_chunks(chunks: list, max_chars: int = 6000) -> str: + """Чанки → компактный текст для контекста тика.""" + out = [] + used = 0 + for c in chunks: + body = c["text"] + if not body: + continue + line = f"═══ {c['score']:.3f} | {c['source']} ═══\n{body}" + if used + len(line) > max_chars: + break + out.append(line) + used += len(line) + return "\n\n".join(out) \ No newline at end of file diff --git a/tests/test_rag_lookup.py b/tests/test_rag_lookup.py new file mode 100644 index 0000000..737c6b1 --- /dev/null +++ b/tests/test_rag_lookup.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""t-260929212316: офлайн-тесты RAG-обёртки (hermetic: копия БД, вендор мокнут). + +Запуск: ./venv/bin/python tests/test_rag_lookup.py + +Проверяет: парсер вывода rag.py, лимит k, пустые вопросы, флаг RAG_DISABLED, +лимит 1 вызова на тик в инструменте воркера (без реального LLM-вызова — +strands-агент не создаётся, проверяем замыкание _rag_tool_for через +мок strands_tool), форматирование чанков, трейс RagQueried в сторе. +""" +import json +import os +import sqlite3 +import sys +import tempfile + +# ── изоляция: копия боевой БД через online-backup (WAL-консистентно) ── +_SRC = "/root/simbiot-harness/harness/harness.db" +_DST = "/tmp/simbiot-test-rag-lookup.db" +for p in (_DST, _DST + "-wal", _DST + "-shm"): + if os.path.exists(p): + os.unlink(p) +s, d = sqlite3.connect(_SRC), sqlite3.connect(_DST) +s.backup(d) +d.close(); s.close() +os.environ["SIMBIOT_DB"] = _DST +sys.path.insert(0, "/root/simbiot-harness") + +from harness import rag_lookup +from harness import es + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "") + if not cond: + fails.append(name) + + +# ── 1. парсер вывода rag.py ── +SAMPLE = """ +═══ 0.842 | COOP_CONTEXT.md#1 ═══ +### Миссия «42» +Все миссии одинаково важны. + +═══ 0.700 | PORTAL_API.md#3 ═══ +GET /api/v1/tasks +""" +chunks = rag_lookup._parse_output(SAMPLE) +check("parse: 2 чанка", len(chunks) == 2, str(len(chunks))) +check("parse: score float", abs(chunks[0]["score"] - 0.842) < 1e-6, str(chunks[0])) +check("parse: source", chunks[0]["source"] == "COOP_CONTEXT.md#1", chunks[0]["source"]) +check("parse: text без хвостов", chunks[0]["text"] == "### Миссия «42»\nВсе миссии одинаково важны.", repr(chunks[0]["text"])) +check("parse: пустой вывод → []", rag_lookup._parse_output("") == []) +check("parse: мусор без заголовков → []", rag_lookup._parse_output("just text\nno markers") == []) + +# ── 2. форматирование ── +fmt = rag_lookup.format_chunks(chunks) +check("format: содержит score", "0.842" in fmt, fmt[:100]) +check("format: содержит source", "COOP_CONTEXT.md#1" in fmt, fmt[:100]) +check("format: кап длины", len(rag_lookup.format_chunks(chunks, max_chars=30)) <= 60, + "max_chars не работает") +check("format: пустой список → пустая строка", rag_lookup.format_chunks([]) == "") + +# ── 3. лимиты rag_query без вендора (вендор-скрипт подменён) ── +os.environ["RAG_DISABLED"] = "1" +check("rag_query: RAG_DISABLED → []", rag_lookup.rag_query("тест") == []) +del os.environ["RAG_DISABLED"] +check("rag_query: пустой вопрос → []", rag_lookup.rag_query(" ") == []) + +# подменяем вендор на несуществующий путь — тишина вместо исключения +_orig_venv, rag_lookup._RAG_VENV_PY = rag_lookup._RAG_VENV_PY, "/nonexistent/python" +try: + r = rag_lookup.rag_query("тест") + check("rag_query: мёртвый вендор → [] (не raises)", r == [], str(r)) +finally: + rag_lookup._RAG_VENV_PY = _orig_venv + +# ── 4. реальный вендор: если /root/rag жив — один живой запрос ── +if os.access(rag_lookup._RAG_VENV_PY, os.X_OK) and os.path.exists(rag_lookup._RAG_SCRIPT): + r = rag_lookup.rag_query("кооператив 42 миссия", k=3, level="hour", trace_id="rag-test-1") + check("rag_query: живой вендор вернул чанки", len(r) > 0, f"{len(r)} chunks") + check("rag_query: k<=3 соблюдён", len(r) <= 3, str(len(r))) + check("rag_query: score в диапазоне", all(0 <= c["score"] <= 1.5 for c in r), + str([c["score"] for c in r])) + # трейс RagQueried записан в стор + es.init_db() + evs = es.get_events(0, es.count_events()) + rag_events = [e for e in evs if e["event_type"] == "RagQueried" + and "rag-test-1" in e["aggregate_id"]] + check("trace: RagQueried в сторе", len(rag_events) >= 1, + f"{len(rag_events)} RagQueried") + if rag_events: + d = json.loads(rag_events[-1]["data"]) + check("trace: payload содержит query/k/results", + d.get("query") and d.get("k") == 3 and d.get("results") == len(r), str(d)[:200]) + check("trace: level записан", d.get("level") == "hour", str(d.get("level"))) +else: + print("SKIP: живой вендор недоступен (не /root/rag среда)") + +# ── 5. лимит 1 запрос на тик в инструменте воркера ── +# создаём инструмент напрямую (strands.tool декоратор), не зовём LLM +from harness import _llm_worker +tool = _llm_worker._rag_tool_for("min", "rag-test-2") +check("worker tool: объект создан", tool is not None) +fn = getattr(tool, "func", tool) or getattr(tool, "_func", None) or tool +# strands @tool может оборачивать функцию — пробуем известные атрибуты +called = {"n": 0} +_rag_orig = rag_lookup.rag_query + + +def _fake_query(q, **kw): + called["n"] += 1 + return [{"score": 0.9, "source": "X.md#0", "text": "найдено"}] + + +rag_lookup.rag_query = _fake_query +try: + # пробуем вызвать внутреннюю функцию инструмента дважды + # strands @tool → DecoratedFunctionTool._tool_func — голая функция + inner = getattr(tool, "_tool_func", tool) + if not callable(inner): + inner = tool + r1 = inner("вопрос") if callable(inner) else None + r2 = inner("вопрос") if callable(inner) else None + if callable(inner) and isinstance(r1, str): + check("worker tool: 1-й вызов вернул чанки", "найдено" in str(r1), str(r1)[:120]) + check("worker tool: 2-й вызов — лимит", "лимит" in str(r2), str(r2)[:120]) + check("worker tool: вендор звался 1 раз", called["n"] == 1, str(called["n"])) + else: + print("SKIP: strands tool-обёртка не даёт прямого вызова:", type(tool)) +finally: + rag_lookup.rag_query = _rag_orig + +# ── 6. es не падает на RagQueried при пустом trace_id ── +rag_lookup._trace(None, None, {"query": "x"}) +check("trace: пустой trace_id — молча", True) + +print() +print("FAILS:", len(fails)) +sys.exit(1 if fails else 0) \ No newline at end of file diff --git a/tests/test_tool_budget.py b/tests/test_tool_budget.py new file mode 100644 index 0000000..28f4aa3 --- /dev/null +++ b/tests/test_tool_budget.py @@ -0,0 +1,128 @@ +# -*- 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"] +os.environ["SIMBIOT_MAX_TURNS"] = "15" +check("budget: env SIMBIOT_MAX_TURNS читается", + int(os.environ.get("SIMBIOT_MAX_TURNS") or 15) == 15) +del os.environ["SIMBIOT_MAX_TURNS"] + +# ── 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) \ No newline at end of file diff --git a/tg_inbox.db b/tg_inbox.db new file mode 100644 index 0000000..e69de29