@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.
@@ -2,26 +2,59 @@ import type { ModelInputCapabilities } from '../core/llm';
2
2
  import type { AttachmentKind } from './classify';
3
3
 
4
4
  /**
5
- * How an attachment reaches the model (verbatim port of the Node rule):
5
+ * How an attachment reaches the model:
6
6
  * - `parse-local` — read to text ourselves (free). Plain-text files only.
7
7
  * - `send-native` — attach the raw bytes to the user message; the selected
8
8
  * model reads it directly. Only when the model accepts that
9
- * modality.
9
+ * modality and, for a document, the file is not declared
10
+ * too large (the caller checks the downloaded length).
10
11
  * - `model-extract` — the helper (vision) model turns it into text first. Used
11
- * for anything the selected model can't accept natively.
12
+ * for anything the selected model can't accept natively,
13
+ * and for documents over {@link NATIVE_DOCUMENT_MAX_BYTES}.
12
14
  */
13
15
  export type AttachmentStrategy =
14
16
  | 'parse-local'
15
17
  | 'send-native'
16
18
  | 'model-extract';
17
19
 
20
+ /**
21
+ * The largest document sent to the model as a native file block (20 KiB).
22
+ * The block is the file's base64, which the runtime counts as tokens at
23
+ * chars/4: 20,480 bytes are ~27k base64 chars ≈ 7k tokens by that estimate.
24
+ * Providers charge a native PDF per page, so this keeps a native document
25
+ * to a few pages. A larger document
26
+ * always goes through the extract lane: its text from the helper model (cut
27
+ * at 50k chars), a sandbox copy and a 500-char inline excerpt with the
28
+ * sandbox path.
29
+ */
30
+ export const NATIVE_DOCUMENT_MAX_BYTES = 20_480;
31
+
18
32
  /**
19
33
  * Decide, for one attachment, how to get it to the selected model — the single
20
34
  * cost-aware, capability-aware routing rule. Pure and total.
35
+ *
36
+ * Documents are routed by SIZE (`opts.size`, the declared byte length), the
37
+ * same for every source (Portal, Matrix, chat channels): a native file block
38
+ * is replayed to the model on every later turn and held in the user's
39
+ * checkpoint, so its cost grows with the file, not with where it came from.
40
+ * A document is a native candidate when the model reads files AND its
41
+ * declared size is unknown or at most {@link NATIVE_DOCUMENT_MAX_BYTES}; only
42
+ * a KNOWN size over the limit goes straight to `model-extract`, which skips
43
+ * the native download. For a candidate the downloaded byte length decides
44
+ * (the pipeline and `view.ts`): at most the limit is sent natively, more goes
45
+ * to the extract lane with the bytes already loaded. So an unsized small
46
+ * file still goes natively, and a declared size that is wrong cannot slip a
47
+ * large one through.
48
+ *
49
+ * Images go native whenever the model reads images, whatever their size:
50
+ * they are downscaled before the model sees them (`resize.ts`) when the
51
+ * oracle binds Cloudflare Images. Audio, video, text and unknown files are
52
+ * routed by capability alone.
21
53
  */
