| 1 | import * as Schema from "effect/Schema"; |
| 2 | |
| 3 | import { |
| 4 | IsoDateTime, |
| 5 | MessageId, |
| 6 | NonNegativeInt, |
| 7 | PositiveInt, |
| 8 | ThreadId, |
| 9 | TrimmedNonEmptyString, |
| 10 | TrimmedString, |
| 11 | } from "./baseSchemas.ts"; |
| 12 | |
| 13 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 14 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 100; |
| 15 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; |
| 16 | const PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES = 80 * 1024 * 1024; |
| 17 | export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; |
| 18 | export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ |
| 19 | "image/gif", |
| 20 | "image/jpeg", |
| 21 | "image/png", |
| 22 | "image/webp", |
| 23 | ] as const; |
| 24 | const 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. */ |
| 29 | export function isProviderSendTurnSupportedImageMimeType(mimeType: string): boolean { |
| 30 | return PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET.has(mimeType.toLowerCase()); |
| 31 | } |
| 32 | const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; |
| 33 | const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; |
| 34 | |
| 35 | export const ChatAttachmentId = TrimmedNonEmptyString.check( |
| 36 | Schema.isMaxLength(CHAT_ATTACHMENT_ID_MAX_CHARS), |
| 37 | Schema.isPattern(/^[a-z0-9_-]+$/i), |
| 38 | ); |
| 39 | export type ChatAttachmentId = typeof ChatAttachmentId.Type; |
| 40 | |
| 41 | export const SNAP_SHOT_ACCESSIBLE_TEXT_MAX_CHARS = 32_000; |
| 42 | export const SNAP_SHOT_ACCESSIBILITY_MAX_NODES = 10_000; |
| 43 | export const SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS = 32_000; |
| 44 | |
| 45 | const SnapShotAccessibilityBounds = Schema.Struct({ |
| 46 | x: NonNegativeInt, |
| 47 | y: NonNegativeInt, |
| 48 | width: PositiveInt, |
| 49 | height: PositiveInt, |
| 50 | }); |
| 51 | |
| 52 | const 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 | |
| 64 | export 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 | |
| 75 | export 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 | |
| 94 | const 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 | |
| 109 | export 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 | ); |
| 128 | export type SnapShotAccessibility = typeof SnapShotAccessibility.Type; |
| 129 | |
| 130 | export 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 | }); |
| 147 | export type SnapShotSource = typeof SnapShotSource.Type; |
| 148 | |
| 149 | export 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 | }); |
| 157 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; |
| 158 | |
| 159 | export const PastedTextAttachmentSource = Schema.TaggedStruct("pasted-text", {}); |
| 160 | export type PastedTextAttachmentSource = typeof PastedTextAttachmentSource.Type; |
| 161 | |
| 162 | export 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 | }); |
| 176 | export 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 | */ |
| 188 | export 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 | }); |
| 198 | export type ChatUnknownAttachment = typeof ChatUnknownAttachment.Type; |
| 199 | |
| 200 | export 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 | }); |
| 211 | export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; |
| 212 | |
| 213 | export const ChatAttachment = Schema.Union([ |
| 214 | ChatImageAttachment, |
| 215 | ChatFileAttachment, |
| 216 | ChatUnknownAttachment, |
| 217 | ]); |
| 218 | export type ChatAttachment = typeof ChatAttachment.Type; |
| 219 | |
| 220 | export 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 | |
| 239 | export const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); |
| 240 | export type UploadChatAttachment = typeof UploadChatAttachment.Type; |
| 241 | |
| 242 | export const PersistChatAttachmentsInput = Schema.Struct({ |
| 243 | threadId: ThreadId, |
| 244 | messageId: MessageId, |
| 245 | attachments: Schema.Array(UploadChatAttachment), |
| 246 | }); |
| 247 | export type PersistChatAttachmentsInput = typeof PersistChatAttachmentsInput.Type; |
| 248 | |
| 249 | export const PersistChatAttachmentsResult = Schema.Struct({ |
| 250 | attachments: Schema.Array(ChatAttachment), |
| 251 | }); |
| 252 | export type PersistChatAttachmentsResult = typeof PersistChatAttachmentsResult.Type; |
| 253 | |
| 254 | export class PersistChatAttachmentsError extends Schema.TaggedError<PersistChatAttachmentsError>()( |
| 255 | "PersistChatAttachmentsError", |
| 256 | { |
| 257 | message: Schema.String, |
| 258 | cause: Schema.optional(Schema.Defect()), |
| 259 | }, |
| 260 | ) {} |