simbiot-harness/harness/_llm_worker.py
Simbiot Harness 935c75d078 fix(spend): BudgetSpent пишется воркером инкрементально + живой usage из 9router (t-260929170544-adebea)
Два корня пустого spend за час:
1) usage-добыча в воркере смотрела m["message"]["usage"] — у strands usage живёт
   в message["metadata"]["usage"] (event_loop.py), поэтому usage всегда был пуст
   (тот же баг, что уже чинили в runtime._extract_usage).
2) BudgetSpent писал scheduler ПОСЛЕ успешного возврата воркера — таймаут убивал
   воркер раньше записи, spend терял весь тик.
3) Модель: strands_harness строит OpenAIResponsesModel, у 9router /responses — шим
   без usage (проверено: response.completed приходит без usage; non-stream возвращает
   chat-completion-объект). Перевёл на OpenAIModel (chat/completions, include_usage) —
   usage приходит и с инструментами, и в reasoning-режиме.

Фикс:
- воркер пишет BudgetSpent ИНКРЕМЕНТАЛЬНО после каждого модельного вызова
  (hook на MessageAddedEvent, assistant с metadata.usage, дедуп по объекту) —
  таймаут теряет максимум хвост текущего вызова, не весь тик;
- _extract_usage: корректная структура + суммирование по шагам (model_calls);
- scheduler дублирует запись только если воркер не смог разобрать usage;
- события помечены source: worker|runner и trace_id тика.

Проверено: дельты воркера == итоговый usage тика (39-шаговый тик сходится);
убитый по таймауту тик сохраняет все завершённые шаги; spend виден в
read model/dashboard.
2026-09-29 20:20:15 +00:00

162 lines
No EOL
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Воркер одного 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 build_level_model_from_payload(payload: dict):
"""Модель для воркера — та же сборка, что и в runtime.build_level_model:
9router понимает chat/completions (usage там же), Responses API — шим без usage."""
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"
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=model_id,
**({"params": params} if params else {}),
)
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):
"""Записать BudgetSpent за один модельный вызов.
Воркер — единственный автор spend-событий, но scheduler может убить
его по таймауту в любой момент (SIGKILL). Поэтому usage пишем
ИНКРЕМЕНТАЛЬНО: каждый завершённый модельный вызов — своё событие.
Хвост недописанного вызова теряется, но весь тик — никогда.
"""
if not usage_delta:
return
try:
from harness import es
es.append_event("harness", "budget", "BudgetSpent", {
"level": level, "trace_id": trace_id, "source": "worker",
**usage_delta,
})
except Exception:
pass # трекинг не должен валить тик
def _wire_budget_hooks(agent, level: str, trace_id: str):
"""После каждого модельного вызова — 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,
})
agent.add_hook(on_message_added, MessageAddedEvent)
return agent
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 "?"
try:
agent = create_harness(
model=build_level_model_from_payload(payload),
effort="auto", # effort уже в параметрах модели
instructions=payload.get("instructions", ""),
session=None,
builtin_tools=("read", "write", "edit", "shell") if payload.get("can_shell") else [],
memory=False,
skills=False,
)
agent = _wire_trace_hooks(agent, es, trace_id)
agent = _wire_budget_hooks(agent, level, trace_id)
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()