fix(mcp): tag unmapped tool failures with their cause vocabulary (#2051) (#2135)

Closes #2051. errorCauseTag() shipped in #2027 written and tested but
wired to nothing. This connects it: the two execution catch paths (sync
call and task) now pass errorCause into mcp.tool_called, carrying the
SQLSTATE or coded-error code, else the error's class name, capped at 64
chars.

The rows this exists for are the UNKNOWN_ERROR residue, whose
errorMessage is the constant "Något gick fel. Försök igen." and whose
errorDetail is the English constant: 465 such rows in the last 30 days
(create_voucher 58 of its 60 failures, query_journal 122) with nothing
to cluster on. A five-character SQLSTATE is protocol vocabulary; a raw
driver message can quote row values from a constraint violation and
belongs in the server log, never in event_log, so the raw message is
deliberately not captured.

A plain `new Error(...)` tags null rather than 'Error': tagging
everything is the same as tagging nothing. Pre-execution denials (scope,
capability, validation, unknown tool) pass nothing because their
errorCode already is the cause.

Self-tested by unwiring one call site and watching the new test name it.


Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-01 22:07:46 +02:00
committed by GitHub
parent 191fb2cfce
commit dd84d6c1bb
4 changed files with 71 additions and 2 deletions
+1
View File
@@ -1457,5 +1457,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-01] F2 bank-data staleness: ship freshness reads only (last_synced_at/consent_expires/error_message on gnubok_connect_bank + new GET /api/v1/.../bank-connections, scope companies:read mirroring the MCP mapping): the daily cron already syncs server-side, so visibility is what the incident lacked; an agent-triggerable sync is a product bet (EB call cost, runaway agents) and was deferred by Emil.
[2026-09-01] Verifikationsserie in the Ny verifikation modal is a closed dropdown instead of a one-letter free-text field: a typo there silently opens a brand-new series with its own number sequence, and the letters only mean anything if everyone uses the same ones. The letters are NOT prescribed by law (BFL 5 kap. 7 § requires only unbroken systematic numbering within each series), and the incumbents disagree: Björn Lundén uses A Huvudserie, F Kundfakturor, I Inbetalningar, L Leverantörsfakturor, N Löner, U Utbetalningar, J Bokslut. We ship FORTNOX's table verbatim (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport), from their own Systemdokumentation, because Fortnox is the system most companies migrate here from and an imported ledger should keep its meaning. REJECTED an earlier draft that labelled A as Kundfakturor: A is the general series manual entries land in (the one point Fortnox and BL agree on, and Fortnox allows manuell kontering ONLY in A), and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Calling it Kundfakturor would mislabel their entire history and the modal's own default. The list is closed but any letter the company already configured, or that a draft was saved with, is appended so no existing value can fall out of the picker. Also: tabbing or clicking into an untouched amount field now proposes the outstanding difference (pre-selected, so typing replaces it) when the row already has an account and the difference belongs on that side. This deliberately reverses part of the note in updateLine that said a balancing amount must never auto-fill: that note was about filling on ACCOUNT selection, which stole the amount before the user had a chance to split it. Filling on focus keeps the split case intact because the proposal is selected text, and it fixes the common moms case where the last line is just the remainder.
[2026-09-01] Settings PUT cross-field VAT validations scoped to touched field groups (vat-completeness, 40m-monthly, periodisk sammanstallning), not fixed at onboarding: partial saves from surfaces without VAT fields (invoice bank-details dialog) were hard-blocked by pre-existing vat_registered-without-number state (Marketio Lab case). The invariant still holds on every save that touches its group; explicit null now counts as a clear instead of falling back to the stored value during validation. Onboarding-side VAT number collection left as follow-up.
[2026-09-01] mcp.tool_called gets errorCause = errorCauseTag(err) on the two execution catch paths only (#2051): SQLSTATE or coded-error code, else the error class name, capped at 64 chars; a plain Error deliberately tags null because the class name 'Error' is noise, and pre-execution denials pass nothing since their errorCode already IS the vocabulary. Raw driver messages stay out of event_log on purpose: a constraint-violation message can quote row values.
[2026-09-01] counterparty_aliases joins the categorization_templates audit-trigger strip list (20260901200000) instead of staying logged: prod falsified the original exclusion list within 30 minutes of 20260901103000 going live (15 of the first 16 UPDATE audit rows were alias+learning noise, ~800/day projected vs ~50/day of real rule changes), because the learning path merges aliases in the same write that bumps occurrence_count. Explicit trade-off: a human editing ONLY aliases is no longer logged; accepted since alias growth is overwhelmingly automatic and any change also touching accounts/VAT/pattern/active still logs (first real one, 19:02:17Z same day, captured correctly). Pre-fix noise rows stay in audit_log (append-only) and the read model stops labelling the column so they render as no-ops.
[2026-09-01] MCP catalog budget attacked at the duplicated staged envelope rather than by demoting more reads: measuring the payload by segment showed outputSchema is 38 % of the whole catalog (23 290 tokens) and STAGED_OPERATION_SCHEMA alone 14 736 of it, the same envelope transmitted 58 times, while descriptions (what the three previous rounds trimmed) are only 10 %. period_status now carries its shape in one sentence instead of declared JSON Schema, matching actor/approve/preview which were always bare objects; 2 552 tokens reclaimed with no tool demoted and no field removed. Every edit is in the LOOSER direction because the server emits structuredContent for every tool and the documented failure mode is a declaration too tight making a strict client reject a successful call. next kept additionalProperties: false: staging.test.ts pins it closed and a guard whose reason is not in front of you is not one to loosen for 420 tokens. Ceiling ratcheted to 60 000 rather than the usual ~300 margin, leaving ~1 070 deliberate working margin: server.ts took 70 commits in 14 days and the previous 116-token margin is what starts the ratchet-block-bump-demote cycle visible in the bench log.
@@ -388,6 +388,59 @@ describe('mcp.tool_called telemetry', () => {
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
})
describe('errorCause: machine vocabulary for the unmapped residue (#2051)', () => {
/**
* 65.1% of real-agent errors in the 30 days before #2027 were
* UNKNOWN_ERROR, whose errorMessage is the constant "Något gick fel.
* Försök igen." and whose errorDetail is the English constant: nothing to
* cluster on. errorCause carries errorCauseTag(err): the SQLSTATE or coded
* error code, else the error's class name. Protocol vocabulary only,
* because a raw driver message can quote row values from a constraint
* violation and belongs in the server log, never in event_log.
*/
it('records the error class name when execute() dies unmapped', async () => {
const eventPromise = captureNextToolCalledEvent()
// Valid call for the key's reports:read scope; the harness supabase is a
// bare vi.fn() mock, so execute() dies on it. Exactly the shape that
// used to log UNKNOWN_ERROR with the constant message and nothing else.
await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_get_trial_balance', arguments: {} })
)
const event = await eventPromise
expect(event.errorKind).toBe('execution')
expect(event.errorCause).toBe('TypeError')
})
it('stays null for a plain Error: the class name "Error" is noise, not vocabulary', async () => {
const eventPromise = captureNextToolCalledEvent()
// gnubok_load_skill throws `new Error("Skill not found: ...")`.
await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_load_skill', arguments: { slug: 'definitely-does-not-exist' } })
)
const event = await eventPromise
expect(event.errorKind).toBe('execution')
expect(event.errorCause).toBeNull()
})
it('stays null on success and on pre-execution denials', async () => {
const successPromise = captureNextToolCalledEvent()
await handleMcpRequest(mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }))
expect((await successPromise).errorCause).toBeNull()
const deniedPromise = captureNextToolCalledEvent()
await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_create_invoice', arguments: { customer_id: 'x', items: [] } })
)
const denied = await deniedPromise
expect(denied.errorKind).toBe('scope_denied')
expect(denied.errorCause).toBeNull()
})
})
it('does NOT block the JSON-RPC response on telemetry: even if a handler throws', async () => {
// Register a handler that throws synchronously. The bus already isolates
// failures via Promise.allSettled, so the response should still arrive.
+15 -1
View File
@@ -48,7 +48,7 @@ import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
import { dbError } from '@/lib/errors/db-error'
import { dbError, errorCauseTag } from '@/lib/errors/db-error'
import { getStructuredError } from '@/lib/errors/get-structured-error'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
@@ -20509,6 +20509,15 @@ function emitToolCallTelemetry(payload: {
* message_sv is already the domain message costs nothing.
*/
errorDetail?: string | null
/**
* Machine vocabulary for the unmapped failures (#2051): errorCauseTag(err),
* i.e. the SQLSTATE or coded-error code, else the error's class name. For
* UNKNOWN_ERROR rows message_sv is the constant "Något gick fel. Försök
* igen.", so without this the residue cannot be clustered at all. Never the
* raw driver message: that can quote row values from a constraint violation
* and belongs in the server log, not in event_log.
*/
errorCause?: string | null
requestId: string | number | null
userId: string
// null/empty while the key's user has no company yet (issue #1814): the
@@ -20542,6 +20551,9 @@ function emitToolCallTelemetry(payload: {
payload.errorDetail && payload.errorDetail !== payload.errorMessage
? payload.errorDetail.slice(0, 500)
: null,
// Protocol vocabulary only (a SQLSTATE, a code, a class name), capped
// hard: anything longer is a message pretending to be a tag.
errorCause: payload.errorCause ? payload.errorCause.slice(0, 64) : null,
requestId: payload.requestId,
userId: payload.userId,
companyId,
@@ -21481,6 +21493,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorKind: 'execution',
errorMessage: structured.error.message_sv,
errorDetail: structured.error.message_en,
errorCause: errorCauseTag(err),
requestId: id ?? null,
userId,
companyId: effectiveCompanyId,
@@ -21574,6 +21587,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// clustering when mining failures for gotchas.
errorMessage: structured.error.message_sv,
errorDetail: structured.error.message_en,
errorCause: errorCauseTag(err),
requestId: id ?? null,
userId,
companyId: effectiveCompanyId,
+2 -1
View File
@@ -251,7 +251,8 @@ export type CoreEvent =
errorMessage: string | null // human-readable error message (truncated to 500 chars), null on success.
// Raw material for clustering real agent failures into curated gotchas:
// errorCode alone can't distinguish "period locked" from "unbalanced".
errorDetail: string | null // The specific English diagnostic, when message_sv is a generic registry
errorDetail: string | null
errorCause: string | null // errorCauseTag(err): SQLSTATE / coded-error code / error class name, <= 64 chars. The clustering key for rows whose errorMessage is the UNKNOWN_ERROR constant (#2051); never a raw driver message, which can quote row values. // The specific English diagnostic, when message_sv is a generic registry
// default that says nothing (VALIDATION_ERROR -> "Förfrågan innehåller
// ogiltiga uppgifter."). Null when it would only repeat errorMessage.
// Without it a 604-call outage looked identical to a typo in the logs: