"""Simbiot Harness — Strands runtime-адаптер. create_harness() на тик: модель/effort из конфига уровня. Hooks → трасса в event store (агрегат trace). """ import json import os import uuid from datetime import datetime, timezone _HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def _env_or_file(name: str, default: str = "") -> str: """env → файл .env рядом с кодом → default. systemd-юниты харнеса не экспортируют .env (только PYTHONUNBUFFERED, t-261001091912-24602f): на агентских серверах .env читается построчно отсюда. Env имеет приоритет (12-фактор), файл — оффлайн-фолбэк. """ val = os.environ.get(name, "") if val: return val path = os.path.join(_HARNESS_DIR, ".env") try: with open(path) as f: for line in f: if line.startswith(name + "="): return line.split("=", 1)[1].strip() except OSError: pass return default def _router_key(): return _env_or_file("ROUTER_KEY") def _router_base_url(): # Фолбэк на .env обязателен для агентов вне эталонного хоста: дефолт # 127.0.0.1:20128 живёт только там, где стоит 9router локально. return _env_or_file("ROUTER_BASE_URL", "http://127.0.0.1:20128/v1") def build_level_model(level_cfg): """Модель уровня для 9router: CHAT COMPLETIONS, не Responses API. strands_harness для провайдера openai строит OpenAIResponsesModel, но у 9router /responses — шим: usage в response.completed отсутствует (проверено: usage:{} и в стриме, и в non-stream). Токены видны только в /chat/completions (OpenAIModel, stream_options include_usage) — а без них BudgetSpent бесполезен. Проверено: OpenAIModel + 9router отдаёт usage и с инструментами, и в reasoning-режиме. Сессии выключены, поэтому server-side state Responses API не нужен; chat/completions stateless и полностью покрывает потребности тика. """ from strands.models.openai import OpenAIModel params = {} if level_cfg.effort and level_cfg.effort != "auto": params["reasoning_effort"] = level_cfg.effort return OpenAIModel( 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")}, model_id=level_cfg.model if level_cfg.model != "none" else "auto", **({"params": params} if params else {}), ) def setup_provider(): """9router как openai-совместимый провайдер strands.""" key = _router_key() if not key: raise RuntimeError("ROUTER_KEY не найден (.env)") os.environ["OPENAI_API_KEY"] = key os.environ["OPENAI_BASE_URL"] = _router_base_url() def _wire_trace_hooks(agent, es, trace_id: str, model: str = None): """Каждая стадия агентного цикла → событие агрегата trace. model (e-260930062120): model_id уровня в InvocationStarted — видно, КАКАЯ модель запускалась на тике (запрошенная; "auto" резолвится роутером на его стороне). """ from strands import Agent from strands.hooks import BeforeInvocationEvent, BeforeToolCallEvent, AfterToolCallEvent seq = {"n": 0} def _trace(kind: str, payload: dict): seq["n"] += 1 es.append_event("trace", f"{trace_id}:{seq['n']}", kind, payload) def on_before_invocation(event): data = {"trace_id": trace_id, "ts": _now()} if model: data["model"] = model _trace("InvocationStarted", data) def on_before_tool(event): tool = getattr(event, "tool_use", None) or {} _trace("ToolCalled", { "tool": tool.get("name", "?"), "input": json.dumps(tool.get("input", {}), ensure_ascii=False, default=str)[:4000], }) def on_after_tool(event): try: res = str(getattr(event, "result", ""))[:4000] except Exception: res = "" _trace("ToolResult", {"tool": "?", "result": res}) agent.add_hook(on_before_invocation, BeforeInvocationEvent) agent.add_hook(on_before_tool, BeforeToolCallEvent) agent.add_hook(on_after_tool, AfterToolCallEvent) return agent def _now(): return datetime.now(timezone.utc).isoformat() def build_agent(level_cfg, session_id=None, extra_instructions=""): """Собирает Strands-агента для тика уровня. None если уровень без LLM.""" if not level_cfg.uses_llm: return None setup_provider() from strands_harness import create_harness instructions = level_cfg.mandate + ("\n\n" + extra_instructions if extra_instructions else "") agent = create_harness( model=build_level_model(level_cfg), effort="auto", # effort уже в параметрах модели (build_level_model) instructions=instructions, session={"id": session_id} if session_id else None, builtin_tools=("read", "write", "edit", "shell") if level_cfg.can_shell else [], memory=False, skills=False, ) return agent def run_cycle(level_cfg, es, prompt: str, session_id=None, trace_prefix=None): """Прогон тика уровня с LLM и трассой. Возвращает (text, usage_dict, trace_id).""" setup_provider() from strands_harness import create_harness trace_id = trace_prefix or uuid.uuid4().hex[:12] instructions = level_cfg.mandate agent = create_harness( model=build_level_model(level_cfg), effort="auto", # effort уже в параметрах модели (build_level_model) instructions=instructions, session={"id": session_id} if session_id else None, builtin_tools=("read", "write", "edit", "shell") if level_cfg.can_shell else [], memory=False, skills=False, ) agent = _wire_trace_hooks(agent, es, trace_id) result = agent(prompt) # AgentResult: текст из message, usage из metrics.accumulated_usage # (старый разбор agent.messages молчал → BudgetSpent всегда был пуст). text = str(result) usage = _extract_usage(result) return text, usage, trace_id def _extract_usage(result): """Usage из AgentResult.metrics.accumulated_usage (input/output/total).""" try: u = getattr(getattr(result, "metrics", None), "accumulated_usage", None) if u: return { "prompt_tokens": u.get("inputTokens"), "completion_tokens": u.get("outputTokens"), "total_tokens": u.get("totalTokens"), } except Exception: pass return {}