Вторая ось к тактам: время — как часто/глубоко думать, доверие — что
можно с этим человеком. Один код + таблица POLICY (уровни × инструменты).
harness/trust.py:
- круг — только по проверенной личности (tg user_id, MCP-агент, портал,
tui), не по тексту; старт: Alex 1, Vlad/Yura 2 (правление); агенты 4,
портал 3, прочие tg 6, чужие 7; менять — только человек
(cli.py trust set вне тика; из тика — отказ и страж, и CLI);
- круг тика = самый недоверенный вход (входящие + задачи батча); задачи
наследуют круг тика (SIMBIOT_TICK_CIRCLE → TaskSubmitted.circle) —
просьба правления не исполняется позже часом с правами владельца;
- страж в воркере (BeforeToolCallEvent.cancel_tool, не промпт): shell
в кругах ≥ 2 — одна команда cli.py из белого списка круга, без ; && |
> $() ` ${; файлы — чтение docs/out/sites/artifacts/входящих, секреты
и запись — нет; portal_mcp — список круга (правлению без казны и
member_create); rag по кругу; отказ → TrustDenied;
- уровни × круги: партнёров/знакомых — только min; батч не смешивает
круги; строка круга в контексте тика;
- задача правления упёрлась в права → владельцу кнопки «Разрешить/Нет»
(только Alex; разрешение = круг 1).
Закрыта дыра: общий SIMBIOT_TG_ALLOWED давал правлению в личке shell
под root наравне с владельцем.
Тест: test_trust 53 PASS (обходы стража, наследование, батчи, одобрение);
фейковые раннеры трёх тестов приняли circle; полный набор — без регрессий.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
511 lines
No EOL
28 KiB
Python
511 lines
No EOL
28 KiB
Python
"""Воркер одного LLM-тика (запускается сабпроцессом из isolated_runner).
|
||
|
||
Читает payload из stdin, гоняет create_harness() один раз и умирает —
|
||
каждый тик получает чистый asyncio-рантайм Strands.
|
||
Трасса пишется напрямую в event store (es доступен).
|
||
Результат — в JSON-файл (path в payload['result_path']); stdout — только лог.
|
||
"""
|
||
import json
|
||
import os
|
||
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 _portal_mcp_tool_for(level: str, trace_id: str):
|
||
"""Тул тика `portal_mcp` — MCP-шлюз портала кооператива (t-261010093054).
|
||
|
||
Звонки/расписание (booking_slots/booking_create), реестры, казна,
|
||
лента звонков портала — живые данные cil.by прямо из тика. Клиент и
|
||
лимиты — harness/portal_mcp.py (4 вызова на тик, таймаут 15с, трейс
|
||
PortalMcpCalled); тул не поднимается без токена агента. Guardrail:
|
||
деньги-тулы (product_create/product_set_mode) запрещены по умолчанию
|
||
(SIMBIOT_MCP_ALLOW_ALL=1 снимает).
|
||
"""
|
||
from strands import tool as strands_tool
|
||
from harness import portal_mcp as _mcp
|
||
state = {"calls": 0}
|
||
|
||
@strands_tool
|
||
def portal_mcp(tool: str, arguments: str = "") -> str:
|
||
"""Вызов инструмента портала кооператива (MCP cil.by): расписание и
|
||
создание звонков с Алексом (booking_slots → booking_create), реестры
|
||
членов/проектов/агентов, казна, лента звонков и событий, whoami.
|
||
Сначала вызови с tool="booking_slots", arguments='{"type": "call"}',
|
||
слот передавай в booking_create как есть. arguments — JSON-строка.
|
||
Лимит 4 вызова на тик. Ответ — JSON-текст портала; ошибки — "error: …"."""
|
||
if state["calls"] >= _mcp.MAX_CALLS_PER_TICK:
|
||
return (f"лимит: {_mcp.MAX_CALLS_PER_TICK} вызовов portal_mcp "
|
||
f"на тик (использованы)")
|
||
state["calls"] += 1
|
||
return _mcp.call_tool(tool, arguments, level=level, trace_id=trace_id)
|
||
|
||
return portal_mcp
|
||
|
||
|
||
def _wire_trust_guard(agent, circle: int, level: str, trace_id: str):
|
||
"""Страж кругов доверия (harness/trust.py): вызов инструмента сверх прав
|
||
круга тика отменяется ДО исполнения (cancel_tool) — не просьба в промпте,
|
||
которую можно обойти. Отказ → TrustDenied в стор (для кнопки владельцу)."""
|
||
from strands.hooks import BeforeToolCallEvent
|
||
from harness import trust
|
||
|
||
def on_before_tool(event):
|
||
tool = getattr(event, "tool_use", None) or {}
|
||
name = tool.get("name", "?")
|
||
inp = tool.get("input") or {}
|
||
if isinstance(inp, str):
|
||
try:
|
||
inp = json.loads(inp)
|
||
except ValueError:
|
||
inp = {"command": inp}
|
||
ok, why = trust.check_tool(name, inp, circle)
|
||
if ok:
|
||
return
|
||
event.cancel_tool = (f"круг доверия {circle} ({trust.NAMES.get(circle)}): {why}. "
|
||
"Не обходи — оформи задачу (cli.py task) и скажи, что она ждёт "
|
||
"одобрения владельца.")
|
||
try:
|
||
from harness import es
|
||
es.append_event("trace", f"trust-deny:{trace_id}", "TrustDenied", {
|
||
"level": level, "trace_id": trace_id, "circle": circle, "tool": name,
|
||
"input": json.dumps(inp, ensure_ascii=False, default=str)[:600], "reason": why})
|
||
except Exception:
|
||
pass
|
||
|
||
agent.add_hook(on_before_tool, BeforeToolCallEvent)
|
||
return agent
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── e-261014163808-4da3ec: TTFT-watchdog + ретрай висящего вызова ──────────
|
||
#
|
||
# Диагноз (13:08 и 16:11 14-10-2026, павшие hour-тики): 9router принимает
|
||
# POST и открывает стрим, но НИ ОДНОГО байта контента не приходит — в логе
|
||
# роутера «▶ POST ... STREAM» есть, «📊 DONE» после него нет. Клиентский
|
||
# timeout=90 и SDK-ретраи не спасали: 4 попытки × тот же висящий апстрим,
|
||
# в итоге APITimeoutError уносил ВЕСЬ тик (TaskFailed по батчу, CycleError,
|
||
# эскалация). Воркер — subprocess, повторить механизм с уровня нельзя,
|
||
# поэтому фикс в самом воркере.
|
||
#
|
||
# Решение: обёртка стрима модели. Признак живого апстрима — первый
|
||
# content-chunk; пока его нет, watchdog: N секунд без токена → стрим
|
||
# закрываем и делаем СВЕЖИЙ вызов (новый POST — роутер сам уводит его на
|
||
# живой апстрим). Начавший отвечать апстрим не трогаем: обрыв на середине
|
||
# ответа дороже редкого зависания.
|
||
#
|
||
# Гарантии: strands дождётся события message_stop новой попытки — цикл
|
||
# агента консистентен; BudgetSpent идемпотентен (usage по фактически
|
||
# дошедшим assistant-сообщениям); инструменты исполняются после конца
|
||
# стрима, так что дублей tool-вызовов нет.
|
||
|
||
_TTFT_FIRST_TOKEN_BUDGET = float(os.environ.get("SIMBIOT_LLM_TTFT_BUDGET", "45"))
|
||
|
||
|
||
async def _aclose_gen(gen):
|
||
"""Мягко закрыть асинхронный генератор (отмена висящего стрима)."""
|
||
close = getattr(gen, "aclose", None)
|
||
if close is not None:
|
||
try:
|
||
await close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _chunk_type(chunk) -> str:
|
||
"""Тип чанка стрима strands: атрибут или ключ словаря."""
|
||
ct = getattr(chunk, "chunk_type", None)
|
||
if not ct and isinstance(chunk, dict):
|
||
ct = chunk.get("chunk_type")
|
||
return ct or ""
|
||
|
||
|
||
def _with_ttft_retry(model, level: str, call_timeout: float):
|
||
"""Обернуть модель: watchdog первого токена + ретраи висящих вызовов.
|
||
|
||
first_budget — сколько ждать ПЕРВЫЙ content-chunk; capped сверху
|
||
клиентским call_timeout (старый таймаут продолжает страховать весь
|
||
вызов на случай совсем глухого апстрима). Попыток 3: нормальный
|
||
TTFT роутера 2-13с (лог), 45с — запас x3; 3×45 + ретраи SDK не
|
||
пробивают tick_timeout уровня (min 480с, hour 2880с).
|
||
"""
|
||
import asyncio
|
||
import time as _time
|
||
|
||
first_budget = min(_TTFT_FIRST_TOKEN_BUDGET, max(float(call_timeout), 1.0))
|
||
max_attempts = 3
|
||
base_stream = model.stream
|
||
|
||
def _note_retry(attempt: int, why: str):
|
||
# след в стор: сколько раз сработал watchdog (видно из трейса тика)
|
||
try:
|
||
from harness import es
|
||
es.append_event("trace", f"ttft-retry:{level}", "TTFTWatchdogRetry", {
|
||
"level": level, "attempt": attempt, "reason": why,
|
||
"first_token_budget_s": first_budget,
|
||
})
|
||
except Exception:
|
||
pass # телеметрия — best effort
|
||
|
||
class _TTFTModel:
|
||
"""Прозрачный прокси: всё делегируется модели, stream() — под watchdog.
|
||
|
||
Не наследуем Model (ABC с abstractmethods): strands_harness.resolve_model
|
||
принимает по isinstance(model, (Model, ModelRouter)), поэтому после
|
||
делегирования регистрируемся как Model через Model.register(_TTFTModel)
|
||
— и делегирование __getattr__, и валидный isinstance для create_harness.
|
||
"""
|
||
|
||
def __init__(self, inner):
|
||
object.__setattr__(self, "_inner", inner)
|
||
|
||
def __getattr__(self, name):
|
||
return getattr(object.__getattribute__(self, "_inner"), name)
|
||
|
||
async def stream(self, *args, **kwargs):
|
||
attempts = 0
|
||
while True:
|
||
attempts += 1
|
||
gen = base_stream(*args, **kwargs)
|
||
it = gen.__aiter__()
|
||
got_content = False
|
||
try:
|
||
while True:
|
||
try:
|
||
chunk = await asyncio.wait_for(
|
||
it.__anext__(), timeout=first_budget)
|
||
except StopAsyncIteration:
|
||
# пустой стрим без контента — тоже симптом висения,
|
||
# но ретраить бессмысленно: считаем ответом и уходим
|
||
return
|
||
ct = _chunk_type(chunk)
|
||
if ct in ("content_delta", "content_start"):
|
||
got_content = True
|
||
yield chunk
|
||
if got_content:
|
||
break
|
||
except asyncio.TimeoutError:
|
||
why = f"{first_budget:.0f}s без первого токена (попытка {attempts})"
|
||
if attempts < max_attempts:
|
||
_note_retry(attempts, why)
|
||
await _aclose_gen(gen)
|
||
continue # свежий POST: роутер уведёт на живой апстрим
|
||
raise TimeoutError(
|
||
f"апстрим завис до первого токена: {max_attempts} попытки "
|
||
f"по {first_budget:.0f}s — {why}") from None
|
||
# апстрим живой (контент пошёл) — доедаем без watchdog
|
||
async for chunk in it:
|
||
yield chunk
|
||
return
|
||
|
||
from strands.models.model import Model as _StrandsModel
|
||
_StrandsModel.register(_TTFTModel)
|
||
return _TTFTModel(model)
|
||
|
||
|
||
def build_level_model_from_payload(payload: dict):
|
||
"""Модель для воркера — та же сборка, что и в runtime.build_level_model:
|
||
9router понимает chat/completions (usage там же), Responses API — шим без usage.
|
||
|
||
SIMBIOT_LLM_CALL_TIMEOUT (e-260930161900-6da5c9, e-260930153143-85246b):
|
||
per-call таймаут LLM-вызова в сек. Роутер-лог показал: зависший тик = POST
|
||
в ollama-апстрим принят, но байт стрима так и не пришёл (DONE без TTFT),
|
||
при этом соседние вызовы ходили за 1-3с. Таймаут уровня (300/2880с) убивает
|
||
ВЕСЬ тик из-за одного висящего вызова; теперь вызов режется (до 90с,
|
||
hour 180с), исключение отлавливает event-loop — тик получает результат
|
||
с пометкой «не успел», spend-хвост и задачи не теряются.
|
||
"""
|
||
from strands.models.openai import OpenAIModel
|
||
import os
|
||
params = {}
|
||
effort = payload.get("effort") or "auto"
|
||
if effort and effort != "auto":
|
||
params["reasoning_effort"] = effort
|
||
model_id = payload.get("model") or "auto"
|
||
if model_id == "none":
|
||
model_id = "auto"
|
||
# per-call таймаут: один зависший апстрим-вызов не должен убивать весь тик
|
||
call_timeout = int(os.environ.get("SIMBIOT_LLM_CALL_TIMEOUT", "90"))
|
||
# SIMBIOT_LLM_MAX_RETRIES (e-261006080102, e-261006160049): transient
|
||
# «Connection error» к роутеру ронял весь тик с первой попытки; openai SDK
|
||
# умеет сам ретраить connection-error/429/5xx с экспонентой — даём запас.
|
||
max_retries = int(os.environ.get("SIMBIOT_LLM_MAX_RETRIES", "3"))
|
||
base = OpenAIModel(
|
||
# timeout открытого клиента (openai SDK) режет каждый chat.completions
|
||
# вызов; при зависании апстрима получаем APITimeoutError за N сек и
|
||
# strands event-loop завершает тик штатно (без потери предыдущих шагов)
|
||
client_args={"api_key": os.environ.get("OPENAI_API_KEY", ""),
|
||
"base_url": os.environ.get("OPENAI_BASE_URL", "http://127.0.0.1:20128/v1"),
|
||
"timeout": call_timeout, "max_retries": max_retries},
|
||
model_id=model_id,
|
||
**({"params": params} if params else {}),
|
||
)
|
||
# e-261014163808-4da3ec (часовой фикс): ретрай висящего ДО-ПЕРВОГО-ТОКЕНА
|
||
# апстрим-вызова. Кейс дня (router-лог 16:09:50): POST принят, TTFT так и
|
||
# не пришёл, ~90с спустя APITimeoutError унёс ВЕСЬ тик вместе с claimed-
|
||
# батчем. SDK-ретраи не спасали: 4 попытки × 90с висения = тик мёртв.
|
||
return _with_ttft_retry(base, payload.get("level") or "hour", call_timeout)
|
||
|
||
|
||
def _emit(payload, obj):
|
||
rp = payload.get("result_path")
|
||
if rp:
|
||
try:
|
||
with open(rp, "w") as f:
|
||
json.dump(obj, f, ensure_ascii=False)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _extract_usage(agent) -> dict:
|
||
"""Usage из истории сообщений агента.
|
||
|
||
Strands прикрепляет usage к каждой assistant-сообщении в
|
||
message["metadata"]["usage"] (event_loop.py) — по одному словарю
|
||
на модельный вызов (шаг тика). Накапливаем по шагам: тик может
|
||
состоять из многих вызовов, бюджет интересует суммарный.
|
||
|
||
Старый разбор m["message"]["usage"] не находил ничего (структура
|
||
другая) — usage всегда возвращался пустым, spend не рос.
|
||
"""
|
||
usage = {"prompt_tokens": 0, "completion_tokens": 0, "model_calls": 0}
|
||
try:
|
||
msgs = getattr(agent, "messages", None) or []
|
||
for m in msgs:
|
||
u = ((m or {}).get("metadata") or {}).get("usage") or {}
|
||
p = u.get("inputTokens") or 0
|
||
c = u.get("outputTokens") or 0
|
||
if isinstance(p, (int, float)):
|
||
usage["prompt_tokens"] += p
|
||
if isinstance(c, (int, float)):
|
||
usage["completion_tokens"] += c
|
||
if p or c:
|
||
usage["model_calls"] += 1
|
||
except Exception:
|
||
pass
|
||
if not usage["model_calls"]:
|
||
return {}
|
||
return usage
|
||
|
||
|
||
def _emit_budget_incremental(level: str, trace_id: str, usage_delta: dict,
|
||
model: str = None):
|
||
"""Записать BudgetSpent за один модельный вызов.
|
||
|
||
Воркер — единственный автор spend-событий, но scheduler может убить
|
||
его по таймауту в любой момент (SIGKILL). Поэтому usage пишем
|
||
ИНКРЕМЕНТАЛЬНО: каждый завершённый модельный вызов — своё событие.
|
||
Хвост недописанного вызова теряется, но весь тик — никогда.
|
||
|
||
model (e-260930062120): запрошенный model_id уровня пишется в каждое
|
||
событие — в кредитах роутера видно, ЧЬИ это траты; "auto" резолвится
|
||
роутером, фактическую модель до usage strands не выносит.
|
||
"""
|
||
if not usage_delta:
|
||
return
|
||
try:
|
||
from harness import es
|
||
es.append_event("harness", "budget", "BudgetSpent", {
|
||
"level": level, "trace_id": trace_id, "source": "worker",
|
||
**({"model": model} if model else {}),
|
||
**usage_delta,
|
||
})
|
||
except Exception:
|
||
pass # трекинг не должен валить тик
|
||
|
||
|
||
def _wire_budget_hooks(agent, level: str, trace_id: str, model: str = None):
|
||
"""После каждого модельного вызова — usage-дельта в стор.
|
||
|
||
Strands вешает usage на assistant-сообщение в
|
||
message["metadata"]["usage"] (одна запись на модельный вызов).
|
||
MessageAddedEvent стреляет на добавление КАЖДОЙ записи истории,
|
||
нас интересуют только assistant с usage.
|
||
"""
|
||
from strands.hooks import MessageAddedEvent
|
||
|
||
seen = set()
|
||
|
||
def on_message_added(event):
|
||
msg = getattr(event, "message", None) or {}
|
||
if msg.get("role") != "assistant":
|
||
return
|
||
usage = ((msg.get("metadata") or {}).get("usage")) or {}
|
||
p = usage.get("inputTokens") or 0
|
||
c = usage.get("outputTokens") or 0
|
||
if not (p or c):
|
||
return
|
||
key = id(msg)
|
||
if key in seen: # MessageUpdatedEvent может переигрывать — дедуп по объекту
|
||
return
|
||
seen.add(key)
|
||
_emit_budget_incremental(level, trace_id, {
|
||
"prompt_tokens": p, "completion_tokens": c,
|
||
}, model=model)
|
||
|
||
agent.add_hook(on_message_added, MessageAddedEvent)
|
||
return agent
|
||
|
||
|
||
def resolve_tool_budget(level: str) -> int:
|
||
"""Бюджет тул-коллов тика по уровню (e-260930071900, просьба Alex:
|
||
часовому уровню нужен большой цикл — «хоть 300»).
|
||
|
||
Приоритет: SIMBIOT_MAX_TOOL_CALLS_<LEVEL> > SIMBIOT_MAX_TOOL_CALLS
|
||
> per-level дефолт. Минута остаётся компактной (30) — жёсткий кап
|
||
и спасает её от таймаута (e-260930031800: 90+ шагов → 300с → смерть).
|
||
День 10_000 («убрать/сделать супер большим», Alex 2026-10-01) —
|
||
фактически не ограничивает: дневной тик упирается в свой таймаут,
|
||
а не в шаги; инцидент 03:00 — e-261001074527-f0c1b4, фикс закрытия
|
||
чужих задач — отдельная тема той же эскалации.
|
||
"""
|
||
env = (os.environ.get(f"SIMBIOT_MAX_TOOL_CALLS_{level.upper()}")
|
||
or os.environ.get("SIMBIOT_MAX_TOOL_CALLS"))
|
||
if env:
|
||
return int(env)
|
||
return {"min": 30, "hour": 300, "day": 10_000}.get(level, 30)
|
||
|
||
|
||
def main():
|
||
payload = json.loads(sys.stdin.read())
|
||
|
||
from harness import es
|
||
from harness.runtime import setup_provider, _wire_trace_hooks
|
||
|
||
setup_provider()
|
||
from strands_harness import create_harness
|
||
|
||
trace_id = payload.get("trace_prefix") or "tick"
|
||
level = payload.get("level") or "?"
|
||
# круг доверия тика (harness/trust.py): в env — его наследуют shell и
|
||
# cli.py (задачи штампуются кругом, trust set из тика запрещён)
|
||
circle = payload.get("circle")
|
||
circle = 1 if circle is None else int(circle)
|
||
os.environ["SIMBIOT_TICK_CIRCLE"] = str(circle)
|
||
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: вендор не должен валить тик
|
||
# MCP портала (t-261010093054): booking_slots/booking_create, реестры,
|
||
# казна, лента звонков — живой портал из тика; без токена тул не поднимется
|
||
try:
|
||
tools.append(_portal_mcp_tool_for(level, trace_id))
|
||
except Exception:
|
||
pass # портал — best effort: шлюз не должен валить тик
|
||
# stdout воркера не читается (subprocess.run с capture_output и
|
||
# длинным выводом переполняет pipe → BrokenPipeError в печати,
|
||
# результат тика терялся, дайджесты 04:11-04:17 ушли пустыми).
|
||
# Результат живёт в JSON-файле (result_path), печать не нужна.
|
||
from strands.handlers.callback_handler import null_callback_handler
|
||
agent = create_harness(
|
||
model=build_level_model_from_payload(payload),
|
||
effort="auto", # effort уже в параметрах модели
|
||
instructions=payload.get("instructions", ""),
|
||
session=None,
|
||
tools=tools,
|
||
callback_handler=null_callback_handler,
|
||
builtin_tools=("read", "write", "edit", "shell") if payload.get("can_shell") else [],
|
||
memory=False,
|
||
skills=False,
|
||
)
|
||
agent = _wire_trace_hooks(agent, es, trace_id, model=payload.get("model"),
|
||
level=level)
|
||
agent = _wire_trust_guard(agent, circle, level, trace_id)
|
||
agent = _wire_budget_hooks(agent, level, trace_id, model=payload.get("model"))
|
||
# хард-кап шагов (e-260930031800): cancel_tool + end_turn.
|
||
# ВАЖНО: НЕ используем limits={"turns": N} — нативный стоп SDK
|
||
# обрывает цикл на границе хода, и result.message остаётся
|
||
# toolResult-сообщением → str(result)='' → пустой дайджест
|
||
# (репродукция: stop_reason=limit_turns, text='', тики 04:18+).
|
||
# Наш end_turn (AfterToolsEvent) кладёт в историю ассистентский
|
||
# текст с итогом — дайджест не пустеет.
|
||
agent = _wire_tool_budget(agent, resolve_tool_budget(level))
|
||
result = agent(payload.get("prompt", ""))
|
||
usage = _extract_usage(agent)
|
||
_emit(payload, {"ok": True, "text": str(result), "usage": usage, "trace_id": trace_id})
|
||
except Exception as e:
|
||
import traceback
|
||
_emit(payload, {"ok": False, "error": f"{e}\n{traceback.format_exc()[-800:]}"})
|
||
# орфан-воркер после убийства родителя может умереть на печати — не критично
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |