fix(agent): the assistant reaches earlier räkenskapsår: period resolved from dates, years listed in the grounding (#2436)

Part 3 of #2185. A user with several imported years concluded the assistant
"only reads the period I am standing in". Nothing restricted it: the report
tools defaulted to the most recent fiscal period when no period_id was given
and then rejected any from_date/to_date/as_of_date outside it, and neither
the snapshot nor the chat identity block told the model which years existed
or how to address them.

- extensions/general/mcp-server/server.ts: resolveReportPeriod takes a date
  hint; without period_id, a date in the call resolves the fiscal period that
  contains it (findFiscalPeriodContaining, company-scoped), and a date no
  period covers fails with the company's span instead of the latest year's
  bounds. Income statement (from_date or to_date), balance sheet (as_of_date)
  and dimension P&L (to_date) use it. The range guard is unchanged. No schema
  change: tools/list sits at its token ceiling.
- lib/agent/fiscal-years.ts: one query and one line, "Räkenskapsår (senaste
  först): ... period_id=<uuid> (senaste|avslutat)", plus the rule on
  addressing an earlier year, shared by the single-call snapshot and the
  streaming chat's always-on identity block.
- lib/agent/intents/shared-rules.ts and TOOL_RULES: pass that year's
  period_id, one call per year, and say which räkenskapsår the answer covers.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-08 21:32:38 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 20925f6c65
commit 500806f001
13 changed files with 471 additions and 10 deletions
+27 -1
View File
@@ -28,7 +28,9 @@ const intent: AgentIntent = {
promptTemplate: () => '',
}
function block(vatStatus: VatStatus): string {
type FiscalYears = Parameters<typeof buildIdentityBlock>[0]['fiscalYears']
function block(vatStatus: VatStatus, fiscalYears?: FiscalYears): string {
return buildIdentityBlock({
intent,
companyId: 'c1',
@@ -38,6 +40,7 @@ function block(vatStatus: VatStatus): string {
rankedMemory: [],
vatStatus,
today: '2026-01-01 (torsdag)',
fiscalYears,
// buildIdentityBlock never touches supabase; it's a pure render of args.
supabase: {} as unknown as SupabaseClient,
})
@@ -109,3 +112,26 @@ describe('chat system prompt: always-on epistemics rules', () => {
expect(out).toContain('redan laddad')
})
})
// #2185: a multi-year ledger read as single-year to the model because the
// report tools default to the most recent period and nothing told it other
// years existed. The inventory and the rule are always-on (Block 2), since
// the question about an earlier year tends to come many turns in.
describe('chat system prompt: räkenskapsår inventory', () => {
it('lists the years with their period_id and the rule for addressing them', () => {
const out = block(null, [
{ id: 'fp-2026', name: '2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
{ id: 'fp-2024', name: '2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true },
])
expect(out).toContain('# Räkenskapsår')
expect(out).toContain('2026-01-01..2026-12-31 period_id=fp-2026 (senaste)')
expect(out).toContain('2024-01-01..2024-12-31 period_id=fp-2024 (avslutat)')
expect(out).toContain('skicka det årets period_id')
expect(out).toContain('Säg alltid vilket räkenskapsår svaret gäller')
})
it('renders no section when the company has no periods', () => {
expect(block(null, [])).not.toContain('# Räkenskapsår')
expect(block(null)).not.toContain('# Räkenskapsår')
})
})
+4 -1
View File
@@ -11,6 +11,7 @@ import { agentToolRegistry } from '@/lib/agent/tools/registry'
import type { AgentTool, AgentActorContext, StagedOperationResult } from '@/lib/agent/tools/types'
import { isStagedOperation } from '@/lib/agent/tools/types'
import { buildSystemPrompt } from './system-prompt'
import { loadFiscalYearInventory } from '@/lib/agent/fiscal-years'
import { createLogger } from '@/lib/logger'
import { swedishToday } from '@/lib/utils'
@@ -258,12 +259,13 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
// On a first turn the caller already read the profile summary to build the
// intent's prompt template, so it hands it over rather than making the same
// round trip again for the system prompt.
const [profile, memory, vatStatus] = await Promise.all([
const [profile, memory, vatStatus, fiscalYears] = await Promise.all([
args.preloadedProfileSummary !== undefined
? Promise.resolve(args.preloadedProfileSummary)
: loadProfileSummary(supabase, companyId),
loadRankedMemory(supabase, companyId, 30),
loadVatStatus(supabase, companyId),
loadFiscalYearInventory(supabase, companyId),
])
const systemPrompt = await buildSystemPrompt({
@@ -275,6 +277,7 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
rankedMemory: memory,
vatStatus,
today: swedishToday(),
fiscalYears,
supabase,
})
+23 -1
View File
@@ -3,6 +3,11 @@ import { join } from 'node:path'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createHash } from 'node:crypto'
import type { AgentIntent } from '../intents/types'
import {
FISCAL_YEAR_RULE,
renderFiscalYearInventory,
type FiscalYearInventoryRow,
} from '@/lib/agent/fiscal-years'
// Builds the system prompt the chat loop sends to Anthropic.
//
@@ -41,6 +46,9 @@ interface BuildArgs {
// relative-time reasoning ("förra månaden", "förfallen", current VAT period)
// to the real date instead of the model's training cutoff. See swedishToday().
today: string
// The company's räkenskapsår, newest first (lib/agent/fiscal-years.ts).
// Optional so a caller that has nothing to say renders no section.
fiscalYears?: FiscalYearInventoryRow[]
supabase: SupabaseClient
}
@@ -213,7 +221,7 @@ async function resolveBodies(
}
export function buildIdentityBlock(args: BuildArgs): string {
const { intent, companyName, firstName, profileSummary, rankedMemory, vatStatus, today } = args
const { intent, companyName, firstName, profileSummary, rankedMemory, vatStatus, today, fiscalYears } = args
const lines: string[] = []
lines.push('# Din roll')
@@ -392,6 +400,20 @@ export function buildIdentityBlock(args: BuildArgs): string {
lines.push('4. **Inga hypoteser om motsatt status.** Spekulera ALDRIG "om du *varit* momsregistrerad hade det blivit X" eller "om du *inte varit* momsregistrerad…": det är källan till hallucinationer mellan turns. Svara för det faktiska tillståndet enligt blocket "Företagets momsstatus" ovan. Om användaren vill ha en hypotetisk genomgång: säg att de kan ändra status i /settings/company och prova om.')
lines.push('')
// Which years the ledger holds. The report tools default to the most recent
// period, so without this inventory a multi-year ledger read as single-year
// to the model and the user concluded the assistant "only sees the period I
// am standing in" (#2185). Always-on rather than first-message: the question
// about an earlier year tends to come many turns in.
const fiscalYearLine = renderFiscalYearInventory(fiscalYears ?? [])
if (fiscalYearLine) {
lines.push('# Räkenskapsår')
lines.push('')
lines.push(fiscalYearLine)
lines.push(FISCAL_YEAR_RULE)
lines.push('')
}
if (profileSummary) {
lines.push('# Företagets profil')
lines.push('')