refactor(api): extract assistant chat orchestration
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
model,
|
||||||
response = await loggedAiCall(provider, model, msgLen, () =>
|
maxTokens,
|
||||||
client.chat.completions.create({
|
temperature,
|
||||||
model,
|
openaiMessages,
|
||||||
messages: openaiMessages,
|
availableTools,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
toolCtx,
|
||||||
tools: availableTools as any,
|
userId,
|
||||||
max_completion_tokens: maxTokens,
|
conversationId,
|
||||||
temperature,
|
collectedActions,
|
||||||
}),
|
collectedInsights,
|
||||||
);
|
maxToolIterations: MAX_TOOL_ITERATIONS,
|
||||||
} 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<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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user