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
28export function isProviderSendTurnSupportedImageMimeType(mimeType: string): boolean {
29 return PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET.has(mimeType.toLowerCase());
30}
31const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
32const CHAT_ATTACHMENT_ID_MAX_CHARS = 128;
33
34export const ChatAttachmentId = TrimmedNonEmptyString.check(
35 Schema.isMaxLength(CHAT_ATTACHMENT_ID_MAX_CHARS),
36 Schema.isPattern(/^[a-z0-9_-]+$/i),
37);
38export type ChatAttachmentId = typeof ChatAttachmentId.Type;
39
40export const SNAP_SHOT_ACCESSIBLE_TEXT_MAX_CHARS = 32_000;
41export const SNAP_SHOT_ACCESSIBILITY_MAX_NODES = 10_000;
42export const SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS = 32_000;
43
44const SnapShotAccessibilityBounds = Schema.Struct({
45 x: NonNegativeInt,
46 y: NonNegativeInt,
47 width: PositiveInt,
48 height: PositiveInt,
49});
50
51const SnapShotAccessibilityState = Schema.Struct({
52 active: Schema.optional(Schema.Boolean),
53 busy: Schema.optional(Schema.Boolean),
54 checked: Schema.optional(Schema.Literals(["on", "off", "mixed"])),
55 editable: Schema.optional(Schema.Boolean),
56 enabled: Schema.optional(Schema.Boolean),
57 expanded: Schema.optional(Schema.Boolean),
58 focused: Schema.optional(Schema.Boolean),
59 selected: Schema.optional(Schema.Boolean),
60 visible: Schema.optional(Schema.Boolean),
61});
62
63export interface SnapShotAccessibilityNode {
64 readonly role: string;
65 readonly name?: string;
66 readonly value?: string;
67 readonly description?: string;
68 readonly bounds: typeof SnapShotAccessibilityBounds.Type | null;
69 readonly state?: typeof SnapShotAccessibilityState.Type;
70 readonly actions?: Array<string>;
71 readonly children: Array<SnapShotAccessibilityNode>;
72}
73
74export const SnapShotAccessibilityNode: Schema.Codec<SnapShotAccessibilityNode> = Schema.Struct({
75 role: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
76 name: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(1_000))),
77 value: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(8_000))),
78 description: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(2_000))),
79 bounds: Schema.NullOr(SnapShotAccessibilityBounds),
80 state: Schema.optionalKey(SnapShotAccessibilityState),
81 actions: Schema.optionalKey(
82 Schema.mutable(Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(100)))).check(
83 Schema.isMaxLength(32),
84 ),
85 ),
86 children: Schema.mutable(
87 Schema.Array(
88 Schema.suspend((): Schema.Codec<SnapShotAccessibilityNode> => SnapShotAccessibilityNode),
89 ),
90 ).check(Schema.isMaxLength(SNAP_SHOT_ACCESSIBILITY_MAX_NODES)),
91});
92
93const SnapShotAccessibilityWire = Schema.Union([
94 Schema.Struct({
95 format: Schema.Literal("flat-text"),
96 text: TrimmedNonEmptyString.check(Schema.isMaxLength(SNAP_SHOT_ACCESSIBLE_TEXT_MAX_CHARS)),
97 truncated: Schema.Boolean,
98 }),
99 Schema.Struct({
100 format: Schema.Literal("element-tree"),
101 coordinateSpace: Schema.Literal("captured-image"),
102 imageSize: Schema.Struct({ width: PositiveInt, height: PositiveInt }),
103 truncated: Schema.Boolean,
104 root: SnapShotAccessibilityNode,
105 }),
106]);
107
108export const SnapShotAccessibility = SnapShotAccessibilityWire.check(
109 Schema.makeFilter((accessibility: typeof SnapShotAccessibilityWire.Type) => {
110 if (accessibility.format === "flat-text") return undefined;
111 let nodes = 0;
112 const stack = [accessibility.root];
113 while (stack.length > 0) {
114 const node = stack.pop()!;
115 nodes += 1;
116 if (nodes > SNAP_SHOT_ACCESSIBILITY_MAX_NODES) {
117 return `Accessibility trees must not exceed ${SNAP_SHOT_ACCESSIBILITY_MAX_NODES} nodes.`;
118 }
119 stack.push(...node.children);
120 }
121 return (
122 JSON.stringify(accessibility).length <= SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS ||
123 `Accessibility trees must not exceed ${SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS} serialized characters.`
124 );
125 }),
126);
127export type SnapShotAccessibility = typeof SnapShotAccessibility.Type;
128
129export const SnapShotSource = Schema.Struct({
130 kind: Schema.Literal("snap-shot"),
131 capturedAt: IsoDateTime,
132 appName: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
133 windowTitle: TrimmedString.check(Schema.isMaxLength(1_000)),
134 accessibleText: Schema.optional(
135 TrimmedNonEmptyString.check(Schema.isMaxLength(SNAP_SHOT_ACCESSIBLE_TEXT_MAX_CHARS)),
136 ),
137 accessibility: Schema.optional(SnapShotAccessibility),
138 appIdentifier: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(255))),
139 appIconDataUrl: Schema.optional(
140 TrimmedNonEmptyString.check(
141 Schema.isMaxLength(100_000),
142 Schema.isPattern(/^data:image\/png;base64,/i),
143 ),
144 ),
145});
146export type SnapShotSource = typeof SnapShotSource.Type;
147
148export const ChatImageAttachment = Schema.Struct({
149 type: Schema.Literal("image"),
150 id: ChatAttachmentId,
151 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
152 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
153 sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
154 source: Schema.optional(SnapShotSource),
155});
156export type ChatImageAttachment = typeof ChatImageAttachment.Type;
157
158export const PastedTextAttachmentSource = Schema.TaggedStruct("pasted-text", {});
159export type PastedTextAttachmentSource = typeof PastedTextAttachmentSource.Type;
160
161export const ChatFileAttachment = Schema.Struct({
162 type: Schema.Literal("file"),
163 id: ChatAttachmentId,
164 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
165 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
166 sizeBytes: NonNegativeInt.check(
167 Schema.isGreaterThanOrEqualTo(1),
168 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES),
169 ),
170 /** Keep path-only so agents inspect selectively without consuming the context folding preserves. */
171 source: Schema.optional(PastedTextAttachmentSource),
172});
173export type ChatFileAttachment = typeof ChatFileAttachment.Type;
174
175/** Accepts unknown attachment types for older readers; malformed known types must fail their own schema. */
176export const ChatUnknownAttachment = Schema.Struct({
177 type: TrimmedNonEmptyString.check(
178 Schema.isMaxLength(50),
179 Schema.isPattern(/^(?!(?:image|file)$)/),
180 ),
181 id: ChatAttachmentId,
182 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
183 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
184 sizeBytes: NonNegativeInt,
185});
186export type ChatUnknownAttachment = typeof ChatUnknownAttachment.Type;
187
188export const UploadChatImageAttachment = Schema.Struct({
189 id: Schema.optional(ChatAttachmentId),
190 type: Schema.Literal("image"),
191 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
192 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
193 sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
194 dataUrl: TrimmedNonEmptyString.check(
195 Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
196 ),
197 source: Schema.optional(SnapShotSource),
198});
199export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type;
200
201export const ChatAttachment = Schema.Union([
202 ChatImageAttachment,
203 ChatFileAttachment,
204 ChatUnknownAttachment,
205]);
206export type ChatAttachment = typeof ChatAttachment.Type;
207
208export function getProviderAttachmentLimitError(
209 attachments: ReadonlyArray<Pick<ChatAttachment, "type" | "mimeType" | "sizeBytes">>,
210): string | undefined {
211 if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
212 return `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message or question response.`;
213 }
214 const imageBytes = attachments.reduce(
215 (total, attachment) =>
216 total +
217 (attachment.type === "image" || isProviderSendTurnSupportedImageMimeType(attachment.mimeType)
218 ? attachment.sizeBytes
219 : 0),
220 0,
221 );
222 if (imageBytes > PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES) {
223 return "Images can total up to 80 MiB per message or question response. Use smaller images or send fewer at once.";
224 }
225}
226
227export const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);
228export type UploadChatAttachment = typeof UploadChatAttachment.Type;
229
230export const PersistChatAttachmentsInput = Schema.Struct({
231 threadId: ThreadId,
232 messageId: MessageId,
233 attachments: Schema.Array(UploadChatAttachment),
234});
235export type PersistChatAttachmentsInput = typeof PersistChatAttachmentsInput.Type;
236
237export const PersistChatAttachmentsResult = Schema.Struct({
238 attachments: Schema.Array(ChatAttachment),
239});
240export type PersistChatAttachmentsResult = typeof PersistChatAttachmentsResult.Type;
241
242export class PersistChatAttachmentsError extends Schema.TaggedError<PersistChatAttachmentsError>()(
243 "PersistChatAttachmentsError",
244 {
245 message: Schema.String,
246 cause: Schema.optional(Schema.Defect()),
247 },
248) {}