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
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: () => requireAuthMock() }))
|
||||
vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1') }))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
||||
@@ -15,16 +16,23 @@ import {
|
||||
} from '@/lib/agent/ask/persist'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// The assistant answers over the read-only MCP tools, which are registered
|
||||
// into the agent tool registry by the mcp-server extension at load. Without
|
||||
// this the registry is empty and the assistant falls back to snapshot-only,
|
||||
// so a hosted deploy would silently lose its ledger tools.
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/agent/ask: a single-call, provider-agnostic assistant answer.
|
||||
* POST /api/agent/ask: a single-call, provider-agnostic assistant answer over a
|
||||
* bounded read-only tool loop.
|
||||
*
|
||||
* Unlike POST /api/agent/invoke (the streaming Anthropic chat runtime, which
|
||||
* is gated on `assistantAvailable` and only runs on the Anthropic family),
|
||||
* this endpoint uses getAiService().generateText, so it runs on ANY configured
|
||||
* backend, including an OpenAI-compatible local model. It is therefore gated
|
||||
* on `configured`, not `assistantAvailable`. This is the replacement chat
|
||||
* surface's server side (audit Option A / rip): a page posts its context and
|
||||
* a question, gets one answer back.
|
||||
* this endpoint answers through getAiService().generateText, so it runs on ANY
|
||||
* configured backend, including an OpenAI-compatible local model. It is
|
||||
* therefore gated on `configured`, not `assistantAvailable`. The service
|
||||
* attaches the read-only MCP tools so it can fetch real figures (audit Option
|
||||
* A / rip): a page posts its context and a question, gets one answer back.
|
||||
*/
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -93,6 +101,7 @@ export async function POST(request: Request): Promise<Response> {
|
||||
const result = await answerAssistantQuestion({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
question: parsed.data.question,
|
||||
pageContext: parsed.data.context,
|
||||
tier: parsed.data.tier,
|
||||
@@ -127,6 +136,8 @@ export async function POST(request: Request): Promise<Response> {
|
||||
const result = await answerAssistantQuestion({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
conversationId,
|
||||
question: parsed.data.question,
|
||||
pageContext: parsed.data.context,
|
||||
tier: parsed.data.tier,
|
||||
|
||||
Reference in New Issue
Block a user