perf(mcp): trim the one schema 58 catalogued tools share, reclaiming 2 552 tokens (#2123)
The catalog had 116 tokens of headroom and server.ts took 70 commits in the preceding 14 days, so the next ordinary tool addition would have failed CI. Measured before cutting, which is what made this findable: outputSchema is 38% of the whole catalog (23 290 tokens), and STAGED_OPERATION_SCHEMA alone accounts for 14 736 of it, the same envelope transmitted 58 times. Descriptions, which the three previous rounds trimmed, are 10%. Three edits to one constant, no tool demoted and no field removed: period_status stops declaring its three sub-properties as JSON Schema and carries them in one sentence (actor, approve and preview were already bare objects, so this makes the envelope internally consistent), the next hint drops args' redundant additionalProperties: true, and operation_id's description loses six words. Every change is in the looser direction on purpose. 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; a looser one cannot do that. next keeps additionalProperties: false because staging.test.ts pins it as a closed shape, and a guard whose reason is not in front of you is not a guard to loosen for 420 tokens. Ceiling ratcheted 61 600 to 60 000, leaving ~1 070 tokens of deliberate working margin rather than the ~300 the previous rounds left. The bench log records the cycle that margin causes: ratchet tight, block the next feature, bump, demote. If more is needed the lever is priced: the envelope still costs ~11 800 tokens across those 58 tools, and the fix is to stop repeating it, not to trim it further. 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:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
b56da5d6c5
commit
8d9bf383d1
@@ -1457,3 +1457,4 @@ 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 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.
|
||||
|
||||
@@ -341,6 +341,40 @@ describe('tools/list payload size guard', () => {
|
||||
// Cheaper than it looks per example, so the next batch should still
|
||||
// demote a read first rather than assume there is room.
|
||||
//
|
||||
// * 61.5K to 58.9K by trimming the ONE schema 58 catalogued tools share
|
||||
// (2026-09-01). Measured first, which is what made it findable:
|
||||
// outputSchema is 38% of the whole catalog (23 290 tokens), and
|
||||
// STAGED_OPERATION_SCHEMA alone accounted for 14 736 of it, the same
|
||||
// envelope transmitted 58 times. Descriptions, which the three rounds
|
||||
// above trimmed, are only 10%.
|
||||
//
|
||||
// Three edits, no tool demoted and no field removed: period_status
|
||||
// stops declaring its sub-shape as JSON Schema and carries it in one
|
||||
// sentence instead (actor, approve and preview were already bare
|
||||
// objects, so this makes the envelope internally consistent), the next
|
||||
// hint drops args' redundant additionalProperties: true, and
|
||||
// operation_id's description loses six words. Every
|
||||
// change is in the LOOSER direction: 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. A looser declaration cannot do that.
|
||||
//
|
||||
// next keeps additionalProperties: false: staging.test.ts pins it as a
|
||||
// closed shape, and a guard whose reason is not in front of you is not
|
||||
// a guard to loosen for 420 tokens.
|
||||
//
|
||||
// Ceiling set to 60 000, not to 59 250. That leaves ~1 070 tokens of
|
||||
// deliberate working margin, about two or three ordinary tool
|
||||
// additions. The log above shows the tight-ratchet cycle: ratchet to
|
||||
// ~300 margin, block the next feature, bump, feel bad, demote. server.ts
|
||||
// took 70 commits in the 14 days before this change and the margin was
|
||||
// 116 tokens, which is how that cycle starts. The margin is here so the
|
||||
// guard catches sustained growth rather than the next commit.
|
||||
//
|
||||
// If more is needed, the lever is known and priced: the envelope still
|
||||
// costs ~11 800 tokens across those 58 tools, and the structural fix is
|
||||
// to stop repeating it, not to trim it further.
|
||||
//
|
||||
// Long-term answer to growth is no longer a ceiling bump. gnubok_call_tool
|
||||
// makes `catalogVisibility: 'search'` usable for READ tools on hosts that
|
||||
// can only invoke what tools/list showed them, which is the constraint that
|
||||
@@ -350,7 +384,7 @@ describe('tools/list payload size guard', () => {
|
||||
// Only READ tools may be demoted: gnubok_call_tool refuses writes, so a
|
||||
// search-only WRITE is uncallable on Claude.ai. That is why the three
|
||||
// bumps above happened instead of demotions.
|
||||
expect(approxTokens).toBeLessThan(61_600)
|
||||
expect(approxTokens).toBeLessThan(60_000)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1461,13 +1461,20 @@ const PAGINATION_PROPS = {
|
||||
next_offset: { type: 'number', description: 'Offset for the next page (omitted on last page)' },
|
||||
} as const
|
||||
|
||||
// Declared loosely on purpose. Every field here is transmitted once per
|
||||
// staged-write tool, and there are 58 of them in the default catalog, so a
|
||||
// character in this constant costs 58 characters of every agent's context.
|
||||
// `additionalProperties: false` stays: staging.test.ts pins the next hint as a
|
||||
// closed shape, and a guard whose reason is not in front of me is not a guard
|
||||
// to loosen for 420 tokens. Only args' redundant `additionalProperties: true`
|
||||
// went, which is the JSON Schema default anyway.
|
||||
const NEXT_ACTION_HINT_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
description: { type: 'string' },
|
||||
tool: { type: 'string' },
|
||||
args: { type: 'object', additionalProperties: true },
|
||||
args: { type: 'object' },
|
||||
resource: { type: 'string' },
|
||||
},
|
||||
required: ['description'],
|
||||
@@ -1477,7 +1484,7 @@ const STAGED_OPERATION_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
staged: { type: 'boolean' },
|
||||
operation_id: { type: 'string', description: 'UUID of the staged operation, present once persisted' },
|
||||
operation_id: { type: 'string', description: 'Staged operation UUID, once persisted' },
|
||||
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
actor: { type: 'object' },
|
||||
dry_run: { type: 'boolean' },
|
||||
@@ -1485,14 +1492,13 @@ const STAGED_OPERATION_SCHEMA = {
|
||||
message: { type: 'string' },
|
||||
approve: { type: 'object' },
|
||||
preview: { type: 'object' },
|
||||
// Shape carried in prose rather than declared: the same three properties
|
||||
// spelled out as JSON Schema cost 58x what one sentence costs, and the
|
||||
// model reads the sentence either way. Same reason actor/approve/preview
|
||||
// above have always been bare objects.
|
||||
period_status: {
|
||||
type: 'object',
|
||||
description: 'Fiscal period covering the affärshändelse date. Use to detect locked/closed periods without a round-trip.',
|
||||
properties: {
|
||||
period_id: { type: ['string', 'null'] },
|
||||
status: { type: 'string', enum: ['open', 'locked', 'closed'] },
|
||||
lock_date: { type: ['string', 'null'] },
|
||||
},
|
||||
description: 'Period of the affärshändelse: period_id, status (open|locked|closed), lock_date. Detects a locked period without a round-trip.',
|
||||
},
|
||||
next: NEXT_ACTION_HINT_SCHEMA,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user