feat(agent): let the single-call assistant read the ledger via read-only MCP tools (#1767)
The /chat assistant (audit Option A / rip) shipped in #1759 reading only the company name + entity type, so it answered "jag har ingen bokföringsdata" to every figures question ("vad är min största utgiftspost?"). It now behaves like an MCP client: it answers over a bounded, READ-only tool loop across the same MCP read tools the old streaming assistant had, plus an always-on company snapshot as the backstop. Provider-agnostic by construction, so it still runs on a local model: - lib/ai generateText gains optional `tools` + `maxSteps`. The OpenAI-compatible service forwards them to the Vercel AI SDK (stopWhen: stepCountIs), which runs the loop; the Anthropic-family service hand-rolls a small loop against messages.create. Kept on the raw Anthropic SDK: no new deps, and the no-tools path is byte-identical, so hosted extraction/composer/etc. are unchanged. - lib/agent/ask/ledger-tools.ts: the read slice of general.help's whitelist (income statement, VAT, ledgers, query_journal, reskontror, lists…) from agentToolRegistry, dispatched with the agent_chat actor run-turn uses. Write/ staging + memory-write tools are excluded; readOnlyHint/destructiveHint are re-checked. Empty in a core-only build → snapshot-only, graceful. - lib/agent/ask/snapshot.ts: a compact company_settings + deadlines block so a model that can't/won't call tools still answers status questions. Never carries figures (those come from the live tools). - ask-service attaches tools + snapshot when a userId is present and uses a tool-aware system prompt; the route calls ensureInitialized() so the registry is populated and threads userId/conversationId through. Works on Bedrock and on any local model with function-calling (Qwen). Tests: the anthropic hand-rolled loop (tool call → result → answer, is_error handling, step-budget forced answer), openai tool forwarding, the read-only adapter filter, the snapshot format, and the ask-service wiring. 457 agent+ai tests green, lint/guards clean. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 4.8
parent
febb4cc0c2
commit
ff4425d10e
@@ -6,6 +6,14 @@ vi.mock('@/lib/ai', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/ai')>()
|
||||
return { ...actual, getAiService: () => ({ generateText }) }
|
||||
})
|
||||
const buildLedgerTools = vi.fn()
|
||||
const buildAssistantSnapshot = vi.fn()
|
||||
vi.mock('../ledger-tools', () => ({
|
||||
buildLedgerTools: (...a: unknown[]) => buildLedgerTools(...a),
|
||||
}))
|
||||
vi.mock('../snapshot', () => ({
|
||||
buildAssistantSnapshot: (...a: unknown[]) => buildAssistantSnapshot(...a),
|
||||
}))
|
||||
|
||||
import { answerAssistantQuestion } from '../ask-service'
|
||||
|
||||
@@ -21,6 +29,8 @@ function supabaseWith(company: { name?: string; entity_type?: string } | null):
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
generateText.mockResolvedValue({ text: 'Svar', model: 'qwen3.8', usage: {} })
|
||||
buildLedgerTools.mockReturnValue([])
|
||||
buildAssistantSnapshot.mockResolvedValue('')
|
||||
})
|
||||
|
||||
describe('answerAssistantQuestion', () => {
|
||||
@@ -69,4 +79,54 @@ describe('answerAssistantQuestion', () => {
|
||||
const call = generateText.mock.calls[0][0]
|
||||
expect(call.prompt.startsWith('Fråga:')).toBe(true)
|
||||
})
|
||||
|
||||
it('without a userId: no tools, no snapshot, the no-tool system prompt', async () => {
|
||||
await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?' })
|
||||
expect(buildLedgerTools).not.toHaveBeenCalled()
|
||||
const call = generateText.mock.calls[0][0]
|
||||
expect(call.tools).toBeUndefined()
|
||||
expect(call.system).toContain('Svara utifrån den kontext du får')
|
||||
expect(call.system).not.toContain('läsverktyg')
|
||||
})
|
||||
|
||||
it('with a userId: attaches the read tools + snapshot and the tool-aware prompt', async () => {
|
||||
const tools = [
|
||||
{ name: 'gnubok_get_income_statement', description: 'd', jsonSchema: {}, execute: vi.fn() },
|
||||
]
|
||||
buildLedgerTools.mockReturnValue(tools)
|
||||
buildAssistantSnapshot.mockResolvedValue('Status: momsregistrerad (momsperiod: quarterly).')
|
||||
|
||||
await answerAssistantQuestion({
|
||||
supabase: supabaseWith({ name: 'Arcim Technology AB', entity_type: 'aktiebolag' }),
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
conversationId: 'conv-1',
|
||||
question: 'Vad är min största utgiftspost den här månaden?',
|
||||
})
|
||||
|
||||
expect(buildLedgerTools).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'conv-1')
|
||||
const call = generateText.mock.calls[0][0]
|
||||
expect(call.tools).toBe(tools)
|
||||
expect(call.maxSteps).toBe(5)
|
||||
// tool-aware system prompt
|
||||
expect(call.system).toContain('läsverktyg')
|
||||
expect(call.system).not.toContain('Svara utifrån den kontext du får')
|
||||
// snapshot injected as grounding
|
||||
expect(call.prompt).toContain('Företagets nuläge')
|
||||
expect(call.prompt).toContain('Status: momsregistrerad (momsperiod: quarterly).')
|
||||
})
|
||||
|
||||
it('honours a custom maxSteps', async () => {
|
||||
buildLedgerTools.mockReturnValue([
|
||||
{ name: 'gnubok_get_vat_report', description: 'd', jsonSchema: {}, execute: vi.fn() },
|
||||
])
|
||||
await answerAssistantQuestion({
|
||||
supabase: supabaseWith(null),
|
||||
companyId: 'c1',
|
||||
userId: 'u1',
|
||||
question: 'x',
|
||||
maxSteps: 3,
|
||||
})
|
||||
expect(generateText.mock.calls[0][0].maxSteps).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { AgentTool } from '@/lib/agent/tools/types'
|
||||
|
||||
const getMany = vi.fn()
|
||||
vi.mock('@/lib/agent/tools/registry', () => ({
|
||||
agentToolRegistry: { getMany: (names: string[]) => getMany(names) },
|
||||
}))
|
||||
|
||||
import { buildLedgerTools, LEDGER_READ_TOOLS } from '../ledger-tools'
|
||||
|
||||
function tool(name: string, ann?: AgentTool['annotations']): AgentTool {
|
||||
return {
|
||||
name,
|
||||
description: `desc ${name}`,
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
...(ann ? { annotations: ann } : {}),
|
||||
execute: vi.fn().mockResolvedValue({ tool: name }),
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = {} as SupabaseClient
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('buildLedgerTools', () => {
|
||||
it('requests exactly the read whitelist from the registry', () => {
|
||||
getMany.mockReturnValue([])
|
||||
buildLedgerTools(supabase, 'c1', 'u1')
|
||||
expect(getMany).toHaveBeenCalledWith([...LEDGER_READ_TOOLS])
|
||||
})
|
||||
|
||||
it('keeps read-only + unannotated tools, drops writable/destructive ones', () => {
|
||||
getMany.mockReturnValue([
|
||||
tool('gnubok_get_income_statement', { readOnlyHint: true }),
|
||||
tool('gnubok_list_accounts'), // unannotated: on the curated read whitelist, kept
|
||||
tool('gnubok_categorize_transaction', { readOnlyHint: false }),
|
||||
tool('gnubok_delete_voucher', { destructiveHint: true }),
|
||||
])
|
||||
const defs = buildLedgerTools(supabase, 'c1', 'u1')
|
||||
expect(defs.map((d) => d.name)).toEqual(['gnubok_get_income_statement', 'gnubok_list_accounts'])
|
||||
})
|
||||
|
||||
it('adapts each tool: name/description/jsonSchema and an execute bound to the actor', async () => {
|
||||
const src = tool('gnubok_get_vat_report', { readOnlyHint: true })
|
||||
getMany.mockReturnValue([src])
|
||||
const defs = buildLedgerTools(supabase, 'company-9', 'user-7', 'conv-3')
|
||||
expect(defs).toHaveLength(1)
|
||||
const def = defs[0]
|
||||
expect(def.name).toBe('gnubok_get_vat_report')
|
||||
expect(def.description).toBe('desc gnubok_get_vat_report')
|
||||
expect(def.jsonSchema).toEqual({ type: 'object', properties: {} })
|
||||
|
||||
const out = await def.execute({ period: '2026-07' })
|
||||
expect(out).toEqual({ tool: 'gnubok_get_vat_report' })
|
||||
expect(src.execute).toHaveBeenCalledWith(
|
||||
{ period: '2026-07' },
|
||||
'company-9',
|
||||
'user-7',
|
||||
supabase,
|
||||
{ type: 'agent_chat', id: 'conv-3' },
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the actor id when there is no conversation', async () => {
|
||||
const src = tool('gnubok_get_trial_balance', { readOnlyHint: true })
|
||||
getMany.mockReturnValue([src])
|
||||
const [def] = buildLedgerTools(supabase, 'c1', 'u1')
|
||||
await def.execute({})
|
||||
expect(src.execute).toHaveBeenCalledWith({}, 'c1', 'u1', supabase, { type: 'agent_chat' })
|
||||
})
|
||||
|
||||
it('returns nothing when the registry is empty (core-only build)', () => {
|
||||
getMany.mockReturnValue([])
|
||||
expect(buildLedgerTools(supabase, 'c1', 'u1')).toEqual([])
|
||||
})
|
||||
|
||||
it('never lists a write or memory-write tool on the whitelist', () => {
|
||||
for (const bad of [
|
||||
'gnubok_categorize_transaction',
|
||||
'gnubok_create_invoice',
|
||||
'gnubok_approve_supplier_invoice',
|
||||
'gnubok_remember_fact',
|
||||
'gnubok_forget_fact',
|
||||
]) {
|
||||
expect(LEDGER_READ_TOOLS).not.toContain(bad)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const getDeadlines = vi.fn()
|
||||
vi.mock('@/lib/deadlines/status-engine', () => ({
|
||||
getDeadlinesNeedingAttention: (...a: unknown[]) => getDeadlines(...a),
|
||||
}))
|
||||
|
||||
import { buildAssistantSnapshot } from '../snapshot'
|
||||
|
||||
function supabaseWith(settings: Record<string, unknown> | null): SupabaseClient {
|
||||
const chain = {
|
||||
select: () => chain,
|
||||
eq: () => chain,
|
||||
maybeSingle: async () => ({ data: settings, error: null }),
|
||||
}
|
||||
return { from: () => chain } as unknown as SupabaseClient
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getDeadlines.mockResolvedValue({ overdue: [], actionNeeded: [] })
|
||||
})
|
||||
|
||||
describe('buildAssistantSnapshot', () => {
|
||||
it('summarises the company status line', async () => {
|
||||
const snap = await buildAssistantSnapshot(
|
||||
supabaseWith({
|
||||
vat_registered: true,
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
pays_salaries: true,
|
||||
}),
|
||||
'c1',
|
||||
)
|
||||
expect(snap).toContain('momsregistrerad (momsperiod: quarterly)')
|
||||
expect(snap).toContain('bokföringsmetod: fakturametod')
|
||||
expect(snap).toContain('betalar löner')
|
||||
})
|
||||
|
||||
it('handles a non-VAT, cash-method company', async () => {
|
||||
const snap = await buildAssistantSnapshot(
|
||||
supabaseWith({ vat_registered: false, accounting_method: 'cash', pays_salaries: false }),
|
||||
'c1',
|
||||
)
|
||||
expect(snap).toContain('ej momsregistrerad')
|
||||
expect(snap).toContain('bokföringsmetod: kontantmetod')
|
||||
expect(snap).toContain('betalar inte löner')
|
||||
})
|
||||
|
||||
it('lists deadlines that need attention (overdue first, capped)', async () => {
|
||||
getDeadlines.mockResolvedValue({
|
||||
overdue: [{ id: '1', title: 'Momsdeklaration', due_date: '2026-08-12', tax_deadline_type: 'vat' }],
|
||||
actionNeeded: [{ id: '2', title: 'Arbetsgivardeklaration', due_date: '2026-08-17', tax_deadline_type: 'employer' }],
|
||||
})
|
||||
const snap = await buildAssistantSnapshot(supabaseWith(null), 'c1')
|
||||
expect(snap).toContain('Deadlines som behöver åtgärd:')
|
||||
expect(snap).toContain('Momsdeklaration (2026-08-12)')
|
||||
expect(snap).toContain('Arbetsgivardeklaration (2026-08-17)')
|
||||
})
|
||||
|
||||
it('is best-effort: a failing settings query still yields the deadlines line', async () => {
|
||||
const throwing = {
|
||||
from: () => ({
|
||||
select: () => ({ eq: () => ({ maybeSingle: async () => { throw new Error('db down') } }) }),
|
||||
}),
|
||||
} as unknown as SupabaseClient
|
||||
getDeadlines.mockResolvedValue({
|
||||
overdue: [],
|
||||
actionNeeded: [{ id: '2', title: 'Moms', due_date: '2026-09-12', tax_deadline_type: 'vat' }],
|
||||
})
|
||||
const snap = await buildAssistantSnapshot(throwing, 'c1')
|
||||
expect(snap).toContain('Moms (2026-09-12)')
|
||||
})
|
||||
|
||||
it('returns an empty string when there is nothing to say', async () => {
|
||||
const snap = await buildAssistantSnapshot(supabaseWith(null), 'c1')
|
||||
expect(snap).toBe('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user