@ixo/oracle-runtime-workers 0.37.2 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/attachments/channel-links.ts +74 -7
- package/src/attachments/channel.ts +15 -4
- package/src/attachments/content-blocks.ts +19 -16
- package/src/attachments/download.ts +173 -50
- package/src/attachments/index.ts +6 -5
- package/src/attachments/pipeline.ts +117 -49
- package/src/attachments/resize.ts +142 -0
- package/src/attachments/retention.ts +26 -70
- package/src/attachments/room-media.ts +643 -0
- package/src/attachments/route.ts +47 -4
- package/src/attachments/sandbox-archive.ts +15 -4
- package/src/attachments/sanitize.ts +24 -13
- package/src/attachments/view.ts +82 -38
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +18 -3
- package/src/core/middlewares/attachment-recall.ts +502 -0
- package/src/core/middlewares/index.ts +5 -0
- package/src/do/contracts.ts +51 -23
- package/src/do/room-mirror.ts +144 -20
- package/src/do/transcript.ts +1 -1
- package/src/do/turn-attachments.ts +45 -6
- package/src/do/user-oracle-do.ts +507 -93
- package/src/llm/provider-error.ts +5 -2
- package/src/llm/stream-liveness.ts +3 -2
- package/src/matrix/gateway-do.ts +164 -102
- package/src/matrix/ingest.ts +12 -3
- package/src/matrix/media-deadline.ts +5 -47
- package/src/plugins/attachments/attachments.plugin.ts +3 -3
- package/src/plugins/attachments/view-attachment-tool.ts +6 -4
- package/src/sqlite/inline-payloads.ts +121 -0
- package/src/sqlite/sqlite-saver.ts +241 -42
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
* processAttachments`:
|
|
5
5
|
*
|
|
6
6
|
* 1. classify + route each attachment by the selected model's native input
|
|
7
|
-
* capabilities (`send-native` / `parse-local` /
|
|
7
|
+
* capabilities (`route.ts`: `send-native` / `parse-local` /
|
|
8
|
+
* `model-extract`) — the same rule for every source; a document whose
|
|
9
|
+
* declared size is over `NATIVE_DOCUMENT_MAX_BYTES` skips the native
|
|
10
|
+
* lane, one of unknown or smaller size is a native candidate;
|
|
8
11
|
* 2. NATIVE lane: download, sniff, base64 into LangChain content blocks on
|
|
9
|
-
* the human message;
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* the human message; an image is first downscaled for the model when the
|
|
13
|
+
* oracle binds Cloudflare Images (`resize.ts`), and the ORIGINAL is
|
|
14
|
+
* archived to the sandbox in the background. A failed download falls
|
|
15
|
+
* back to extraction so a bad download never drops the whole message; a
|
|
16
|
+
* file that is itself refused (empty, over the budget) is noted, and one
|
|
17
|
+
* whose content is not what it claims, a document whose downloaded
|
|
18
|
+
* length is over `NATIVE_DOCUMENT_MAX_BYTES` (the length decides: a
|
|
19
|
+
* declared size may be missing or wrong) or an image the resize step
|
|
20
|
+
* could not make fit (`dropped`) goes to the extract lane with its
|
|
21
|
+
* bytes — never downloaded a second time, never sent as a native block;
|
|
15
22
|
* 3. EXTRACT lane: sequential, plain text read locally, everything else
|
|
16
23
|
* described / transcribed / extracted by the helper model, then archived
|
|
17
24
|
* to the sandbox — when the archive succeeds the text handed to the model
|
|
@@ -28,9 +35,13 @@
|
|
|
28
35
|
* A channel file (`channelRef`, see `channel.ts`) is private: it is read
|
|
29
36
|
* through the owner object's channel source and always goes to a model as
|
|
30
37
|
* the bytes already loaded, never by URL; the model, the notes and the
|
|
31
|
-
* stored metadata know it only by its `channel:` reference.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
38
|
+
* stored metadata know it only by its `channel:` reference. The gateway
|
|
39
|
+
* serves files of up to 50 MiB, but any file declared over `MAX_FILE_SIZE`
|
|
40
|
+
* is noted without being read: no model is handed it, and the sandbox
|
|
41
|
+
* archive refuses files over 25 MB (413), so reading it would only cost
|
|
42
|
+
* memory. A file the owner object reads while posting it to the session
|
|
43
|
+
* thread (`room-media.ts`) arrives as a pending copy in `deps.preloaded`: the
|
|
44
|
+
* turn waits for that one read instead of starting a second.
|
|
34
45
|
*/
|
|
35
46
|
import type { MessageContent } from '@langchain/core/messages';
|
|
36
47
|
import type { ModelInputCapabilities } from '../core/llm';
|
|
@@ -43,7 +54,6 @@ import {
|
|
|
43
54
|
type NativeAttachment,
|
|
44
55
|
} from './content-blocks';
|
|
45
56
|
import {
|
|
46
|
-
CHANNEL_MAX_FILE_BYTES,
|
|
47
57
|
channelArchiveName,
|
|
48
58
|
ChannelFileUnavailableError,
|
|
49
59
|
type ChannelMediaSource,
|
|
@@ -55,6 +65,7 @@ import {
|
|
|
55
65
|
loadAttachmentBytes,
|
|
56
66
|
MAX_FILE_SIZE,
|
|
57
67
|
MAX_TOTAL_SIZE,
|
|
68
|
+
untilAborted,
|
|
58
69
|
type MatrixMediaSource,
|
|
59
70
|
} from './download';
|
|
60
71
|
import {
|
|
@@ -65,7 +76,8 @@ import {
|
|
|
65
76
|
type ExtractionUsage,
|
|
66
77
|
} from './extract';
|
|
67
78
|
import { categorizeFile, verifyMagicBytes, type FileCategory } from './magic';
|
|
68
|
-
import {
|
|
79
|
+
import { resizeForModel } from './resize';
|
|
80
|
+
import { NATIVE_DOCUMENT_MAX_BYTES, routeAttachment } from './route';
|
|
69
81
|
import {
|
|
70
82
|
buildAnalysisMarkdown,
|
|
71
83
|
uploadToSandbox,
|
|
@@ -90,10 +102,25 @@ export interface AttachmentPipelineDeps {
|
|
|
90
102
|
source: MatrixMediaSource;
|
|
91
103
|
/** Where `channelRef` files are read; absent = none can be read (noted). */
|
|
92
104
|
channel?: ChannelMediaSource;
|
|
105
|
+
/**
|
|
106
|
+
* Channel files this turn is already reading, by `channel:` reference:
|
|
107
|
+
* each resolves with the verified bytes, or null when no copy came (the
|
|
108
|
+
* file is then read from `channel` as usual). A copy is counted against
|
|
109
|
+
* the budget like a download and serves only its own reference; the
|
|
110
|
+
* entry is removed when it is taken, so the map lets go of the bytes as
|
|
111
|
+
* the turn moves on. Never rejects (`room-media.ts`); the wait ends with
|
|
112
|
+
* `signal` too.
|
|
113
|
+
*/
|
|
114
|
+
preloaded?: Map<
|
|
115
|
+
string,
|
|
116
|
+
Promise<{ bytes: Uint8Array; mimetype: string } | null>
|
|
117
|
+
>;
|
|
93
118
|
/** Helper-model provider for the extract lane; null → media cannot be extracted here. */
|
|
94
119
|
extraction: ExtractionProvider | null;
|
|
95
120
|
/** Sandbox archive target; null → originals are not archived (logged once per turn). */
|
|
96
121
|
sandbox: SandboxUploadConfig | null;
|
|
122
|
+
/** Cloudflare Images: downscales native images for the model; absent = sent as they are. */
|
|
123
|
+
images?: ImagesBinding;
|
|
97
124
|
fetchImpl?: typeof fetch;
|
|
98
125
|
signal?: AbortSignal;
|
|
99
126
|
logger: Logger;
|
|
@@ -145,9 +172,17 @@ export function attachmentMetaFor(attachment: AttachmentInput): AttachmentMeta {
|
|
|
145
172
|
};
|
|
146
173
|
}
|
|
147
174
|
|
|
175
|
+
/** A filename as log lines show it (cleaned like every model-facing name). */
|
|
176
|
+
function logName(attachment: AttachmentInput): string {
|
|
177
|
+
return sanitizeAttachmentFilename(attachment.filename);
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
/** A filename as the hidden notes quote it (no controls, brackets or quotes). */
|
|
149
181
|
function noteName(attachment: AttachmentInput): string {
|
|
150
|
-
return sanitizeNoteField(
|
|
182
|
+
return sanitizeNoteField(
|
|
183
|
+
sanitizeAttachmentFilename(attachment.filename),
|
|
184
|
+
NOTE_FILENAME_MAX,
|
|
185
|
+
);
|
|
151
186
|
}
|
|
152
187
|
|
|
153
188
|
/** A mimetype as the hidden notes show it. */
|
|
@@ -196,11 +231,6 @@ function archiveName(attachment: AttachmentInput): string {
|
|
|
196
231
|
: safeName;
|
|
197
232
|
}
|
|
198
233
|
|
|
199
|
-
/** The largest file of this source the turn downloads. */
|
|
200
|
-
function fileCap(attachment: AttachmentInput): number {
|
|
201
|
-
return attachment.channelRef ? CHANNEL_MAX_FILE_BYTES : MAX_FILE_SIZE;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
234
|
function megabytes(bytes: number): string {
|
|
205
235
|
return `${Math.round(bytes / 1024 / 1024)} MB`;
|
|
206
236
|
}
|
|
@@ -213,9 +243,10 @@ const BUDGET_EXCEEDED = 'Total attachment size budget exceeded';
|
|
|
213
243
|
|
|
214
244
|
/**
|
|
215
245
|
* The turn's download budget: `MAX_TOTAL_SIZE` across every attachment,
|
|
216
|
-
* both lanes. A file is checked against what
|
|
217
|
-
* (declared size) and fetched under a cap of
|
|
218
|
-
* never runs past the budget and an
|
|
246
|
+
* both lanes, and `MAX_FILE_SIZE` per file. A file is checked against what
|
|
247
|
+
* is left before its download (declared size) and fetched under a cap of
|
|
248
|
+
* what is left, so a download never runs past the budget and an
|
|
249
|
+
* over-budget file is never fetched twice.
|
|
219
250
|
*/
|
|
220
251
|
class DownloadBudget {
|
|
221
252
|
private used = 0;
|
|
@@ -230,13 +261,19 @@ class DownloadBudget {
|
|
|
230
261
|
params: PrepareAttachmentsParams,
|
|
231
262
|
deps: AttachmentPipelineDeps,
|
|
232
263
|
): Promise<{ bytes: Uint8Array; mimetype: string; sniffed: boolean }> {
|
|
233
|
-
const perFile =
|
|
264
|
+
const perFile = MAX_FILE_SIZE;
|
|
234
265
|
if (attachment.size && attachment.size > perFile)
|
|
235
266
|
throw new AttachmentTooLargeError('File exceeds maximum size');
|
|
236
267
|
const remaining = this.remaining;
|
|
237
268
|
if (remaining === 0 || (attachment.size && attachment.size > remaining))
|
|
238
269
|
throw new AttachmentRejectedError(BUDGET_EXCEEDED);
|
|
239
270
|
const cap = Math.min(perFile, remaining);
|
|
271
|
+
const ref = attachment.channelRef;
|
|
272
|
+
const pending = ref ? deps.preloaded?.get(ref) : undefined;
|
|
273
|
+
if (ref && pending) deps.preloaded?.delete(ref);
|
|
274
|
+
const preloaded = pending
|
|
275
|
+
? await (deps.signal ? untilAborted(pending, deps.signal) : pending)
|
|
276
|
+
: null;
|
|
240
277
|
try {
|
|
241
278
|
const loaded = await loadAttachmentBytes(
|
|
242
279
|
attachment,
|
|
@@ -247,6 +284,14 @@ class DownloadBudget {
|
|
|
247
284
|
signal: deps.signal,
|
|
248
285
|
maxBytes: cap,
|
|
249
286
|
...(deps.channel ? { channel: deps.channel } : {}),
|
|
287
|
+
...(preloaded
|
|
288
|
+
? {
|
|
289
|
+
preloaded: {
|
|
290
|
+
bytes: preloaded.bytes,
|
|
291
|
+
contentType: preloaded.mimetype,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
: {}),
|
|
250
295
|
},
|
|
251
296
|
);
|
|
252
297
|
this.used += loaded.bytes.length;
|
|
@@ -280,17 +325,26 @@ async function extractOne(
|
|
|
280
325
|
const category: FileCategory = categorizeFile(attachment.mimetype);
|
|
281
326
|
if (category === 'unsupported') {
|
|
282
327
|
deps.logger.warn(
|
|
283
|
-
`[attachments] unsupported file type ${attachment
|
|
328
|
+
`[attachments] unsupported file type ${noteType(attachment)} for "${logName(attachment)}"`,
|
|
284
329
|
);
|
|
285
330
|
return {
|
|
286
331
|
text: `[File "${noteName(attachment)}" (${noteType(attachment)}) is not a supported file type and could not be processed]`,
|
|
287
332
|
};
|
|
288
333
|
}
|
|
289
334
|
|
|
335
|
+
// No model reads it and the sandbox refuses it: not worth a download.
|
|
336
|
+
if (attachment.size !== undefined && attachment.size > MAX_FILE_SIZE)
|
|
337
|
+
return {
|
|
338
|
+
text: `[File "${noteName(attachment)}" (${noteType(attachment)}, ${megabytes(attachment.size)}) is larger than the ${megabytes(MAX_FILE_SIZE)} a model or the sandbox takes here, so it was neither read nor archived]`,
|
|
339
|
+
};
|
|
340
|
+
|
|
290
341
|
const bytes =
|
|
291
342
|
preloaded ?? (await budget.load(attachment, params, deps)).bytes;
|
|
292
|
-
verifyMagicBytes(
|
|
293
|
-
|
|
343
|
+
verifyMagicBytes(
|
|
344
|
+
bytes,
|
|
345
|
+
category,
|
|
346
|
+
{ mimetype: attachment.mimetype, filename: logName(attachment) },
|
|
347
|
+
(m) => deps.logger.warn(`[attachments] ${m}`),
|
|
294
348
|
);
|
|
295
349
|
|
|
296
350
|
// Public http(s) images/videos: let the provider fetch the URL itself. A
|
|
@@ -302,10 +356,7 @@ async function extractOne(
|
|
|
302
356
|
/^https?:\/\//i.test(attachment.mxcUri ?? '');
|
|
303
357
|
let text: string | undefined;
|
|
304
358
|
let usage: ExtractionUsage | undefined;
|
|
305
|
-
if (
|
|
306
|
-
// A channel file larger than any model is handed here: kept, not read.
|
|
307
|
-
text = `[File "${noteName(attachment)}" (${noteType(attachment)}, ${megabytes(bytes.length)}) is larger than the ${megabytes(MAX_FILE_SIZE)} a model reads here, so its content was not read]`;
|
|
308
|
-
} else if (
|
|
359
|
+
if (
|
|
309
360
|
isHttpUrl &&
|
|
310
361
|
deps.extraction &&
|
|
311
362
|
(category === 'image' || category === 'video')
|
|
@@ -323,7 +374,7 @@ async function extractOne(
|
|
|
323
374
|
}
|
|
324
375
|
} catch (error) {
|
|
325
376
|
deps.logger.warn(
|
|
326
|
-
`[attachments] URL passthrough failed for "${attachment
|
|
377
|
+
`[attachments] URL passthrough failed for "${logName(attachment)}", falling back to bytes: ${errorMessage(error)}`,
|
|
327
378
|
);
|
|
328
379
|
}
|
|
329
380
|
}
|
|
@@ -356,10 +407,7 @@ async function extractOne(
|
|
|
356
407
|
);
|
|
357
408
|
const actualPath = sanitizeSandboxPath(archived.path);
|
|
358
409
|
let analysisPath: string | undefined;
|
|
359
|
-
if (
|
|
360
|
-
bytes.length <= MAX_FILE_SIZE &&
|
|
361
|
-
(category === 'image' || category === 'video' || category === 'audio')
|
|
362
|
-
) {
|
|
410
|
+
if (category === 'image' || category === 'video' || category === 'audio') {
|
|
363
411
|
const analysisFilename = `${storedName.replace(/\.[^.]+$/, '')}-analysis.md`;
|
|
364
412
|
const analysisDest = `${SANDBOX_OUTPUT_PREFIX}/${analysisFilename}`;
|
|
365
413
|
try {
|
|
@@ -382,7 +430,7 @@ async function extractOne(
|
|
|
382
430
|
analysisPath = sanitizeSandboxPath(analysis.path);
|
|
383
431
|
} catch (error) {
|
|
384
432
|
deps.logger.warn(
|
|
385
|
-
`[attachments] analysis .md upload failed for "${attachment
|
|
433
|
+
`[attachments] analysis .md upload failed for "${logName(attachment)}": ${errorMessage(error)}`,
|
|
386
434
|
);
|
|
387
435
|
}
|
|
388
436
|
}
|
|
@@ -404,7 +452,7 @@ async function extractOne(
|
|
|
404
452
|
};
|
|
405
453
|
} catch (error) {
|
|
406
454
|
deps.logger.warn(
|
|
407
|
-
`[attachments] sandbox upload failed for "${attachment
|
|
455
|
+
`[attachments] sandbox upload failed for "${logName(attachment)}": ${errorMessage(error)}`,
|
|
408
456
|
);
|
|
409
457
|
return {
|
|
410
458
|
text: `${text}\n\n[Warning: sandbox upload failed — file content is included above]`,
|
|
@@ -443,9 +491,11 @@ export async function prepareAttachments(
|
|
|
443
491
|
}> = [];
|
|
444
492
|
for (const attachment of params.attachments) {
|
|
445
493
|
const kind = classifyAttachment(attachment);
|
|
446
|
-
const strategy = routeAttachment(kind, params.caps
|
|
494
|
+
const strategy = routeAttachment(kind, params.caps, {
|
|
495
|
+
size: attachment.size,
|
|
496
|
+
});
|
|
447
497
|
deps.logger.log(
|
|
448
|
-
`[attachments] "${attachment
|
|
498
|
+
`[attachments] "${logName(attachment)}" (${noteType(attachment)}) kind=${kind} size=${attachment.size ?? 'unknown'} model=${params.model} → ${strategy}`,
|
|
449
499
|
);
|
|
450
500
|
// A file larger than a model is handed is never sent as a block.
|
|
451
501
|
if (
|
|
@@ -474,28 +524,46 @@ export async function prepareAttachments(
|
|
|
474
524
|
mimetype,
|
|
475
525
|
filename: sniffed ? '' : attachment.filename,
|
|
476
526
|
});
|
|
477
|
-
if (
|
|
527
|
+
if (kind !== classifyAttachment(attachment)) {
|
|
528
|
+
deps.logger.warn(
|
|
529
|
+
`[attachments] "${logName(attachment)}" claims ${noteType(attachment)} but is ${mimetype} — not sent natively`,
|
|
530
|
+
);
|
|
478
531
|
extractQueue.push({ attachment, preloaded: bytes });
|
|
479
532
|
continue;
|
|
480
533
|
}
|
|
481
|
-
if (kind
|
|
482
|
-
deps.logger.
|
|
483
|
-
`[attachments] "${attachment
|
|
534
|
+
if (kind === 'document' && bytes.length > NATIVE_DOCUMENT_MAX_BYTES) {
|
|
535
|
+
deps.logger.log(
|
|
536
|
+
`[attachments] "${logName(attachment)}" is ${bytes.length} bytes (declared ${attachment.size ?? 'unknown'}), over the ${NATIVE_DOCUMENT_MAX_BYTES}-byte native document limit → model-extract`,
|
|
484
537
|
);
|
|
485
538
|
extractQueue.push({ attachment, preloaded: bytes });
|
|
486
539
|
continue;
|
|
487
540
|
}
|
|
541
|
+
// The model gets a downscaled copy; the sandbox keeps the original.
|
|
542
|
+
let sent = { bytes, mimetype };
|
|
543
|
+
if (kind === 'image') {
|
|
544
|
+
const resized = await resizeForModel(bytes, mimetype, deps.images, {
|
|
545
|
+
logger: deps.logger,
|
|
546
|
+
});
|
|
547
|
+
if (resized.dropped) {
|
|
548
|
+
deps.logger.log(
|
|
549
|
+
`[attachments] "${logName(attachment)}" cannot go to the model as an image (${resized.dropped}) → model-extract`,
|
|
550
|
+
);
|
|
551
|
+
extractQueue.push({ attachment, preloaded: bytes });
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
sent = resized;
|
|
555
|
+
}
|
|
488
556
|
natives.push({
|
|
489
557
|
kind: kind === 'image' ? 'image' : 'file',
|
|
490
|
-
mimeType: mimetype,
|
|
491
|
-
base64: bytesToBase64(bytes),
|
|
558
|
+
mimeType: sent.mimetype,
|
|
559
|
+
base64: bytesToBase64(sent.bytes),
|
|
492
560
|
filename: sanitizeAttachmentFilename(attachment.filename),
|
|
493
561
|
});
|
|
494
562
|
const note = nativeAttachmentNote(attachment);
|
|
495
563
|
if (note)
|
|
496
564
|
nativeNotes.push({ text: note, meta: attachmentMetaFor(attachment) });
|
|
497
565
|
deps.logger.log(
|
|
498
|
-
`[attachments] NATIVE → "${attachment
|
|
566
|
+
`[attachments] NATIVE → "${logName(attachment)}" sent directly to ${params.model} (${sent.bytes.length} bytes, ${kind})`,
|
|
499
567
|
);
|
|
500
568
|
if (deps.sandbox) {
|
|
501
569
|
const storedName = archiveName(attachment);
|
|
@@ -510,20 +578,20 @@ export async function prepareAttachments(
|
|
|
510
578
|
deps.fetchImpl,
|
|
511
579
|
).catch((error: unknown) => {
|
|
512
580
|
deps.logger.warn(
|
|
513
|
-
`[attachments] background archive failed for "${attachment
|
|
581
|
+
`[attachments] background archive failed for "${logName(attachment)}": ${errorMessage(error)}`,
|
|
514
582
|
);
|
|
515
583
|
});
|
|
516
584
|
}
|
|
517
585
|
} catch (error) {
|
|
518
586
|
if (error instanceof AttachmentRejectedError) {
|
|
519
587
|
deps.logger.warn(
|
|
520
|
-
`[attachments] "${attachment
|
|
588
|
+
`[attachments] "${logName(attachment)}" refused: ${errorMessage(error)}`,
|
|
521
589
|
);
|
|
522
590
|
extractQueue.push({ attachment, refused: error });
|
|
523
591
|
continue;
|
|
524
592
|
}
|
|
525
593
|
deps.logger.warn(
|
|
526
|
-
`[attachments] native load failed for "${attachment
|
|
594
|
+
`[attachments] native load failed for "${logName(attachment)}", falling back to extraction: ${errorMessage(error)}`,
|
|
527
595
|
);
|
|
528
596
|
extractQueue.push({ attachment });
|
|
529
597
|
}
|
|
@@ -563,7 +631,7 @@ export async function prepareAttachments(
|
|
|
563
631
|
text = result.text;
|
|
564
632
|
} catch (error) {
|
|
565
633
|
deps.logger.error(
|
|
566
|
-
`[attachments] failed to process ${attachment
|
|
634
|
+
`[attachments] failed to process "${logName(attachment)}": ${errorMessage(error)}`,
|
|
567
635
|
);
|
|
568
636
|
// A channel file's error never names its link (`channel.ts`).
|
|
569
637
|
text =
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downscale an image before the model sees it, through the optional
|
|
3
|
+
* Cloudflare Images binding (`IMAGES`, docs/configuration.md). A native image
|
|
4
|
+
* block is the file's base64 and is replayed on every later turn, so a 4 MB
|
|
5
|
+
* phone photo costs ~5.3 MB of request per call; at a 1568 px long edge as
|
|
6
|
+
* JPEG it is a few hundred KB and reads the same to a vision model.
|
|
7
|
+
*
|
|
8
|
+
* Only the copy handed to the model changes: callers archive the original
|
|
9
|
+
* bytes. Without the binding, for a type the binding does not take (SVG,
|
|
10
|
+
* BMP, TIFF, HEIC, …), for an image already within the bound, and when
|
|
11
|
+
* `info()` fails for another reason than a malformed image (its dimensions
|
|
12
|
+
* are then unknown), the input is returned unchanged, so an oracle that does
|
|
13
|
+
* not bind Images behaves as before.
|
|
14
|
+
*
|
|
15
|
+
* An image that must not go to the model as it is comes back with
|
|
16
|
+
* `dropped`, a short reason, and the caller does not send it natively: one
|
|
17
|
+
* the binding reports as malformed (not an image, an unsupported format, a
|
|
18
|
+
* file it cannot decode), one over `RESIZE_HARD_MAX_PIXELS` /
|
|
19
|
+
* `RESIZE_HARD_MAX_SIDE` (never transformed), and one over the bound whose
|
|
20
|
+
* transformation fails or yields nothing. `bytes`/`mimetype` are then the
|
|
21
|
+
* input's, for the caller's log.
|
|
22
|
+
*
|
|
23
|
+
* The bound is on the LONG edge: `maxWidth` caps both width and height, so a
|
|
24
|
+
* tall phone screenshot is reduced too. The dimensions are read first with
|
|
25
|
+
* `info()` (not billed); only an image with either side over the bound is
|
|
26
|
+
* transformed (one unique transformation, billed after the monthly free
|
|
27
|
+
* allowance). `fit: 'scale-down'` keeps the aspect ratio and never upscales.
|
|
28
|
+
*/
|
|
29
|
+
import type { Logger } from '../plugin-api/types';
|
|
30
|
+
|
|
31
|
+
/** Longest edge handed to a model (px): Anthropic's recommended maximum edge. */
|
|
32
|
+
export const RESIZE_MAX_EDGE = 1568;
|
|
33
|
+
/** JPEG quality of a downscaled image. */
|
|
34
|
+
export const RESIZE_JPEG_QUALITY = 82;
|
|
35
|
+
/**
|
|
36
|
+
* Largest image area transformed (pixels). Cloudflare refuses images over
|
|
37
|
+
* 100 megapixels (error 9413); half of that leaves no photo out and keeps a
|
|
38
|
+
* decompression bomb from being sent on.
|
|
39
|
+
*/
|
|
40
|
+
export const RESIZE_HARD_MAX_PIXELS = 50_000_000;
|
|
41
|
+
/** Longest side transformed (px); a longer one is dropped. */
|
|
42
|
+
export const RESIZE_HARD_MAX_SIDE = 12_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cloudflare Images error codes that mean the input itself is unusable:
|
|
46
|
+
* 9412 not an image, 9520 an unsupported format, 9523 an image it could
|
|
47
|
+
* not decode (developers.cloudflare.com/images/reference/troubleshooting).
|
|
48
|
+
*/
|
|
49
|
+
const MALFORMED_IMAGE_ERROR = /\b(9412|9520|9523)\b/;
|
|
50
|
+
|
|
51
|
+
export interface ResizedImage {
|
|
52
|
+
bytes: Uint8Array;
|
|
53
|
+
mimetype: string;
|
|
54
|
+
/**
|
|
55
|
+
* Set when the image must not be sent to the model natively: why, in a
|
|
56
|
+
* few words (for logs and the model-facing note).
|
|
57
|
+
*/
|
|
58
|
+
dropped?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Raster types the binding decodes. A GIF is flattened to its first frame by
|
|
63
|
+
* the JPEG output.
|
|
64
|
+
*/
|
|
65
|
+
const RESIZABLE_TYPES = new Set([
|
|
66
|
+
'image/jpeg',
|
|
67
|
+
'image/png',
|
|
68
|
+
'image/webp',
|
|
69
|
+
'image/gif',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/** Fill under transparent pixels: JPEG has no alpha channel. */
|
|
73
|
+
const JPEG_BACKGROUND = '#ffffff';
|
|
74
|
+
|
|
75
|
+
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
76
|
+
return new Response(bytes).body ?? new ReadableStream<Uint8Array>();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function resizeForModel(
|
|
80
|
+
bytes: Uint8Array,
|
|
81
|
+
mimetype: string,
|
|
82
|
+
images: ImagesBinding | undefined,
|
|
83
|
+
opts: { maxWidth?: number; quality?: number; logger?: Logger } = {},
|
|
84
|
+
): Promise<ResizedImage> {
|
|
85
|
+
const original = { bytes, mimetype };
|
|
86
|
+
if (!images) return original;
|
|
87
|
+
const type = mimetype.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
88
|
+
if (!RESIZABLE_TYPES.has(type)) return original;
|
|
89
|
+
// `maxWidth` (the caller's name) bounds both sides.
|
|
90
|
+
const maxEdge = opts.maxWidth ?? RESIZE_MAX_EDGE;
|
|
91
|
+
const drop = (reason: string): ResizedImage => {
|
|
92
|
+
opts.logger?.warn(
|
|
93
|
+
`[attachments] image not sent to the model (${type}, ${bytes.length} bytes): ${reason}`,
|
|
94
|
+
);
|
|
95
|
+
return { ...original, dropped: reason };
|
|
96
|
+
};
|
|
97
|
+
let info: Awaited<ReturnType<ImagesBinding['info']>>;
|
|
98
|
+
try {
|
|
99
|
+
info = await images.info(streamOf(bytes));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = errorMessage(error);
|
|
102
|
+
if (MALFORMED_IMAGE_ERROR.test(message))
|
|
103
|
+
return drop(`not a readable image (${message})`);
|
|
104
|
+
opts.logger?.warn(
|
|
105
|
+
`[attachments] image info failed (${type}, ${bytes.length} bytes), sending the original: ${message}`,
|
|
106
|
+
);
|
|
107
|
+
return original;
|
|
108
|
+
}
|
|
109
|
+
// An SVG reports no dimensions; it is never rasterised here.
|
|
110
|
+
if (!('width' in info) || (info.width <= maxEdge && info.height <= maxEdge))
|
|
111
|
+
return original;
|
|
112
|
+
if (
|
|
113
|
+
info.width > RESIZE_HARD_MAX_SIDE ||
|
|
114
|
+
info.height > RESIZE_HARD_MAX_SIDE ||
|
|
115
|
+
info.width * info.height > RESIZE_HARD_MAX_PIXELS
|
|
116
|
+
)
|
|
117
|
+
return drop(`${info.width}×${info.height}px is over the size limit`);
|
|
118
|
+
try {
|
|
119
|
+
const result = await images
|
|
120
|
+
.input(streamOf(bytes))
|
|
121
|
+
.transform({ width: maxEdge, height: maxEdge, fit: 'scale-down' })
|
|
122
|
+
.output({
|
|
123
|
+
format: 'image/jpeg',
|
|
124
|
+
quality: opts.quality ?? RESIZE_JPEG_QUALITY,
|
|
125
|
+
background: JPEG_BACKGROUND,
|
|
126
|
+
});
|
|
127
|
+
const resized = new Uint8Array(await result.response().arrayBuffer());
|
|
128
|
+
if (resized.length === 0) throw new Error('empty output');
|
|
129
|
+
opts.logger?.log(
|
|
130
|
+
`[attachments] image ${info.width}×${info.height}px resized to fit ${maxEdge}px (${bytes.length} → ${resized.length} bytes)`,
|
|
131
|
+
);
|
|
132
|
+
return { bytes: resized, mimetype: result.contentType() };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return drop(
|
|
135
|
+
`${info.width}×${info.height}px could not be resized (${errorMessage(error)})`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function errorMessage(error: unknown): string {
|
|
141
|
+
return error instanceof Error ? error.message : String(error);
|
|
142
|
+
}
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Attachment
|
|
2
|
+
* Attachment payloads and their placeholder.
|
|
3
3
|
*
|
|
4
|
-
* A human turn whose attachments the model reads natively
|
|
5
|
-
* inline, as base64 content blocks,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
* the metadata the Portal
|
|
12
|
-
* is left untouched, so
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
4
|
+
* A human turn whose attachments the model reads natively carries the bytes
|
|
5
|
+
* inline, as base64 content blocks, for the turn that posted them. Those
|
|
6
|
+
* bytes are never stored with the conversation: the checkpointer writes
|
|
7
|
+
* every such message with its inline blocks replaced by one compact text
|
|
8
|
+
* placeholder (`offloadInlinePayloads`, applied in `src/sqlite/
|
|
9
|
+
* inline-payloads.ts`), so every later turn, and a run restarted mid-turn,
|
|
10
|
+
* loads the placeholder. The placeholder lists the message's attachments
|
|
11
|
+
* with the reference `view_attachment` takes; the metadata the Portal
|
|
12
|
+
* renders from (`additional_kwargs.attachment(s)`) is left untouched, so
|
|
13
|
+
* transcripts do not change. Recent images are put back into each model
|
|
14
|
+
* request by the attachment recall middleware
|
|
15
|
+
* (`src/core/middlewares/attachment-recall.ts`), never into the stored
|
|
16
|
+
* messages.
|
|
16
17
|
*/
|
|
17
18
|
import { HumanMessage, type BaseMessage } from '@langchain/core/messages';
|
|
18
19
|
import type { AttachmentMeta } from '../sqlite/serialization';
|
|
@@ -22,25 +23,16 @@ import {
|
|
|
22
23
|
sanitizeNoteField,
|
|
23
24
|
} from './sanitize';
|
|
24
25
|
|
|
25
|
-
/** Newest user turns whose inline attachment payloads stay in the messages. */
|
|
26
|
-
export const ATTACHMENT_PAYLOAD_TURNS = 2;
|
|
27
|
-
|
|
28
26
|
/**
|
|
29
27
|
* `additional_kwargs.lc_source` of the human message `view_attachment` adds
|
|
30
|
-
* to re-attach a payload — hidden from transcripts and not a user turn
|
|
28
|
+
* to re-attach a payload — hidden from transcripts and not a user turn (it
|
|
29
|
+
* belongs to the user turn before it).
|
|
31
30
|
*/
|
|
32
31
|
export const ATTACHMENT_VIEW_SOURCE = 'attachment_view';
|
|
33
32
|
|
|
34
33
|
/** Every placeholder text block starts with this (transcripts drop it). */
|
|
35
34
|
export const ATTACHMENT_PLACEHOLDER_PREFIX = '[attachment offloaded]';
|
|
36
35
|
|
|
37
|
-
/**
|
|
38
|
-
* Substring every inline payload block serialises to (`message_content`
|
|
39
|
-
* holds the JSON of an array content), for a SQL `LIKE` pre-filter that
|
|
40
|
-
* finds candidate rows without inflating message blobs.
|
|
41
|
-
*/
|
|
42
|
-
export const INLINE_PAYLOAD_NEEDLE = '"source_type":"base64"';
|
|
43
|
-
|
|
44
36
|
export const VIEW_ATTACHMENT_TOOL_NAME = 'view_attachment';
|
|
45
37
|
|
|
46
38
|
interface InlinePayloadBlock {
|
|
@@ -132,21 +124,23 @@ export function buildPlaceholderText(metas: AttachmentMeta[]): string {
|
|
|
132
124
|
? `Attachments of this message:\n${lines.join('\n')}\n`
|
|
133
125
|
: 'This message had an attachment whose metadata was not recorded; it cannot be fetched again.\n';
|
|
134
126
|
return (
|
|
135
|
-
`${ATTACHMENT_PLACEHOLDER_PREFIX} The attachment bytes of this message are
|
|
136
|
-
`
|
|
127
|
+
`${ATTACHMENT_PLACEHOLDER_PREFIX} The attachment bytes of this message are not stored ` +
|
|
128
|
+
`with the conversation. ${listing}` +
|
|
137
129
|
`To look at one again, call ${VIEW_ATTACHMENT_TOOL_NAME} with its ref.`
|
|
138
130
|
);
|
|
139
131
|
}
|
|
140
132
|
|
|
141
133
|
/**
|
|
142
|
-
* A copy of `message` with every inline payload block replaced by
|
|
143
|
-
* placeholder text block (id, kwargs and response metadata
|
|
144
|
-
* null when
|
|
134
|
+
* A copy of a human `message` with every inline payload block replaced by
|
|
135
|
+
* one placeholder text block (id, name, kwargs and response metadata
|
|
136
|
+
* preserved, the original untouched), or null when it is not a human
|
|
137
|
+
* message or carries no inline payload. The copy shares every other block
|
|
138
|
+
* with the original; only the payload strings are left behind.
|
|
145
139
|
*/
|
|
146
140
|
export function offloadInlinePayloads(
|
|
147
141
|
message: BaseMessage,
|
|
148
|
-
):
|
|
149
|
-
if (!Array.isArray(message.content)) return null;
|
|
142
|
+
): HumanMessage | null {
|
|
143
|
+
if (message.type !== 'human' || !Array.isArray(message.content)) return null;
|
|
150
144
|
if (!message.content.some((block) => isInlinePayloadBlock(block)))
|
|
151
145
|
return null;
|
|
152
146
|
const placeholder = {
|
|
@@ -168,46 +162,8 @@ export function offloadInlinePayloads(
|
|
|
168
162
|
return new HumanMessage({
|
|
169
163
|
id: message.id,
|
|
170
164
|
content,
|
|
165
|
+
...(message.name !== undefined ? { name: message.name } : {}),
|
|
171
166
|
additional_kwargs: message.additional_kwargs,
|
|
172
167
|
response_metadata: message.response_metadata,
|
|
173
168
|
});
|
|
174
169
|
}
|
|
175
|
-
|
|
176
|
-
export interface AttachmentRetentionResult {
|
|
177
|
-
/** Ids of the messages whose inline payload stays (inside the window). */
|
|
178
|
-
retainedIds: Set<string>;
|
|
179
|
-
/** Rewritten copies of the messages that fell out of the window. */
|
|
180
|
-
rewrites: BaseMessage[];
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Decide, for the thread's current messages (oldest first), which inline
|
|
185
|
-
* payloads stay and which get a placeholder. A payload belongs to the user
|
|
186
|
-
* turn it was posted in — a re-attachment message belongs to the turn of the
|
|
187
|
-
* user message before it — and stays while that turn is one of the newest
|
|
188
|
-
* `keepTurns` user turns. Messages without an id cannot be rewritten in
|
|
189
|
-
* place and are left alone.
|
|
190
|
-
*/
|
|
191
|
-
export function applyAttachmentRetention(
|
|
192
|
-
messages: readonly BaseMessage[],
|
|
193
|
-
keepTurns: number = ATTACHMENT_PAYLOAD_TURNS,
|
|
194
|
-
): AttachmentRetentionResult {
|
|
195
|
-
const retainedIds = new Set<string>();
|
|
196
|
-
const rewrites: BaseMessage[] = [];
|
|
197
|
-
let turnsSeen = 0;
|
|
198
|
-
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
199
|
-
const message = messages[i]!;
|
|
200
|
-
if (message.type !== 'human') continue;
|
|
201
|
-
const userTurn = !isAttachmentViewMessage(message);
|
|
202
|
-
if (userTurn) turnsSeen += 1;
|
|
203
|
-
if (!hasInlinePayload(message) || !message.id) continue;
|
|
204
|
-
const turnIndex = userTurn ? turnsSeen : turnsSeen + 1;
|
|
205
|
-
if (turnIndex <= keepTurns) {
|
|
206
|
-
retainedIds.add(message.id);
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
const rewritten = offloadInlinePayloads(message);
|
|
210
|
-
if (rewritten) rewrites.push(rewritten);
|
|
211
|
-
}
|
|
212
|
-
return { retainedIds, rewrites };
|
|
213
|
-
}
|