evOo-local/packages/adapters/src/suggestions.ts
cynarAI c69f00e891
feat(avatars): bots offer a new Oo look from chat (#145)
update_bot takes a look (oo1: value), validated and normalized in core.
A suggested look renders as a "New look" card with the avatar preview on
web and mobile; accepting applies it to the bot.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 04:03:10 +02:00

252 lines
8.5 KiB
TypeScript

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<string, unknown>;
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<SuggestionRequest>;
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<ThreadEvents, "notify">;
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<ExecutorDeps, "prisma" | "events">,
input: {
run: { id: string; spaceId: string; userId: string; threadId: string; botId: string };
bot: { id: string; kind: string };
executionId: string;
exposedToolNames: ReadonlySet<string>;
args: Record<string, unknown>;
},
): Promise<Record<string, unknown>> {
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<string, unknown>) : {};
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<ExecutorDeps, "prisma" | "events">,
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<ExecutorDeps, "prisma" | "events">,
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<AgentRunRequest["script"]>;
} | 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;
}