@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
package/src/attachments/route.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
61
|
-
...(
|
|
62
|
-
...(
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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(
|
|
58
|
+
return cutUnits(visibleText(value).replace(/"/g, ''), max);
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
package/src/attachments/view.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Re-fetch one attachment of a session on demand — the `view_attachment`
|
|
3
|
-
* lane
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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 "${
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 "${
|
|
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 } =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
|
|
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 "${
|
|
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(
|
|
329
|
-
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 {
|
package/src/core/main-agent.ts
CHANGED
|
@@ -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
|
|
833
|
-
//
|
|
834
|
-
//
|
|
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
|