feat(odin): Phase 2a read-only agentic assistant + tool-trace UI

- agentic chat loop (max 6 round-trips, budget re-checked between iterations)
  over 10 read-only, permission-delegated tools in src/services/ai-tools.ts
- persist assistant tool trace in ai_chat_messages.content_json (+ migration)
- consulted-tools chips in OdinThread; header now shows month spend only
  (budget cap hidden from the UI, server-side 402 guard unchanged)
- seed: ai.use permission; Settings exposes the ai permission module
- docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 23:31:50 +02:00
parent 2dbacc3bec
commit 674b44d047
26 changed files with 13728 additions and 2935 deletions

View File

@@ -18,7 +18,7 @@ import {
getMonthSpendUsd,
getBudgetUsd,
setBudgetUsd,
chat,
agenticChat,
extractInvoice,
listConversations,
createConversation,
@@ -75,7 +75,9 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
},
);
// POST /api/admin/ai/chat
// POST /api/admin/ai/chat — agentic turn with read-only tools (Phase 2a).
// The tools execute AS this user: the authData context (permissions +
// admin bypass) is what each tool handler checks — see ai-tools.ts.
app.post(
"/chat",
{ preHandler: requirePermission("ai.use") },
@@ -85,16 +87,24 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
if ("error" in body) return error(reply, body.error, 400);
const budgetErr = await assertBudgetAvailable();
if (budgetErr) return error(reply, budgetErr.error, budgetErr.status);
const { reply: text } = await chat(
body.data.messages,
request.authData!.userId,
);
const auth = request.authData!;
const { reply: text, toolTrace } = await agenticChat(body.data.messages, {
userId: auth.userId,
// AuthData.roleName is nullable; a null role simply never matches the
// admin bypass and holds only its explicit permissions.
roleName: auth.roleName ?? "",
permissions: auth.permissions,
});
const [budgetAfter, spendAfter] = await Promise.all([
getBudgetUsd(),
getMonthSpendUsd(),
]);
const remaining_usd = Math.max(0, budgetAfter - spendAfter);
return success(reply, { reply: text, remaining_usd });
return success(reply, {
reply: text,
tool_trace: toolTrace,
remaining_usd,
});
},
);