blob: ad9211b69b03242f0fad1da12ddd61bd55a1c014 [file]
import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
import {
decodeMessageContent as decodeCanonicalMessageContent,
isCanonicalAttachmentRef,
type MessageContent,
} from '@maka/core/events';
import { invalidProtocolFrame } from './errors.js';
import {
assertExactKeys,
requireEntityId,
requireExactRecord,
requireId,
requireRecord,
requireString,
} from './codec.js';
import { defineOperation } from './operation-spec.js';
export interface TurnStartInput {
sessionId: string;
turnId: string;
content: MessageContent;
}
export type { MessageContent };
export const TURN_MESSAGE_TEXT_MAX_BYTES = 48 * 1024;
export const TURN_MESSAGE_CONTENT_MAX_BYTES = 52 * 1024;
export const TURN_MESSAGE_QUOTE_MAX_COUNT = 16;
export const TURN_MESSAGE_QUOTE_TEXT_MAX_LENGTH = 32_000;
export const TURN_MESSAGE_QUOTE_LABEL_MAX_LENGTH = 200;
const ATTACHMENT_NAME_MAX_BYTES = 512;
const ATTACHMENT_MIME_TYPE_MAX_BYTES = 256;
const ATTACHMENT_PATH_MAX_BYTES = 4096;
export interface TurnQueryInput {
sessionId: string;
turnId: string;
}
export interface TurnStopInput {
sessionId: string;
turnId: string;
runId: string;
}
export type TurnRunStatus =
| 'admitted'
| 'created'
| 'running'
| 'waiting_for_user'
| 'completed'
| 'failed'
| 'cancelled';
interface TurnSnapshotBase {
sessionId: string;
turnId: string;
runId: string;
}
export type TurnSnapshot =
| (TurnSnapshotBase & {
status: Exclude<TurnRunStatus, 'completed' | 'failed' | 'cancelled'>;
})
| (TurnSnapshotBase & { status: 'completed'; terminalEventId: string })
| (TurnSnapshotBase & {
status: 'failed';
terminalEventId: string;
failureClass: string;
})
| (TurnSnapshotBase & {
status: 'cancelled';
terminalEventId: string;
abortSource: string;
});
export const TURN_OPERATION_SPECS = {
'turn.start': defineOperation({
mode: 'command',
availability: 'ready',
errors: [
'host_not_ready',
'host_draining',
'operation_unavailable',
'not_found',
'session_archived',
'session_busy',
'operation_conflict',
'internal_failure',
] as const,
decodeInput: decodeTurnStartInput,
decodeOutput: decodeTurnSnapshot,
}),
'turn.query': defineOperation({
mode: 'query',
availability: 'ready',
errors: [
'host_not_ready',
'host_draining',
'operation_unavailable',
'not_found',
'internal_failure',
] as const,
decodeInput: decodeTurnQueryInput,
decodeOutput: decodeTurnSnapshot,
}),
'turn.stop': defineOperation({
mode: 'control',
availability: 'ready',
errors: [
'host_not_ready',
'host_draining',
'operation_unavailable',
'not_found',
'operation_conflict',
'internal_failure',
] as const,
decodeInput: decodeTurnStopInput,
decodeOutput: decodeTurnSnapshot,
}),
} as const;
function decodeTurnStartInput(value: unknown): TurnStartInput {
const record = requireExactRecord(value, 'turn.start input', ['sessionId', 'turnId', 'content']);
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
turnId: requireEntityId(record.turnId, 'turnId'),
content: decodeMessageContent(record.content),
};
}
export function decodeMessageContent(value: unknown): MessageContent {
let content: MessageContent;
try {
content = decodeCanonicalMessageContent(value);
} catch {
throw invalidProtocolFrame('Invalid Message content');
}
requireUtf8String(content.text, 'Message text', TURN_MESSAGE_TEXT_MAX_BYTES, false);
if (content.displayText !== undefined) {
requireUtf8String(
content.displayText,
'Message displayText',
TURN_MESSAGE_TEXT_MAX_BYTES,
true,
);
}
if ((content.attachments?.length ?? 0) > MAX_ATTACHMENT_COUNT) {
throw invalidProtocolFrame('Invalid Message attachments');
}
for (const attachment of content.attachments ?? []) {
if (!isCanonicalAttachmentRef(attachment)) {
throw invalidProtocolFrame('Invalid AttachmentRef');
}
requireUtf8String(attachment.name, 'AttachmentRef name', ATTACHMENT_NAME_MAX_BYTES, false);
requireUtf8String(
attachment.mimeType,
'AttachmentRef mimeType',
ATTACHMENT_MIME_TYPE_MAX_BYTES,
false,
);
if (attachment.bytes > MAX_ATTACHMENT_BYTES) {
throw invalidProtocolFrame('Invalid AttachmentRef bytes');
}
if (attachment.ref.kind === 'session_file') {
requireEntityId(attachment.ref.sessionId, 'AttachmentRef sessionId');
}
const path =
attachment.ref.kind === 'external_file'
? attachment.ref.absolutePath
: attachment.ref.relativePath;
requireUtf8String(path, 'AttachmentRef path', ATTACHMENT_PATH_MAX_BYTES, false);
}
if ((content.quotes?.length ?? 0) > TURN_MESSAGE_QUOTE_MAX_COUNT) {
throw invalidProtocolFrame('Invalid Message quotes');
}
for (const quote of content.quotes ?? []) {
requireString(quote.text, 'QuoteRef text', TURN_MESSAGE_QUOTE_TEXT_MAX_LENGTH);
if (quote.label !== undefined) {
requireString(quote.label, 'QuoteRef label', TURN_MESSAGE_QUOTE_LABEL_MAX_LENGTH);
}
if (quote.sourceTurnId !== undefined) {
requireEntityId(quote.sourceTurnId, 'QuoteRef sourceTurnId');
}
}
requireEncodedByteLimit(content, 'Message content', TURN_MESSAGE_CONTENT_MAX_BYTES);
return content;
}
function requireUtf8String(
value: unknown,
label: string,
maxBytes: number,
allowEmpty: boolean,
): string {
if (
typeof value !== 'string' ||
(!allowEmpty && value.length === 0) ||
Buffer.byteLength(value, 'utf8') > maxBytes
) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value;
}
function requireEncodedByteLimit(value: unknown, label: string, maxBytes: number): void {
let encoded: string | undefined;
try {
encoded = JSON.stringify(value);
} catch {
throw invalidProtocolFrame(`Invalid ${label}`);
}
if (encoded === undefined || Buffer.byteLength(encoded, 'utf8') > maxBytes) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
}
function decodeTurnQueryInput(value: unknown): TurnQueryInput {
const record = requireExactRecord(value, 'turn.query input', ['sessionId', 'turnId']);
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
turnId: requireEntityId(record.turnId, 'turnId'),
};
}
function decodeTurnStopInput(value: unknown): TurnStopInput {
const record = requireExactRecord(value, 'turn.stop input', ['sessionId', 'turnId', 'runId']);
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
turnId: requireEntityId(record.turnId, 'turnId'),
runId: requireEntityId(record.runId, 'runId'),
};
}
export function decodeTurnSnapshot(value: unknown): TurnSnapshot {
const record = requireRecord(value, 'Turn snapshot');
const base = {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
turnId: requireEntityId(record.turnId, 'turnId'),
runId: requireEntityId(record.runId, 'runId'),
};
const status = requireTurnRunStatus(record.status);
if (status === 'completed') {
assertExactKeys(record, 'completed Turn snapshot', [
'sessionId',
'turnId',
'runId',
'status',
'terminalEventId',
]);
return {
...base,
status,
terminalEventId: requireId(record.terminalEventId, 'terminalEventId'),
};
}
if (status === 'failed') {
assertExactKeys(record, 'failed Turn snapshot', [
'sessionId',
'turnId',
'runId',
'status',
'terminalEventId',
'failureClass',
]);
return {
...base,
status,
terminalEventId: requireId(record.terminalEventId, 'terminalEventId'),
failureClass: requireString(record.failureClass, 'failureClass', 128),
};
}
if (status === 'cancelled') {
assertExactKeys(record, 'cancelled Turn snapshot', [
'sessionId',
'turnId',
'runId',
'status',
'terminalEventId',
'abortSource',
]);
return {
...base,
status,
terminalEventId: requireId(record.terminalEventId, 'terminalEventId'),
abortSource: requireString(record.abortSource, 'abortSource', 128),
};
}
assertExactKeys(record, 'non-terminal Turn snapshot', ['sessionId', 'turnId', 'runId', 'status']);
return { ...base, status };
}
function requireTurnRunStatus(value: unknown): TurnRunStatus {
if (
value === 'admitted' ||
value === 'created' ||
value === 'running' ||
value === 'waiting_for_user' ||
value === 'completed' ||
value === 'failed' ||
value === 'cancelled'
) {
return value;
}
throw invalidProtocolFrame('Invalid Turn run status');
}