import type { AgentRunRequest } from "@rakazo/adapter-kit"; import type { CardBlock } from "@rakazo/contracts"; import { buildSuggestionCard, describeSuggestion, normalizeOoLook, ooLookFormat, UNSUGGESTABLE_TOOLS, withSuggestionStatus, } from "@rakazo/core"; import type { PrismaClient, ThreadEvents } from "@rakazo/db"; import { updateCardInTransaction } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; import { publishMessage } from "./executor-computer.js"; import { filterBuiltinToolsForBot } from "./executor-tools.js"; import type { ExecutorDeps } from "./executor-types.js"; import { resolveScheduleOwner } from "./schedule-tools.js"; const BUILTIN_NAMES = new Set([...builtinAgentTools, ...agentConnectionTools].map((t) => t.name)); /** What a suggestion effect stores: the exact call, who runs it, and where its card is. */ export interface SuggestionRequest { tool: string; args: Record; botId: string; threadId: string; why?: string; /** Set once the card is posted. */ card?: { threadId: string; messageId: string }; /** Same tool and subject: a newer suggestion replaces an open older one. */ subject: string; } export function suggestionRequest(value: unknown): SuggestionRequest | null { if (!value || typeof value !== "object") return null; const request = value as Partial; return typeof request.tool === "string" && typeof request.botId === "string" && typeof request.threadId === "string" && request.args && typeof request.args === "object" ? (request as SuggestionRequest) : null; } type Notify = Pick; async function notify(events: Notify, threadId: string, seq: number) { await events.notify(threadId, seq).catch((error) => { getLogger().error("suggestion card realtime notification", error); }); } /** * `suggest`: check the call as if it ran now, store it as a proposed effect and post its card. * Nothing runs until the user accepts. */ export async function createSuggestionFromTool( deps: Pick, input: { run: { id: string; spaceId: string; userId: string; threadId: string; botId: string }; bot: { id: string; kind: string }; executionId: string; exposedToolNames: ReadonlySet; args: Record; }, ): Promise> { const tool = typeof input.args.tool === "string" ? input.args.tool.trim() : ""; if (!tool) return { error: "tool is required." }; if (UNSUGGESTABLE_TOOLS.has(tool) || tool.startsWith("autopilot_")) { return { error: `${tool} cannot be suggested; use it directly.` }; } const raw = input.args.args; const args = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; if (tool === "update_bot" && args.look !== undefined && !normalizeOoLook(args.look)) { return { error: `look must be ${ooLookFormat()}` }; } const owner = await resolveScheduleOwner( deps.prisma, { botId: input.bot.id, kind: input.bot.kind, spaceId: input.run.spaceId, userId: input.run.userId, threadId: input.run.threadId, }, typeof input.args.bot === "string" ? input.args.bot : undefined, ); if ("error" in owner) return owner; if (owner.spaceId !== input.run.spaceId) { return { error: "Suggest only for bots in this project." }; } if (owner.botId === input.bot.id) { if (!input.exposedToolNames.has(tool)) return { error: `You cannot use ${tool} here.` }; } else if (BUILTIN_NAMES.has(tool)) { const target = await deps.prisma.bot.findUnique({ where: { id: owner.botId }, select: { kind: true, restrictBuiltinTools: true, allowedBuiltinTools: true }, }); const allowed = target ? filterBuiltinToolsForBot( builtinAgentTools.filter((candidate) => candidate.name === tool), target, ) : []; if (!allowed.length) return { error: `${owner.botName ?? "That bot"} cannot use ${tool}.` }; } const why = typeof input.args.why === "string" ? input.args.why.trim() : ""; const { title, details } = describeSuggestion(tool, args); const request: SuggestionRequest = { tool, args, botId: owner.botId, threadId: owner.threadId, subject: [title, ...details].join(" ยท "), ...(why ? { why } : {}), }; const idempotencyKey = `suggestion:${input.run.id}:${input.executionId}`; const existing = await deps.prisma.externalEffect.findUnique({ where: { idempotencyKey } }); const effect = existing ?? (await deps.prisma.externalEffect.create({ data: { spaceId: input.run.spaceId, runId: input.run.id, kind: "suggestion", idempotencyKey, status: "proposed", request: request as never, }, })); if (!existing) { await expireOlderSuggestions(deps, effect.id, input.run.threadId, request); const card = buildSuggestionCard({ id: effect.id, effectId: effect.id, tool, args, ...(why ? { why } : {}), }); const message = await publishMessage( deps as ExecutorDeps, input.run, "bot", [card], undefined, `suggestion:${effect.id}`, ); await deps.prisma.externalEffect.update({ where: { id: effect.id }, data: { request: { ...request, card: { threadId: input.run.threadId, messageId: message.id }, } as never, }, }); } return { ok: true, suggested: request.subject, note: "The user sees it as a card with Accept and Dismiss. Do not ask for it again in text.", }; } /** A newer suggestion about the same thing replaces an open older one in the same thread. */ async function expireOlderSuggestions( deps: Pick, keepId: string, threadId: string, request: SuggestionRequest, ) { const open = await deps.prisma.externalEffect.findMany({ where: { kind: "suggestion", status: "proposed", id: { not: keepId }, AND: [ { request: { path: ["card", "threadId"], equals: threadId } }, { request: { path: ["subject"], equals: request.subject } }, ], }, select: { id: true, request: true }, }); for (const older of open) { const card = suggestionRequest(older.request)?.card; await settleSuggestionCard(deps, older.id, card, "expired"); } } /** Move a suggestion effect and its card to a final state. */ export async function settleSuggestionCard( deps: Pick, effectId: string, card: SuggestionRequest["card"], status: "done" | "failed" | "expired", error?: string, ) { const updated = await deps.prisma.$transaction(async (tx) => { const effect = await tx.externalEffect.findUnique({ where: { id: effectId }, select: { result: true }, }); const previous = effect?.result && typeof effect.result === "object" ? (effect.result as object) : {}; await tx.externalEffect.update({ where: { id: effectId }, data: { status, ...(error ? { result: { ...previous, error } } : {}) }, }); if (!card) return null; return updateCardInTransaction( tx, { threadId: card.threadId, messageId: card.messageId, cardId: effectId }, (current: CardBlock) => withSuggestionStatus(current, status, error), ); }); if (updated && card) await notify(deps.events, card.threadId, updated.seq); } /** The call an accepted suggestion runs, as a script that needs no model. */ export async function loadAcceptedSuggestion( prisma: PrismaClient, runId: string, ): Promise<{ effectId: string; card: SuggestionRequest["card"]; script: NonNullable; } | null> { const suggestion = await prisma.externalEffect.findFirst({ where: { kind: "suggestion", result: { path: ["runId"], equals: runId } }, select: { id: true, request: true }, }); const request = suggestionRequest(suggestion?.request); if (!suggestion || !request) return null; return { effectId: suggestion.id, card: request.card, script: [ { assistant: "", toolCalls: [{ name: request.tool, args: request.args }], complete: true }, ], }; } /** A tool result's failure, in the tool's words. */ export function toolResultError(result: unknown): string | undefined { if (!result || typeof result !== "object") return undefined; const error = (result as { error?: unknown }).error; return typeof error === "string" && error ? error : undefined; }