1import * as Schema from "effect/Schema";
2
3import {
4 IsoDateTime,
5 MessageId,
6 NonNegativeInt,
7 PositiveInt,
8 ThreadId,
9 TrimmedNonEmptyString,
10 TrimmedString,
11} from "./baseSchemas.ts";
12
13export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
14export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 100;
15export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
16const PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES = 80 * 1024 * 1024;
17export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024;
18export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [
19 "image/gif",
20 "image/jpeg",
21 "image/png",
22 "image/webp",
23] as const;
24const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET = new Set<string>(
25 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
26);
27
28/** Whether a pasted or picked image mime type can be sent on a provider turn. */
29export function isProviderSendTurnSupportedImageMimeType(mimeType: string): boolean {
30 return PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET.has(mimeType.toLowerCase());
31}
32const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
33const CHAT_ATTACHMENT_ID_MAX_CHARS = 128;
34
35export const ChatAttachmentId = TrimmedNonEmptyString.check(
36 Schema.isMaxLength(CHAT_ATTACHMENT_ID_MAX_CHARS),
37 Schema.isPattern(/^[a-z0-9_-]+$/i),
38);
39export type ChatAttachmentId = typeof ChatAttachmentId.Type;
40
41export const SNAP_SHOT_ACCESSIBLE_TEXT_MAX_CHARS = 32_000;
42export const SNAP_SHOT_ACCESSIBILITY_MAX_NODES = 10_000;
43export const SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS = 32_000;
44
45const SnapShotAccessibilityBounds = Schema.Struct({
46 x: NonNegativeInt,
47 y: NonNegativeInt,
48 width: PositiveInt,
49 height: PositiveInt,
50});
51
52const SnapShotAccessibilityState = Schema.Struct({
53 active: Schema.optional(Schema.Boolean),
54 busy: Schema.optional(Schema.Boolean),
55 checked: Schema.optional(Schema.Literals(["on", "off", "mixed"])),
56 editable: Schema.optional(Schema.Boolean),
57 enabled: Schema.optional(Schema.Boolean),
58 expanded: Schema.optional(Schema.Boolean),
59 focused: Schema.optional(Schema.Boolean),
60 selected: Schema.optional(Schema.Boolean),
61 visible: Schema.optional(Schema.Boolean),
62});
63
64export interface SnapShotAccessibilityNode {
65 readonly role: string;
66 readonly name?: string;
67 readonly value?: string;
68 readonly description?: string;
69 readonly bounds: typeof SnapShotAccessibilityBounds.Type | null;
70 readonly state?: typeof SnapShotAccessibilityState.Type;
71 readonly actions?: Array<string>;
72 readonly children: Array<SnapShotAccessibilityNode>;
73}
74
75export const SnapShotAccessibilityNode: Schema.Codec<SnapShotAccessibilityNode> = Schema.Struct({
76 role: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
77 name: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(1_000))),
78 value: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(8_000))),
79 description: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(2_000))),
80 bounds: Schema.NullOr(SnapShotAccessibilityBounds),
81 state: Schema.optionalKey(SnapShotAccessibilityState),
82 actions: Schema.optionalKey(
83 Schema.mutable(Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(100)))).check(
84 Schema.isMaxLength(32),
85 ),
86 ),
87 children: Schema.mutable(
88 Schema.Array(
89 Schema.suspend((): Schema.Codec<SnapShotAccessibilityNode> => SnapShotAccessibilityNode),
90 ),
91 ).check(Schema.isMaxLength(SNAP_SHOT_ACCESSIBILITY_MAX_NODES)),
92});
93
94const SnapShotAccessibilityWire = Schema.Union([
95 Schema.Struct({
96 format: Schema.Literal("flat-text"),
97 text: TrimmedNonEmptyString.check(Schema.isMaxLength(SNAP_SHOT_ACCESSIBLE_TEXT_MAX_CHARS)),
98 truncated: Schema.Boolean,
99 }),
100 Schema.Struct({
101 format: Schema.Literal("element-tree"),
102 coordinateSpace: Schema.Literal("captured-image"),
103 imageSize: Schema.Struct({ width: PositiveInt, height: PositiveInt }),
104 truncated: Schema.Boolean,
105 root: SnapShotAccessibilityNode,
106 }),
107]);
108
109export const SnapShotAccessibility = SnapShotAccessibilityWire.check(
110 Schema.makeFilter((accessibility: typeof SnapShotAccessibilityWire.Type) => {
111 if (accessibility.format === "flat-text") return undefined;
112 let nodes = 0;
113 const stack = [accessibility.root];
114 while (stack.length > 0) {
115 const node = stack.pop()!;
116 nodes += 1;
117 if (nodes > SNAP_SHOT_ACCESSIBILITY_MAX_NODES) {
118 return `Accessibility trees must not exceed ${SNAP_SHOT_ACCESSIBILITY_MAX_NODES} nodes.`;
119 }
120 stack.push(...node.children);
121 }
122 return (
123 JSON.stringify(accessibility).length <= SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS ||
124 `Accessibility trees must not exceed ${SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS} serialized characters.`
125 );
126 }),
127);
128export type SnapShotAccessibility = typeof SnapShotAccessibility.Type;
129
130export const SnapShotSource = Schema.Struct({
131 kind: Schema.Literal("snap-shot"),
132 capturedAt: IsoDateTime,
133 appName: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
134 windowTitle: TrimmedString.check(Schema.isMaxLength(1_000)),
135 accessibleText: Schema.optional(
136 TrimmedNonEmptyString.check(Schema.isMaxLength(SNAP_SHOT_ACCESSIBLE_TEXT_MAX_CHARS)),
137 ),
138 accessibility: Schema.optional(SnapShotAccessibility),
139 appIdentifier: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(255))),
140 appIconDataUrl: Schema.optional(
141 TrimmedNonEmptyString.check(
142 Schema.isMaxLength(100_000),
143 Schema.isPattern(/^data:image\/png;base64,/i),
144 ),
145 ),
146});
147export type SnapShotSource = typeof SnapShotSource.Type;
148
149export const ChatImageAttachment = Schema.Struct({
150 type: Schema.Literal("image"),
151 id: ChatAttachmentId,
152 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
153 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
154 sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
155 source: Schema.optional(SnapShotSource),
156});
157export type ChatImageAttachment = typeof ChatImageAttachment.Type;
158
159export const PastedTextAttachmentSource = Schema.TaggedStruct("pasted-text", {});
160export type PastedTextAttachmentSource = typeof PastedTextAttachmentSource.Type;
161
162export const ChatFileAttachment = Schema.Struct({
163 type: Schema.Literal("file"),
164 id: ChatAttachmentId,
165 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
166 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
167 sizeBytes: NonNegativeInt.check(
168 Schema.isGreaterThanOrEqualTo(1),
169 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES),
170 ),
171 /** Clipboard text folded by a client. Providers keep these path-only so the
172 agent can inspect the file selectively instead of eagerly spending the
173 same context the fold is intended to preserve. */
174 source: Schema.optional(PastedTextAttachmentSource),
175});
176export type ChatFileAttachment = typeof ChatFileAttachment.Type;
177
178/**
179 * Catch-all for attachment types this build does not know. Attachments ride on
180 * persisted events and thread streams, so a newer server or client must be able
181 * to introduce a type without making older readers fail to decode the whole
182 * message. Decoders keep the shared base fields; consumers skip these or render
183 * them as unsupported. Mirrors how `OrchestrationThreadActivity` keeps `kind`
184 * open. The known discriminators are excluded so a malformed image or file
185 * attachment fails its own schema instead of sliding through here with its
186 * size and mime constraints unchecked.
187 */
188export const ChatUnknownAttachment = Schema.Struct({
189 type: TrimmedNonEmptyString.check(
190 Schema.isMaxLength(50),
191 Schema.isPattern(/^(?!(?:image|file)$)/),
192 ),
193 id: ChatAttachmentId,
194 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
195 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
196 sizeBytes: NonNegativeInt,
197});
198export type ChatUnknownAttachment = typeof ChatUnknownAttachment.Type;
199
200export const UploadChatImageAttachment = Schema.Struct({
201 id: Schema.optional(ChatAttachmentId),
202 type: Schema.Literal("image"),
203 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
204 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
205 sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
206 dataUrl: TrimmedNonEmptyString.check(
207 Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
208 ),
209 source: Schema.optional(SnapShotSource),
210});
211export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type;
212
213export const ChatAttachment = Schema.Union([
214 ChatImageAttachment,
215 ChatFileAttachment,
216 ChatUnknownAttachment,
217]);
218export type ChatAttachment = typeof ChatAttachment.Type;
219
220export function getProviderAttachmentLimitError(
221 attachments: ReadonlyArray<Pick<ChatAttachment, "type" | "mimeType" | "sizeBytes">>,
222): string | undefined {
223 if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
224 return `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message or question response.`;
225 }
226 const imageBytes = attachments.reduce(
227 (total, attachment) =>
228 total +
229 (attachment.type === "image" || isProviderSendTurnSupportedImageMimeType(attachment.mimeType)
230 ? attachment.sizeBytes
231 : 0),
232 0,
233 );
234 if (imageBytes > PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES) {
235 return "Images can total up to 80 MiB per message or question response. Use smaller images or send fewer at once.";
236 }
237}
238
239export const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);
240export type UploadChatAttachment = typeof UploadChatAttachment.Type;
241
242export const PersistChatAttachmentsInput = Schema.Struct({
243 threadId: ThreadId,
244 messageId: MessageId,
245 attachments: Schema.Array(UploadChatAttachment),
246});
247export type PersistChatAttachmentsInput = typeof PersistChatAttachmentsInput.Type;
248
249export const PersistChatAttachmentsResult = Schema.Struct({
250 attachments: Schema.Array(ChatAttachment),
251});
252export type PersistChatAttachmentsResult = typeof PersistChatAttachmentsResult.Type;
253
254export class PersistChatAttachmentsError extends Schema.TaggedError<PersistChatAttachmentsError>()(
255 "PersistChatAttachmentsError",
256 {
257 message: Schema.String,
258 cause: Schema.optional(Schema.Defect()),
259 },
260) {}