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>
252 lines
8.5 KiB
TypeScript
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;
|
|
}
|