"""Воркер одного 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 # ── кэп результатов инструментов в истории тика (burn-аудит 14.10, рек.1) ── # # Диагноз (docs/aum/burn-audit-2026-10-14.md): 99.9% бюджета харнеса — # промпт-токены: каждый агентный шаг пересылает НАКОПЛЕННУЮ историю # (tool-result'ы целиком, до 75k ток/шаг), при этом 46% запросов # завершаются ≤100 токенов выхода — платим за пересылку, а не за работу. # Хозяйке-ключу f4b2347d: 11.18 → 27.84 → 37.95 у.е./день (14→16 окт). # # Фикс: AfterToolCallEvent.result — writable-поле хука (проверено по # исходникам strands: _can_write = ["result", "retry"]). Режем text- # контент результата ДО попадания в историю: полный вывод остаётся # в EventStore (ToolResult пишет runtime-хук до нас — но порядок хуков # не гарантирован, поэтому срезаем копию с маркером, а полный текст # живёт в трейсе тика events.db). Разрез только когда история уже # большая (после ~60k симв. накопленных результатов): первые шаги тика # работают на полном контексте, резать начинаем когда история реально # раздулась. _TOOL_RESULT_CAP_CHARS = int(os.environ.get("SIMBIOT_TOOL_RESULT_CAP", "8000")) _TOOL_RESULT_SOFT_SUM = int(os.environ.get("SIMBIOT_TOOL_RESULT_SOFT_SUM", "60000")) def _wire_tool_result_cap(agent): """Срезать гигантские tool-result'ы до попадания в историю тика.""" from strands.hooks import AfterToolCallEvent state = {"sum": 0, "cut": 0} def on_after_tool(event): try: res = event.result if not isinstance(res, dict): return content = res.get("content") if not isinstance(content, list): return changed = False for item in content: if not isinstance(item, dict): continue text = item.get("text") if not isinstance(text, str) or len(text) <= _TOOL_RESULT_CAP_CHARS: continue item["text"] = (text[:_TOOL_RESULT_CAP_CHARS] + f"\n…[срез {len(text) - _TOOL_RESULT_CAP_CHARS} симв.; " f"полный вывод — в трейсе тика (events.db)]") changed = True if changed: state["cut"] += 1 state["sum"] += _TOOL_RESULT_CAP_CHARS else: state["sum"] += sum(len(i.get("text", "")) for i in content if isinstance(i, dict)) # мягкий порог: пока сумма результатов мала — не трогаем if state["sum"] <= _TOOL_RESULT_SOFT_SUM: return # после — дополнительно режем и средние результаты (по 4k) for item in content: if not isinstance(item, dict): continue text = item.get("text") if isinstance(text, str) and len(text) > 4000: item["text"] = (text[:4000] + "\n…[срез: история тика раздута, " "полный вывод — в трейсе (events.db)]") except Exception: pass # кэп — best effort: не должен валить тик agent.add_hook(on_after_tool, AfterToolCallEvent) return agent # ── e-261014163808-4da3ec: TTFT-watchdog + ретрай висящего вызова ────────── # # Диагноз (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_ > 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()) trace_id = payload.get("trace_prefix") or "tick" level = payload.get("level") or "?" # круг доверия тика (harness/trust.py): в env — его наследуют shell и # cli.py (задачи штампуются кругом, trust set из тика запрещён) from harness.trust import norm as _norm_circle circle = _norm_circle(payload.get("circle")) os.environ["SIMBIOT_TICK_CIRCLE"] = str(circle) # движок тика (harness/cc_backend.py): claude_code — тик исполняет Claude Code # со своей моделью на уровень (роутер не нужен); иначе — Strands + 9router from harness import cc_backend if cc_backend.backend_for(level) == "claude_code": try: _emit(payload, cc_backend.run(payload, _emit_budget_incremental)) except Exception as e: import traceback _emit(payload, {"ok": False, "error": f"claude_code: {e}\n{traceback.format_exc()[-800:]}"}) return from harness import es from harness.runtime import setup_provider, _wire_trace_hooks setup_provider() from strands_harness import create_harness 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)) agent = _wire_tool_result_cap(agent) 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()