Files
gitmost/docs/reading-ai-logs.md
T
agent_coder 3f217f4835 perf(ai-chat): формат трейса tool_calls v2 — outputs только в parts (#490)
Каждый tool-output хранился ДВАЖДЫ: в metadata.parts (assistantParts) И в
tool_calls (serializeSteps). При 50-шаговом ране с outputs по 50–200 KB это
127–510 МБ записи в Postgres за ход (+WAL/TOAST/dead tuples), т.к. onStepFinish
переписывает всю строку. Копия в parts — та, что реально реплеится модели и
рендерится UI/markdown-экспортом, так что копия в трейсе была чистым дублем.

Новый формат элементов tool_calls (v2), парно на каждый вызов:
  {toolName, input}                      — вызов
  {toolName, ok: true}                   — успех (БЕЗ output)
  {toolName, error, kind: 'thrown'}      — брошенный tool-error
  {toolName, error, kind: 'interrupted'} — прерван mid-step (abort/restart)

kind обязателен: синтетический «Tool call did not complete.» при прерывании иначе
неотличим от реального hard-fail и загрязняет error-rate. Различие структурное
(errorsById-хит против синтетической ветки), НЕ per-tool классификатор — soft-
маркеры в трейс не выносятся (остаются в metadata.parts).

metadata.toolTraceVersion: 2 — маркер эры; старые строки НЕ мигрируются
(перезапись гигантских jsonb — тот самый WAL-чарн). serializeSteps пейрит
результаты/ошибки по toolCallId (как assistantParts); общая константа
TOOL_CALL_INCOMPLETE_TEXT держит текст реплея и трейса в синхроне.

docs/reading-ai-logs.md переписан dual-shape: ветвление по toolTraceVersion,
soft-анализ v2 через metadata.parts, правило «не сравнивать агрегаты через границу
эр». UI action-log и markdown-экспорт читают только parts — не затронуты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:39:42 +03:00

21 KiB

Reading the AI dialog logs (how agents call tools, and where they fail)

How to inspect the agent conversation history in the database — and, more importantly, the one non-obvious trap that will make you report the wrong answer: the persisted history silently hides hard tool failures. Written from real pain (a "which tools fail most?" analysis that confidently answered "patchNode: 0 errors" while the UI was visibly full of red patchNode failures).

Read the Gotchas section before you trust any error count.

TWO ERAS — check the marker first. The tool_calls shape changed in #490 (trace v2). A row written by v2 carries metadata.toolTraceVersion = 2; older rows have no such key. The two shapes store DIFFERENT things (v2 dropped the tool OUTPUT from the trace), so every query below is dual-shape — branch on the marker. Never compare an aggregate or trend across the era boundary: a metric jump on the cut-over week is an artifact of the shape change, not a behavior change.

TL;DR

  • Agent chats live in Postgres, DB docmost, tables ai_chat_*.
  • Era marker: metadata.toolTraceVersion = 2 ⇒ v2 (#490) row; absent ⇒ legacy row.
  • Each tool invocation is stored as two consecutive array elements — a tool-call part then an OUTCOME part — so naive counting double-counts.
    • v2 (#490): outcome is {toolName, ok: true} on success, or {toolName, error, kind: 'thrown'|'interrupted'} on failure. The tool OUTPUT is NOT in tool_calls any more — it lives once in metadata.parts (this removed a hundreds-of-MB-per-run write duplication). Soft-failure analysis therefore reads metadata.parts, not tool_calls.
    • legacy: outcome is {toolName, output} on success; a thrown failure is a {toolName, error} element only on rows after #407, and is dropped entirely (silent orphan) on pre-#407 rows.
  • A tool that throws writes no result part. In v2 it is a {error, kind:'thrown'} element; an interrupted/aborted call is a distinct {error, kind:'interrupted'}. isError/success=false scans read the output and so under-report thrown failures in every era.
  • To find where agents fail: (1) soft-failure markers in metadata.parts outputs (v2) / tool_calls outputs (legacy), (2) the error/kind fields for thrown failures (v2 + post-#407), (3) server logs / the live UI for full stack traces.

Where the data lives

Host island.lc (10.31.40.120), container gitmost-postgresql (pgvector/pgvector:pg18), database docmost.

ssh island.lc
# one-off query:
docker exec gitmost-postgresql psql -U docmost -d docmost -P pager=off -c "SELECT ..."
# interactive:
docker exec -it gitmost-postgresql psql -U docmost -d docmost

The main app container is gitmost (DATABASE_URL=postgresql://docmost:...@db:5432/docmost). All workspaces (vvzvlad / wb / asakusa / …) share this single database — they are rows in workspaces, not separate deployments.

Relevant tables

Table What it holds
ai_chats one row per conversation (title, role_id, page_id, creator_id)
ai_chat_messages every message; tool calls live in tool_calls jsonb
ai_chat_runs one row per agent run (turn): status, error, step_count
ai_agent_roles agent definitions (instructions, model_config)
ai_mcp_servers configured MCP tool servers per workspace

ai_chat_messages columns that matter: role (user | assistant — there is no separate tool role), content (text), tool_calls (jsonb array), metadata (jsonb, holds run error + rendered parts), status, tsv (full-text index).

Era marker — check this before every query

-- how many rows are in each era?
SELECT COALESCE((metadata->>'toolTraceVersion'), 'legacy') AS era, count(*)
FROM ai_chat_messages
WHERE role = 'assistant' AND jsonb_typeof(tool_calls) = 'array'
GROUP BY 1 ORDER BY 2 DESC;
  • toolTraceVersion = '2'v2 (#490): outcome flags, no output in the trace.
  • NULL ('legacy') → pre-#490: outcome carries the tool output inline.

Do not trend a metric across the cut-over. The shape change alone shifts counts (e.g. "elements with output" collapses to zero for v2), so a week that straddles the boundary shows an artifact, not a behavior change. Segment by era, or restrict to one era, before comparing.

How tool calls are stored — READ THIS

Tool calls are not one-object-per-call. Each logical invocation is split into two consecutive elements of the tool_calls array — a call then an outcome. The outcome shape is era-dependent:

# v2 (#490) — metadata.toolTraceVersion = 2
index 0: { "toolName":"getPage", "input":{...} }                       ← call     (has input)
index 1: { "toolName":"getPage", "ok":true }                          ← success  (NO output here)
   or  : { "toolName":"getPage", "error":"…", "kind":"thrown" }       ← threw
   or  : { "toolName":"getPage", "error":"…", "kind":"interrupted" }  ← aborted mid-step

# legacy — no toolTraceVersion
index 0: { "toolName":"getPage", "input":{...} }                       ← call     (has input, NO output)
index 1: { "toolName":"getPage", "output":{...} }                     ← success  (has output)
   or  : { "toolName":"getPage", "error":"…" }                        ← threw (post-#407 only)

The keys that can appear: toolName, input (call), and on the outcome — v2: ok or error+kind; legacy: output or (post-#407) error. There is no state, no errorText, no type in tool_calls (those live on metadata.parts). Consequences:

  1. Real invocation count — count the OUTCOME elements, not every element (else you double-count): v2 = elements with ok or error; legacy = elements with output or error.
  2. Pairing: a call (input) is followed by its outcome. toolName is on both, so you can group by tool on either. In v2 the kind field separates a real hard-fail (thrown) from an aborted call (interrupted) — a distinction legacy rows cannot make (both are orphans; see below).
  3. The tool OUTPUT is only in metadata.parts on v2 rows. To inspect what a tool returned (soft-error markers, page bodies) on a v2 row, read the parts (part->>'type' LIKE 'tool-%', part->>'state' = 'output-available', part->'output'), not tool_calls.

The two classes of failure (and which the DB can see)

1. Soft failures — tool RAN and returned an error-shaped result → PERSISTED

These are visible in the tool outputon v2 rows in metadata.parts (the output-available part's output), on legacy rows in the tool_calls outcome element's output. The marker differs per tool:

Tool(s) Error marker in output
editPageText failed is a non-empty array of {find, reason} (e.g. text not found in the document, matches N times — provide a longer fragment or set replaceAll). Also a soft warning when the find string contained markdown that only matched after stripping.
semanticSearch { "unavailable": true, "reason": "semantic search unavailable" } (feature/infra, not the agent's fault)
MCP passthrough (Habr_*, some Search_*) output is an array (raw MCP content) whose text starts with Error executing tool … validation error …
generic output.isError = true or output.success = false

Note editPageText returns failed: [] on success — filtering on the presence of the key gives false positives; filter on non-empty.

2. Hard failures — tool THREW → PERSISTED

When a tool throws (the classic one is patchNode / insertNode / tableUpdateCellFailed to encode document to Yjs (fromJSON): Unknown node type: undefined), the runtime writes no tool-result part — the failure is an ai@6 tool-error content part. How that lands in tool_calls depends on the era:

  • v2 (#490): a {toolName, error, kind:'thrown'} outcome element. An interrupted / aborted mid-step call is a distinct {toolName, error:'Tool call did not complete.', kind:'interrupted'} element — so you can tell a real hard-fail from an abort directly, without the orphan heuristic. Query kind = 'thrown'.
  • post-#407 legacy: a {toolName, error} element (no kind) right after the call.
  • pre-#407 legacy: the error is dropped — a silent orphan (a call with no output and no error).

The same real error text is replayed to the model on the next turn (an output-error part with the real errorText, from metadata.parts), in every era.

Cutover caveat. Only pre-#407 legacy rows need the orphan proxy: an orphan is a tool-call with no matching outcome. Orphans there also appear when a run is aborted mid-flight (server restart), so a high-volume tool (createComment, searchInPage, Search_web_search) shows orphans from aborts, not real errors. Treat the orphan gap as an upper bound and cross-check the tool: a gap on a structural editor (patchNode, insertNode, updatePageJson, transformPage) is almost certainly a thrown Yjs-encode error; a gap on createComment is mostly aborts. On v2 rows this ambiguity is gonekind labels each outcome.

3. Run-level failures → ai_chat_runs

statussucceeded | aborted | failed | running; error holds the text. Seen in the wild: Run interrupted by a server restart. (aborts) and Failed after N attempts. Last error: The service may be temporarily overloaded (LLM provider 529). These are infra/provider, not agent tool misuse.

Ready-to-use queries

Run all of these via docker exec gitmost-postgresql psql -U docmost -d docmost -P pager=off -c "…".

Real invocation count per tool (outcome parts only — the correct denominator). Dual-shape: a v2 outcome has ok or error; a legacy outcome has output or error:

SELECT elem->>'toolName' AS tool, count(*) AS calls
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array'
  AND (elem ? 'ok' OR elem ? 'output' OR elem ? 'error')
GROUP BY 1 ORDER BY 2 DESC;

Soft errors per tool. The soft-error marker lives in the tool OUTPUT — which on v2 rows is in metadata.parts, on legacy rows is in the tool_calls outcome element. This query UNIONs both eras, projecting each output as o:

WITH res AS (
  -- v2 (#490): output is in metadata.parts (output-available tool parts)
  SELECT part->>'type' AS tool, part->'output' AS o
  FROM ai_chat_messages m, jsonb_array_elements(m.metadata->'parts') part
  WHERE (m.metadata->>'toolTraceVersion') = '2'
    AND part->>'type' LIKE 'tool-%' AND part->>'state' = 'output-available'
  UNION ALL
  -- legacy: output is inline in the tool_calls outcome element
  SELECT elem->>'toolName' AS tool, elem->'output' AS o
  FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
  WHERE (m.metadata->>'toolTraceVersion') IS NULL
    AND jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'output'
)
SELECT tool, count(*) AS calls,
       sum(COALESCE(
         (o->>'isError') = 'true'
         OR (o->>'success') = 'false'
         OR (jsonb_typeof(o->'failed') = 'array' AND o->'failed' <> '[]'::jsonb)
         OR (o->>'unavailable') = 'true'
         OR o::text ~* 'error executing tool|validation error'
       , false)::int) AS soft_errors
FROM res GROUP BY tool HAVING sum(COALESCE(
         (o->>'isError') = 'true' OR (o->>'success') = 'false'
         OR (jsonb_typeof(o->'failed') = 'array' AND o->'failed' <> '[]'::jsonb)
         OR (o->>'unavailable') = 'true' OR o::text ~* 'error executing tool|validation error'
       , false)::int) > 0
ORDER BY soft_errors DESC;

Note the v2 tool label is the part type (tool-editPageText); strip the tool- prefix if you join it against the legacy toolName.

editPageText failure reasons (the most common real agent mistake — bad find). Same dual-shape output source:

WITH res AS (
  SELECT part->'output' AS o
  FROM ai_chat_messages m, jsonb_array_elements(m.metadata->'parts') part
  WHERE (m.metadata->>'toolTraceVersion') = '2'
    AND part->>'type' = 'tool-editPageText' AND part->>'state' = 'output-available'
  UNION ALL
  SELECT elem->'output' AS o
  FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
  WHERE (m.metadata->>'toolTraceVersion') IS NULL
    AND jsonb_typeof(m.tool_calls) = 'array'
    AND elem->>'toolName' = 'editPageText' AND elem ? 'output'
)
SELECT f->>'reason' AS reason, count(*)
FROM res, jsonb_array_elements(o->'failed') f
WHERE jsonb_typeof(o->'failed') = 'array'
GROUP BY 1 ORDER BY 2 DESC;

Hard errors — persisted error field per tool (v2 + post-#407 rows) — thrown tool failures carry their real reason, so query them directly. On v2 rows exclude the interrupted kind so an aborted call is not counted as a hard-fail:

SELECT elem->>'toolName' AS tool, count(*) AS thrown_errors,
       min(elem->>'error') AS sample_error
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'error'
  -- v2 rows label the kind; a legacy error element has no kind (count it).
  AND COALESCE(elem->>'kind', 'thrown') = 'thrown'
GROUP BY 1 ORDER BY 2 DESC;

Aborted mid-step calls on v2 rows are a distinct, directly countable population:

SELECT elem->>'toolName' AS tool, count(*) AS interrupted
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem->>'kind' = 'interrupted'
GROUP BY 1 ORDER BY 2 DESC;

Hard-error proxy for OLD rows (pre-#407) — orphan gap per tool, WITH a spread column (call parts minus outcome parts, plus how many distinct chats the gap is spread across). This is needed ONLY for pre-#407 legacy rows (v2 and post-#407 rows carry the error / kind directly — use the queries above). The WHERE restricts to the legacy era so v2 rows (where an ok outcome is not an output) never produce phantom orphans:

WITH parts AS (
  SELECT m.chat_id, elem->>'toolName' AS tool,
         (elem ? 'input' AND NOT (elem ? 'output') AND NOT (elem ? 'ok')) AS is_call,
         (elem ? 'output' OR elem ? 'error' OR elem ? 'ok')               AS is_result
  FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
  WHERE jsonb_typeof(m.tool_calls) = 'array' AND m.role = 'assistant'
    AND (m.metadata->>'toolTraceVersion') IS NULL
),
per_chat AS (
  SELECT tool, chat_id, sum(is_call::int) - sum(is_result::int) AS gap
  FROM parts GROUP BY tool, chat_id
)
SELECT tool,
       sum(gap) FILTER (WHERE gap > 0)   AS missing_results,
       count(*) FILTER (WHERE gap > 0)   AS chats_spread,   -- disambiguates!
       max(gap)                          AS worst_single_chat
FROM per_chat GROUP BY tool
HAVING sum(gap) FILTER (WHERE gap > 0) > 0
ORDER BY missing_results DESC;

The is_result predicate counts an error element as a paired result too, so on new rows a persisted thrown error no longer inflates the orphan gap; a remaining gap is an aborted/interrupted call.

On OLD rows, missing_results mixes thrown errors AND aborted/interrupted runs — you cannot split them from output alone (a positional "what follows the orphan" heuristic breaks on parallel tool batches, which persist as call,call,…,result,result). Use chats_spread to disambiguate:

  • spread across many chats (e.g. createComment 96 over 29 chats) → a systemic real error (here: inline-comment anchor text not found on the page).
  • concentrated in one chat (e.g. searchInPage 55, of which 51 in a single chat) → one runaway/aborted session, not a real per-call error — discount it.
  • a gap on a structural editor (patchNode, insertNode, tableUpdateCell, updatePageJson, transformPage) is almost always a thrown Yjs-encode error.

Run-level failures:

SELECT status, count(*), min(error) AS sample_error
FROM ai_chat_runs GROUP BY status ORDER BY 2 DESC;

Full-text search across messages. The tsv GIN index is built as to_tsvector('english', unaccent(content)) — so it stems English but not Russian (Russian lexemes are stored unstemmed, so only exact word forms match). Most content here is Russian, so prefer ILIKE for substring search:

-- Russian / substring — reliable:
SELECT chat_id, left(content, 120)
FROM ai_chat_messages WHERE content ILIKE '%иранск%' LIMIT 20;

-- English phrase — can use the index:
SELECT chat_id, left(content, 120)
FROM ai_chat_messages
WHERE tsv @@ websearch_to_tsquery('english', 'some phrase') LIMIT 20;

Don't blow up your context

Tool outputs embed full page content and search payloads (hundreds of KB per row). On legacy rows they are in tool_calls; on v2 rows they moved to metadata->'parts' (the tool_calls trace itself is now small). Never SELECT tool_calls / metadata (or jsonb_pretty(...)) raw — project just the keys you need and truncate:

-- v2: outputs live in metadata.parts
SELECT part->>'type',
       left(regexp_replace((part->'output')::text, '\s+', ' ', 'g'), 200)
FROM ai_chat_messages m, jsonb_array_elements(m.metadata->'parts') part
WHERE (m.metadata->>'toolTraceVersion') = '2'
  AND part->>'state' = 'output-available' LIMIT 5;

-- legacy: outputs live in tool_calls
SELECT elem->>'toolName',
       left(regexp_replace((elem->'output')::text, '\s+', ' ', 'g'), 200)
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE elem ? 'output' LIMIT 5;

Server logs & live UI (for the error text the DB drops)

docker logs -f --tail=100 gitmost            # main app
docker compose -p gitmost logs -f --tail=100 # whole stack

Logging is json-file, max-size=10m max-file=5 → ~50 MB retained, then rotated, and wiped on container recreate. Thrown-tool error text is persisted — in the error field of tool_calls (v2 kind:'thrown' / post-#407 legacy) — so you no longer depend on live logs for it. Logs/live UI remain useful for pre-#407 rows (whose thrown errors were dropped) and for full stack traces beyond the truncated stored message. A per-tool tool_calls_total{tool,status} metric to VictoriaMetrics is still a possible future add for aggregate dashboards.

Gotchas checklist

  • Check metadata.toolTraceVersion first. v2 (= 2) has no output in tool_calls; legacy has it inline. Never trend a metric across the era boundary.
  • Counting every tool_calls element → overcount. Count OUTCOME elements — v2: ok or error; legacy: output or error — never both call+outcome as invocations.
  • isError / success=false ≈ 0 does not mean "no errors" — thrown errors are an error element (v2 kind:'thrown' / post-#407), not in the output.
  • v2: soft-error markers (the tool output) are in metadata.parts, NOT tool_calls. Legacy: they are in the tool_calls outcome output.
  • v2: kind splits a real hard-fail (thrown) from an aborted call (interrupted) directly — no orphan heuristic needed. The orphan gap is a pre-#407-legacy-only proxy.
  • editPageText.failed is [] on success — test for non-empty, not presence.
  • aborted runs = server restarts, failed runs = provider overload — not agent mistakes.
  • Never dump a raw tool_calls or metadata.parts cell — outputs are hundreds of KB.
  • Logs are ephemeral (≤50 MB, wiped on recreate) — grab pre-#407 hard-error text live.

Snapshot (2026-07-07, illustrative — rerun the queries for current numbers)

All rows in this snapshot predate #490, so they are legacy-era (outputs inline in tool_calls, orphan proxy for thrown errors). Do not trend these numbers against v2 rows — segment by toolTraceVersion first.

  • 226 chats, 732 messages, 46 runs; ~4 400 real tool invocations.
  • Soft errors (persisted): editPageText 4/79 (bad/non-unique find) + 9 markdown-in-find warnings; semanticSearch 3/4 (unavailable); Habr_update_draft_from_docmost 1/2 (doc sent as object, not string).
  • Missing-result proxy, read WITH the spread column:
    • Systemic (spread) → real errors: createComment 96 over 29 chats (comment anchor text not found — the biggest real error hotspot); editPageText 31 over 12 chats (+ the 4 soft above); structural-editor Yjs throws insertNode 10 / updatePageContent 9 / tableUpdateCell 6 / patchNode 5 / updatePageJson 2 / transformPage 2.
    • Concentrated → NOT real errors: searchInPage 55 (51 in one chat); Search_web_search 15 & Search_searxng_web_search 6 (timeouts/aborts in long research sessions).
  • Runs: 34 succeeded, 10 aborted (server restart), 1 failed (provider overload).