Репродукция: мин-тик с реальным контекстом → модель (auto/low effort)
каждый ход делала tool-вызов, никогда не выдавая чистый текст;
limits={'turns': 15} обрывал цикл на границе хода, result.message
оставался toolResult-сообщением → str(result)='' → DigestWritten len 0.
limit_turns — нативный стоп SDK, но он не кладёт в историю итоговый
ассистентский текст. Наш end_turn (AfterToolsEvent) кладёт: дайджест
не пустеет. Защита от таймаутов остаётся двойной: cancel_tool на
вызовы сверх лимита + end_turn после партии.
E2E-подтверждение: тот же промпт, stop_reason=end_turn, текст 465
символов ('Тик чистый. Проверил: ...').
Симптом: DigestWritten min с пустым text при живом агенте (15 tool-вызовов,
BudgetSpent писались). Корень: PrintingCallbackHandler стримит в stdout
воркера; scheduler читает capture_output=True; длинный вывод (reasoning +
Tool #N на каждый вызов) переполнял pipe → BrokenPipeError внутри
stream_async → ok=False, текст тика потерян. Воркер перезапускался каждый
тик — падения шли серией, мин-дайджесты пустые.
Результат тика живёт в JSON-файле (result_path) — печать не нужна вовсе:
create_harness(callback_handler=null_callback_handler).
E2E: воркер с 2 shell-вызовами, stdout 0 байт, ok=True, текст на месте.
Два корня пустого 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.