13 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.
TL;DR
- Agent chats live in Postgres, DB
docmost, tablesai_chat_*. - Each tool invocation is stored as two array elements (a
tool-callpart and atool-resultpart), so naive counting double-counts. - A tool that throws writes no result part at all. Its error text is nowhere
in the DB — not in
tool_calls,content, ormetadata. It is shown live in the UI only. SoisError/success=falsescans under-report by design. - To find where agents fail you need three sources: (1) soft-failure markers in
tool_calls, (2) the orphan-gap proxy for thrown errors, (3) server logs / the live UI for the actual error text.
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).
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:
index 0: { "toolName": "getPage", "input": { "pageId": "…" } } ← tool-call (has input, NO output)
index 1: { "toolName": "getPage", "output": { … } } ← tool-result (has output, NO input)
The only keys that ever appear on an element are toolName, input, output.
There is no state, no errorText, no type. Consequences:
- Real invocation count = elements that have
output. Counting every element double-counts (you get ~2× and a spurious "~50% of every tool has no output"). - Pairing: a successful call = a
tool-callpart followed by itstool-resultpart. Both carrytoolName, so you can group by tool on either.
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-result 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 → NOT PERSISTED ❌ (the trap)
When a tool throws (the classic one is patchNode / insertNode / tableUpdateCell
→ Failed to encode document to Yjs (fromJSON): Unknown node type: undefined), the
runtime writes no tool-result part. The orphaned tool-call part stays, but
the error text is nowhere in the DB. It is streamed to the UI live and (until
rotation) to server logs — that is it.
So any query like count(*) FILTER (WHERE output.success = false) will happily
return 0 for patchNode even when the chat is visibly full of red failures.
That is survivorship bias, not reliability.
The only DB-side proxy for a thrown error is an orphan: a tool-call part with
no matching tool-result. Caveat: orphans 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 from real errors. Treat the
orphan gap as an upper bound on hard errors, 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.
3. Run-level failures → ai_chat_runs
status ∈ succeeded | 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 (result parts only — the correct denominator):
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 ? 'output'
GROUP BY 1 ORDER BY 2 DESC;
Soft errors per tool (everything the DB can honestly see):
WITH res AS (
SELECT elem->>'toolName' AS tool, elem->'output' AS o
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE 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;
editPageText failure reasons (the most common real agent mistake — bad find):
WITH res AS (
SELECT elem->'output' AS o
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE 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-error proxy — orphan gap per tool, WITH a spread column (call parts minus result parts, plus how many distinct chats the gap is spread across):
WITH parts AS (
SELECT m.chat_id, elem->>'toolName' AS tool,
(elem ? 'input' AND NOT (elem ? 'output')) AS is_call,
(elem ? 'output') 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'
),
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;
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.
createComment96 over 29 chats) → a systemic real error (here: inline-comment anchor text not found on the page). - concentrated in one chat (e.g.
searchInPage55, 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
A single tool_calls row can be 300–400 KB (results embed full page content and
search payloads). Never SELECT tool_calls (or jsonb_pretty(tool_calls)) raw.
Always project just the keys you need and truncate:
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. So thrown-tool error text is only reliably
caught in real time (or in the live chat UI, which renders the failed part with
its message). There is no durable, queryable store of hard tool errors today — if you
need one, that is a feature to add (persist output-error parts, or emit a
tool_calls_total{tool,status} metric to VictoriaMetrics).
Gotchas checklist
- Counting every
tool_callselement → 2× overcount. Count elements withoutput. isError/success=false≈ 0 does not mean "no errors" — thrown errors aren't persisted.editPageText.failedis[]on success — test for non-empty, not presence.- Orphan gap mixes thrown errors and aborted runs — split by tool before concluding.
abortedruns = server restarts,failedruns = provider overload — not agent mistakes.- Never dump a raw
tool_callscell — it can be hundreds of KB. - Logs are ephemeral (≤50 MB, wiped on recreate) — grab hard-error text live.
Snapshot (2026-07-07, illustrative — rerun the queries for current numbers)
- 226 chats, 732 messages, 46 runs; ~4 400 real tool invocations.
- Soft errors (persisted):
editPageText4/79 (bad/non-uniquefind) + 9 markdown-in-findwarnings;semanticSearch3/4 (unavailable);Habr_update_draft_from_docmost1/2 (docsent as object, not string). - Missing-result proxy, read WITH the spread column:
- Systemic (spread) → real errors:
createComment96 over 29 chats (comment anchor text not found — the biggest real error hotspot);editPageText31 over 12 chats (+ the 4 soft above); structural-editor Yjs throwsinsertNode10 /updatePageContent9 /tableUpdateCell6 /patchNode5 /updatePageJson2 /transformPage2. - Concentrated → NOT real errors:
searchInPage55 (51 in one chat);Search_web_search15 &Search_searxng_web_search6 (timeouts/aborts in long research sessions).
- Systemic (spread) → real errors:
- Runs: 34 succeeded, 10 aborted (server restart), 1 failed (provider overload).