2d543ac999
* feat(agent): move every model call to Sonnet 5
Sonnet 5 is verified enabled on our Bedrock account already: a live probe of
eu.anthropic.claude-sonnet-5 in eu-north-1 answered normally, so no model-access
request was needed. The bare anthropic.claude-sonnet-5 is rejected (on-demand
throughput needs the cross-region inference profile), so the eu. prefix we
already use stays.
This is not a model-string swap. Sonnet 5 REJECTS the fixed thinking budget
outright: thinking {type:'enabled', budget_tokens} returns 400 "not supported
for this model. Use thinking.type.adaptive and output_config.effort". Every
chat intent set a budget, so the assistant would have failed on the first turn
after a bare ID change. Reasoning depth is now an effort level (STANDARD high,
DEEP xhigh), and max_tokens is explicit per tier rather than derived from a
budget that no longer exists.
display:'summarized' is load-bearing, not cosmetic. The default is 'omitted',
which still emits thinking blocks but with empty text. Measured on our own
account at xhigh effort: summarized returned ~1k characters of reasoning, the
default returned none. Without it the collapsible "Tänker ..." block in the
chat would have gone silently empty, which no mocked test would have caught.
Ceilings are raised (16k standard, 24k deep) because Sonnet 5's tokenizer
produces roughly 30% more tokens for the same text and max_tokens now caps
thinking and the visible reply together.
Also resolves the Opus 4.7 landmine recorded in the readiness doc: the composer
comment told ops to flip BEDROCK_OPUS_MODEL_ID to Opus 4.7, which would have
400d every thinking intent against the legacy budget shape. Both model
constants now point at Sonnet 5 and the stale instruction is gone.
Checked but deliberately unchanged: forced tool_choice in atom-selection. The
Sonnet 5 docs require thinking:{type:'disabled'} alongside a forced tool_choice
on Bedrock; probed against our account, the forced call succeeds without it, so
no change was made rather than adding a guard we cannot show is needed.
Other call sites moved too: invoice-inbox extraction, document extraction, the
compliance config, and the CI/CD workflows (pr-agent MODEL and MODEL_WEAK,
swedish-compliance-review, compliance-swarm).
Verified: 11315 tests pass, lint and tsc clean on every touched file, guards
pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(agent): review triage: keep the no-thinking output ceiling, finish the model sweep
max_tokens now caps thinking and the visible reply together, so collapsing the
two tiers into one made every non-thinking intent inherit a 16000 ceiling where
it used to have 4096. Give it its own MAX_TOKENS_NO_THINKING instead, set to the
old 4096 scaled ~30% for Sonnet 5's tokenizer so the effective reply length is
unchanged rather than quietly cut.
scripts/swedish-compliance-review.mjs still fell back to Sonnet 4.6 when
REVIEW_MODEL was unset, so a manual run silently used the old model. The initial
sweep only covered .ts and .yml.
pr-agent's FALLBACK_MODELS listed the primary model as its own fallback, which is
not a fallback; dropped it and rewrote the surrounding comments, which still
described Opus 4.8 and a 200k window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.8 KiB
TypeScript
112 lines
4.8 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { AgentIntent } from '@/lib/agent/intents/types'
|
|
import { buildIdentityBlock } from '../system-prompt'
|
|
|
|
// buildIdentityBlock is the always-on Block 2 of the chat system prompt. Unlike
|
|
// the per-intent ground rules (which sit only in the first user message and
|
|
// fall out of salience deep in a conversation), this block is re-sent on every
|
|
// turn. These guards lock in the epistemics rules added after the agent
|
|
// confidently answered "matvaror är 12 %" from stale training memory: it
|
|
// dropped to 6 % in April 2026, and invented a "ränteintäkter från ALMI"
|
|
// concern by inferring a lending business from an SNI code.
|
|
|
|
type VatStatus = Parameters<typeof buildIdentityBlock>[0]['vatStatus']
|
|
|
|
// Minimal base-typed intent: buildIdentityBlock only reads id, sheetTitle and
|
|
// atoms.mode. (The concrete intents have narrow capture/template generics that
|
|
// don't unify with the base AgentIntent the builder expects; the real call site
|
|
// resolves intents through the registry as base-typed.)
|
|
const intent: AgentIntent = {
|
|
id: 'general.help',
|
|
buttonLabel: 'x',
|
|
sheetTitle: 'Fråga din assistent',
|
|
atoms: { mode: 'progressive', horizontal: [], includeCompanyVertical: false, includeCompanyModifiers: false },
|
|
tools: [],
|
|
model: 'claude-sonnet-5',
|
|
capture: async () => ({}),
|
|
promptTemplate: () => '',
|
|
}
|
|
|
|
function block(vatStatus: VatStatus): string {
|
|
return buildIdentityBlock({
|
|
intent,
|
|
companyId: 'c1',
|
|
companyName: 'Testbolaget AB',
|
|
firstName: 'Jakob',
|
|
profileSummary: null,
|
|
rankedMemory: [],
|
|
vatStatus,
|
|
today: '2026-01-01 (torsdag)',
|
|
// buildIdentityBlock never touches supabase; it's a pure render of args.
|
|
supabase: {} as unknown as SupabaseClient,
|
|
})
|
|
}
|
|
|
|
const VAT_STATES: VatStatus[] = [
|
|
null,
|
|
{ vat_registered: true, vat_number: 'SE556677889901' },
|
|
{ vat_registered: false, vat_number: null },
|
|
]
|
|
|
|
describe('chat system prompt: always-on epistemics rules', () => {
|
|
it('forces load-before-answer for regulatory figures, on every VAT status', () => {
|
|
for (const vs of VAT_STATES) {
|
|
const out = block(vs)
|
|
expect(out).toContain('# Säkerhet i sak: ladda reglerna, gissa aldrig från minnet')
|
|
// Must point at the load tool and demand reading before answering.
|
|
expect(out).toContain('gnubok_load_skill')
|
|
// The canonical staleness trap must be named so the rule is concrete,
|
|
// not abstract: a model answering food VAT "12 %" from memory is wrong.
|
|
expect(out).toContain('12 %→6 %')
|
|
}
|
|
})
|
|
|
|
it('kills the "I am sure" escape hatch and turns "are you sure?" into a verify signal', () => {
|
|
const out = block(null)
|
|
expect(out).toContain('ja, jag är säker')
|
|
expect(out).toContain('är du säker?')
|
|
// The instruction must be to load/verify, not to repeat the prior answer.
|
|
expect(out.toLowerCase()).toContain('upprepa')
|
|
})
|
|
|
|
it('forbids inferring the business from weak signals like SNI codes', () => {
|
|
const out = block(null)
|
|
expect(out).toContain('# Påstå inget om bolaget du inte grundat i data')
|
|
expect(out).toContain('SNI-kod')
|
|
// Resolve real uncertainty by reading data or asking: not by speculating.
|
|
expect(out).toMatch(/läsverktyg|fråga/i)
|
|
})
|
|
|
|
it('anchors relative-time reasoning to the supplied current date', () => {
|
|
// Without an explicit "today" the model dates "förra månaden" / overdue
|
|
// invoices / the current VAT period off its training cutoff. The date the
|
|
// caller passes must land verbatim in the always-on block.
|
|
const out = block(null)
|
|
expect(out).toContain('# Dagens datum')
|
|
expect(out).toContain('Idag är 2026-01-01 (torsdag).')
|
|
// Must tell the model to trust this over its own sense of "now".
|
|
expect(out).toContain('träningsdata')
|
|
})
|
|
|
|
it('addresses the user by their own tilltalsnamn, not owner/signatory names from the profile', () => {
|
|
// Regression: the agent answered "vad heter jag" with the registered
|
|
// firmatecknare's legal name from "Företagets profil" instead of the
|
|
// user's own chosen name. The role block must name the user (firstName)
|
|
// and explicitly demote company owner/signatory names.
|
|
const out = block(null)
|
|
expect(out).toContain('Jakob')
|
|
expect(out).toMatch(/tilltalsnamn/i)
|
|
expect(out).toContain('firmatecknare')
|
|
})
|
|
|
|
it('lets the agent read a pre-loaded atom directly instead of re-loading it', () => {
|
|
// Declarative intents pre-load swedish-vat etc. into Block 1, so the rule
|
|
// must not force a redundant gnubok_load_skill when the owning atom is
|
|
// already present. This nuance used to live only in the per-intent KÄLLOR
|
|
// line; it now lives here, in the single canonical epistemics home.
|
|
const out = block(null)
|
|
expect(out).toContain('redan laddad')
|
|
})
|
|
})
|