12 KiB
logging.md
Extensive debug logging for tai, behind --debug. One DebugLog
owns three outputs: a sink (file, stdout, stderr), the vault logs
table, and nothing else. LoggingDocTests fails when this file
drifts from the code.
Flags and sinks
--debug takes an optional sink: --debug alone means
--debug file, --debug stdout and --debug stderr redirect the
stream. --debug-format is sentence (default) or json.
TAI_DEBUG and TAI_DEBUG_FORMAT are env fallbacks when the flags
are absent. Without either flag or env var, logging is fully off:
no file, no rows, no overhead beyond one boolean check per hook.
The file sink is <TAI_HOME>/debug.log, one line per event,
flushed on every write so a crash loses nothing. File mode also
writes every event to the logs table when a store exists; modes
without a store (demo, mplex client, grr client) are file-only.
stdout/stderr sinks still write the table too: the sink only moves
the human stream.
Wipe rule
The log file is wiped on the first write of a root process, and a
process is root when TAI_CHILD is unset. A child tai (currently
the CapAnswerer research spawn, which sets TAI_CHILD=1) appends
to whatever is there and never wipes. In-process workers (fork,
scheduler) share the open handle, so they neither wipe nor reopen.
Wipe-on-first-write, not wipe-on-boot, so --version and other
instant exits never destroy the previous log. Two concurrent roots
on one home are last-wipe-wins, documented, not prevented.
Formats
Both formats carry the same fields: ts (UTC ISO), level,
run (per-process hex id, correlates everything one boot wrote),
profile, bot, chat (telegram chat id, else empty), then the
dotted event name, the message, and the meta map.
Sentence (default), free text last so the head splits cleanly:
2026-10-18T12:01:02Z INFO run=ab12cd34 profile=work bot=main chat= component.event k=v k="quoted value" :: message
Meta values containing whitespace or quotes are double-quoted with
backslash escapes; everything before :: is whitespace-split
key=value, nothing else. JSON is one object per line with sorted
keys:
{"bot": "main", "chat": "", "component": "agent", "event": "turn_end", "level": "INFO", "message": "turn finished", "meta": {"exec_ms": 12340, "steps": 4}, "profile": "work", "run": "ab12cd34", "ts": "2026-10-18T12:01:02Z"}
Vault table
logs(id INTEGER PRIMARY KEY, profile, bot, chat, ts, run, level, component, event, message, meta_json)
Every filter column is indexed: idx_logs_profile(profile, id),
idx_logs_bot(bot), idx_logs_chat(chat), idx_logs_level(level),
idx_logs_event(component, event), idx_logs_run(run). An FTS
trigger indexes each row under kind='log' (trg_logs_ai, and
trg_logs_ad removes pruned rows), so /search and fts_search
rank log lines like any other vault text. message and meta_json
are sealed with tai_enc whenever the vault seal is enabled, and
unsealed only through Store.log_query, which takes the same
filters as the indexes plus limit and returns newest first.
The logger opens its own SQLite connection (WAL, 30s busy timeout,
one lock for all threads) instead of sharing Store.db, commits every row (so the logger
never holds a write transaction that could stall the main store
connection, and readers never lag), and prunes to LOG_DB_KEEP
newest rows every LOG_DB_PRUNE_EVERY inserts plus once per
configure. Logging never raises: a failed write is counted in
dropped and skipped.
Caps
| Cap | Value | Meaning |
|---|---|---|
LOG_DB_KEEP |
20000 | newest log rows kept per vault; older pruned with their FTS rows |
LOG_DB_PRUNE_EVERY |
256 | inserts between prune checks |
LOG_MSG_MAX |
2000 | message truncation, chars |
LOG_TEXT_MAX |
4000 | each trapped text (system/user/assistant) in DB meta, chars |
LOG_PREVIEW |
300 | each trapped text in the file sink, chars |
LOG_META_MAX |
16384 | meta_json cap, bytes; oversized metas shrink longest strings first, so the stored JSON stays valid |
The rules
R1. One event per interesting action, at exactly one site. No
action logs twice: Tools.dispatch covers all 61 tools, so no
run_* method logs; the bridge, the scheduler watcher, and the
serve dispatcher each log once for their layer.
R2. Level follows consequence, not volume. INFO for actions
taken, WARNING for retries and refused auth, ERROR for failed
operations and uncaught exceptions, DEBUG for durability
mutations (record/secret/schedule writes). There is no TRACE:
anything worth trapping is worth INFO.
R3. Every event carries the execution context it ran in: profile, bot, chat (telegram only, else empty), and the process run id. Callers pass what they know; the logger merges its sticky defaults under explicit arguments.
R4. LLM completions trap the full accounting tuple: backend,
model, exec_ms, tokens_in, tokens_out, tokens_total,
cost_usd, message char counts, the system, user, and assistant
texts (full to LOG_TEXT_MAX in the DB, previewed to
LOG_PREVIEW in the file), and the tool census: how many schemas
were actually sent, split into MCP, core, and lazy-native, plus
the sorted name list. Tokens are estimated at 4 chars each unless
a backend reports real usage (none do today); cost_usd is null
until a backend reports cost.
R5. Timed operations always log exec_ms as an integer. The
measurement wraps the operation, never the logging.
R6. Secret values, passphrases, keys, tokens, and header values
never reach a log. Names are fine (secret_saved name=wifi).
Every trapped text passes through Store.redact for its profile
before truncation, so a known value lands as [redacted:NAME];
tool arguments whose key names a secret (LOG_MASKED_ARGS) are
masked to *** before logging, which also covers values that are
not stored yet and therefore unknown to redact.
R7. Errors log where they are decided, with short_error text:
backend failure at the raise site, tool failure at dispatch, auth
refusal at the check, uncaught exceptions via sys.excepthook.
A catch that only re-raises or translates never logs.
R8. Hot paths never log: TUI render, input, and compositor paths,
token estimators, redact itself, FTS internals, health records,
and everything inside DebugLog. A hook in a per-keystroke or
per-frame path is a bug, not coverage.
R9. The logger never raises, never blocks on the network, never touches a tool, and never calls back into logged code. A logging failure is a dropped event, counted, not an error event.
R10. Child processes append, roots wipe (see Wipe rule). Log volume is bounded by the caps above, never by sampling: pruning deletes oldest first, deterministically.
R11. Sentence format keeps free text after :: and quotes any
meta value containing whitespace. JSON format sorts keys. Both are
line-delimited and greppable without a parser.
R12. New events extend the registry below with a fixed level, a
single site, and a fixed meta vocabulary (exec_ms, tokens_in,
tokens_out, tokens_total, cost_usd, outcome, error,
steps, tools_sent, tools_mcp, tools_core, tools_lazy,
tool_names, *_chars, *_text). A schema counts as MCP when
its name is not a native TOOL_SCHEMAS name. Synonyms are
rejected in review.
Event registry
| Event | Level | Site | Meta |
|---|---|---|---|
app.boot |
INFO | boot() end |
version, home, mode, profile |
app.exception |
ERROR | sys.excepthook |
error |
agent.turn_start |
INFO | run_turn wrapper |
goal, capture, max_steps, depth, routed |
agent.turn_end |
INFO | run_turn wrapper |
exec_ms, steps, tools_used, outcome, reply_chars |
agent.verified |
INFO | ensure_verified end |
success, failures |
agent.compact |
INFO | compact_messages end |
before_tokens, after_tokens, kept_turns |
llm.completion |
INFO | try_candidate success |
backend, model, exec_ms, tokens_in, tokens_out, tokens_total, cost_usd, history_msgs, system_chars, user_chars, assistant_chars, system_text, user_text, assistant_text, tool_calls, retried_without_tools, tools_sent, tools_mcp, tools_core, tools_lazy, tool_names |
llm.failed |
WARNING | try_candidate failure |
backend, model, error, transient |
llm.round_retry |
WARNING | complete() before sleep |
round, pause_s, candidates, errors |
llm.fatal |
ERROR | complete() wrapper on BackendError |
error |
tool.call |
INFO | Tools.dispatch |
name, args, exec_ms, outcome, error, result_chars |
approval.decision |
INFO | ask_approval wrapper |
command, decision, mode |
vault.record_saved |
DEBUG | add_record end |
id, kind, size |
vault.record_deleted |
DEBUG | delete_record end |
id |
vault.secret_saved |
DEBUG | save_secret end |
name |
vault.secret_deleted |
DEBUG | delete_secret end |
name |
vault.schedule_saved |
DEBUG | add_schedule end |
id, name |
vault.schedule_removed |
DEBUG | remove_schedule end |
id |
vault.seal_rotated |
INFO | rotate_seal end |
home |
vault.schema_upgraded |
INFO | boot() after print |
notes |
sched.fired |
INFO | scheduler_tick after spawn |
id, profile, every_sec |
sched.finished |
INFO | watch_schedule end |
id, status, result_chars |
serve.request |
INFO | dispatch_message |
method, tool, session, exec_ms, outcome |
serve.denied |
WARNING | do_POST auth check |
path |
cap.run_start |
INFO | cap_run_main head |
sessions |
cap.turn |
INFO | CapFleet.ask |
target, payload_chars, exec_ms, reply_chars, outcome |
telegram.update |
INFO | handle_telegram_update wrapper |
chat, text_chars, reply_chars, voice, outcome |
Outcomes are from a fixed set: ok, error, denied,
unknown_tool, bad_args, timeout. goal, args, command,
and all *_text values are redacted then truncated; *_chars
always reports the pre-truncation length.
Validation against the source
Every registry site was checked before implementation: boot,
run_turn, ensure_verified, compact_messages,
try_candidate, complete, Tools.dispatch, ask_approval,
add_record, delete_record, save_secret, delete_secret,
add_schedule, remove_schedule, rotate_seal,
scheduler_tick, watch_schedule, dispatch_message, do_POST,
cap_run_main, CapFleet.ask, and handle_telegram_update all
exist with the shapes the hooks assume. Adjusted during review:
serve logging moved from do_POST (seven early returns) into
dispatch_message plus one auth line; scheduler completion moved
from poll_agent (shared, would double-log forks) into
watch_schedule; uncaught exceptions moved from a main() wrap
into sys.excepthook; run_turn, complete, and handle_telegram_update became thin
wrappers over renamed inners so their many exits log once;
ask_approval instead logs through an inline decide() closure,
because TuiChatTests calls it unbound on a stub that a rename
would break.
Code map
LOG_* caps pin the bounds; DebugLog owns sink, format, run
id, wipe-on-first-write, the DB connection, prune, and redact;
module-global LOG is the one logger, inert until configure();
TAI_CHILD selects append; LOG_MASKED_ARGS masks secret-shaped
tool arguments; Store.log_query reads rows back seal-aware.
CapAnswerer.research marks its child.
Test hooks
LoggingDocTests asserts this file names every LOG_* cap, every
registry event, the logs table with its columns and indexes, the
trg_logs_* triggers, both flags, both formats, and TAI_CHILD,
with no em dash. LoggerTests covers formats, wipe/append,
redaction, prune, seal, log_query, the never-raise rule, and
representative hooks (dispatch, completion, approval, turn).