Files
accounted/lib/ai/services/anthropic-family.ts
T
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

237 lines
8.9 KiB
TypeScript

import type Anthropic from '@anthropic-ai/sdk'
import { createAiClient, toProviderModelId, type AiClient } from '../provider'
import type { ResolvedAiConfig } from '../config'
import { capabilitiesFor } from '../config'
import type {
AiDocumentInput,
AiService,
AiTier,
AiToolDef,
AiUsage,
ExtractFromDocumentRequest,
ExtractFromDocumentResult,
GenerateStructuredRequest,
GenerateStructuredResult,
GenerateTextRequest,
GenerateTextResult,
} from '../types'
const DEFAULT_MAX_STEPS = 4
const EMPTY_USAGE: AiUsage = {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
}
/** Sum usage across the turns of a tool loop so the reported cost is truthful. */
function addUsage(a: AiUsage, b: AiUsage): AiUsage {
const s = (x: number | null, y: number | null) => (x ?? 0) + (y ?? 0)
return {
inputTokens: s(a.inputTokens, b.inputTokens),
outputTokens: s(a.outputTokens, b.outputTokens),
cacheCreationInputTokens: s(a.cacheCreationInputTokens, b.cacheCreationInputTokens),
cacheReadInputTokens: s(a.cacheReadInputTokens, b.cacheReadInputTokens),
}
}
/**
* The Anthropic family (AWS Bedrock and the direct API) behind the job-shaped
* interface. Delegates to the existing client factory and builds the EXACT
* request literals the call sites built before the abstraction existed, so
* hosted stays byte-identical on the wire: the request-shape tests in
* lib/ai/__tests__ deep-equal those literals.
*/
type MessageCreateParams = Anthropic.Messages.MessageCreateParamsNonStreaming
function usageOf(resp: { usage?: unknown }): AiUsage {
const usage = (resp.usage ?? {}) as {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
}
return {
inputTokens: usage.input_tokens ?? null,
outputTokens: usage.output_tokens ?? null,
cacheCreationInputTokens: usage.cache_creation_input_tokens ?? null,
cacheReadInputTokens: usage.cache_read_input_tokens ?? null,
}
}
function textOf(resp: { content: Array<{ type: string; text?: string }> }): string {
return resp.content
.flatMap((b) => (b.type === 'text' && typeof b.text === 'string' ? [b.text] : []))
.join('')
.trim()
}
/** User content blocks for one document + trailing instruction. Same literals the inbox extractor sent. */
export function buildAnthropicDocumentContent(
document: AiDocumentInput,
instruction: string
): Anthropic.Messages.ContentBlockParam[] {
const tail = { type: 'text' as const, text: instruction }
if (document.kind === 'text') {
return [{ type: 'text' as const, text: document.text }, tail]
}
const base64 = document.data.toString('base64')
if (document.kind === 'pdf') {
return [
{
type: 'document' as const,
source: { type: 'base64' as const, media_type: 'application/pdf' as const, data: base64 },
},
tail,
]
}
return [
{
type: 'image' as const,
source: { type: 'base64' as const, media_type: document.mediaType, data: base64 },
},
tail,
]
}
export function createAnthropicFamilyService(cfg: ResolvedAiConfig): AiService {
let client: AiClient | null = null
const getClient = (): AiClient => {
if (!client) client = createAiClient()
return client
}
const modelFor = (tier: AiTier): string =>
// The Anthropic family always has a model (Claude default), so the null
// branch is unreachable; keep the fallback so the type stays honest.
toProviderModelId(cfg.models[tier] ?? 'claude-sonnet-5', cfg.provider)
return {
provider: cfg.provider,
capabilities: capabilitiesFor(cfg),
modelFor,
async generateText(req: GenerateTextRequest): Promise<GenerateTextResult> {
const model = modelFor(req.tier)
// Fast path (unchanged: keeps hosted byte-identical for every non-tool
// caller, which is all of them today): one turn, no tools.
if (!req.tools || req.tools.length === 0) {
const params: MessageCreateParams = {
model,
max_tokens: req.maxTokens,
...(req.system ? { system: req.system } : {}),
messages: [{ role: 'user', content: req.prompt }],
}
const resp = await getClient().messages.create(params)
return { text: textOf(resp), model, usage: usageOf(resp) }
}
// Bounded read-only tool loop. The same dispatch shape run-turn.ts uses,
// minus streaming and staging: the model asks for a report, we run it,
// feed the JSON back, repeat up to maxSteps model turns, then answer.
const tools: Anthropic.Messages.Tool[] = req.tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.jsonSchema as Anthropic.Messages.Tool['input_schema'],
}))
const byName = new Map<string, AiToolDef>(req.tools.map((t) => [t.name, t]))
const messages: Anthropic.Messages.MessageParam[] = [{ role: 'user', content: req.prompt }]
const maxSteps = req.maxSteps ?? DEFAULT_MAX_STEPS
let usage = EMPTY_USAGE
for (let step = 0; step < maxSteps; step++) {
const resp = await getClient().messages.create({
model,
max_tokens: req.maxTokens,
...(req.system ? { system: req.system } : {}),
tools,
messages,
})
usage = addUsage(usage, usageOf(resp))
if (resp.stop_reason !== 'tool_use') {
return { text: textOf(resp), model, usage }
}
messages.push({ role: 'assistant', content: resp.content })
const results: Anthropic.Messages.ContentBlockParam[] = []
for (const block of resp.content) {
if (block.type !== 'tool_use') continue
const def = byName.get(block.name)
let content: string
let isError = false
try {
if (!def) {
isError = true
content = JSON.stringify({ error: `Verktyget ${block.name} är inte tillgängligt.` })
} else {
const out = await def.execute((block.input ?? {}) as Record<string, unknown>)
content = JSON.stringify(out ?? null)
}
} catch (err) {
isError = true
content = JSON.stringify({ error: err instanceof Error ? err.message : 'Tool failed' })
}
results.push({ type: 'tool_result', tool_use_id: block.id, content, is_error: isError })
}
messages.push({ role: 'user', content: results })
}
// Spent the step budget without a final answer: force one with tools off.
const final = await getClient().messages.create({
model,
max_tokens: req.maxTokens,
...(req.system ? { system: req.system } : {}),
messages,
})
return { text: textOf(final), model, usage: addUsage(usage, usageOf(final)) }
},
async generateStructured(req: GenerateStructuredRequest): Promise<GenerateStructuredResult> {
const model = modelFor(req.tier)
// Forced tool choice is the reliable way to get schema-shaped output
// from Claude (mirrors receipt-hunt's adjudicate/mail-intelligence).
const params: MessageCreateParams = {
model,
max_tokens: req.maxTokens,
...(req.system ? { system: req.system } : {}),
tools: [
{
name: req.schema.name,
...(req.schema.description ? { description: req.schema.description } : {}),
input_schema: req.schema.jsonSchema as Anthropic.Messages.Tool['input_schema'],
},
],
tool_choice: { type: 'tool', name: req.schema.name },
messages: [{ role: 'user', content: req.prompt }],
}
const resp = await getClient().messages.create(params)
const toolUse = resp.content.find(
(b): b is Anthropic.Messages.ToolUseBlock => b.type === 'tool_use' && b.name === req.schema.name
)
if (!toolUse) {
throw new Error(`Model answered without the forced tool "${req.schema.name}"`)
}
return { value: toolUse.input, model, usage: usageOf(resp) }
},
async extractFromDocument(req: ExtractFromDocumentRequest): Promise<ExtractFromDocumentResult> {
if (!cfg.configured) return { ok: false, skipped: 'ai_unconfigured' }
const model = modelFor('extraction')
// The system prompt is byte-stable per deploy and a few KB: marking it
// ephemeral lets the backend reuse the prompt cache on rapid sequential
// extractions (a user uploading a stack of receipts within minutes).
// Bedrock honours the short default TTL; the direct API the same.
const params: MessageCreateParams = {
model,
max_tokens: req.maxTokens,
system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }],
messages: [{ role: 'user', content: buildAnthropicDocumentContent(req.document, req.instruction) }],
}
const resp = await getClient().messages.create(params)
return { ok: true, text: textOf(resp), model, usage: usageOf(resp) }
},
}
}