22
54
  export function routeAttachment(
23
55
  kind: AttachmentKind,
24
56
  caps: ModelInputCapabilities,
57
+ opts: { size?: number } = {},
25
58
  ): AttachmentStrategy {
26
59
  switch (kind) {
27
60
  case 'text':
@@ -29,7 +62,9 @@ export function routeAttachment(
29
62
  case 'image':
30
63
  return caps.image ? 'send-native' : 'model-extract';
31
64
  case 'document':
32
- return caps.file ? 'send-native' : 'model-extract';
65
+ return caps.file && mayFitNativeDocument(opts.size)
66
+ ? 'send-native'
67
+ : 'model-extract';
33
68
  case 'audio':
34
69
  return caps.audio ? 'send-native' : 'model-extract';
35
70
  case 'video':
@@ -39,3 +74,11 @@ export function routeAttachment(
39
74
  return 'model-extract';
40
75
  }
41
76
  }
77
+
78
+ /**
79
+ * Whether a document declared at `size` bytes may go as a native file block:
80
+ * unknown counts as maybe, and the downloaded length decides.
81
+ */
82
+ function mayFitNativeDocument(size: number | undefined): boolean {
83
+ return size === undefined || size <= NATIVE_DOCUMENT_MAX_BYTES;
84
+ }
@@ -5,6 +5,11 @@
5
5
  * to `<SANDBOX_MCP_URL without /mcp>/artifacts/upload`, authenticated with
6
6
  * the same `Authorization: Bearer <ucan>` + `X-Auth-Type: ucan` the sandbox
7
7
  * MCP client sends.
8
+ *
9
+ * The endpoint parses the whole multipart body (`Request.formData()`) and
10
+ * refuses a file over 25 MB with `413`, so the pipeline never reads a file
11
+ * over `MAX_FILE_SIZE` only to archive it, and a streamed body would buy
12
+ * nothing: every archived file is one the turn already holds in memory.
8
13
  */
9
14
  import { sanitizeSandboxPath } from './sanitize';
10
15
 
@@ -55,11 +60,17 @@ export async function uploadToSandbox(
55
60
  );
56
61
  }
57
62
  const raw: unknown = await response.json();
58
- const result = raw as { path?: string; url?: string; previewUrl?: string };
63
+ const field = (name: string): string | undefined => {
64
+ const value: unknown =
65
+ typeof raw === 'object' && raw !== null ? Reflect.get(raw, name) : null;
66
+ return typeof value === 'string' && value ? value : undefined;
67
+ };
68
+ const url = field('url');
69
+ const previewUrl = field('previewUrl');
59
70
  return {
60
- path: typeof result.path === 'string' ? result.path : safePath,
61
- ...(result.url ? { url: result.url } : {}),
62
- ...(result.previewUrl ? { previewUrl: result.previewUrl } : {}),
71
+ path: field('path') ?? safePath,
72
+ ...(url ? { url } : {}),
73
+ ...(previewUrl ? { previewUrl } : {}),
63
74
  };
64
75
  }
65
76
 
@@ -17,18 +17,28 @@ function cutUnits(value: string, max: number): string {
17
17
  }
18
18
 
19
19
  /**
20
- * Strip control characters and bracket sequences from a filename to prevent
21
- * prompt injection when interpolated into LLM context. The archive writes
22
- * under this exact name.
20
+ * `value` without what can disguise text: Unicode controls (Cc), format
21
+ * characters (Cf: bidi overrides such as U+202E, zero-width joiners), line
22
+ * and paragraph separators (Zl, Zp), and `[` / `]` (they would forge a
23
+ * `[source: …]` note).
24
+ */
25
+ function visibleText(value: string): string {
26
+ return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}[\]]/gu, '');
27
+ }
28
+
29
+ /**
30
+ * Clean a client-supplied filename before it reaches LLM context, a log
31
+ * line, a room event or an archive path (the archive writes under this exact
32
+ * name): `visibleText`, then only the last path segment is kept (`/` and
33
+ * `\` both separate) and any remaining `..` becomes `_`, cut to 255 UTF-16
34
+ * units without splitting a surrogate pair.
23
35
  */
