Files
accounted/lib/agent/ask/ledger-tools.ts
T
Jakob Wennberg ff4425d10e 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>
2026-08-20 21:18:21 +02:00

87 lines
3.3 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { AiToolDef } from '@/lib/ai'
import { agentToolRegistry } from '@/lib/agent/tools/registry'
import type { AgentActorContext } from '@/lib/agent/tools/types'
/**
* The read-only tools the single-call assistant may call to answer questions
* about the ledger (audit Option A: "single-call actions over the existing MCP
* tool functions"). This is what makes the console behave like an MCP client:
* the model asks for a report, we run the real tool, feed the JSON back.
*
* The list is the read slice of general.help's old tool whitelist: the
* analytical reports and the lookups across the working set. Write/staging
* tools (categorize, create_invoice, approve_supplier_invoice, stage_year_end,
* …) and the memory-write tools (remember/forget) are deliberately absent: the
* console has no ApprovalCard surface, so it reads and guides only; write
* actions belong to the page-specific intents where a single entity is in
* focus and the user expects a staged card.
*
* The registry is populated by the mcp-server extension at init
* (ensureInitialized). In a core-only build it is empty, so this returns an
* empty list and the assistant answers from the prompt + snapshot alone: the
* graceful, correct degradation.
*/
export const LEDGER_READ_TOOLS: readonly string[] = [
// Reports (the canonical analytical surface)
'gnubok_get_income_statement',
'gnubok_get_balance_sheet',
'gnubok_get_trial_balance',
'gnubok_get_general_ledger',
'gnubok_get_kpi_report',
'gnubok_get_vat_report',
'gnubok_vat_close_check',
'gnubok_get_ar_ledger',
'gnubok_get_supplier_ledger',
'gnubok_get_reconciliation_status',
'gnubok_get_salary_journal',
'gnubok_year_end_readiness',
// Lookups across the working set
'gnubok_query_journal',
'gnubok_list_uncategorized_transactions',
'gnubok_list_transactions_without_documents',
'gnubok_list_invoices',
'gnubok_list_customers',
'gnubok_list_suppliers',
'gnubok_list_supplier_invoices',
'gnubok_list_accounts',
'gnubok_list_fiscal_periods',
'gnubok_list_employees',
'gnubok_list_inbox_items',
'gnubok_list_unmatched_documents',
'gnubok_list_voucher_gaps',
'gnubok_explain_voucher_gap',
'gnubok_get_counterparty_templates',
]
/**
* Build the read-only tool defs for one company/user, bound to the same actor
* context the streaming runtime uses (`agent_chat` + the conversation id, for
* BFL audit). Each def's execute dispatches the real registered tool.
*/
export function buildLedgerTools(
supabase: SupabaseClient,
companyId: string,
userId: string,
conversationId?: string,
): AiToolDef[] {
const actor: AgentActorContext = {
type: 'agent_chat',
...(conversationId ? { id: conversationId } : {}),
}
return agentToolRegistry
.getMany([...LEDGER_READ_TOOLS])
// Belt-and-suspenders on top of the curated whitelist: never expose a tool
// the registry itself marks writable or destructive, even if it somehow
// ended up on the list.
.filter((t) => t.annotations?.readOnlyHint !== false && t.annotations?.destructiveHint !== true)
.map((t) => ({
name: t.name,
description: t.description,
jsonSchema: t.inputSchema,
execute: (args: Record<string, unknown>) =>
t.execute(args, companyId, userId, supabase, actor),
}))
}