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:
parent
f115447808
commit
34df7f3d72
8 changed files with 529 additions and 2 deletions
23
README.md
23
README.md
|
|
@ -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
17
cli.py
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
123
harness/rag_lookup.py
Normal 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
142
tests/test_rag_lookup.py
Normal 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
128
tests/test_tool_budget.py
Normal 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
0
tg_inbox.db
Normal file
Loading…
Reference in a new issue