An autopilot per thread continues the user's order run after run, with no time limit. Each run reports a checkpoint; after three runs without progress or three failed runs it pauses with Continue and Stop. Start, pause, stop and finish show as quiet lines in the conversation. Approvals during an autopilot run are parked as suggestions, and the temporary approval rules end with the autopilot. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
261 lines
8.7 KiB
TypeScript
261 lines
8.7 KiB
TypeScript
import type { AttachmentKind, AttachmentMimeType, MessageBlock } from "@rakazo/contracts";
|
|
import {
|
|
ATTACHMENT_MAX_BASE64_LENGTH,
|
|
ATTACHMENT_MAX_BYTES,
|
|
attachmentKindOf,
|
|
isAllowedAttachmentMimeType,
|
|
isAttachmentImageMimeType,
|
|
} from "@rakazo/contracts";
|
|
import { autopilotHistoryText } from "./autopilot.js";
|
|
import { cardHistoryText } from "./cards.js";
|
|
import { routineEventHistoryText } from "./routine-events.js";
|
|
|
|
export class AttachmentValidationError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "AttachmentValidationError";
|
|
}
|
|
}
|
|
|
|
export function decodeAttachmentBase64(contentBase64: string): Uint8Array {
|
|
const normalized = contentBase64.trim();
|
|
if (!normalized) throw new AttachmentValidationError("Attachment content is empty");
|
|
if (normalized.length > ATTACHMENT_MAX_BASE64_LENGTH) {
|
|
throw new AttachmentValidationError("Attachment exceeds the 10 MiB limit");
|
|
}
|
|
if (
|
|
normalized.length % 4 !== 0 ||
|
|
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized)
|
|
) {
|
|
throw new AttachmentValidationError("Attachment content is not valid base64");
|
|
}
|
|
let bytes: Buffer;
|
|
try {
|
|
bytes = Buffer.from(normalized, "base64");
|
|
} catch {
|
|
throw new AttachmentValidationError("Attachment content is not valid base64");
|
|
}
|
|
if (bytes.byteLength === 0) throw new AttachmentValidationError("Attachment content is empty");
|
|
if (bytes.byteLength > ATTACHMENT_MAX_BYTES) {
|
|
throw new AttachmentValidationError("Attachment exceeds the 10 MiB limit");
|
|
}
|
|
return new Uint8Array(bytes);
|
|
}
|
|
|
|
export function validateAttachmentMimeType(mimeType: string): void {
|
|
if (!isAllowedAttachmentMimeType(mimeType)) {
|
|
throw new AttachmentValidationError(`Unsupported attachment type: ${mimeType}`);
|
|
}
|
|
}
|
|
|
|
export function attachmentKindForMimeType(mimeType: string): AttachmentKind {
|
|
return attachmentKindOf(mimeType);
|
|
}
|
|
|
|
export function messageBlockForArtifact(artifact: {
|
|
id: string;
|
|
name: string;
|
|
mimeType: string;
|
|
size: number;
|
|
width?: number | null;
|
|
height?: number | null;
|
|
durationMs?: number | null;
|
|
}): Extract<MessageBlock, { kind: "image" | "video" | "audio" | "file" }> {
|
|
const kind = attachmentKindForMimeType(artifact.mimeType);
|
|
if (kind === "image") {
|
|
return {
|
|
kind: "image",
|
|
artifactId: artifact.id,
|
|
mimeType: artifact.mimeType,
|
|
name: artifact.name,
|
|
};
|
|
}
|
|
if (kind === "video") {
|
|
return {
|
|
kind: "video",
|
|
artifactId: artifact.id,
|
|
mimeType: artifact.mimeType,
|
|
name: artifact.name,
|
|
size: artifact.size,
|
|
...(artifact.durationMs != null ? { durationMs: artifact.durationMs } : {}),
|
|
...(artifact.width != null ? { width: artifact.width } : {}),
|
|
...(artifact.height != null ? { height: artifact.height } : {}),
|
|
};
|
|
}
|
|
if (kind === "audio") {
|
|
return {
|
|
kind: "audio",
|
|
artifactId: artifact.id,
|
|
mimeType: artifact.mimeType,
|
|
name: artifact.name,
|
|
size: artifact.size,
|
|
...(artifact.durationMs != null ? { durationMs: artifact.durationMs } : {}),
|
|
};
|
|
}
|
|
return {
|
|
kind: "file",
|
|
artifactId: artifact.id,
|
|
mimeType: artifact.mimeType,
|
|
name: artifact.name,
|
|
size: artifact.size,
|
|
};
|
|
}
|
|
|
|
export function promptTextForAttachments(
|
|
text: string | undefined,
|
|
artifacts: Array<{ name: string; mimeType: string; size: number }>,
|
|
): string {
|
|
const caption = text?.trim() ?? "";
|
|
const notes = artifacts
|
|
.filter((artifact) => !isAttachmentImageMimeType(artifact.mimeType))
|
|
.map(
|
|
(artifact) =>
|
|
`User attached file ${JSON.stringify(artifact.name)} (${artifact.mimeType}, ${artifact.size} bytes).`,
|
|
);
|
|
return [caption, ...notes].filter(Boolean).join("\n\n") || "See attached files.";
|
|
}
|
|
|
|
export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
|
|
return blocks
|
|
.map((block) => {
|
|
if (block.kind === "text") return block.text;
|
|
if (block.kind === "chart") return `[chart: ${block.name}]`;
|
|
if (block.kind === "file") {
|
|
return `[file: ${block.name} (${block.mimeType}, ${block.size} bytes)]`;
|
|
}
|
|
// Media keeps the artifact id so a later turn can edit or export what it refers to.
|
|
if (block.kind === "image" || block.kind === "video" || block.kind === "audio") {
|
|
return `[${block.kind}: ${block.name} (artifact ${block.artifactId})]`;
|
|
}
|
|
if (block.kind === "media") {
|
|
const outputs = block.outputs
|
|
.map((output) => `${output.kind} ${output.name} (artifact ${output.artifactId})`)
|
|
.join(", ");
|
|
return `[media ${block.operation} ${block.status}: ${block.title}${outputs ? ` -> ${outputs}` : ""}]`;
|
|
}
|
|
// Keep attribution on peer messages: without it a later turn cannot tell
|
|
// which lines came from another bot rather than the user.
|
|
if (block.kind === "bot_message_received") return `[from ${block.fromBotName}] ${block.text}`;
|
|
if (block.kind === "bot_message_sent") return `[to ${block.toBotName}] ${block.text}`;
|
|
if (block.kind === "task") {
|
|
return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
|
|
}
|
|
if (block.kind === "event") return routineEventHistoryText(block);
|
|
if (block.kind === "autopilot") return autopilotHistoryText(block);
|
|
if (block.kind === "card") return cardHistoryText(block);
|
|
if (block.kind === "handoff") {
|
|
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
|
|
}
|
|
// A card's question and answer, so a later turn knows what the user already chose.
|
|
if (block.kind === "choice") {
|
|
const answer =
|
|
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
|
|
return answer ? `${block.question}\n[answered: ${answer}]` : block.question;
|
|
}
|
|
if ("text" in block && typeof block.text === "string") return block.text;
|
|
return "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
|
|
const EXTENSION_MIME_TYPES: Record<string, AttachmentMimeType> = {
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".webp": "image/webp",
|
|
".gif": "image/gif",
|
|
".pdf": "application/pdf",
|
|
".txt": "text/plain",
|
|
".md": "text/markdown",
|
|
".markdown": "text/markdown",
|
|
".csv": "text/csv",
|
|
".json": "application/json",
|
|
".mp4": "video/mp4",
|
|
".m4v": "video/mp4",
|
|
".webm": "video/webm",
|
|
".mov": "video/quicktime",
|
|
".mp3": "audio/mpeg",
|
|
".m4a": "audio/mp4",
|
|
".wav": "audio/wav",
|
|
".ogg": "audio/ogg",
|
|
".oga": "audio/ogg",
|
|
".vtt": "text/vtt",
|
|
".srt": "application/x-subrip",
|
|
};
|
|
|
|
const MIME_TYPE_EXTENSIONS: Record<AttachmentMimeType, string> = {
|
|
"image/jpeg": ".jpg",
|
|
"image/png": ".png",
|
|
"image/webp": ".webp",
|
|
"image/gif": ".gif",
|
|
"application/pdf": ".pdf",
|
|
"text/plain": ".txt",
|
|
"text/markdown": ".md",
|
|
"text/csv": ".csv",
|
|
"application/json": ".json",
|
|
"video/mp4": ".mp4",
|
|
"video/webm": ".webm",
|
|
"video/quicktime": ".mov",
|
|
"audio/mpeg": ".mp3",
|
|
"audio/mp4": ".m4a",
|
|
"audio/wav": ".wav",
|
|
"audio/webm": ".weba",
|
|
"audio/ogg": ".ogg",
|
|
"text/vtt": ".vtt",
|
|
"application/x-subrip": ".srt",
|
|
};
|
|
|
|
export function inferAttachmentMimeType(
|
|
name: string,
|
|
reportedType?: string,
|
|
): AttachmentMimeType | null {
|
|
const dot = name.lastIndexOf(".");
|
|
const extensionType = dot < 0 ? undefined : EXTENSION_MIME_TYPES[name.slice(dot).toLowerCase()];
|
|
// Some browsers and native document pickers report Markdown as text/plain.
|
|
if (extensionType === "text/markdown" && (!reportedType || reportedType === "text/plain")) {
|
|
return extensionType;
|
|
}
|
|
if (reportedType && isAllowedAttachmentMimeType(reportedType)) return reportedType;
|
|
return extensionType ?? null;
|
|
}
|
|
|
|
export function attachmentExtensionForMimeType(mimeType: string): string {
|
|
return isAllowedAttachmentMimeType(mimeType) ? MIME_TYPE_EXTENSIONS[mimeType] : "";
|
|
}
|
|
|
|
export function attachmentsForThread<T extends { threadKey: string }>(
|
|
attachments: readonly T[],
|
|
threadKey: string | undefined,
|
|
): T[] {
|
|
if (!threadKey) return [];
|
|
return attachments.filter((attachment) => attachment.threadKey === threadKey);
|
|
}
|
|
|
|
export function attachmentsForBot<T extends { botId: string }>(
|
|
attachments: readonly T[],
|
|
botId: string | undefined,
|
|
): T[] {
|
|
if (!botId) return [];
|
|
return attachments.filter((attachment) => attachment.botId === botId);
|
|
}
|
|
|
|
export function userTurnBlocksForRun(
|
|
trigger: string,
|
|
runId: string,
|
|
messages: Array<{
|
|
id?: string;
|
|
role: string;
|
|
runId?: string | null;
|
|
blocks: MessageBlock[];
|
|
}>,
|
|
sourceMessageId?: string | null,
|
|
): MessageBlock[] | undefined {
|
|
if (trigger !== "user") return undefined;
|
|
return messages.find(
|
|
(message) =>
|
|
message.role === "user" &&
|
|
(sourceMessageId ? message.id === sourceMessageId : message.runId === runId),
|
|
)?.blocks;
|
|
}
|