Skip to content
Source
Sign inSign up

composerContext.ts

333 lines12.0 KB

1import * as Schema from "effect/Schema";
2​
3import { AssistantCitation } from "./assistantCitations.ts";
4import {
5 EnvironmentId,
6 ForwardCompatibleArray,
7 GuestMessageId,
8 GuestShareId,
9 NonNegativeInt,
10 PositiveInt,
11 ThreadId,
12 TrimmedNonEmptyString,
13 TurnItemId,
14} from "./baseSchemas.ts";
15import { GUEST_MESSAGE_MAX_LENGTH, GUEST_NAME_MAX_LENGTH } from "./guestShare.ts";
16​
17/** Image and file records reference `ChatAttachment` ids, never bytes. */
18​
19export const COMPOSER_CONTEXT_KINDS = [
20 "image",
21 "file",
22 "terminal",
23 "element",
24 "preview-annotation",
25 "review-comment",
26 "mention",
27 "skill",
28 "thread",
29 "environment",
30 "response",
31] as const;
32export type KnownComposerContextKind = (typeof COMPOSER_CONTEXT_KINDS)[number];
33​
34const CONTEXT_ID_MAX_CHARS = 128;
35const CONTEXT_ID_PATTERN = /^[a-z0-9_-]+$/i;
36const CONTEXT_KIND_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
37const KNOWN_KIND_PATTERN = new RegExp(`^(?!(?:${COMPOSER_CONTEXT_KINDS.join("|")})$)`);
38​
39export const COMPOSER_CONTEXT_LABEL_MAX_CHARS = 200;
40export const COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS = 64_000;
41const COMPOSER_CONTEXT_ELEMENT_HTML_MAX_CHARS = 8_000;
42const COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS = 8_000;
43export const COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS = 16_000;
44export const COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS = 32_000;
45const COMPOSER_CONTEXT_PREVIEW_COMMENT_MAX_CHARS = 8_000;
46​
47export const ComposerContextId = TrimmedNonEmptyString.check(
48 Schema.isMaxLength(CONTEXT_ID_MAX_CHARS),
49 Schema.isPattern(CONTEXT_ID_PATTERN),
50).pipe(Schema.brand("ComposerContextId"));
51export type ComposerContextId = typeof ComposerContextId.Type;
52​
53export const ComposerContextReferenceId = TrimmedNonEmptyString.check(
54 Schema.isMaxLength(CONTEXT_ID_MAX_CHARS),
55 Schema.isPattern(CONTEXT_ID_PATTERN),
56).pipe(Schema.brand("ComposerContextReferenceId"));
57export type ComposerContextReferenceId = typeof ComposerContextReferenceId.Type;
58​
59export const ComposerContextKind = TrimmedNonEmptyString.check(
60 Schema.isPattern(CONTEXT_KIND_PATTERN),
61);
62export type ComposerContextKind = typeof ComposerContextKind.Type;
63​
64// Duplicates the private ChatAttachmentId rules to avoid an orchestration.ts import cycle.
65const ContextAttachmentId = TrimmedNonEmptyString.check(
66 Schema.isMaxLength(CONTEXT_ID_MAX_CHARS),
67 Schema.isPattern(CONTEXT_ID_PATTERN),
68);
69const ContextLabel = Schema.String.check(Schema.isMaxLength(COMPOSER_CONTEXT_LABEL_MAX_CHARS));
70const BoundedString = (max: number) => Schema.String.check(Schema.isMaxLength(max));
71const ShortString = BoundedString(2_048);
72const NullableShortString = Schema.NullOr(ShortString);
73​
74export const PullRequestContextMetadata = Schema.Struct({
75 number: PositiveInt,
76 title: ShortString,
77 url: ShortString,
78 headBranch: ShortString,
79 baseBranch: ShortString,
80 state: Schema.Literals(["open", "closed", "merged"]),
81 isDraft: Schema.Boolean,
82});
83export type PullRequestContextMetadata = typeof PullRequestContextMetadata.Type;
84​
85const recordBase = {
86 version: Schema.Literal(1),
87 contextId: ComposerContextId,
88 label: ContextLabel,
89} as const;
90​
91const attachmentBinding = {
92 attachmentId: ContextAttachmentId,
93 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
94 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)),
95 sizeBytes: NonNegativeInt,
96} as const;
97​
98export const ImageContextRecord = Schema.Struct({
99 ...recordBase,
100 kind: Schema.Literal("image"),
101 ...attachmentBinding,
102});
103export type ImageContextRecord = typeof ImageContextRecord.Type;
104​
105export const FileContextRecord = Schema.Struct({
106 ...recordBase,
107 kind: Schema.Literal("file"),
108 ...attachmentBinding,
109});
110export type FileContextRecord = typeof FileContextRecord.Type;
111​
112export const TerminalContextRecord = Schema.Struct({
113 ...recordBase,
114 kind: Schema.Literal("terminal"),
115 terminalId: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
116 terminalLabel: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
117 lineStart: NonNegativeInt,
118 lineEnd: NonNegativeInt,
119 text: BoundedString(COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS),
120}).check(Schema.makeFilter((record) => record.lineEnd >= record.lineStart));
121export type TerminalContextRecord = typeof TerminalContextRecord.Type;
122​
123export const ElementContextSource = Schema.Struct({
124 functionName: NullableShortString,
125 fileName: NullableShortString,
126 lineNumber: Schema.NullOr(NonNegativeInt),
127 columnNumber: Schema.NullOr(NonNegativeInt),
128});
129export type ElementContextSource = typeof ElementContextSource.Type;
130​
131export const ElementContextDetails = Schema.Struct({
132 pageUrl: ShortString,
133 pageTitle: NullableShortString,
134 tagName: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
135 selector: NullableShortString,
136 htmlPreview: BoundedString(COMPOSER_CONTEXT_ELEMENT_HTML_MAX_CHARS),
137 componentName: NullableShortString,
138 source: Schema.NullOr(ElementContextSource),
139 styles: BoundedString(COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS),
140});
141export type ElementContextDetails = typeof ElementContextDetails.Type;
142​
143export const ElementContextRecord = Schema.Struct({
144 ...recordBase,
145 kind: Schema.Literal("element"),
146 ...ElementContextDetails.fields,
147});
148export type ElementContextRecord = typeof ElementContextRecord.Type;
149​
150// `optionalKey` rejects explicit undefined within one record, so only that record is dropped.
151export const PreviewAnnotationContextRecord = Schema.Struct({
152 ...recordBase,
153 kind: Schema.Literal("preview-annotation"),
154 annotationId: ShortString,
155 pageUrl: ShortString,
156 pageTitle: NullableShortString,
157 comment: BoundedString(COMPOSER_CONTEXT_PREVIEW_COMMENT_MAX_CHARS),
158 targetSummary: ShortString,
159 styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)),
160 elements: Schema.optionalKey(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))),
161 elementIds: Schema.optionalKey(Schema.Array(ShortString).check(Schema.isMaxLength(50))),
162 regionCount: Schema.optionalKey(NonNegativeInt),
163 strokeCount: Schema.optionalKey(NonNegativeInt),
164 styleChangeDetails: Schema.optionalKey(
165 Schema.Array(
166 Schema.Struct({
167 targetId: ShortString,
168 selector: NullableShortString,
169 property: ShortString,
170 previousValue: BoundedString(COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS),
171 value: BoundedString(COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS),
172 }),
173 ).check(Schema.isMaxLength(200)),
174 ),
175 screenshotContextId: Schema.optionalKey(ComposerContextId),
176});
177export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type;
178​
179export const ReviewCommentContextRecord = Schema.Struct({
180 ...recordBase,
181 kind: Schema.Literal("review-comment"),
182 sectionId: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
183 sectionTitle: ShortString,
184 filePath: TrimmedNonEmptyString.check(Schema.isMaxLength(2_048)),
185 startIndex: NonNegativeInt,
186 endIndex: NonNegativeInt,
187 rangeLabel: ShortString,
188 text: BoundedString(COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS),
189 diff: BoundedString(COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS),
190 fenceLanguage: Schema.optionalKey(BoundedString(64)),
191 pullRequest: Schema.optionalKey(PullRequestContextMetadata),
192}).check(Schema.makeFilter((record) => record.endIndex >= record.startIndex));
193export type ReviewCommentContextRecord = typeof ReviewCommentContextRecord.Type;
194​
195export const MentionContextRecord = Schema.Struct({
196 ...recordBase,
197 kind: Schema.Literal("mention"),
198 path: TrimmedNonEmptyString.check(Schema.isMaxLength(2_048)),
199});
200export type MentionContextRecord = typeof MentionContextRecord.Type;
201​
202export const SkillContextRecord = Schema.Struct({
203 ...recordBase,
204 kind: Schema.Literal("skill"),
205 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
206});
207export type SkillContextRecord = typeof SkillContextRecord.Type;
208​
209export const ThreadContextRecord = Schema.Struct({
210 ...recordBase,
211 kind: Schema.Literal("thread"),
212 environmentId: EnvironmentId,
213 threadId: ThreadId,
214 title: ContextLabel,
215});
216export type ThreadContextRecord = typeof ThreadContextRecord.Type;
217​
218/**
219 * One of the user's machines (a T3 Code environment), mentioned so the agent
220 * links it or works there. Only identity travels: the agent links it by id,
221 * and the user's client supplies its addresses.
222 */
223export const EnvironmentContextRecord = Schema.Struct({
224 ...recordBase,
225 kind: Schema.Literal("environment"),
226 environmentId: EnvironmentId,
227});
228export type EnvironmentContextRecord = typeof EnvironmentContextRecord.Type;
229​
230/**
231 * An earlier assistant response the user is replying to. Only its identity and opening travel:
232 * the agent rereads the rest by item id, so a reply costs the same however long the response is.
233 */
234export const ResponseContextRecord = Schema.Struct({
235 ...recordBase,
236 kind: Schema.Literal("response"),
237 /** Where the response is and how it starts, as the reader saw it. */
238 citation: AssistantCitation,
239 itemId: TurnItemId,
240 /** The subagents whose results that response reported on. */
241 subagents: Schema.optional(
242 Schema.Array(
243 Schema.Struct({
244 threadId: ThreadId,
245 title: ContextLabel,
246 /** Set when the subagent's thread lives on a linked environment. */
247 environmentId: Schema.optional(EnvironmentId),
248 }),
249 ).check(Schema.isMaxLength(20)),
250 ),
251});
252export type ResponseContextRecord = typeof ResponseContextRecord.Type;
253​
254const isJson = Schema.is(Schema.Json);
255​
256/** Excludes known discriminators so malformed known records fail their own schema. */
257export const UnknownContextRecord = Schema.Struct({
258 ...recordBase,
259 kind: ComposerContextKind.check(Schema.isPattern(KNOWN_KIND_PATTERN)),
260 payload: Schema.Unknown.check(
261 Schema.makeFilter((payload) => {
262 let encoded: string | undefined;
263 try {
264 encoded = JSON.stringify(payload);
265 } catch {
266 return false;
267 }
268 return encoded !== undefined && encoded.length <= 64_000 && isJson(payload);
269 }),
270 ),
271});
272export type UnknownContextRecord = typeof UnknownContextRecord.Type;
273​
274export const KnownComposerContextRecord = Schema.Union([
275 ImageContextRecord,
276 FileContextRecord,
277 TerminalContextRecord,
278 ElementContextRecord,
279 PreviewAnnotationContextRecord,
280 ReviewCommentContextRecord,
281 MentionContextRecord,
282 SkillContextRecord,
283 ThreadContextRecord,
284 EnvironmentContextRecord,
285 ResponseContextRecord,
286]);
287export type KnownComposerContextRecord = typeof KnownComposerContextRecord.Type;
288​
289export const ComposerContextRecord = Schema.Union([
290 ...KnownComposerContextRecord.members,
291 UnknownContextRecord,
292]);
293export type ComposerContextRecord = typeof ComposerContextRecord.Type;
294​
295export const COMPOSER_CONTEXT_MAX_RECORDS = 200;
296const COMPOSER_CONTEXT_MAX_SERIALIZED_CHARS = 16_000_000;
297​
298/**
299 * Marks a user message as a guest's, sent by the owner. The message `text` keeps the attributed
300 * form the agent reads; clients show `text` here with `name` beside it. Only the server sets it.
301 */
302export const GuestMessageAttribution = Schema.Struct({
303 guestMessageId: GuestMessageId,
304 name: TrimmedNonEmptyString.check(Schema.isMaxLength(GUEST_NAME_MAX_LENGTH)),
305 text: Schema.String.check(Schema.isMaxLength(GUEST_MESSAGE_MAX_LENGTH)),
306 /** The link the guest used. Only that link's guest may load the message's files. */
307 shareId: Schema.optional(GuestShareId),
308});
309export type GuestMessageAttribution = typeof GuestMessageAttribution.Type;
310​
311/** Structured context riding on a user message. Undecodable records are dropped, not fatal. */
312export const OrchestrationMessageContext = Schema.Struct({
313 version: Schema.Literal(1),
314 guest: Schema.optional(GuestMessageAttribution),
315 records: Schema.Array(Schema.Unknown)
316 .check(
317 Schema.isMaxLength(COMPOSER_CONTEXT_MAX_RECORDS),
318 Schema.makeFilter((records) => {
319 try {
320 return JSON.stringify(records).length <= COMPOSER_CONTEXT_MAX_SERIALIZED_CHARS;
321 } catch {
322 return false;
323 }
324 }),
325 )
326 .pipe(Schema.decodeTo(ForwardCompatibleArray(ComposerContextRecord)))
327 .check(
328 Schema.makeFilter(
329 (records) => new Set(records.map((record) => record.contextId)).size === records.length,
330 ),
331 ),
332});
333export type OrchestrationMessageContext = typeof OrchestrationMessageContext.Type;