@ixo/oracle-runtime-workers 0.12.0 → 0.15.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/README.md +32 -10
- package/package.json +14 -9
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +198 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +523 -111
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* the Node runtime's SSRF guard, manual redirect validation and streaming
|
|
5
5
|
* size cap.
|
|
6
6
|
*/
|
|
7
|
-
import { detectMimeFromMagicBytes } from './magic';
|
|
7
|
+
import { containerMatchesClaim, detectMimeFromMagicBytes } from './magic';
|
|
8
8
|
import type { AttachmentInput } from './types';
|
|
9
9
|
|
|
10
10
|
export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB per file
|
|
11
11
|
export const MAX_TOTAL_SIZE = 50 * 1024 * 1024; // 50 MB across all attachments
|
|
12
12
|
export const ALLOWED_URI_SCHEMES = /^(mxc|https?):\/\//i;
|
|
13
13
|
const MAX_REDIRECT_COUNT = 5;
|
|
14
|
-
|
|
14
|
+
/** Upper bound on one attachment download, from the request to the last byte. */
|
|
15
|
+
export const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
15
16
|
|
|
16
17
|
/** Blocks redirects to internal / cloud-metadata addresses (SSRF). */
|
|
17
18
|
const BLOCKED_HOST_PATTERNS = [
|
|
@@ -29,6 +30,60 @@ const BLOCKED_HOST_PATTERNS = [
|
|
|
29
30
|
/^metadata\.google\.internal$/i,
|
|
30
31
|
];
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The file itself is refused — empty, or larger than the cap it was fetched
|
|
35
|
+
* under. Another download of the same file cannot succeed, so the pipeline
|
|
36
|
+
* notes it instead of retrying.
|
|
37
|
+
*/
|
|
38
|
+
export class AttachmentRejectedError extends Error {
|
|
39
|
+
override readonly name: string = 'AttachmentRejectedError';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The file is larger than the cap the download ran under. */
|
|
43
|
+
export class AttachmentTooLargeError extends AttachmentRejectedError {
|
|
44
|
+
override readonly name: string = 'AttachmentTooLargeError';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tooLarge(maxBytes: number): AttachmentTooLargeError {
|
|
48
|
+
return new AttachmentTooLargeError(
|
|
49
|
+
`File exceeds maximum size (${Math.round(maxBytes / 1024 / 1024)} MB) — download aborted`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The signal's reason as an Error (an aborter may pass any value). */
|
|
54
|
+
function abortError(signal: AbortSignal): Error {
|
|
55
|
+
const reason: unknown = signal.reason;
|
|
56
|
+
if (reason instanceof Error) return reason;
|
|
57
|
+
return new Error(
|
|
58
|
+
reason === undefined ? 'Attachment download aborted' : String(reason),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `work`, or the signal's reason as soon as it aborts — so a transfer that
|
|
64
|
+
* ignores the signal still cannot hold the caller past it.
|
|
65
|
+
*/
|
|
66
|
+
function untilAborted<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
67
|
+
if (signal.aborted) {
|
|
68
|
+
work.catch(() => undefined);
|
|
69
|
+
return Promise.reject(abortError(signal));
|
|
70
|
+
}
|
|
71
|
+
return new Promise<T>((resolve, reject) => {
|
|
72
|
+
const onAbort = (): void => reject(abortError(signal));
|
|
73
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
74
|
+
work.then(
|
|
75
|
+
(value) => {
|
|
76
|
+
signal.removeEventListener('abort', onAbort);
|
|
77
|
+
resolve(value);
|
|
78
|
+
},
|
|
79
|
+
(error: unknown) => {
|
|
80
|
+
signal.removeEventListener('abort', onAbort);
|
|
81
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
32
87
|
/** Throws when the URL is not http(s) or points at a blocked internal host. */
|
|
33
88
|
export function validateUrlTarget(targetUrl: string): void {
|
|
34
89
|
let parsed: URL;
|
|
@@ -50,13 +105,21 @@ export function validateUrlTarget(targetUrl: string): void {
|
|
|
50
105
|
/**
|
|
51
106
|
* Matrix media access the pipeline needs. Implemented over the gateway
|
|
52
107
|
* Durable Object in the user object; tests pass an in-memory fake.
|
|
108
|
+
*
|
|
109
|
+
* `signal` aborts at the download deadline or when the turn is aborted: an
|
|
110
|
+
* implementation stops the transfer then (`readBytesCapped` with the signal
|
|
111
|
+
* cancels the stream). The pipeline stops waiting at that moment either way.
|
|
53
112
|
*/
|
|
54
113
|
export interface MatrixMediaSource {
|
|
55
114
|
/**
|
|
56
115
|
* Raw bytes behind an `mxc://` URI (authenticated media download), the
|
|
57
116
|
* transfer cut off once it passes `maxBytes`.
|
|
58
117
|
*/
|
|
59
|
-
downloadMxc(
|
|
118
|
+
downloadMxc(
|
|
119
|
+
mxc: string,
|
|
120
|
+
maxBytes?: number,
|
|
121
|
+
signal?: AbortSignal,
|
|
122
|
+
): Promise<Uint8Array>;
|
|
60
123
|
/**
|
|
61
124
|
* Bytes of the media event `eventId` in `roomId`, decrypted when the event
|
|
62
125
|
* carries an encrypted `file`, the transfer cut off once it passes
|
|
@@ -66,6 +129,7 @@ export interface MatrixMediaSource {
|
|
|
66
129
|
roomId: string,
|
|
67
130
|
eventId: string,
|
|
68
131
|
maxBytes?: number,
|
|
132
|
+
signal?: AbortSignal,
|
|
69
133
|
): Promise<{
|
|
70
134
|
bytes: Uint8Array;
|
|
71
135
|
mimetype?: string;
|
|
@@ -80,16 +144,29 @@ export interface DownloadOptions {
|
|
|
80
144
|
maxBytes?: number;
|
|
81
145
|
}
|
|
82
146
|
|
|
83
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* A controller that aborts on `signal` (with its reason) OR after
|
|
149
|
+
* `timeoutMs` (with a timeout error), whichever comes first.
|
|
150
|
+
*/
|
|
84
151
|
function boundedSignal(
|
|
85
152
|
signal: AbortSignal | undefined,
|
|
86
153
|
timeoutMs: number,
|
|
87
154
|
): { signal: AbortSignal; release: () => void } {
|
|
88
155
|
const controller = new AbortController();
|
|
89
|
-
const onAbort = (): void =>
|
|
90
|
-
|
|
156
|
+
const onAbort = (): void => {
|
|
157
|
+
if (signal) controller.abort(signal.reason);
|
|
158
|
+
};
|
|
159
|
+
if (signal?.aborted) controller.abort(signal.reason);
|
|
91
160
|
signal?.addEventListener('abort', onAbort);
|
|
92
|
-
const timer = setTimeout(
|
|
161
|
+
const timer = setTimeout(
|
|
162
|
+
() =>
|
|
163
|
+
controller.abort(
|
|
164
|
+
new Error(
|
|
165
|
+
`Attachment download timed out after ${Math.round(timeoutMs / 1000)} s`,
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
timeoutMs,
|
|
169
|
+
);
|
|
93
170
|
return {
|
|
94
171
|
signal: controller.signal,
|
|
95
172
|
release: () => {
|
|
@@ -111,27 +188,43 @@ function concat(chunks: Uint8Array[], total: number): Uint8Array {
|
|
|
111
188
|
|
|
112
189
|
/**
|
|
113
190
|
* Collect a stream into bytes with a running size check: the moment the
|
|
114
|
-
* total passes `maxBytes` the stream is cancelled and an
|
|
115
|
-
* oversized file never lands in
|
|
191
|
+
* total passes `maxBytes` the stream is cancelled and an
|
|
192
|
+
* `AttachmentTooLargeError` thrown, so an oversized file never lands in
|
|
193
|
+
* memory whole. When `signal` aborts, the stream is cancelled and the
|
|
194
|
+
* signal's reason thrown — a stalled source cannot hold the reader.
|
|
116
195
|
*/
|
|
117
196
|
export async function readBytesCapped(
|
|
118
197
|
stream: ReadableStream<Uint8Array>,
|
|
119
198
|
maxBytes: number,
|
|
199
|
+
signal?: AbortSignal,
|
|
120
200
|
): Promise<Uint8Array> {
|
|
121
201
|
const reader = stream.getReader();
|
|
202
|
+
const cancel = (reason?: unknown): void => {
|
|
203
|
+
reader.cancel(reason).catch(() => undefined);
|
|
204
|
+
};
|
|
205
|
+
if (signal?.aborted) {
|
|
206
|
+
cancel(signal.reason);
|
|
207
|
+
throw abortError(signal);
|
|
208
|
+
}
|
|
209
|
+
const onAbort = (): void => cancel(signal?.reason);
|
|
210
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
122
211
|
const chunks: Uint8Array[] = [];
|
|
123
212
|
let total = 0;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
213
|
+
try {
|
|
214
|
+
for (;;) {
|
|
215
|
+
const { done, value } = await reader.read();
|
|
216
|
+
// A cancel settles the pending read as `done`: not a complete file.
|
|
217
|
+
if (signal?.aborted) throw abortError(signal);
|
|
218
|
+
if (done) break;
|
|
219
|
+
total += value.byteLength;
|
|
220
|
+
if (total > maxBytes) {
|
|
221
|
+
cancel();
|
|
222
|
+
throw tooLarge(maxBytes);
|
|
223
|
+
}
|
|
224
|
+
chunks.push(value);
|
|
133
225
|
}
|
|
134
|
-
|
|
226
|
+
} finally {
|
|
227
|
+
signal?.removeEventListener('abort', onAbort);
|
|
135
228
|
}
|
|
136
229
|
return concat(chunks, total);
|
|
137
230
|
}
|
|
@@ -168,8 +261,13 @@ export async function downloadFromUrl(
|
|
|
168
261
|
}
|
|
169
262
|
break;
|
|
170
263
|
}
|
|
171
|
-
if (!response
|
|
172
|
-
|
|
264
|
+
if (!response) throw new Error('Too many redirects');
|
|
265
|
+
if (response.status >= 300 && response.status < 400) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
response.headers.get('location')
|
|
268
|
+
? 'Too many redirects'
|
|
269
|
+
: `HTTP ${response.status} redirect without a Location header from ${currentUrl}`,
|
|
270
|
+
);
|
|
173
271
|
}
|
|
174
272
|
if (!response.ok) {
|
|
175
273
|
throw new Error(`HTTP ${response.status} downloading ${url}`);
|
|
@@ -178,13 +276,13 @@ export async function downloadFromUrl(
|
|
|
178
276
|
response.headers.get('content-type')?.split(';')[0]?.trim() ?? undefined;
|
|
179
277
|
const contentLength = response.headers.get('content-length');
|
|
180
278
|
if (contentLength && parseInt(contentLength, 10) > maxBytes) {
|
|
181
|
-
throw new
|
|
279
|
+
throw new AttachmentTooLargeError(
|
|
182
280
|
`File too large: server reports ${Math.round(parseInt(contentLength, 10) / 1024 / 1024)} MB (limit: ${Math.round(maxBytes / 1024 / 1024)} MB)`,
|
|
183
281
|
);
|
|
184
282
|
}
|
|
185
283
|
if (!response.body) throw new Error('Response body is not readable');
|
|
186
284
|
return {
|
|
187
|
-
data: await readBytesCapped(response.body, maxBytes),
|
|
285
|
+
data: await readBytesCapped(response.body, maxBytes, bound.signal),
|
|
188
286
|
contentType,
|
|
189
287
|
...(currentUrl !== url ? { finalUrl: currentUrl } : {}),
|
|
190
288
|
};
|
|
@@ -195,16 +293,20 @@ export async function downloadFromUrl(
|
|
|
195
293
|
|
|
196
294
|
/**
|
|
197
295
|
* Bytes for one attachment from whichever source it names, with the per-file
|
|
198
|
-
* cap
|
|
199
|
-
*
|
|
200
|
-
*
|
|
296
|
+
* cap (`opts.maxBytes`, default `MAX_FILE_SIZE`) enforced before and during
|
|
297
|
+
* the transfer and the `DOWNLOAD_TIMEOUT_MS` deadline (or `opts.timeoutMs`)
|
|
298
|
+
* on every source — Matrix media included. `mimetype` is the best-known
|
|
299
|
+
* type: content sniffing (`sniffed` is then true; a container signature
|
|
300
|
+
* consistent with the claim, such as a zip for a `.docx`, keeps the claimed
|
|
301
|
+
* type), then the HTTP `content-type`, then the client's claim. An empty
|
|
302
|
+
* file is refused.
|
|
201
303
|
*/
|
|
202
304
|
export async function loadAttachmentBytes(
|
|
203
305
|
attachment: AttachmentInput,
|
|
204
306
|
roomId: string | undefined,
|
|
205
307
|
source: MatrixMediaSource,
|
|
206
308
|
opts: DownloadOptions = {},
|
|
207
|
-
): Promise<{ bytes: Uint8Array; mimetype: string }> {
|
|
309
|
+
): Promise<{ bytes: Uint8Array; mimetype: string; sniffed: boolean }> {
|
|
208
310
|
if (!attachment.eventId && !attachment.mxcUri) {
|
|
209
311
|
throw new Error('Either mxcUri or eventId must be provided');
|
|
210
312
|
}
|
|
@@ -213,26 +315,44 @@ export async function loadAttachmentBytes(
|
|
|
213
315
|
}
|
|
214
316
|
const maxBytes = opts.maxBytes ?? MAX_FILE_SIZE;
|
|
215
317
|
if (attachment.size && attachment.size > maxBytes) {
|
|
216
|
-
throw new
|
|
318
|
+
throw new AttachmentTooLargeError('File exceeds maximum size');
|
|
217
319
|
}
|
|
218
320
|
|
|
219
321
|
let bytes: Uint8Array;
|
|
220
322
|
let httpType: string | undefined;
|
|
221
|
-
if (attachment.eventId) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
);
|
|
226
|
-
const media = await source.downloadEvent(
|
|
227
|
-
roomId,
|
|
228
|
-
attachment.eventId,
|
|
229
|
-
maxBytes,
|
|
323
|
+
if (attachment.eventId || attachment.mxcUri!.startsWith('mxc://')) {
|
|
324
|
+
const bound = boundedSignal(
|
|
325
|
+
opts.signal,
|
|
326
|
+
opts.timeoutMs ?? DOWNLOAD_TIMEOUT_MS,
|
|
230
327
|
);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
328
|
+
try {
|
|
329
|
+
if (attachment.eventId) {
|
|
330
|
+
if (!roomId)
|
|
331
|
+
throw new Error(
|
|
332
|
+
`Cannot fetch event ${attachment.eventId}: no Matrix room for this turn`,
|
|
333
|
+
);
|
|
334
|
+
const media = await untilAborted(
|
|
335
|
+
source.downloadEvent(
|
|
336
|
+
roomId,
|
|
337
|
+
attachment.eventId,
|
|
338
|
+
maxBytes,
|
|
339
|
+
bound.signal,
|
|
340
|
+
),
|
|
341
|
+
bound.signal,
|
|
342
|
+
);
|
|
343
|
+
if (!media)
|
|
344
|
+
throw new Error(`Matrix event ${attachment.eventId} not found`);
|
|
345
|
+
bytes = media.bytes;
|
|
346
|
+
httpType = media.mimetype;
|
|
347
|
+
} else {
|
|
348
|
+
bytes = await untilAborted(
|
|
349
|
+
source.downloadMxc(attachment.mxcUri!, maxBytes, bound.signal),
|
|
350
|
+
bound.signal,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
bound.release();
|
|
355
|
+
}
|
|
236
356
|
} else {
|
|
237
357
|
const result = await downloadFromUrl(attachment.mxcUri!, {
|
|
238
358
|
...opts,
|
|
@@ -241,10 +361,12 @@ export async function loadAttachmentBytes(
|
|
|
241
361
|
bytes = result.data;
|
|
242
362
|
httpType = result.contentType;
|
|
243
363
|
}
|
|
244
|
-
if (bytes.length > maxBytes)
|
|
245
|
-
|
|
246
|
-
}
|
|
364
|
+
if (bytes.length > maxBytes) throw tooLarge(maxBytes);
|
|
365
|
+
if (bytes.length === 0) throw new AttachmentRejectedError('File is empty');
|
|
247
366
|
const sniffed = detectMimeFromMagicBytes(bytes);
|
|
367
|
+
if (sniffed && containerMatchesClaim(sniffed, attachment.mimetype)) {
|
|
368
|
+
return { bytes, mimetype: attachment.mimetype, sniffed: true };
|
|
369
|
+
}
|
|
248
370
|
return {
|
|
249
371
|
bytes,
|
|
250
372
|
mimetype:
|
|
@@ -252,5 +374,6 @@ export async function loadAttachmentBytes(
|
|
|
252
374
|
(httpType && httpType !== 'application/octet-stream'
|
|
253
375
|
? httpType
|
|
254
376
|
: attachment.mimetype),
|
|
377
|
+
sniffed: sniffed !== null,
|
|
255
378
|
};
|
|
256
379
|
}
|
package/src/attachments/index.ts
CHANGED
|
@@ -5,6 +5,9 @@ export {
|
|
|
5
5
|
} from './content-blocks';
|
|
6
6
|
export {
|
|
7
7
|
ALLOWED_URI_SCHEMES,
|
|
8
|
+
AttachmentRejectedError,
|
|
9
|
+
AttachmentTooLargeError,
|
|
10
|
+
DOWNLOAD_TIMEOUT_MS,
|
|
8
11
|
downloadFromUrl,
|
|
9
12
|
loadAttachmentBytes,
|
|
10
13
|
MAX_FILE_SIZE,
|
|
@@ -83,3 +86,9 @@ export {
|
|
|
83
86
|
type AttachmentViewSurface,
|
|
84
87
|
type ViewAttachmentDeps,
|
|
85
88
|
} from './view';
|
|
89
|
+
export {
|
|
90
|
+
AttachmentTextCacheStore,
|
|
91
|
+
LOCAL_TEXT_EXTRACTION,
|
|
92
|
+
VIEW_CACHE_MAX_CHARS,
|
|
93
|
+
type AttachmentTextCache,
|
|
94
|
+
} from './view-cache';
|
package/src/attachments/magic.ts
CHANGED
|
@@ -56,6 +56,38 @@ const MAGIC_MIME_CATEGORIES: Record<string, FileCategory[]> = {
|
|
|
56
56
|
'video/mp4': ['video', 'audio'],
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Signatures that mark a container rather than one format: the bytes are
|
|
61
|
+
* consistent with every claim listed here, and the claim is the more precise
|
|
62
|
+
* type (a `.docx` is a zip archive, a HEIC photo an ISO-BMFF `ftyp` box).
|
|
63
|
+
*/
|
|
64
|
+
const CONTAINER_CLAIMS: Record<string, (claimed: string) => boolean> = {
|
|
65
|
+
'application/zip': (claimed) =>
|
|
66
|
+
claimed.startsWith('application/vnd.openxmlformats-officedocument.') ||
|
|
67
|
+
claimed.startsWith('application/vnd.oasis.opendocument.') ||
|
|
68
|
+
claimed === 'application/epub+zip',
|
|
69
|
+
'application/msword': (claimed) =>
|
|
70
|
+
claimed === 'application/vnd.ms-excel' ||
|
|
71
|
+
claimed === 'application/vnd.ms-powerpoint',
|
|
72
|
+
'video/mp4': (claimed) =>
|
|
73
|
+
/^image\/(heic|heif|avif)(-sequence)?$/.test(claimed) ||
|
|
74
|
+
/^audio\/(mp4|m4a|x-m4a)$/.test(claimed) ||
|
|
75
|
+
/^video\/(quicktime|3gpp2?)$/.test(claimed),
|
|
76
|
+
'image/webp': (claimed) =>
|
|
77
|
+
/^audio\/(wav|wave|x-wav)$/.test(claimed) ||
|
|
78
|
+
/^video\/(avi|x-msvideo)$/.test(claimed),
|
|
79
|
+
'video/webm': (claimed) =>
|
|
80
|
+
claimed === 'audio/webm' || /^(audio|video)\/x-matroska$/.test(claimed),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Whether a sniffed container type is consistent with the client's more precise claim. */
|
|
84
|
+
export function containerMatchesClaim(
|
|
85
|
+
sniffed: string,
|
|
86
|
+
claimed: string,
|
|
87
|
+
): boolean {
|
|
88
|
+
return CONTAINER_CLAIMS[sniffed]?.(claimed.toLowerCase()) ?? false;
|
|
89
|
+
}
|
|
90
|
+
|
|
59
91
|
/** The MIME a file's magic bytes announce, or null when no signature matches. */
|
|
60
92
|
export function detectMimeFromMagicBytes(bytes: Uint8Array): string | null {
|
|
61
93
|
if (bytes.length < 4) return null;
|
|
@@ -124,6 +156,7 @@ export function verifyMagicBytes(
|
|
|
124
156
|
);
|
|
125
157
|
return;
|
|
126
158
|
}
|
|
159
|
+
if (containerMatchesClaim(detected, attachment.mimetype)) return;
|
|
127
160
|
const allowed = MAGIC_MIME_CATEGORIES[detected];
|
|
128
161
|
if (!allowed || !allowed.includes(claimedCategory)) {
|
|
129
162
|
throw new Error(
|
|
@@ -6,18 +6,23 @@
|
|
|
6
6
|
* 1. classify + route each attachment by the selected model's native input
|
|
7
7
|
* capabilities (`send-native` / `parse-local` / `model-extract`);
|
|
8
8
|
* 2. NATIVE lane: download, sniff, base64 into LangChain content blocks on
|
|
9
|
-
* the human message
|
|
10
|
-
*
|
|
11
|
-
* drops the whole message
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* the human message; the original is archived to the sandbox in the
|
|
10
|
+
* background. A failed download falls back to extraction so a bad
|
|
11
|
+
* download never drops the whole message; a file that is itself refused
|
|
12
|
+
* (empty, over the budget) is noted, and one whose content is not what it
|
|
13
|
+
* claims goes to the extract lane with its bytes — never downloaded a
|
|
14
|
+
* second time, never sent as a native block;
|
|
15
|
+
* 3. EXTRACT lane: sequential, plain text read locally, everything else
|
|
15
16
|
* described / transcribed / extracted by the helper model, then archived
|
|
16
17
|
* to the sandbox — when the archive succeeds the text handed to the model
|
|
17
18
|
* is truncated to 500 chars and points at the sandbox copy (the agent's
|
|
18
19
|
* file tools read the rest), exactly as on Node;
|
|
19
20
|
* 4. every attachment's metadata lands on the human message so transcripts
|
|
20
21
|
* render file chips regardless of which lane consumed it.
|
|
22
|
+
*
|
|
23
|
+
* Both lanes share one `MAX_TOTAL_SIZE` download budget (`DownloadBudget`):
|
|
24
|
+
* declared sizes are checked against what is left before a download, and
|
|
25
|
+
* each download is cut off at what is left.
|
|
21
26
|
*/
|
|
22
27
|
import type { MessageContent } from '@langchain/core/messages';
|
|
23
28
|
import type { ModelInputCapabilities } from '../core/llm';
|
|
@@ -31,6 +36,8 @@ import {
|
|
|
31
36
|
} from './content-blocks';
|
|
32
37
|
import {
|
|
33
38
|
ALLOWED_URI_SCHEMES,
|
|
39
|
+
AttachmentRejectedError,
|
|
40
|
+
AttachmentTooLargeError,
|
|
34
41
|
loadAttachmentBytes,
|
|
35
42
|
MAX_FILE_SIZE,
|
|
36
43
|
MAX_TOTAL_SIZE,
|
|
@@ -118,22 +125,67 @@ function errorMessage(error: unknown): string {
|
|
|
118
125
|
return error instanceof Error ? error.message : String(error);
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
|
|
128
|
+
const BUDGET_EXCEEDED = 'Total attachment size budget exceeded';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The turn's download budget: `MAX_TOTAL_SIZE` across every attachment,
|
|
132
|
+
* both lanes. A file is checked against what is left before its download
|
|
133
|
+
* (declared size) and fetched under a cap of what is left, so a download
|
|
134
|
+
* never runs past the budget and an over-budget file is never fetched twice.
|
|
135
|
+
*/
|
|
136
|
+
class DownloadBudget {
|
|
137
|
+
private used = 0;
|
|
138
|
+
|
|
139
|
+
get remaining(): number {
|
|
140
|
+
return Math.max(0, MAX_TOTAL_SIZE - this.used);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The bytes of `attachment`, counted against the budget. */
|
|
144
|
+
async load(
|
|
145
|
+
attachment: AttachmentInput,
|
|
146
|
+
params: PrepareAttachmentsParams,
|
|
147
|
+
deps: AttachmentPipelineDeps,
|
|
148
|
+
): Promise<{ bytes: Uint8Array; mimetype: string; sniffed: boolean }> {
|
|
149
|
+
if (attachment.size && attachment.size > MAX_FILE_SIZE)
|
|
150
|
+
throw new AttachmentTooLargeError('File exceeds maximum size');
|
|
151
|
+
const remaining = this.remaining;
|
|
152
|
+
if (remaining === 0 || (attachment.size && attachment.size > remaining))
|
|
153
|
+
throw new AttachmentRejectedError(BUDGET_EXCEEDED);
|
|
154
|
+
const cap = Math.min(MAX_FILE_SIZE, remaining);
|
|
155
|
+
try {
|
|
156
|
+
const loaded = await loadAttachmentBytes(
|
|
157
|
+
attachment,
|
|
158
|
+
params.roomId,
|
|
159
|
+
deps.source,
|
|
160
|
+
{ fetchImpl: deps.fetchImpl, signal: deps.signal, maxBytes: cap },
|
|
161
|
+
);
|
|
162
|
+
this.used += loaded.bytes.length;
|
|
163
|
+
return loaded;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// Cut off by the budget rather than by the per-file cap.
|
|
166
|
+
if (error instanceof AttachmentTooLargeError && cap < MAX_FILE_SIZE)
|
|
167
|
+
throw new AttachmentRejectedError(BUDGET_EXCEEDED);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* One attachment through the extract lane (Node's `processAttachment`).
|
|
175
|
+
* `preloaded` is the file the native lane already downloaded.
|
|
176
|
+
*/
|
|
122
177
|
async function extractOne(
|
|
123
178
|
attachment: AttachmentInput,
|
|
124
|
-
|
|
179
|
+
budget: DownloadBudget,
|
|
125
180
|
params: PrepareAttachmentsParams,
|
|
126
181
|
deps: AttachmentPipelineDeps,
|
|
127
|
-
|
|
182
|
+
preloaded?: Uint8Array,
|
|
183
|
+
): Promise<{ text: string; usage?: ExtractionUsage }> {
|
|
128
184
|
const safeName = sanitizeAttachmentFilename(attachment.filename);
|
|
129
185
|
if (!attachment.eventId && !attachment.mxcUri)
|
|
130
186
|
throw new Error('Either mxcUri or eventId must be provided');
|
|
131
187
|
if (attachment.mxcUri && !ALLOWED_URI_SCHEMES.test(attachment.mxcUri))
|
|
132
188
|
throw new Error('Invalid URI scheme');
|
|
133
|
-
if (attachment.size && attachment.size > MAX_FILE_SIZE)
|
|
134
|
-
throw new Error('File exceeds maximum size');
|
|
135
|
-
if (attachment.size && currentTotal + attachment.size > MAX_TOTAL_SIZE)
|
|
136
|
-
throw new Error('Total attachment size budget exceeded');
|
|
137
189
|
|
|
138
190
|
const category: FileCategory = categorizeFile(attachment.mimetype);
|
|
139
191
|
if (category === 'unsupported') {
|
|
@@ -142,16 +194,11 @@ async function extractOne(
|
|
|
142
194
|
);
|
|
143
195
|
return {
|
|
144
196
|
text: `[File "${safeName}" (${attachment.mimetype}) is not a supported file type and could not be processed]`,
|
|
145
|
-
downloaded: 0,
|
|
146
197
|
};
|
|
147
198
|
}
|
|
148
199
|
|
|
149
|
-
const
|
|
150
|
-
attachment,
|
|
151
|
-
params.roomId,
|
|
152
|
-
deps.source,
|
|
153
|
-
{ fetchImpl: deps.fetchImpl, signal: deps.signal },
|
|
154
|
-
);
|
|
200
|
+
const bytes =
|
|
201
|
+
preloaded ?? (await budget.load(attachment, params, deps)).bytes;
|
|
155
202
|
verifyMagicBytes(bytes, category, attachment, (m) =>
|
|
156
203
|
deps.logger.warn(`[attachments] ${m}`),
|
|
157
204
|
);
|
|
@@ -198,7 +245,7 @@ async function extractOne(
|
|
|
198
245
|
}
|
|
199
246
|
|
|
200
247
|
if (!deps.sandbox) {
|
|
201
|
-
return { text,
|
|
248
|
+
return { text, ...(usage ? { usage } : {}) };
|
|
202
249
|
}
|
|
203
250
|
const destPath = `${SANDBOX_OUTPUT_PREFIX}/${safeName}`;
|
|
204
251
|
try {
|
|
@@ -245,7 +292,6 @@ async function extractOne(
|
|
|
245
292
|
: `\n\n[Full file saved to sandbox at ${actualPath}]`;
|
|
246
293
|
return {
|
|
247
294
|
text: text.slice(0, SANDBOX_TRUNCATE_LIMIT) + paths,
|
|
248
|
-
downloaded: bytes.length,
|
|
249
295
|
...(usage ? { usage } : {}),
|
|
250
296
|
};
|
|
251
297
|
}
|
|
@@ -254,7 +300,6 @@ async function extractOne(
|
|
|
254
300
|
: `\n\n[File also saved to sandbox at ${actualPath}]`;
|
|
255
301
|
return {
|
|
256
302
|
text: text + suffix,
|
|
257
|
-
downloaded: bytes.length,
|
|
258
303
|
...(usage ? { usage } : {}),
|
|
259
304
|
};
|
|
260
305
|
} catch (error) {
|
|
@@ -263,7 +308,6 @@ async function extractOne(
|
|
|
263
308
|
);
|
|
264
309
|
return {
|
|
265
310
|
text: `${text}\n\n[Warning: sandbox upload failed — file content is included above]`,
|
|
266
|
-
downloaded: bytes.length,
|
|
267
311
|
...(usage ? { usage } : {}),
|
|
268
312
|
};
|
|
269
313
|
}
|
|
@@ -290,7 +334,14 @@ export async function prepareAttachments(
|
|
|
290
334
|
}
|
|
291
335
|
|
|
292
336
|
const nativeCandidates: AttachmentInput[] = [];
|
|
293
|
-
|
|
337
|
+
// A file the native lane already refused (empty, over budget) carries the
|
|
338
|
+
// reason and is noted; one it downloaded but will not send carries its
|
|
339
|
+
// bytes. Neither is downloaded again.
|
|
340
|
+
const extractQueue: Array<{
|
|
341
|
+
attachment: AttachmentInput;
|
|
342
|
+
refused?: Error;
|
|
343
|
+
preloaded?: Uint8Array;
|
|
344
|
+
}> = [];
|
|
294
345
|
for (const attachment of params.attachments) {
|
|
295
346
|
const kind = classifyAttachment(attachment);
|
|
296
347
|
const strategy = routeAttachment(kind, params.caps);
|
|
@@ -298,31 +349,33 @@ export async function prepareAttachments(
|
|
|
298
349
|
`[attachments] "${attachment.filename}" (${attachment.mimetype}) kind=${kind} model=${params.model} → ${strategy}`,
|
|
299
350
|
);
|
|
300
351
|
if (strategy === 'send-native') nativeCandidates.push(attachment);
|
|
301
|
-
else extractQueue.push(attachment);
|
|
352
|
+
else extractQueue.push({ attachment });
|
|
302
353
|
}
|
|
303
354
|
|
|
355
|
+
const budget = new DownloadBudget();
|
|
304
356
|
const natives: NativeAttachment[] = [];
|
|
305
|
-
let nativeBytesTotal = 0;
|
|
306
357
|
for (const attachment of nativeCandidates) {
|
|
307
358
|
try {
|
|
308
|
-
const { bytes, mimetype } = await
|
|
359
|
+
const { bytes, mimetype, sniffed } = await budget.load(
|
|
309
360
|
attachment,
|
|
310
|
-
params
|
|
311
|
-
deps
|
|
312
|
-
{ fetchImpl: deps.fetchImpl, signal: deps.signal },
|
|
361
|
+
params,
|
|
362
|
+
deps,
|
|
313
363
|
);
|
|
314
|
-
|
|
364
|
+
// Only a file whose bytes are what it claims goes to the model as a
|
|
365
|
+
// block: a sniffed type is classified on its own (a PDF named `.png`
|
|
366
|
+
// is a document), and a kind other than the claimed one goes through
|
|
367
|
+
// the extract lane, whose content check notes the mismatch.
|
|
368
|
+
const kind = classifyAttachment({
|
|
369
|
+
mimetype,
|
|
370
|
+
filename: sniffed ? '' : attachment.filename,
|
|
371
|
+
});
|
|
372
|
+
if (kind !== classifyAttachment(attachment)) {
|
|
315
373
|
deps.logger.warn(
|
|
316
|
-
`[attachments]
|
|
374
|
+
`[attachments] "${attachment.filename}" claims ${attachment.mimetype} but is ${mimetype} — not sent natively`,
|
|
317
375
|
);
|
|
318
|
-
extractQueue.push(attachment);
|
|
376
|
+
extractQueue.push({ attachment, preloaded: bytes });
|
|
319
377
|
continue;
|
|
320
378
|
}
|
|
321
|
-
nativeBytesTotal += bytes.length;
|
|
322
|
-
const kind = classifyAttachment({
|
|
323
|
-
mimetype,
|
|
324
|
-
filename: attachment.filename,
|
|
325
|
-
});
|
|
326
379
|
natives.push({
|
|
327
380
|
kind: kind === 'image' ? 'image' : 'file',
|
|
328
381
|
mimeType: mimetype,
|
|
@@ -349,10 +402,17 @@ export async function prepareAttachments(
|
|
|
349
402
|
});
|
|
350
403
|
}
|
|
351
404
|
} catch (error) {
|
|
405
|
+
if (error instanceof AttachmentRejectedError) {
|
|
406
|
+
deps.logger.warn(
|
|
407
|
+
`[attachments] "${attachment.filename}" refused: ${errorMessage(error)}`,
|
|
408
|
+
);
|
|
409
|
+
extractQueue.push({ attachment, refused: error });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
352
412
|
deps.logger.warn(
|
|
353
413
|
`[attachments] native load failed for "${attachment.filename}", falling back to extraction: ${errorMessage(error)}`,
|
|
354
414
|
);
|
|
355
|
-
extractQueue.push(attachment);
|
|
415
|
+
extractQueue.push({ attachment });
|
|
356
416
|
}
|
|
357
417
|
}
|
|
358
418
|
|
|
@@ -366,23 +426,19 @@ export async function prepareAttachments(
|
|
|
366
426
|
'[attachments] sandbox archive unavailable for this turn — originals are not archived',
|
|
367
427
|
);
|
|
368
428
|
}
|
|
369
|
-
|
|
370
|
-
for (const attachment of extractQueue) {
|
|
429
|
+
for (const { attachment, refused, preloaded } of extractQueue) {
|
|
371
430
|
const meta = metaFor(attachment);
|
|
372
431
|
let text: string;
|
|
373
432
|
try {
|
|
433
|
+
if (refused) throw refused;
|
|
374
434
|
const result = await extractOne(
|
|
375
435
|
attachment,
|
|
376
|
-
|
|
436
|
+
budget,
|
|
377
437
|
params,
|
|
378
438
|
deps,
|
|
439
|
+
preloaded,
|
|
379
440
|
);
|
|
380
|
-
|
|
381
|
-
if (downloadedTotal > MAX_TOTAL_SIZE) {
|
|
382
|
-
throw new Error(
|
|
383
|
-
`Total downloaded size (${Math.round(downloadedTotal / 1024 / 1024)} MB) exceeds budget (${Math.round(MAX_TOTAL_SIZE / 1024 / 1024)} MB)`,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
441
|
+
// Counted as soon as it is paid for, whatever happens next.
|
|
386
442
|
if (result.usage) {
|
|
387
443
|
usage.cost += result.usage.cost ?? 0;
|
|
388
444
|
usage.promptTokens += result.usage.promptTokens ?? 0;
|