diff --git a/packages/api/src/__tests__/assistant-chat-loop.test.ts b/packages/api/src/__tests__/assistant-chat-loop.test.ts new file mode 100644 index 0000000..2887dcc --- /dev/null +++ b/packages/api/src/__tests__/assistant-chat-loop.test.ts @@ -0,0 +1,295 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../ai-client.js", () => ({ + loggedAiCall: vi.fn(async (_provider, _model, _promptLength, fn: () => Promise) => fn()), + parseAiError: vi.fn((error: unknown) => error instanceof Error ? error.message : String(error)), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../lib/content-filter.js", () => ({ + checkAiOutput: vi.fn((content: string) => ({ clean: true, redacted: content })), +})); + +vi.mock("../lib/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("../router/assistant-approvals.js", () => { + class AssistantApprovalStorageUnavailableError extends Error {} + + return { + AssistantApprovalStorageUnavailableError, + createPendingAssistantApproval: vi.fn(), + toApprovalPayload: vi.fn((approval: { id: string; toolName: string; summary: string }, status: string) => ({ + id: approval.id, + toolName: approval.toolName, + summary: approval.summary, + status, + })), + }; +}); + +vi.mock("../router/assistant-insights.js", () => ({ + buildAssistantInsight: vi.fn(), +})); + +vi.mock("../router/assistant-tools.js", () => ({ + MUTATION_TOOLS: new Set(["create_project"]), + executeTool: vi.fn(), +})); + +import { createAuditEntry } from "../lib/audit.js"; +import { logger } from "../lib/logger.js"; +import { checkAiOutput } from "../lib/content-filter.js"; +import { + AssistantApprovalStorageUnavailableError, + createPendingAssistantApproval, +} from "../router/assistant-approvals.js"; +import { ASSISTANT_CONFIRMATION_PREFIX } from "../router/assistant-confirmation.js"; +import { buildAssistantInsight } from "../router/assistant-insights.js"; +import { runAssistantToolLoop } from "../router/assistant-chat-loop.js"; +import { executeTool } from "../router/assistant-tools.js"; + +function createClient(...responses: unknown[]) { + return { + chat: { + completions: { + create: vi.fn() + .mockImplementation(async () => { + const next = responses.shift(); + if (!next) { + throw new Error("No mock AI response configured"); + } + return next; + }), + }, + }, + }; +} + +function createLoopInput(overrides: Partial[0]> = {}) { + return { + db: {} as never, + dbUserId: "user_1", + client: createClient({ + choices: [{ message: { content: "ok" } }], + }), + provider: "openai", + model: "gpt-4o-mini", + maxTokens: 2000, + temperature: 0.4, + openaiMessages: [{ role: "system", content: "system" }], + availableTools: [], + toolCtx: { + db: {} as never, + userId: "user_1", + userRole: "USER", + permissions: new Set(), + session: null, + dbUser: null, + roleDefaults: null, + }, + userId: "user_1", + conversationId: "conv_1", + collectedActions: [], + collectedInsights: [], + maxToolIterations: 8, + ...overrides, + }; +} + +describe("assistant chat loop", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkAiOutput).mockImplementation((content: string) => ({ clean: true, redacted: content })); + vi.mocked(buildAssistantInsight).mockReturnValue(undefined); + }); + + it("returns a confirmation response instead of executing mutation tools immediately", async () => { + vi.mocked(createPendingAssistantApproval).mockResolvedValue({ + id: "approval_1", + toolName: "create_project", + summary: "create project (name=Apollo)", + } as never); + + const result = await runAssistantToolLoop(createLoopInput({ + client: createClient({ + choices: [{ + message: { + tool_calls: [{ + id: "call_1", + function: { + name: "create_project", + arguments: "{\"name\":\"Apollo\"}", + }, + }], + }, + }], + }), + })); + + expect(result).toMatchObject({ + role: "assistant", + content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.`, + approval: { + id: "approval_1", + toolName: "create_project", + status: "pending", + }, + }); + expect(executeTool).not.toHaveBeenCalled(); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityName: "create_project", + summary: "AI tool blocked pending confirmation: create_project", + })); + }); + + it("continues after read-only tool calls and returns collected actions and insights", async () => { + vi.mocked(executeTool).mockResolvedValue({ + content: "{\"resources\":1}", + data: { resources: 1 }, + action: { type: "navigate", href: "/resources" }, + } as never); + vi.mocked(buildAssistantInsight).mockReturnValue({ + kind: "holiday_region", + title: "Berlin", + subtitle: "Resolved public holiday set", + metrics: [{ label: "Resolved holidays", value: "1" }], + }); + + const result = await runAssistantToolLoop(createLoopInput({ + client: createClient( + { + choices: [{ + message: { + tool_calls: [{ + id: "call_1", + function: { + name: "search_resources", + arguments: "{\"query\":\"Alice\"}", + }, + }], + }, + }], + }, + { + choices: [{ message: { content: "Hier ist die passende Resource." } }], + }, + ), + })); + + expect(result).toMatchObject({ + content: "Hier ist die passende Resource.", + actions: [{ type: "navigate", href: "/resources" }], + insights: [{ + kind: "holiday_region", + title: "Berlin", + }], + }); + expect(executeTool).toHaveBeenCalledWith( + "search_resources", + "{\"query\":\"Alice\"}", + expect.objectContaining({ userId: "user_1" }), + ); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityName: "search_resources", + summary: "AI executed tool: search_resources", + })); + }); + + it("redacts unsafe AI output before returning it", async () => { + vi.mocked(checkAiOutput).mockReturnValue({ + clean: false, + redacted: "[redacted]", + }); + + const result = await runAssistantToolLoop(createLoopInput({ + client: createClient({ + choices: [{ message: { content: "API key is sk-secret" } }], + }), + })); + + expect(result.content).toBe("[redacted]"); + expect(logger.warn).toHaveBeenCalledWith( + { userId: "user_1" }, + "AI output contained sensitive content — redacted before delivery", + ); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityName: "AiOutputRedacted", + })); + }); + + it("returns a stable fallback after too many tool-call iterations", async () => { + vi.mocked(executeTool).mockResolvedValue({ + content: "{\"ok\":true}", + data: { ok: true }, + } as never); + + const result = await runAssistantToolLoop(createLoopInput({ + client: createClient( + { + choices: [{ + message: { + tool_calls: [{ + id: "call_1", + function: { + name: "search_resources", + arguments: "{\"query\":\"A\"}", + }, + }], + }, + }], + }, + { + choices: [{ + message: { + tool_calls: [{ + id: "call_2", + function: { + name: "search_resources", + arguments: "{\"query\":\"B\"}", + }, + }], + }, + }], + }, + ), + maxToolIterations: 2, + })); + + expect(result.content).toBe("I had to stop after too many tool calls. Please try a simpler question."); + }); + + it("degrades mutation confirmations when approval storage is unavailable", async () => { + vi.mocked(createPendingAssistantApproval).mockRejectedValue( + new AssistantApprovalStorageUnavailableError("missing table"), + ); + + const result = await runAssistantToolLoop(createLoopInput({ + client: createClient({ + choices: [{ + message: { + tool_calls: [{ + id: "call_1", + function: { + name: "create_project", + arguments: "{\"name\":\"Apollo\"}", + }, + }], + }, + }], + }), + })); + + expect(result.content).toContain("Schreibende Assistant-Aktionen sind gerade nicht verfuegbar"); + expect(executeTool).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/__tests__/assistant-chat-response.test.ts b/packages/api/src/__tests__/assistant-chat-response.test.ts new file mode 100644 index 0000000..e28ec77 --- /dev/null +++ b/packages/api/src/__tests__/assistant-chat-response.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../router/assistant-approvals.js", () => ({ + clearPendingAssistantApproval: vi.fn().mockResolvedValue(undefined), + consumePendingAssistantApproval: vi.fn(), + toApprovalPayload: vi.fn((approval: { id: string; toolName: string; summary: string }, status: string) => ({ + id: approval.id, + toolName: approval.toolName, + summary: approval.summary, + status, + })), +})); + +vi.mock("../router/assistant-confirmation.js", () => ({ + canExecuteMutationTool: vi.fn(), + isCancellationReply: vi.fn(), + parseToolArguments: vi.fn(() => ({ name: "Apollo" })), +})); + +vi.mock("../router/assistant-insights.js", () => ({ + buildAssistantInsight: vi.fn(), +})); + +vi.mock("../router/assistant-tool-results.js", () => ({ + readToolError: vi.fn(), + readToolSuccessMessage: vi.fn(), +})); + +vi.mock("../router/assistant-tools.js", () => ({ + executeTool: vi.fn(), +})); + +import { createAuditEntry } from "../lib/audit.js"; +import { + clearPendingAssistantApproval, + consumePendingAssistantApproval, +} from "../router/assistant-approvals.js"; +import { + canExecuteMutationTool, + isCancellationReply, +} from "../router/assistant-confirmation.js"; +import { buildAssistantInsight } from "../router/assistant-insights.js"; +import { handlePendingAssistantApproval } from "../router/assistant-chat-response.js"; +import { + readToolError, + readToolSuccessMessage, +} from "../router/assistant-tool-results.js"; +import { executeTool } from "../router/assistant-tools.js"; + +function createPendingApproval() { + return { + id: "approval_1", + userId: "user_1", + conversationId: "conv_1", + toolName: "create_project", + toolArguments: "{\"name\":\"Apollo\"}", + summary: "create project (name=Apollo)", + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + }; +} + +function createHandleInput(overrides: Partial[0]> = {}) { + return { + db: {} as never, + dbUserId: "user_1", + toolCtx: { + db: {} as never, + userId: "user_1", + userRole: "USER", + permissions: new Set(), + session: null, + dbUser: null, + roleDefaults: null, + }, + conversationId: "conv_1", + pendingApproval: createPendingApproval(), + lastUserMessage: { role: "user" as const, content: "ja" }, + messages: [ + { role: "assistant" as const, content: "__CAPAKRAKEN_CONFIRM__ create project (name=Apollo). Bitte bestätigen." }, + { role: "user" as const, content: "ja" }, + ], + collectedActions: [], + collectedInsights: [], + ...overrides, + }; +} + +describe("assistant pending approval handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isCancellationReply).mockReturnValue(false); + vi.mocked(canExecuteMutationTool).mockReturnValue(true); + vi.mocked(readToolError).mockReturnValue(null); + vi.mocked(readToolSuccessMessage).mockReturnValue(null); + vi.mocked(buildAssistantInsight).mockReturnValue(undefined); + }); + + it("cancels pending approvals when the user aborts", async () => { + vi.mocked(isCancellationReply).mockReturnValue(true); + + const result = await handlePendingAssistantApproval(createHandleInput({ + lastUserMessage: { role: "user", content: "nein, abbrechen" }, + })); + + expect(result).toMatchObject({ + response: { + content: "Aktion verworfen: create project (name=Apollo)", + approval: { + id: "approval_1", + status: "cancelled", + }, + }, + }); + expect(clearPendingAssistantApproval).toHaveBeenCalledWith({}, "user_1", "conv_1"); + expect(consumePendingAssistantApproval).not.toHaveBeenCalled(); + expect(executeTool).not.toHaveBeenCalled(); + }); + + it("executes the confirmed mutation and returns its success response", async () => { + vi.mocked(consumePendingAssistantApproval).mockResolvedValue({ + ...createPendingApproval(), + summary: "create project (name=Apollo, status=DRAFT)", + } as never); + vi.mocked(executeTool).mockResolvedValue({ + content: "{\"message\":\"Projekt Apollo angelegt\"}", + data: { message: "Projekt Apollo angelegt" }, + action: { type: "refresh" }, + } as never); + vi.mocked(buildAssistantInsight).mockReturnValue({ + kind: "holiday_region", + title: "Berlin", + metrics: [{ label: "Resolved holidays", value: "1" }], + }); + vi.mocked(readToolSuccessMessage).mockReturnValue("Projekt Apollo angelegt"); + + const result = await handlePendingAssistantApproval(createHandleInput()); + + expect(result).toMatchObject({ + response: { + content: "Ausgeführt: Projekt Apollo angelegt", + approval: { + id: "approval_1", + status: "approved", + }, + actions: [{ type: "refresh" }], + insights: [{ + kind: "holiday_region", + title: "Berlin", + }], + }, + }); + expect(executeTool).toHaveBeenCalledWith( + "create_project", + "{\"name\":\"Apollo\"}", + expect.objectContaining({ userId: "user_1" }), + ); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityName: "create_project", + summary: "AI executed previously approved tool: create_project", + })); + }); + + it("does nothing when the user reply is not a valid confirmation", async () => { + vi.mocked(canExecuteMutationTool).mockReturnValue(false); + + const result = await handlePendingAssistantApproval(createHandleInput({ + lastUserMessage: { role: "user", content: "vielleicht" }, + })); + + expect(result).toBeNull(); + expect(consumePendingAssistantApproval).not.toHaveBeenCalled(); + expect(executeTool).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/router/assistant-chat-loop.ts b/packages/api/src/router/assistant-chat-loop.ts new file mode 100644 index 0000000..86e92d0 --- /dev/null +++ b/packages/api/src/router/assistant-chat-loop.ts @@ -0,0 +1,309 @@ +import { TRPCError } from "@trpc/server"; +import { loggedAiCall, parseAiError } from "../ai-client.js"; +import { createAuditEntry } from "../lib/audit.js"; +import { checkAiOutput } from "../lib/content-filter.js"; +import { logger } from "../lib/logger.js"; +import { + AssistantApprovalStorageUnavailableError, + createPendingAssistantApproval, + toApprovalPayload, +} from "./assistant-approvals.js"; +import { + ASSISTANT_CONFIRMATION_PREFIX, + parseToolArguments, +} from "./assistant-confirmation.js"; +import { + buildAssistantChatResponse, + mergeInsights, + type AssistantChatResponse, +} from "./assistant-chat-response.js"; +import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js"; +import { + executeTool, + MUTATION_TOOLS, + type ToolAction, + type ToolContext, +} from "./assistant-tools.js"; + +type AssistantToolCall = { + id: string; + function: { + name: string; + arguments: string; + }; +}; + +type AssistantChoiceMessage = { + content?: string | null; + tool_calls?: AssistantToolCall[]; +}; + +type AssistantCompletionResponse = { + choices?: Array<{ + message?: AssistantChoiceMessage; + }>; +}; + +type AssistantChatClient = { + chat: { + completions: { + create(...args: any[]): Promise; + }; + }; +}; + +export async function runAssistantToolLoop(input: { + db: ToolContext["db"]; + dbUserId?: string | undefined; + client: AssistantChatClient; + provider: string; + model: string; + maxTokens: number; + temperature: number; + openaiMessages: Array<{ content?: unknown } & Record>; + availableTools: unknown[]; + toolCtx: ToolContext; + userId: string; + conversationId: string; + collectedActions: ToolAction[]; + collectedInsights: AssistantInsight[]; + maxToolIterations: number; +}): Promise { + let collectedActions = input.collectedActions; + let collectedInsights = input.collectedInsights; + + for (let i = 0; i < input.maxToolIterations; i++) { + const response = await requestAssistantCompletion(input); + const choice = response.choices?.[0]; + const msg = choice?.message; + if (!msg) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No response from AI" }); + } + + if (msg.tool_calls && msg.tool_calls.length > 0) { + input.openaiMessages.push(msg); + + const toolResult = await handleAssistantToolCalls({ + db: input.db, + dbUserId: input.dbUserId, + openaiMessages: input.openaiMessages, + toolCalls: msg.tool_calls, + toolCtx: input.toolCtx, + userId: input.userId, + conversationId: input.conversationId, + collectedActions, + collectedInsights, + }); + + collectedActions = toolResult.collectedActions; + collectedInsights = toolResult.collectedInsights; + + if (toolResult.response) { + return toolResult.response; + } + + continue; + } + + return buildFinalAssistantResponse({ + db: input.db, + dbUserId: input.dbUserId, + content: msg.content, + collectedActions, + collectedInsights, + }); + } + + return buildAssistantChatResponse({ + content: "I had to stop after too many tool calls. Please try a simpler question.", + insights: collectedInsights, + actions: collectedActions, + }); +} + +async function requestAssistantCompletion(input: { + client: AssistantChatClient; + provider: string; + model: string; + maxTokens: number; + temperature: number; + openaiMessages: Array<{ content?: unknown } & Record>; + availableTools: unknown[]; +}): Promise { + const msgLen = input.openaiMessages.reduce( + (total, message) => total + (typeof message.content === "string" ? message.content.length : 0), + 0, + ); + + try { + const response = await loggedAiCall(input.provider, input.model, msgLen, () => + input.client.chat.completions.create({ + model: input.model, + messages: input.openaiMessages, + tools: input.availableTools, + max_completion_tokens: input.maxTokens, + temperature: input.temperature, + }), + ); + return response as AssistantCompletionResponse; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `AI error: ${parseAiError(error)}`, + }); + } +} + +async function handleAssistantToolCalls(input: { + db: ToolContext["db"]; + dbUserId?: string | undefined; + openaiMessages: Array<{ content?: unknown } & Record>; + toolCalls: AssistantToolCall[]; + toolCtx: ToolContext; + userId: string; + conversationId: string; + collectedActions: ToolAction[]; + collectedInsights: AssistantInsight[]; +}): Promise<{ + response: AssistantChatResponse | null; + collectedActions: ToolAction[]; + collectedInsights: AssistantInsight[]; +}> { + let collectedActions = input.collectedActions; + let collectedInsights = input.collectedInsights; + + for (const toolCall of input.toolCalls) { + if (MUTATION_TOOLS.has(toolCall.function.name)) { + try { + const approval = await createPendingAssistantApproval( + input.db, + input.userId, + input.conversationId, + toolCall.function.name, + toolCall.function.arguments, + ); + + void createAuditEntry({ + db: input.db, + entityType: "AiToolExecution", + entityId: toolCall.id, + entityName: toolCall.function.name, + action: "CREATE", + source: "ai", + summary: `AI tool blocked pending confirmation: ${toolCall.function.name}`, + after: { + approvalId: approval.id, + params: parseToolArguments(toolCall.function.arguments), + executed: false, + }, + ...(input.dbUserId !== undefined ? { userId: input.dbUserId } : {}), + }); + + return { + response: buildAssistantChatResponse({ + content: `${ASSISTANT_CONFIRMATION_PREFIX} ${approval.summary}. Bitte bestätigen.`, + approval: toApprovalPayload(approval, "pending"), + insights: collectedInsights, + actions: collectedActions, + }), + collectedActions, + collectedInsights, + }; + } catch (error) { + if (!(error instanceof AssistantApprovalStorageUnavailableError)) { + throw error; + } + + return { + response: buildAssistantChatResponse({ + content: "Schreibende Assistant-Aktionen sind gerade nicht verfuegbar, weil der Bestaetigungsspeicher in der Datenbank fehlt. Bitte die CapaKraken-DB-Migration anwenden und dann erneut versuchen.", + insights: collectedInsights, + actions: collectedActions, + }), + collectedActions, + collectedInsights, + }; + } + } + + const result = await executeTool( + toolCall.function.name, + toolCall.function.arguments, + input.toolCtx, + ); + + const insight = buildAssistantInsight(toolCall.function.name, result.data); + if (insight) { + collectedInsights = mergeInsights(collectedInsights, insight); + } + + if (result.action) { + collectedActions = [...collectedActions, result.action]; + } + + input.openaiMessages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: result.content, + }); + + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse(toolCall.function.arguments) as Record; + } catch { + parsedArgs = {}; + } + + void createAuditEntry({ + db: input.db, + entityType: "AiToolExecution", + entityId: toolCall.id, + entityName: toolCall.function.name, + action: "CREATE", + source: "ai", + summary: `AI executed tool: ${toolCall.function.name}`, + after: { params: parsedArgs, executed: true }, + ...(input.dbUserId !== undefined ? { userId: input.dbUserId } : {}), + }); + } + + return { + response: null, + collectedActions, + collectedInsights, + }; +} + +function buildFinalAssistantResponse(input: { + db: ToolContext["db"]; + dbUserId?: string | undefined; + content?: string | null | undefined; + collectedActions: ToolAction[]; + collectedInsights: AssistantInsight[]; +}): AssistantChatResponse { + let finalContent = input.content ?? "I couldn't generate a response."; + const contentCheck = checkAiOutput(finalContent); + if (!contentCheck.clean) { + logger.warn( + { userId: input.dbUserId }, + "AI output contained sensitive content — redacted before delivery", + ); + finalContent = contentCheck.redacted; + void createAuditEntry({ + db: input.db, + entityType: "SecurityAlert", + entityId: crypto.randomUUID(), + entityName: "AiOutputRedacted", + action: "CREATE", + source: "ai", + summary: "AI output contained potentially sensitive content and was redacted", + ...(input.dbUserId !== undefined ? { userId: input.dbUserId } : {}), + }); + } + + return buildAssistantChatResponse({ + content: finalContent, + insights: input.collectedInsights, + actions: input.collectedActions, + }); +} diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 2013db0..1193419 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -7,31 +7,26 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared"; import { createTRPCRouter, protectedProcedure } from "../trpc.js"; -import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; -import { MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js"; +import { createAiClient, isAiConfigured } from "../ai-client.js"; +import { TOOL_DEFINITIONS, type ToolContext, type ToolAction } from "./assistant-tools.js"; import { - AssistantApprovalStorageUnavailableError, - createPendingAssistantApproval, listPendingAssistantApprovals, peekPendingAssistantApproval, toApprovalPayload, - type PendingAssistantApproval, } from "./assistant-approvals.js"; import { ASSISTANT_CONFIRMATION_PREFIX, - parseToolArguments, type ChatMessage, } from "./assistant-confirmation.js"; import { buildAssistantChatResponse, handlePendingAssistantApproval, - mergeInsights, } from "./assistant-chat-response.js"; +import { runAssistantToolLoop } from "./assistant-chat-loop.js"; import { getAvailableAssistantTools } from "./assistant-tool-policy.js"; import { selectAssistantToolsForRequest } from "./assistant-tool-selection.js"; -import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js"; +import { type AssistantInsight } from "./assistant-insights.js"; import { checkPromptInjection } from "../lib/prompt-guard.js"; -import { checkAiOutput } from "../lib/content-filter.js"; import { createAuditEntry } from "../lib/audit.js"; import { logger } from "../lib/logger.js"; @@ -236,174 +231,22 @@ export const assistantRouter = createTRPCRouter({ return pendingApprovalResult.response; } - for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let response: any; - const provider = settings!.aiProvider ?? "openai"; - const msgLen = openaiMessages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0); - try { - response = await loggedAiCall(provider, model, msgLen, () => - client.chat.completions.create({ - model, - messages: openaiMessages, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tools: availableTools as any, - max_completion_tokens: maxTokens, - temperature, - }), - ); - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `AI error: ${parseAiError(err)}`, - }); - } - - const choice = response.choices?.[0]; - if (!choice) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No response from AI" }); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const msg = choice.message as any; - - // If the AI wants to call tools - if (msg.tool_calls && msg.tool_calls.length > 0) { - openaiMessages.push(msg); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - for (const toolCall of msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }>) { - if (MUTATION_TOOLS.has(toolCall.function.name)) { - let approval: PendingAssistantApproval; - try { - approval = await createPendingAssistantApproval( - ctx.db, - userId, - conversationId, - toolCall.function.name, - toolCall.function.arguments, - ); - } catch (error) { - if (!(error instanceof AssistantApprovalStorageUnavailableError)) { - throw error; - } - return { - ...buildAssistantChatResponse({ - content: "Schreibende Assistant-Aktionen sind gerade nicht verfuegbar, weil der Bestaetigungsspeicher in der Datenbank fehlt. Bitte die CapaKraken-DB-Migration anwenden und dann erneut versuchen.", - insights: collectedInsights, - actions: collectedActions, - }), - }; - } - - void createAuditEntry({ - db: ctx.db, - entityType: "AiToolExecution", - entityId: toolCall.id, - entityName: toolCall.function.name, - action: "CREATE", - userId: ctx.dbUser?.id, - source: "ai", - summary: `AI tool blocked pending confirmation: ${toolCall.function.name}`, - after: { - approvalId: approval.id, - params: parseToolArguments(toolCall.function.arguments), - executed: false, - }, - }); - - return { - ...buildAssistantChatResponse({ - content: `${ASSISTANT_CONFIRMATION_PREFIX} ${approval.summary}. Bitte bestätigen.`, - approval: toApprovalPayload(approval, "pending"), - insights: collectedInsights, - actions: collectedActions, - }), - }; - } - - const result = await executeTool( - toolCall.function.name, - toolCall.function.arguments, - toolCtx, - ); - - const insight = buildAssistantInsight(toolCall.function.name, result.data); - if (insight) { - collectedInsights = mergeInsights(collectedInsights, insight); - } - - // Collect any actions (e.g. navigation) - if (result.action) { - collectedActions.push(result.action); - } - - openaiMessages.push({ - role: "tool", - tool_call_id: toolCall.id, - content: result.content, - }); - - // Audit trail for AI tool execution (IAAI 3.6.35) - let parsedArgs: Record = {}; - try { - parsedArgs = JSON.parse(toolCall.function.arguments) as Record; - } catch { - // keep empty object if args are not valid JSON - } - void createAuditEntry({ - db: ctx.db, - entityType: "AiToolExecution", - entityId: toolCall.id, - entityName: toolCall.function.name, - action: "CREATE", - userId: ctx.dbUser?.id, - source: "ai", - summary: `AI executed tool: ${toolCall.function.name}`, - after: { params: parsedArgs, executed: true }, - }); - } - - continue; - } - - // AI returned a text response — apply content filter (EGAI 4.3.2.1) - let finalContent = (msg.content as string) ?? "I couldn't generate a response."; - const contentCheck = checkAiOutput(finalContent); - if (!contentCheck.clean) { - logger.warn( - { userId: ctx.dbUser?.id }, - "AI output contained sensitive content — redacted before delivery", - ); - finalContent = contentCheck.redacted; - void createAuditEntry({ - db: ctx.db, - entityType: "SecurityAlert", - entityId: crypto.randomUUID(), - entityName: "AiOutputRedacted", - action: "CREATE", - userId: ctx.dbUser?.id, - source: "ai", - summary: "AI output contained potentially sensitive content and was redacted", - }); - } - - return { - ...buildAssistantChatResponse({ - content: finalContent, - insights: collectedInsights, - actions: collectedActions, - }), - }; - } - - // Exceeded max iterations - return { - ...buildAssistantChatResponse({ - content: "I had to stop after too many tool calls. Please try a simpler question.", - insights: collectedInsights, - actions: collectedActions, - }), - }; + return runAssistantToolLoop({ + db: ctx.db, + dbUserId: ctx.dbUser?.id, + client, + provider: settings!.aiProvider ?? "openai", + model, + maxTokens, + temperature, + openaiMessages, + availableTools, + toolCtx, + userId, + conversationId, + collectedActions, + collectedInsights, + maxToolIterations: MAX_TOOL_ITERATIONS, + }); }), });