refactor(api): extract assistant chat orchestration

This commit is contained in:
2026-03-31 13:15:44 +02:00
parent 002114fcb1
commit 4bea9ddd14
4 changed files with 804 additions and 178 deletions
@@ -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<unknown>) => 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<Parameters<typeof runAssistantToolLoop>[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();
});
});
@@ -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<Parameters<typeof handlePendingAssistantApproval>[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();
});
});
@@ -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<unknown>;
};
};
};
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<string, unknown>>;
availableTools: unknown[];
toolCtx: ToolContext;
userId: string;
conversationId: string;
collectedActions: ToolAction[];
collectedInsights: AssistantInsight[];
maxToolIterations: number;
}): Promise<AssistantChatResponse> {
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<string, unknown>>;
availableTools: unknown[];
}): Promise<AssistantCompletionResponse> {
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<string, unknown>>;
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<string, unknown> = {};
try {
parsedArgs = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
} 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,
});
}
+16 -173
View File
@@ -7,31 +7,26 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared"; import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; import { createAiClient, isAiConfigured } from "../ai-client.js";
import { MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js"; import { TOOL_DEFINITIONS, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { import {
AssistantApprovalStorageUnavailableError,
createPendingAssistantApproval,
listPendingAssistantApprovals, listPendingAssistantApprovals,
peekPendingAssistantApproval, peekPendingAssistantApproval,
toApprovalPayload, toApprovalPayload,
type PendingAssistantApproval,
} from "./assistant-approvals.js"; } from "./assistant-approvals.js";
import { import {
ASSISTANT_CONFIRMATION_PREFIX, ASSISTANT_CONFIRMATION_PREFIX,
parseToolArguments,
type ChatMessage, type ChatMessage,
} from "./assistant-confirmation.js"; } from "./assistant-confirmation.js";
import { import {
buildAssistantChatResponse, buildAssistantChatResponse,
handlePendingAssistantApproval, handlePendingAssistantApproval,
mergeInsights,
} from "./assistant-chat-response.js"; } from "./assistant-chat-response.js";
import { runAssistantToolLoop } from "./assistant-chat-loop.js";
import { getAvailableAssistantTools } from "./assistant-tool-policy.js"; import { getAvailableAssistantTools } from "./assistant-tool-policy.js";
import { selectAssistantToolsForRequest } from "./assistant-tool-selection.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 { checkPromptInjection } from "../lib/prompt-guard.js";
import { checkAiOutput } from "../lib/content-filter.js";
import { createAuditEntry } from "../lib/audit.js"; import { createAuditEntry } from "../lib/audit.js";
import { logger } from "../lib/logger.js"; import { logger } from "../lib/logger.js";
@@ -236,174 +231,22 @@ export const assistantRouter = createTRPCRouter({
return pendingApprovalResult.response; return pendingApprovalResult.response;
} }
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { return runAssistantToolLoop({
// eslint-disable-next-line @typescript-eslint/no-explicit-any db: ctx.db,
let response: any; dbUserId: ctx.dbUser?.id,
const provider = settings!.aiProvider ?? "openai"; client,
const msgLen = openaiMessages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0); provider: settings!.aiProvider ?? "openai",
try {
response = await loggedAiCall(provider, model, msgLen, () =>
client.chat.completions.create({
model, model,
messages: openaiMessages, maxTokens,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools: availableTools as any,
max_completion_tokens: maxTokens,
temperature, temperature,
}), openaiMessages,
); availableTools,
} catch (err) { toolCtx,
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, userId,
conversationId, conversationId,
toolCall.function.name, collectedActions,
toolCall.function.arguments, collectedInsights,
); maxToolIterations: MAX_TOOL_ITERATIONS,
} 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<string, unknown> = {};
try {
parsedArgs = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
} 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,
}),
};
}), }),
}); });