Trace - журнал хода
Ядро пишет одну строку JSONL на событие: от апдейта, который принял Bridge, до ответа,
который доставил Outbox. Читателей двое - вьюер плагина trace и iva trace, - поэтому
контракт описан здесь, а не в коде (ADR-0010). Термины - по CONTEXT.md.
Файл: data/trace/YYYY-MM-DD.jsonl в каталоге данных (ASSISTANT_DATA_DIR, по умолчанию
./data). Пишут два процесса: агент и мост. Append-only, одна строка на запись.
Схема события
Полей ровно семь, порядок всегда такой:
| Поле | Что это |
|---|---|
ts | ISO-8601 UTC, момент записи |
turn | ключ хода - три случая, см. ниже |
session | сессия eve (пустая, пока ход не начался) |
source | telegram, bridge, web, http, rollup, watch, brief, insight, cron, unknown |
kind | группа: bridge, inbound, gate, context, turn, eve, outbox, stop |
name | конкретное событие внутри группы |
data | объект: имена, тайминги, размеры, содержимое |
source равен unknown, когда событие eve пришло без вида канала. В журналах 0.4.11 и
раньше встречается и digest. ts записан в UTC, а
файл дня назван по часовому поясу установки (ASSISTANT_TIMEZONE): около полуночи
первые строки файла несут метку предыдущих UTC-суток. Так и задумано - журнал режет дни
так же, как их режет vault.
Строка не длиннее 16 КБ в байтах UTF-8. Событие, которое не влезло, пересобирается со
всеми полями содержимого по 2000 знаков; только если не влезло и так, оно теряет содержимое
(data.traceTrimmed: true). Имена, тайминги и размеры остаются всегда.
Три пространства ключа turn
- До появления хода - ключ апдейта
tg:<chatId>:<messageId>. Его умеют посчитать и мост, и ядро;update_idядру не виден. - После старта хода -
turnIdeve (turn_0,turn_1, …). Шаги субагента идут с суффиксом:turn_3#custom-node, тот же ключ, что вdata/usage.jsonl. - У ночного хода ключа хода нет вовсе. Свёртка и прочие cron-отправки идут через
клиент eve, а он отдаёт только id сессии, поэтому их строки
gate.outboundиoutbox.*несутturn: "", непустуюsessionиsourceиз {rollup,cron}. Части Watch, Brief и Insight шлёт сам тик проактивности: их строки несутturn: "", безsession, иsourcewatch,briefилиinsight. События eve того же ночного хода при этом несутturn_N- их пишет хук внутри агента.
Как читатель собирает один ход
- Ход из чата: взять
turn.bound, собрать всё, у чегоturnравен егоdata.updateKey(мост, inbound, гейт входа), плюс всё, у чегоsessionиturnравны его собственным (события eve, Outbox, Стоп), и отсортировать поts; строка сturn_Nбез сессии примыкает к последнему ходу с этимturn_N. - Ночной ход: группировать по паре
sessionиturn_N. В одной сессии eve ходов много - ночная свёртка, например, держит свою сессию живой через ночи, - поэтому однаsessionсклеила бы две недели ночей в один ход. - Строка без ключа хода (
gate.outboundиoutbox.*ночной отправки) относится к последнему поtsходу своей сессии. sourceне входит ни в один ключ. Внутри одного ночного хода строки eve несутsource: "unknown", аgate.outboundиoutbox.*-rollup, поэтому группировка по источнику разрезала бы один ход на два.
Имя одного хода. <session>/<turn>, например wrun_01M…/turn_0, - это то, что печатает
и принимает iva trace show, что даёт список сбоев Insight и что берёт iva diagnose --turn.
Голый turn_N называет новейший ход с этим номером.
Колбэки (кнопки /menu, прежняя iva_cancel) получают ключ tg:<chatId>:cb:<callbackId>, и
делает его только мост: до runTelegramInbound колбэк не доходит, поэтому такие
строки bridge.* остаются сиротами - ни turn.bound, ни inbound.* у них не будет.
Нативный стоп черновика (stopped_message_generation) получает ключ tg:<chatId>:stop:<update_id> на входе; stop.* хранит сверенную пару сессии и хода.
Каталог событий
kind.name | data |
|---|---|
bridge.admitted / bridge.dropped | updateId, chatId, messageId, kind (message/callback), decision (owned/terminal-drop/unownable/write-failed) |
bridge.queued | updateId, chatId, messageId, kind, count (сколько теперь ждёт в очереди чата), parts; время события и есть время постановки |
bridge.delivered / bridge.rejected | те же поля плюс accepted (true/false/"handled"), ms, parts (сколько сообщений в доставке: пачка из очереди несёт несколько) |
inbound.received | chatId, chatType, messageId, userId, allowlisted; содержимое: text |
inbound.accepted / inbound.dropped | chatId, chatKey, parts, partChars[]; содержимое: context[] |
gate.inbound / gate.web | surface, blocked, reason, flags[], truncatedChars, chars |
turn.bound | chatKey, updateKey |
context.parts | core, persona, moc, daily - размеры файлов памяти в байтах, unit, approximate |
eve.turn.started / turn.completed / turn.cancelled | sequence |
eve.turn.failed / eve.step.failed | sequence, stepIndex, code; содержимое: message, details |
eve.step.started | sequence, stepIndex |
eve.step.completed | sequence, stepIndex, finishReason, usage {in,out,cacheRead,cacheWrite,costUsd?} |
eve.actions.requested | sequence, stepIndex, actions[{kind,callId,toolName|name}]; содержимое: args[] позиция в позицию с actions |
eve.action.result | sequence, stepIndex, status, callId, toolName, name? (скилл load_skill), subagentName?, isError, errorCode?, exitCode?, failure?, outChars?; содержимое: result (одна строка JSON), error (только если отличается от result) |
eve.message.completed | sequence, stepIndex, finishReason; содержимое: message |
eve.message.received | sequence, parts; содержимое: message |
eve.reasoning.completed | sequence, stepIndex; содержимое: reasoning |
eve.subagent.started / subagent.completed / subagent.called | callId, subagentName, name, childSessionId; содержимое: output |
| события eve внутри субагента | те же поля плюс subagent, parentCallId |
gate.outbound | clean, findings[] (тип:имя, без превью секрета), chars; содержимое: text - уже после редактуры |
outbox.delivered / outbox.failed | ok, delivered, fellBack, error, chars, ms |
stop.requested / stop.idle / stop.failed | chatKey, outcome |
У любого события eve в data может оказаться sessionId (если он есть в payload), а в
содержимом - input (и inputChars): хук копирует оба по имени.
failure помечает упавший вызов инструмента классом, никогда не текстом: isError (eve
или инструмент пометил ответ ошибкой), status:failed (ответ назвал свои code и message;
код лежит в errorCode), ok:false, error, timeout, exit <код> (bash, только с непустым
stderr). rejected и отменённый вызов - не сбой. Сбой, спрятанный конвейером или 2>&1,
failure не получает. Строковый ответ, в котором лежит JSON-объект, читается как этот
объект, как его читают eve и сторож повторов. Строки, записанные до этой правки, несут
result объектом и без failure.
gate.outbound бывает без парного outbox.*. Служебные реплики канала (статус
«Работаю…», объяснение сбоя, пометка про медиа) проходят тот же outbound-гейт, но мимо
шва Outbox, и дают строку гейта в одиночку. Не жди события доставки после каждого
вердикта.
outbox.delivered бывает с fellBack: true и error. Ответ дошёл до чата, но не в
том виде, что просил текст: кусок ушёл без разметки или Telegram отверг rich-сообщение, и
ответ ушёл HTML-путём без кнопок. Во втором случае error такой:
buttons dropped: sendRichMessage 400: <причина от Telegram>.
Пометки писателя в data: traceTrimmed - событие поехало без содержимого;
traceUnreadable - payload не сериализуется. Пометки в значениях: …[truncated] - строка
или список обрезаны, …[deep] - вложенность глубже четырёх уровней, …[keys] - сколько
полей было у обрезанного объекта, …[unreadable] - поле не прочиталось. Результат bash,
который влезает в поле, пишется целиком; который не влезает, хранит конец stdout и
последние 1000 знаков stderr: …[truncated] в начале поля значит, что срезано начало.
Остальные ответы ставят ok, error, message, isError, code и exitCode первыми,
поэтому обрезка с конца их не теряет. outChars - размер ответа, который получила модель.
Событие, пересобранное по 2000 знаков (ниже), режет каждое поле с конца, и результат bash
тоже.
Чего в журнале нет
- Дельта-события (
message.appended,reasoning.appended,action.partial). Они повторяют накопленный текст сотни раз за ход; итог приходит в*.completed. - Состав контекста от самого eve. CORE, PERSONA и время инжектят динамические
инструкции eve, а отчитаться изнутри они не могут.
context.partsдаёт размер тех же файлов на диске в момент старта хода - приближение, о чём говорит само событие (approximate: true). - Вердикты гейта вне хода. Санитайзер зовут и скрипты, и юнит-тесты: без ключа хода вердикт прицепить некуда, поэтому такие вызовы не пишут ничего. Метка хода в процессе живёт 60 секунд: если разбор медиа одного входящего занял больше минуты, следующий за ним вердикт гейта в журнал не попадёт.
Содержимое: тумблер и обрезка
data/settings.json, поле captureContent (по умолчанию включено):
{ "captureContent": false }
Выключенный тумблер оставляет имена, тайминги и размеры - ход виден целиком, только без
текста. Каждое поле содержимого обрезано 4096 знаками с пометкой …[truncated].
Размеры пишутся только для строковых полей содержимого, как <ключ>Chars (text →
textChars). У массивов - args[], context[] - размера нет: их количество уже лежит в
data как actions[] и parts.
Хранение
30 дней: сегодняшний файл и 29 предыдущих. Чистка идёт по дате в имени файла, никогда
по mtime (ADR-0002: время файла врёт после копирования и восстановления), и запускается на
первой записи журнала в процессе и потом на каждой смене файла дня. Файлы с именами не по
шаблону YYYY-MM-DD.jsonl не трогаются.
Что гарантировано читателю
- Каждая строка - самостоятельный валидный JSON; битая строка не портит соседние.
- Ошибка записи никогда не ломает и не тормозит ход: она уходит в лог службы (не чаще раза в минуту) и на этом всё.
- В один файл пишут два процесса, поэтому порядок строк - порядок записи, а не порядок
событий хода. Сортируй по
ts.
Исторические события Trace вроде turn_3#planner сохраняют прежний смысл.