feat(rag)+fix(timeout): RAG-инструмент в тиках + хард-кап шагов воркера

RAG (t-260929212316): каждому LLM-тику доступен rag_lookup — семантический
поиск по базе кооператива (/root/rag, fastembed+sqlite). Лимиты зашиты в
инструмент: 1 запрос/тик, k<=5, таймаут 20с, сабпроцесс rag-venv, мёртвый
вендор не валит тик. Трейс RagQueried в сторе. CLI: rag-query.

Хард-кап шагов (e-260930031800): мин-воркер тонул в 90+ tool-вызовах и
умирал по таймауту 300с. До: 3 CycleError за 2 часа, эскалации min→hour.
Фикс: cancel_tool на вызовы сверх лимита + end_turn (AfterToolsEvent)
останавливает цикл после партии + limits.turns (нативный механизм SDK).
e2e: 20 параллельных вызовов при лимите 3 → ровно 3 исполнения, цикл
остановлен, тик завершён текстом за ~12с (было: таймаут 300с).

Мандат мин-уровня: про rag_lookup и приоритет RAG для вопросов про кооператив.

Тесты: tests/test_rag_lookup.py (29 PASS), tests/test_tool_budget.py (12 PASS).
This commit is contained in:
Simbiot Harness 2026-09-30 04:16:46 +00:00
parent f115447808
commit 34df7f3d72
8 changed files with 529 additions and 2 deletions

View file

@ -89,7 +89,6 @@ journalctl -u simbiot-harness -f
Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`. Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`.
## Без-LLM команды бота (t-260929210755) ## Без-LLM команды бота (t-260929210755)
`/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model: `/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model:
их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`), их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`),
минутный LLM-контекст на команды не тратится. Алиасы (латиница/транслит, минутный LLM-контекст на команды не тратится. Алиасы (латиница/транслит,
@ -106,6 +105,28 @@ tg-поллера или вручную:
`build_context` не показывает команды мин-уровню — даже без ответа `build_context` не показывает команды мин-уровню — даже без ответа
(гонка на границе тика / лежащий TG не заставят минуту тратить LLM). (гонка на границе тика / лежащий 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).
## Дашборд задач ## Дашборд задач
Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов + Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов +

17
cli.py
View file

@ -80,6 +80,12 @@ def main():
p_init = sub.add_parser("init", help="инициализация стора") p_init = sub.add_parser("init", help="инициализация стора")
p_init.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива") 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() args = ap.parse_args()
if args.cmd == "init": if args.cmd == "init":
@ -169,6 +175,17 @@ def main():
print("(у бота нет зарегистрированных команд)") print("(у бота нет зарегистрированных команд)")
for name, desc in cmds: for name, desc in cmds:
print(f"/{name} — {desc}") 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": elif args.cmd == "escalate":
es.init_db() es.init_db()
eid, to_level = commands.raise_escalation( eid, to_level = commands.raise_escalation(

View file

@ -12,6 +12,80 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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): def build_level_model_from_payload(payload: dict):
"""Модель для воркера — та же сборка, что и в runtime.build_level_model: """Модель для воркера — та же сборка, что и в runtime.build_level_model:
9router понимает chat/completions (usage там же), Responses API — шим без usage.""" 9router понимает chat/completions (usage там же), Responses API — шим без usage."""
@ -138,18 +212,36 @@ def main():
trace_id = payload.get("trace_prefix") or "tick" trace_id = payload.get("trace_prefix") or "tick"
level = payload.get("level") or "?" level = payload.get("level") or "?"
try: 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( agent = create_harness(
model=build_level_model_from_payload(payload), model=build_level_model_from_payload(payload),
effort="auto", # effort уже в параметрах модели effort="auto", # effort уже в параметрах модели
instructions=payload.get("instructions", ""), instructions=payload.get("instructions", ""),
session=None, session=None,
tools=tools,
builtin_tools=("read", "write", "edit", "shell") if payload.get("can_shell") else [], builtin_tools=("read", "write", "edit", "shell") if payload.get("can_shell") else [],
memory=False, memory=False,
skills=False, skills=False,
) )
agent = _wire_trace_hooks(agent, es, trace_id) agent = _wire_trace_hooks(agent, es, trace_id)
agent = _wire_budget_hooks(agent, level, 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) 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})
except Exception as e: except Exception as e:

View file

@ -49,6 +49,10 @@ LEVELS = {
"cli.py tg-reply <id> \"текст\" (тон: кратко, по-русски, тёпло; можно " "cli.py tg-reply <id> \"текст\" (тон: кратко, по-русски, тёпло; можно "
"выполнить просьбу инструментами и ответить результатом). Быстрые " "выполнить просьбу инструментами и ответить результатом). Быстрые "
"мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. " "мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. "
"У тебя есть инструмент rag_lookup — семантический поиск по базе "
"знаний кооператива (контекст, проекты, порталы, встречи, документы). "
"Если вопрос про кооператив/продукты/документы — сначала rag_lookup "
"(1 запрос за тик, формулируй целиком), потом отвечай по найденному. "
"ВАЖНО: НЕ исследуй исходники харнеса (scheduler.py, commands.py, es.py, " "ВАЖНО: НЕ исследуй исходники харнеса (scheduler.py, commands.py, es.py, "
"isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. " "isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. "
"Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. " "Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. "

123
harness/rag_lookup.py Normal file
View file

@ -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)

142
tests/test_rag_lookup.py Normal file
View file

@ -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)

128
tests/test_tool_budget.py Normal file
View file

@ -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)

0
tg_inbox.db Normal file
View file