24
36
  export function sanitizeAttachmentFilename(filename: string): string {
25
- return cutUnits(
26
- filename
27
- // eslint-disable-next-line no-control-regex
28
- .replace(/[\x00-\x1f\x7f]/g, '')
29
- .replace(/[[\]]/g, ''),
30
- 255,
37
+ const visible = visibleText(filename);
38
+ const basename = visible.slice(
39
+ Math.max(visible.lastIndexOf('/'), visible.lastIndexOf('\\')) + 1,
31
40
  );
41
+ return cutUnits(basename.replace(/\.\.+/g, '_'), 255);
32
42
  }
33
43
 
34
44
  /** Longest filename / mimetype / event id / mxc URI written into a hidden note. */
@@ -39,12 +49,13 @@ export const NOTE_URI_MAX = 512;
39
49
 
40
50
  /**
41
51
  * Clean a client-supplied value (filename, mimetype, media event id, mxc
42
- * URI) for the hidden notes the model reads: the filename cleaner plus
43
- * quotes removed (the notes wrap names and refs in `"…"`), cut to `max`
44
- * UTF-16 units without splitting a surrogate pair.
52
+ * URI) for the hidden notes the model reads: `visibleText` (no path
53
+ * handling: a mimetype or a URI keeps its slashes) plus quotes removed
54
+ * (the notes wrap names and refs in `"…"`), cut to `max` UTF-16 units
55
+ * without splitting a surrogate pair.
45
56
  */
46
57
  export function sanitizeNoteField(value: string, max: number): string {
47
- return cutUnits(sanitizeAttachmentFilename(value).replace(/"/g, ''), max);
58
+ return cutUnits(visibleText(value).replace(/"/g, ''), max);
48
59
  }
49
60
 
50
61
  /**
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * Re-fetch one attachment of a session on demand — the `view_attachment`
3
- * lane behind payload retention (`retention.ts`). The same routing as the
4
- * turn pipeline applies: when the current model reads the modality the bytes
5
- * come back as a native content block, otherwise the helper (`vision` role)
6
- * model turns them into text; plain text is decoded locally. Text results
7
- * are cached per session (`deps.cache`); native views are not (they are the
8
- * bytes themselves).
3
+ * lane: attachment bytes are never stored with the conversation (the
4
+ * checkpointer keeps a placeholder, `src/sqlite/inline-payloads.ts`), so a
5
+ * file of an earlier turn is read again from its source. The same routing as
6
+ * the turn pipeline applies (`route.ts`: capability, and size for a
7
+ * document): when the current model reads the modality the bytes come back
8
+ * as a native content block (an image downscaled when `deps.images` is
9
+ * bound, see `resize.ts`), otherwise the helper (`vision` role) model turns
10
+ * them into text; plain text is decoded locally. A document whose downloaded
11
+ * length is over `NATIVE_DOCUMENT_MAX_BYTES`, or an image the resize step
12
+ * could not make fit (`dropped`), comes back as text from the bytes already
13
+ * loaded. Text results are cached per session (`deps.cache`); native views
14
+ * are not (they are the bytes themselves). Log lines name a file by its
15
+ * cleaned name (`sanitizeAttachmentFilename`).
9
16
  */
10
17
  import type { ChannelMediaSource } from './channel';
11
18
  import { classifyAttachment } from './classify';
@@ -20,10 +27,12 @@ import {
20
27
  import { categorizeFile, isPlainTextType, verifyMagicBytes } from './magic';
21
28
  import { extractPdfText } from './pdf-text';
22
29
  import { attachmentMetas, attachmentRef } from './retention';
23
- import { routeAttachment } from './route';
30
+ import { resizeForModel } from './resize';
31
+ import { NATIVE_DOCUMENT_MAX_BYTES, routeAttachment } from './route';
24
32
  import {
25
33
  NOTE_FILENAME_MAX,
26
34
  NOTE_MIMETYPE_MAX,
35
+ sanitizeAttachmentFilename,
27
36
  sanitizeNoteField,
28
37
  } from './sanitize';
29
38
  import {
@@ -47,6 +56,8 @@ export interface ViewAttachmentDeps {
47
56
  /** Where `channel:` references are read (the owner object's links). */
48
57
  channel?: ChannelMediaSource;
49
58
  extraction: ExtractionProvider | null;
59
+ /** Cloudflare Images: downscales native images for the model; absent = sent as they are. */
60
+ images?: ImagesBinding;
50
61
  /** Capabilities of the model that will read the result. */
51
62
  caps: ModelInputCapabilities;
52
63
  roomId?: string;
@@ -129,7 +140,10 @@ export async function viewAttachment(
129
140
  ): Promise<AttachmentView> {
130
141
  const attachment = toInput(meta);
131
142
  const kind = classifyAttachment(attachment);
132
- const strategy = routeAttachment(kind, deps.caps);
143
+ const strategy = routeAttachment(kind, deps.caps, { size: meta.size });
144
+ const name = sanitizeAttachmentFilename(attachment.filename);
145
+ // Downloaded for a native view but unfit for one: read as text instead.
146
+ let preloaded: { bytes: Uint8Array; mimetype: string } | undefined;
133
147
  if (strategy === 'send-native') {
134
148
  const { bytes, mimetype, sniffed } = await loadAttachmentBytes(
135
149
  attachment,
@@ -138,7 +152,7 @@ export async function viewAttachment(
138
152
  downloadOptions(deps),
139
153
  );
140
154
  deps.logger.log(
141
- `[attachments] view "${attachment.filename}" (${mimetype}) kind=${kind} → ${strategy} (${bytes.length} bytes)`,
155
+ `[attachments] view "${name}" (${mimetype}) kind=${kind} size=${meta.size ?? 'unknown'} → ${strategy} (${bytes.length} bytes)`,
142
156
  );
143
157
  const loaded = classifyAttachment({
144
158
  mimetype,
@@ -149,15 +163,37 @@ export async function viewAttachment(
149
163
  throw new Error(
150
164
  `File content mismatch: claimed ${attachment.mimetype} but detected ${mimetype}`,
151
165
  );
152
- return {
153
- kind: 'native',
154
- native: {
155
- kind: loaded === 'image' ? 'image' : 'file',
156
- mimeType: mimetype,
157
- base64: bytesToBase64(bytes),
158
- filename: attachment.filename,
159
- },
166
+ let sent: { bytes: Uint8Array; mimetype: string } | null = {
167
+ bytes,
168
+ mimetype,
160
169
  };
170
+ if (loaded === 'document' && bytes.length > NATIVE_DOCUMENT_MAX_BYTES) {
171
+ deps.logger.log(
172
+ `[attachments] view "${name}" is ${bytes.length} bytes, over the ${NATIVE_DOCUMENT_MAX_BYTES}-byte native document limit → model-extract`,
173
+ );
174
+ sent = null;
175
+ } else if (loaded === 'image') {
176
+ const resized = await resizeForModel(bytes, mimetype, deps.images, {
177
+ logger: deps.logger,
178
+ });
179
+ if (resized.dropped) {
180
+ deps.logger.log(
181
+ `[attachments] view "${name}" cannot go to the model as an image (${resized.dropped}) → model-extract`,
182
+ );
183
+ sent = null;
184
+ } else sent = resized;
185
+ }
186
+ if (sent)
187
+ return {
188
+ kind: 'native',
189
+ native: {
190
+ kind: loaded === 'image' ? 'image' : 'file',
191
+ mimeType: sent.mimetype,
192
+ base64: bytesToBase64(sent.bytes),
193
+ filename: name,
194
+ },
195
+ };
196
+ preloaded = { bytes, mimetype };
161
197
  }
162
198
 
163
199
  const category = categorizeFile(attachment.mimetype);
@@ -178,22 +214,28 @@ export async function viewAttachment(
178
214
  const cached = await deps.cache?.get(cacheKey.ref, cacheKey.model);
179
215
  if (cached !== undefined) {
180
216
  deps.logger.log(
181
- `[attachments] view "${attachment.filename}" → cached text (${cacheKey.model})`,
217
+ `[attachments] view "${name}" → cached text (${cacheKey.model})`,
182
218
  );
183
219
  return { kind: 'text', text: cached };
184
220
  }
185
221
  }
186
- const { bytes, mimetype } = await loadAttachmentBytes(
187
- attachment,
188
- deps.roomId,
189
- deps.source,
190
- downloadOptions(deps),
191
- );
192
- deps.logger.log(
193
- `[attachments] view "${attachment.filename}" (${mimetype}) kind=${kind} → ${strategy} (${bytes.length} bytes)`,
194
- );
195
- verifyMagicBytes(bytes, category, attachment, (m) =>
196
- deps.logger.warn(`[attachments] ${m}`),
222
+ const { bytes, mimetype } =
223
+ preloaded ??
224
+ (await loadAttachmentBytes(
225
+ attachment,
226
+ deps.roomId,
227
+ deps.source,
228
+ downloadOptions(deps),
229
+ ));
230
+ if (!preloaded)
231
+ deps.logger.log(
232
+ `[attachments] view "${name}" (${mimetype}) kind=${kind} size=${meta.size ?? 'unknown'} → ${strategy} (${bytes.length} bytes)`,
233
+ );
234
+ verifyMagicBytes(
235
+ bytes,
236
+ category,
237
+ { mimetype: attachment.mimetype, filename: name },
238
+ (m) => deps.logger.warn(`[attachments] ${m}`),
197
239
  );
198
240
  const { text } = await extractText(
199
241
  bytes,
@@ -237,6 +279,7 @@ export async function extractAttachmentText(
237
279
  opts: { maxChars: number },
238
280
  ): Promise<AttachmentTextExtraction> {
239
281
  const attachment = toInput(meta);
282
+ const name = sanitizeAttachmentFilename(attachment.filename);
240
283
  const category = categorizeFile(attachment.mimetype);
241
284
  if (category === 'unsupported') {
242
285
  throw new Error(
@@ -276,7 +319,7 @@ export async function extractAttachmentText(
276
319
  const hit = deps.ingestCache?.get(ref, key);
277
320
  if (!hit) continue;
278
321
  deps.logger.log(
279
- `[attachments] ingest "${attachment.filename}" → cached text (${key}, memory)`,
322
+ `[attachments] ingest "${name}" → cached text (${key}, memory)`,
280
323
  );
281
324
  const capped = capText(hit.text, opts.maxChars);
282
325
  return result(
@@ -294,9 +337,7 @@ export async function extractAttachmentText(
294
337
  for (const [key, method] of lookups) {
295
338
  const cached = await cache.get(ref, key);
296
339
  if (cached === undefined) continue;
297
- deps.logger.log(
298
- `[attachments] ingest "${attachment.filename}" → cached text (${key})`,
299
- );
340
+ deps.logger.log(`[attachments] ingest "${name}" → cached text (${key})`);
300
341
  const capped = capText(cached, opts.maxChars);
301
342
  return result(
302
343
  capped.text,
@@ -325,11 +366,14 @@ export async function extractAttachmentText(
325
366
  deps.source,
326
367
  downloadOptions(deps),
327
368
  );
328
- verifyMagicBytes(bytes, category, attachment, (m) =>
329
- deps.logger.warn(`[attachments] ${m}`),
369
+ verifyMagicBytes(
370
+ bytes,
371
+ category,
372
+ { mimetype: attachment.mimetype, filename: name },
373
+ (m) => deps.logger.warn(`[attachments] ${m}`),
330
374
  );
331
375
  deps.logger.log(
332
- `[attachments] ingest "${attachment.filename}" (${mimetype}) (${bytes.length} bytes)`,
376
+ `[attachments] ingest "${name}" (${mimetype}) (${bytes.length} bytes)`,
333
377
  );
334
378
  const store = async (
335
379
  key: string,
@@ -378,13 +422,13 @@ export async function extractAttachmentText(
378
422
  );
379
423
  }
380
424
  deps.logger.log(
381
- `[attachments] ingest "${attachment.filename}": no text layer (${pdf.pages} page(s)), using the helper model`,
425
+ `[attachments] ingest "${name}": no text layer (${pdf.pages} page(s)), using the helper model`,
382
426
  );
383
427
  } catch (error) {
384
428
  // A cancelled turn stops here; it must not start a model call.
385
429
  if (deps.signal?.aborted) throw error;
386
430
  deps.logger.warn(
387
- `[attachments] ingest "${attachment.filename}": local PDF parse failed (${error instanceof Error ? error.message : String(error)}), using the helper model`,
431
+ `[attachments] ingest "${name}": local PDF parse failed (${error instanceof Error ? error.message : String(error)}), using the helper model`,
388
432
  );
389
433
  }
390
434
  }
@@ -1,5 +1,6 @@
1
1
  import type { TaskExecutionProfile } from '../core/execution-profile';
2
2
  import type { ContextBudget } from './context-budget';
3
+ import type { AttachmentRecallMiddlewareOptions } from './middlewares/attachment-recall';
3
4
  import type { ContextGuardEvent } from './middlewares/context-guard';
4
5
  import type { ResultCapConfig } from './middlewares/result-cap';
5
6
  import type { ReadResultOutcome } from '../do/result-store';
@@ -144,6 +145,13 @@ export interface MainAgentHooks {
144
145
  * the run once the tool has answered, without another model call.
145
146
  */
146
147
  turnTools?: Array<{ tool: PluginTool; returnDirect?: boolean }>;
148
+ /**
149
+ * Puts the images of the newest user turns back into each model request
150
+ * (attachment-recall.ts): attachment bytes are never stored with the
151
+ * conversation, so a message loaded from the checkpoint carries a
152
+ * placeholder instead. Absent = no recall.
153
+ */
154
+ attachmentRecall?: Omit<AttachmentRecallMiddlewareOptions, 'logger'>;
147
155
  }
148
156
 
149
157
  export interface MainAgentArgs {
@@ -28,6 +28,7 @@ import { describeBudget } from './context-budget';
28
28
  import { createContextGuardMiddleware } from './middlewares/context-guard';
29
29
  import { repetitionCapsFromEnv } from './middlewares/tool-repetition-guard';
30
30
  import {
31
+ createAttachmentRecallMiddleware,
31
32
  createByoHistorySanitizerMiddleware,
32
33
  createCapabilityGateMiddleware,
33
34
  createDanglingToolCallRepairMiddleware,
@@ -829,9 +830,10 @@ export async function createMainAgent(
829
830
  ...(returnDirectNames.size > 0
830
831
  ? [createReturnDirectFirstMiddleware(returnDirectNames)]
831
832
  : []),
832
- // Innermost: sees the request exactly as it goes to the provider. Prunes
833
- // old tool results under pressure, refuses what cannot fit, and learns
834
- // the window from a provider overflow (context-guard.ts).
833
+ // Innermost but one: sees the request as it goes to the provider, except
834
+ // the images the recall middleware below adds inside it. Prunes old tool
835
+ // results under pressure, refuses what cannot fit, and learns the window
836
+ // from a provider overflow (context-guard.ts).
835
837
  ...(contextBudget
836
838
  ? [
837
839
  createContextGuardMiddleware({
@@ -844,6 +846,19 @@ export async function createMainAgent(
844
846
  }),
845
847
  ]
846
848
  : []),
849
+ // Inside the guard: recalled images (attachment-recall.ts) reach the
850
+ // provider but are not counted as chars/4 text, which their base64 is
851
+ // not (they are bounded by the recall's own caps instead); graph state
852
+ // never holds them. A provider refusal of a request carrying them is
853
+ // retried here once without them, before the guard sees the error.
854
+ ...(hooks?.attachmentRecall
855
+ ? [
856
+ createAttachmentRecallMiddleware({
857
+ ...hooks.attachmentRecall,
858
+ logger: ambient.logger,
859
+ }),
860
+ ]
861
+ : []),
847
862
  // Innermost around the tool itself (the first middleware in the list is
848
863
  // the outermost layer): charged and scheduled per attempt, and it sees
849
864
  // the tool's own error before the retry middlewares turn it into a