@ixo/oracle-runtime-workers 0.37.1 → 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 +53 -23
- package/src/do/flush-schedule.ts +8 -2
- 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 +588 -105
- 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
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recalls the images of recent user turns into each model request.
|
|
3
|
+
*
|
|
4
|
+
* Attachment bytes are never stored with the conversation: the checkpointer
|
|
5
|
+
* keeps a placeholder text block in their place (`src/sqlite/
|
|
6
|
+
* inline-payloads.ts`), so a message loaded from the checkpoint — every
|
|
7
|
+
* earlier turn, and the turn itself after a durable run restarted — reaches
|
|
8
|
+
* the model without the image the person sent. Right before each model call
|
|
9
|
+
* this middleware puts the images of the newest `ATTACHMENT_RECALL_TURNS`
|
|
10
|
+
* user turns back, in the request only (graph state, the checkpoint and the
|
|
11
|
+
* transcript never see them):
|
|
12
|
+
*
|
|
13
|
+
* - a human message qualifies when it holds the placeholder (its bytes
|
|
14
|
+
* were left out of storage), no inline block, and image attachments in
|
|
15
|
+
* its metadata; a `view_attachment` re-attachment message belongs to the
|
|
16
|
+
* user turn before it;
|
|
17
|
+
* - images only: a document is never recalled, the model calls
|
|
18
|
+
* `view_attachment` for it;
|
|
19
|
+
* - an image already in the request (a message that still holds its
|
|
20
|
+
* bytes: the turn's own message, a `view_attachment` re-attachment) is
|
|
21
|
+
* not recalled again, and neither is the same file under another
|
|
22
|
+
* reference (a channel photo sent twice gets a new media event id but
|
|
23
|
+
* keeps its `channelRef`);
|
|
24
|
+
* - at most `ATTACHMENT_RECALL_MAX_IMAGES` per request, newest first,
|
|
25
|
+
* fetched one at a time; an image whose recorded size is over
|
|
26
|
+
* `ATTACHMENT_RECALL_MAX_IMAGE_BYTES` is skipped without a fetch, one of
|
|
27
|
+
* unknown size is fetched with that cap and skipped when cut off; the
|
|
28
|
+
* images of one request hold at most `ATTACHMENT_RECALL_MAX_TOTAL_BYTES`
|
|
29
|
+
* of base64 together;
|
|
30
|
+
* - a finished image (resized, base64) is kept in a small LRU
|
|
31
|
+
* (`RecalledImageCache`) that the host holds per user object, so the
|
|
32
|
+
* next turn's recall neither fetches nor transforms it again; each fetch
|
|
33
|
+
* has a deadline of `ATTACHMENT_RECALL_TIMEOUT_MS`; a failure leaves the
|
|
34
|
+
* placeholder in place and is logged once per reference per run;
|
|
35
|
+
* - when the provider refuses a request that carries recalled images with
|
|
36
|
+
* a client error (4xx, not a context overflow, not a credential, billing,
|
|
37
|
+
* timeout or rate-limit status), the call is made once more with the
|
|
38
|
+
* request as it came in (no recalled images), and those images are not
|
|
39
|
+
* recalled again for the rest of the run;
|
|
40
|
+
* - nothing happens when the turn's model does not read images.
|
|
41
|
+
*
|
|
42
|
+
* The placeholder of a message whose images came back lists only the
|
|
43
|
+
* attachments that did not, or goes when none is left. The middleware sits
|
|
44
|
+
* innermost, after the context guard: the guard's chars/4 estimate would
|
|
45
|
+
* count an image's base64 as text, far above what the provider charges for
|
|
46
|
+
* it. The guard therefore never sees the recalled images.
|
|
47
|
+
*/
|
|
48
|
+
import { HumanMessage, type BaseMessage } from '@langchain/core/messages';
|
|
49
|
+
import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
50
|
+
import { isContextOverflowError } from '../context-window';
|
|
51
|
+
import {
|
|
52
|
+
attachmentMetas,
|
|
53
|
+
attachmentRef,
|
|
54
|
+
buildPlaceholderText,
|
|
55
|
+
hasInlinePayload,
|
|
56
|
+
isAttachmentPlaceholderText,
|
|
57
|
+
isAttachmentViewMessage,
|
|
58
|
+
} from '../../attachments/retention';
|
|
59
|
+
import {
|
|
60
|
+
nativeContentBlock,
|
|
61
|
+
type NativeAttachment,
|
|
62
|
+
} from '../../attachments/content-blocks';
|
|
63
|
+
import type { Logger } from '../../plugin-api/types';
|
|
64
|
+
import type { AttachmentMeta } from '../../sqlite/serialization';
|
|
65
|
+
import { NOOP_LOGGER } from '../utils';
|
|
66
|
+
|
|
67
|
+
/** Newest user turns whose images are recalled into a request. */
|
|
68
|
+
export const ATTACHMENT_RECALL_TURNS = 2;
|
|
69
|
+
|
|
70
|
+
/** Images recalled into one request at most. */
|
|
71
|
+
export const ATTACHMENT_RECALL_MAX_IMAGES = 4;
|
|
72
|
+
|
|
73
|
+
/** Deadline of one image fetch, from the request to the resized bytes. */
|
|
74
|
+
export const ATTACHMENT_RECALL_TIMEOUT_MS = 15_000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Largest original image recalled (bytes). A larger recorded size is
|
|
78
|
+
* skipped without a fetch; the loader cuts a download off at this size.
|
|
79
|
+
*/
|
|
80
|
+
export const ATTACHMENT_RECALL_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Base64 characters the recalled images of one request hold together (the
|
|
84
|
+
* request carries them as they are, ~4/3 of the image bytes).
|
|
85
|
+
*/
|
|
86
|
+
export const ATTACHMENT_RECALL_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
|
|
87
|
+
|
|
88
|
+
/** Finished images a `RecalledImageCache` keeps by default. */
|
|
89
|
+
export const ATTACHMENT_RECALL_CACHE_ENTRIES = 8;
|
|
90
|
+
|
|
91
|
+
/** Base64 characters a `RecalledImageCache` keeps by default. */
|
|
92
|
+
export const ATTACHMENT_RECALL_CACHE_BYTES = 10 * 1024 * 1024;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fetches one image attachment of the session, ready for the model (the
|
|
96
|
+
* host downloads, checks and resizes it). The download must stop once it
|
|
97
|
+
* passes `maxBytes` (a rejection then). Resolves null when the file is not
|
|
98
|
+
* available, or when the image may not go to the model natively (the
|
|
99
|
+
* resize step reports it `dropped`); a rejection counts the same.
|
|
100
|
+
*/
|
|
101
|
+
export type RecallImageLoader = (
|
|
102
|
+
meta: AttachmentMeta,
|
|
103
|
+
opts: { signal: AbortSignal; maxBytes: number },
|
|
104
|
+
) => Promise<NativeAttachment | null>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The finished (resized, base64) images recalled lately, by reference,
|
|
108
|
+
* least recently used evicted first, bounded by entry count and by base64
|
|
109
|
+
* characters. The host keeps one per user object and hands it to every
|
|
110
|
+
* turn's recall, so an image recalled on one turn is neither fetched nor
|
|
111
|
+
* transformed (each transformation is billed) on the next.
|
|
112
|
+
*/
|
|
113
|
+
export class RecalledImageCache {
|
|
114
|
+
private readonly entries = new Map<string, NativeAttachment>();
|
|
115
|
+
private chars = 0;
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly maxEntries = ATTACHMENT_RECALL_CACHE_ENTRIES,
|
|
119
|
+
private readonly maxChars = ATTACHMENT_RECALL_CACHE_BYTES,
|
|
120
|
+
) {}
|
|
121
|
+
|
|
122
|
+
get(ref: string): NativeAttachment | undefined {
|
|
123
|
+
const hit = this.entries.get(ref);
|
|
124
|
+
if (hit === undefined) return undefined;
|
|
125
|
+
// Re-inserted: a Map iterates in insertion order, oldest first.
|
|
126
|
+
this.entries.delete(ref);
|
|
127
|
+
this.entries.set(ref, hit);
|
|
128
|
+
return hit;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
set(ref: string, native: NativeAttachment): void {
|
|
132
|
+
const size = native.base64.length;
|
|
133
|
+
this.delete(ref);
|
|
134
|
+
if (size > this.maxChars) return;
|
|
135
|
+
this.entries.set(ref, native);
|
|
136
|
+
this.chars += size;
|
|
137
|
+
for (const [oldest, entry] of this.entries) {
|
|
138
|
+
if (this.entries.size <= this.maxEntries && this.chars <= this.maxChars)
|
|
139
|
+
break;
|
|
140
|
+
this.entries.delete(oldest);
|
|
141
|
+
this.chars -= entry.base64.length;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get size(): number {
|
|
146
|
+
return this.entries.size;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private delete(ref: string): void {
|
|
150
|
+
const entry = this.entries.get(ref);
|
|
151
|
+
if (entry === undefined) return;
|
|
152
|
+
this.entries.delete(ref);
|
|
153
|
+
this.chars -= entry.base64.length;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface AttachmentRecallMiddlewareOptions {
|
|
158
|
+
loadImage: RecallImageLoader;
|
|
159
|
+
/** Whether the turn's model reads images; nothing is recalled otherwise. */
|
|
160
|
+
imageInput: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* The finished-image cache shared across turns (one per user object).
|
|
163
|
+
* Absent = a cache for this recaller only, i.e. for one run.
|
|
164
|
+
*/
|
|
165
|
+
cache?: RecalledImageCache;
|
|
166
|
+
/** The turn's abort signal: a cancelled turn stops its fetches. */
|
|
167
|
+
signal?: AbortSignal;
|
|
168
|
+
timeoutMs?: number;
|
|
169
|
+
maxImages?: number;
|
|
170
|
+
maxImageBytes?: number;
|
|
171
|
+
maxTotalBytes?: number;
|
|
172
|
+
logger?: Logger;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What one `recall` put into the request. */
|
|
176
|
+
export interface RecallResult {
|
|
177
|
+
/** The request's messages: the same array when nothing came back. */
|
|
178
|
+
messages: BaseMessage[];
|
|
179
|
+
/** The references of the images put back, newest first. */
|
|
180
|
+
recalled: string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface RecallCandidate {
|
|
184
|
+
index: number;
|
|
185
|
+
message: BaseMessage;
|
|
186
|
+
images: AttachmentMeta[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
190
|
+
return typeof value === 'object' && value !== null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isPlaceholderBlock(block: unknown): boolean {
|
|
194
|
+
return (
|
|
195
|
+
isRecord(block) &&
|
|
196
|
+
block.type === 'text' &&
|
|
197
|
+
typeof block.text === 'string' &&
|
|
198
|
+
isAttachmentPlaceholderText(block.text)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hasPlaceholder(message: BaseMessage): boolean {
|
|
203
|
+
return (
|
|
204
|
+
Array.isArray(message.content) &&
|
|
205
|
+
message.content.some((block) => isPlaceholderBlock(block))
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Every identifier of one attachment: the media event id, the channel
|
|
211
|
+
* file's reference and the URI. A channel photo sent again gets a new
|
|
212
|
+
* event id but keeps its `channelRef`, so either one names the same file.
|
|
213
|
+
*/
|
|
214
|
+
function attachmentKeys(meta: AttachmentMeta): string[] {
|
|
215
|
+
const keys: string[] = [];
|
|
216
|
+
for (const key of [meta.eventId, meta.channelRef, meta.mxcUri])
|
|
217
|
+
if (key !== undefined && key.length > 0) keys.push(key);
|
|
218
|
+
return keys;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The human messages of the newest `ATTACHMENT_RECALL_TURNS` user turns
|
|
223
|
+
* whose image bytes were left out of storage, newest first, with those
|
|
224
|
+
* images: each file once across the request, none that a message of the
|
|
225
|
+
* request still holds inline, none in `excluded` (by reference).
|
|
226
|
+
*/
|
|
227
|
+
function recallCandidates(
|
|
228
|
+
messages: readonly BaseMessage[],
|
|
229
|
+
excluded: ReadonlySet<string>,
|
|
230
|
+
): RecallCandidate[] {
|
|
231
|
+
const candidates: RecallCandidate[] = [];
|
|
232
|
+
// The files the request already carries as bytes come first, wherever
|
|
233
|
+
// they sit: the turn's own message, a re-attachment message.
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
for (const message of messages) {
|
|
236
|
+
if (message.type !== 'human' || !hasInlinePayload(message)) continue;
|
|
237
|
+
for (const meta of attachmentMetas(message))
|
|
238
|
+
for (const key of attachmentKeys(meta)) seen.add(key);
|
|
239
|
+
}
|
|
240
|
+
let turnsSeen = 0;
|
|
241
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
242
|
+
const message = messages[i]!;
|
|
243
|
+
if (message.type !== 'human') continue;
|
|
244
|
+
const userTurn = !isAttachmentViewMessage(message);
|
|
245
|
+
if (userTurn) turnsSeen += 1;
|
|
246
|
+
// A re-attachment belongs to the user turn before it (not counted yet).
|
|
247
|
+
const turnIndex = userTurn ? turnsSeen : turnsSeen + 1;
|
|
248
|
+
if (turnIndex > ATTACHMENT_RECALL_TURNS) {
|
|
249
|
+
if (userTurn) break;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (hasInlinePayload(message) || !hasPlaceholder(message)) continue;
|
|
253
|
+
const images = attachmentMetas(message).filter((meta) => {
|
|
254
|
+
const ref = attachmentRef(meta);
|
|
255
|
+
if (meta.category !== 'image' || ref === undefined) return false;
|
|
256
|
+
const keys = attachmentKeys(meta);
|
|
257
|
+
if (keys.some((key) => seen.has(key))) return false;
|
|
258
|
+
// An excluded file still claims its keys: an older copy of it under
|
|
259
|
+
// another reference is not recalled in its place.
|
|
260
|
+
for (const key of keys) seen.add(key);
|
|
261
|
+
return !excluded.has(ref);
|
|
262
|
+
});
|
|
263
|
+
if (images.length > 0) candidates.push({ index: i, message, images });
|
|
264
|
+
}
|
|
265
|
+
return candidates;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A copy of the candidate's message with its recalled images in place of
|
|
270
|
+
* its placeholder: the image blocks, then a placeholder for the attachments
|
|
271
|
+
* still left out.
|
|
272
|
+
*/
|
|
273
|
+
function withRecalledImages(
|
|
274
|
+
{ message, images }: RecallCandidate,
|
|
275
|
+
recalled: ReadonlyMap<string, NativeAttachment>,
|
|
276
|
+
): BaseMessage {
|
|
277
|
+
if (!Array.isArray(message.content)) return message;
|
|
278
|
+
const own = new Set(images.map((meta) => attachmentRef(meta)));
|
|
279
|
+
const restored: NativeAttachment[] = [];
|
|
280
|
+
const left: AttachmentMeta[] = [];
|
|
281
|
+
for (const meta of attachmentMetas(message)) {
|
|
282
|
+
const ref = attachmentRef(meta);
|
|
283
|
+
const native =
|
|
284
|
+
ref !== undefined && own.has(ref) ? recalled.get(ref) : undefined;
|
|
285
|
+
if (native) restored.push(native);
|
|
286
|
+
else left.push(meta);
|
|
287
|
+
}
|
|
288
|
+
if (restored.length === 0) return message;
|
|
289
|
+
const content: typeof message.content = [];
|
|
290
|
+
for (const block of message.content) {
|
|
291
|
+
if (!isPlaceholderBlock(block)) {
|
|
292
|
+
content.push(block);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
content.push(...restored.map(nativeContentBlock));
|
|
296
|
+
if (left.length > 0)
|
|
297
|
+
content.push({ type: 'text', text: buildPlaceholderText(left) });
|
|
298
|
+
}
|
|
299
|
+
return new HumanMessage({
|
|
300
|
+
id: message.id,
|
|
301
|
+
content,
|
|
302
|
+
...(message.name !== undefined ? { name: message.name } : {}),
|
|
303
|
+
additional_kwargs: message.additional_kwargs,
|
|
304
|
+
response_metadata: message.response_metadata,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function errorText(error: unknown): string {
|
|
309
|
+
return error instanceof Error ? error.message : String(error);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** How far an error's `cause` chain is walked (one wrap per middleware layer). */
|
|
313
|
+
const MAX_CAUSE_DEPTH = 32;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 4xx statuses that say nothing about the request's content: credentials,
|
|
317
|
+
* billing, a timeout, a rate limit. Dropping the images cannot help them.
|
|
318
|
+
*/
|
|
319
|
+
const NOT_CONTENT_STATUSES = new Set([401, 402, 403, 408, 429]);
|
|
320
|
+
|
|
321
|
+
/** The HTTP status of a provider error or of one it wraps. */
|
|
322
|
+
function providerStatus(error: unknown): number | undefined {
|
|
323
|
+
let current: unknown = error;
|
|
324
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) {
|
|
325
|
+
if (typeof current !== 'object' || current === null) return undefined;
|
|
326
|
+
const status: unknown = Reflect.get(current, 'status');
|
|
327
|
+
if (typeof status === 'number') return status;
|
|
328
|
+
const response: unknown = Reflect.get(current, 'response');
|
|
329
|
+
if (typeof response === 'object' && response !== null) {
|
|
330
|
+
const responseStatus: unknown = Reflect.get(response, 'status');
|
|
331
|
+
if (typeof responseStatus === 'number') return responseStatus;
|
|
332
|
+
}
|
|
333
|
+
current = Reflect.get(current, 'cause');
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Whether the error, or one it wraps, is a context overflow. */
|
|
339
|
+
function isOverflow(error: unknown): boolean {
|
|
340
|
+
let current: unknown = error;
|
|
341
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) {
|
|
342
|
+
if (current === undefined || current === null) return false;
|
|
343
|
+
if (isContextOverflowError(current)) return true;
|
|
344
|
+
if (typeof current !== 'object') return false;
|
|
345
|
+
current = Reflect.get(current, 'cause');
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The status of a provider refusal the recalled images may have caused: a
|
|
352
|
+
* client error (4xx) that is not a context overflow (the context guard's
|
|
353
|
+
* business) and not one of `NOT_CONTENT_STATUSES`. Undefined otherwise.
|
|
354
|
+
*/
|
|
355
|
+
function imageRefusalStatus(error: unknown): number | undefined {
|
|
356
|
+
const status = providerStatus(error);
|
|
357
|
+
if (status === undefined || status < 400 || status >= 500) return undefined;
|
|
358
|
+
if (NOT_CONTENT_STATUSES.has(status) || isOverflow(error)) return undefined;
|
|
359
|
+
return status;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The recall step on its own: `recall(messages)` returns the request's
|
|
364
|
+
* messages with recent images put back (the same array when nothing came
|
|
365
|
+
* back) and the references put back. Holds the run's failures; finished
|
|
366
|
+
* images live in the `RecalledImageCache`.
|
|
367
|
+
*/
|
|
368
|
+
export function createAttachmentRecaller(
|
|
369
|
+
options: AttachmentRecallMiddlewareOptions,
|
|
370
|
+
): {
|
|
371
|
+
recall(messages: BaseMessage[]): Promise<RecallResult>;
|
|
372
|
+
/** Never recall these references again in this run. */
|
|
373
|
+
exclude(refs: readonly string[]): void;
|
|
374
|
+
} {
|
|
375
|
+
const logger = options.logger ?? NOOP_LOGGER;
|
|
376
|
+
const timeoutMs = options.timeoutMs ?? ATTACHMENT_RECALL_TIMEOUT_MS;
|
|
377
|
+
const maxImages = options.maxImages ?? ATTACHMENT_RECALL_MAX_IMAGES;
|
|
378
|
+
const maxImageBytes =
|
|
379
|
+
options.maxImageBytes ?? ATTACHMENT_RECALL_MAX_IMAGE_BYTES;
|
|
380
|
+
const maxTotal = options.maxTotalBytes ?? ATTACHMENT_RECALL_MAX_TOTAL_BYTES;
|
|
381
|
+
const cache = options.cache ?? new RecalledImageCache();
|
|
382
|
+
// References not recalled again this run: a failed or skipped fetch (so
|
|
383
|
+
// it is neither retried nor logged again) and an image the provider
|
|
384
|
+
// refused.
|
|
385
|
+
const excluded = new Set<string>();
|
|
386
|
+
|
|
387
|
+
const fetchImage = async (
|
|
388
|
+
ref: string,
|
|
389
|
+
meta: AttachmentMeta,
|
|
390
|
+
): Promise<NativeAttachment | null> => {
|
|
391
|
+
const cached = cache.get(ref);
|
|
392
|
+
if (cached) return cached;
|
|
393
|
+
if (meta.size !== undefined && meta.size > maxImageBytes) {
|
|
394
|
+
excluded.add(ref);
|
|
395
|
+
logger.warn(
|
|
396
|
+
`[attachments] recall: ${ref} is ${meta.size} bytes, over the ${maxImageBytes}-byte recall limit; the placeholder stays`,
|
|
397
|
+
);
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
401
|
+
const signal = options.signal
|
|
402
|
+
? AbortSignal.any([options.signal, deadline])
|
|
403
|
+
: deadline;
|
|
404
|
+
const started = Date.now();
|
|
405
|
+
try {
|
|
406
|
+
const native = await options.loadImage(meta, {
|
|
407
|
+
signal,
|
|
408
|
+
maxBytes: maxImageBytes,
|
|
409
|
+
});
|
|
410
|
+
if (!native) {
|
|
411
|
+
excluded.add(ref);
|
|
412
|
+
logger.warn(
|
|
413
|
+
`[attachments] recall: ${ref} is not available; the placeholder stays`,
|
|
414
|
+
);
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
logger.log(
|
|
418
|
+
`[attachments] recall: ${ref} re-attached (${native.mimeType}, ${native.base64.length} base64 chars, ${Date.now() - started} ms)`,
|
|
419
|
+
);
|
|
420
|
+
cache.set(ref, native);
|
|
421
|
+
return native;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
excluded.add(ref);
|
|
424
|
+
logger.warn(
|
|
425
|
+
`[attachments] recall: ${ref} failed (${errorText(error)}); the placeholder stays`,
|
|
426
|
+
);
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
exclude(refs) {
|
|
433
|
+
for (const ref of refs) excluded.add(ref);
|
|
434
|
+
},
|
|
435
|
+
async recall(messages) {
|
|
436
|
+
const nothing: RecallResult = { messages, recalled: [] };
|
|
437
|
+
if (!options.imageInput) return nothing;
|
|
438
|
+
const candidates = recallCandidates(messages, excluded);
|
|
439
|
+
if (candidates.length === 0) return nothing;
|
|
440
|
+
|
|
441
|
+
// Newest first, at most `maxImages` across the request.
|
|
442
|
+
const wanted: Array<{ ref: string; meta: AttachmentMeta }> = [];
|
|
443
|
+
for (const candidate of candidates) {
|
|
444
|
+
for (const meta of candidate.images) {
|
|
445
|
+
const ref = attachmentRef(meta);
|
|
446
|
+
if (ref === undefined || wanted.length >= maxImages) continue;
|
|
447
|
+
wanted.push({ ref, meta });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// One at a time: only one download and its base64 are in flight.
|
|
451
|
+
const recalled = new Map<string, NativeAttachment>();
|
|
452
|
+
let total = 0;
|
|
453
|
+
for (const { ref, meta } of wanted) {
|
|
454
|
+
if (options.signal?.aborted) break;
|
|
455
|
+
const native = await fetchImage(ref, meta);
|
|
456
|
+
if (!native) continue;
|
|
457
|
+
if (total + native.base64.length > maxTotal) {
|
|
458
|
+
logger.warn(
|
|
459
|
+
`[attachments] recall: ${ref} left out (${native.base64.length} base64 chars; the request already holds ${total} of ${maxTotal})`,
|
|
460
|
+
);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
total += native.base64.length;
|
|
464
|
+
recalled.set(ref, native);
|
|
465
|
+
}
|
|
466
|
+
if (recalled.size === 0) return nothing;
|
|
467
|
+
|
|
468
|
+
const out = messages.slice();
|
|
469
|
+
for (const candidate of candidates)
|
|
470
|
+
out[candidate.index] = withRecalledImages(candidate, recalled);
|
|
471
|
+
return { messages: out, recalled: [...recalled.keys()] };
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function createAttachmentRecallMiddleware(
|
|
477
|
+
options: AttachmentRecallMiddlewareOptions,
|
|
478
|
+
): AgentMiddleware {
|
|
479
|
+
const logger = options.logger ?? NOOP_LOGGER;
|
|
480
|
+
const recaller = createAttachmentRecaller(options);
|
|
481
|
+
return createMiddleware({
|
|
482
|
+
name: 'AttachmentRecallMiddleware',
|
|
483
|
+
wrapModelCall: async (request, handler) => {
|
|
484
|
+
const { messages, recalled } = await recaller.recall(request.messages);
|
|
485
|
+
if (recalled.length === 0) return handler(request);
|
|
486
|
+
try {
|
|
487
|
+
return await handler({ ...request, messages });
|
|
488
|
+
} catch (error) {
|
|
489
|
+
const status = imageRefusalStatus(error);
|
|
490
|
+
if (status === undefined || options.signal?.aborted) throw error;
|
|
491
|
+
// The images may be what the provider refused (a type, size or
|
|
492
|
+
// dimension it does not take): once more without them, and not
|
|
493
|
+
// again this run, so the turn and its next calls go through.
|
|
494
|
+
recaller.exclude(recalled);
|
|
495
|
+
logger.warn(
|
|
496
|
+
`[attachments] recall: the provider refused a request with ${recalled.length} recalled image(s) (${status}: ${errorText(error).slice(0, 200)}); retrying once without them, not recalled again this run: ${recalled.join(', ')}`,
|
|
497
|
+
);
|
|
498
|
+
return handler(request);
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
}
|
|
@@ -18,6 +18,11 @@ export {
|
|
|
18
18
|
} from './summarization';
|
|
19
19
|
export type { SummarizationMiddlewareOptions } from './summarization';
|
|
20
20
|
export { createByoHistorySanitizerMiddleware } from './byo-history-sanitizer';
|
|
21
|
+
export { createAttachmentRecallMiddleware } from './attachment-recall';
|
|
22
|
+
export type {
|
|
23
|
+
AttachmentRecallMiddlewareOptions,
|
|
24
|
+
RecallImageLoader,
|
|
25
|
+
} from './attachment-recall';
|
|
21
26
|
export {
|
|
22
27
|
createDanglingToolCallRepairMiddleware,
|
|
23
28
|
repairDanglingToolCalls,
|
package/src/do/contracts.ts
CHANGED
|
@@ -248,6 +248,13 @@ export interface OracleWorkerEnv {
|
|
|
248
248
|
* `"ai": { "binding": "AI" }` in the wrangler config.
|
|
249
249
|
*/
|
|
250
250
|
AI?: Ai;
|
|
251
|
+
/**
|
|
252
|
+
* Cloudflare Images binding. Optional: images sent natively to a model are
|
|
253
|
+
* downscaled to a 1568 px long edge as JPEG first (`attachments/resize.ts`); the
|
|
254
|
+
* sandbox keeps the original. Absent = images go as they are. Declare it
|
|
255
|
+
* with `"images": { "binding": "IMAGES" }` in the wrangler config.
|
|
256
|
+
*/
|
|
257
|
+
IMAGES?: ImagesBinding;
|
|
251
258
|
|
|
252
259
|
// --- llm ----------------------------------------------------------------
|
|
253
260
|
OPEN_ROUTER_API_KEY: string;
|
|
@@ -724,6 +731,8 @@ export interface StorageStatus {
|
|
|
724
731
|
uploadedGeneration?: number;
|
|
725
732
|
lastChecksum?: string;
|
|
726
733
|
flushFailures: number;
|
|
734
|
+
/** Set while the owner copy waits for a delegation instead of retrying (reason, since). */
|
|
735
|
+
flushWaitsForDelegation: { reason: string; since: number } | null;
|
|
727
736
|
lastVacuumAt?: number;
|
|
728
737
|
/** True once the user's legacy Matrix copy has been removed or found absent. */
|
|
729
738
|
legacyCleared?: boolean;
|
|
@@ -980,10 +989,9 @@ export interface CreateRoomOptions {
|
|
|
980
989
|
}
|
|
981
990
|
|
|
982
991
|
/**
|
|
983
|
-
* Deadline for one gateway media
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
* cancelling the returned stream.
|
|
992
|
+
* Deadline for one gateway media event lookup (`eventMediaInfo`; the
|
|
993
|
+
* gateway's default applies when omitted). Only the timeout crosses the RPC:
|
|
994
|
+
* an `AbortSignal` cannot.
|
|
987
995
|
*/
|
|
988
996
|
export interface MediaDownloadDeadline {
|
|
989
997
|
timeoutMs?: number;
|
|
@@ -998,22 +1006,20 @@ export interface MediaDownloadDeadline {
|
|
|
998
1006
|
*/
|
|
999
1007
|
export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
1000
1008
|
/**
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1009
|
+
* Where the media of event `eventId` in `roomId` lives, as small JSON: the
|
|
1010
|
+
* gateway fetches and decrypts the EVENT (only it holds the room keys),
|
|
1011
|
+
* never the media. The caller downloads `mxc` from the homeserver itself
|
|
1012
|
+
* (`openHomeserverMedia`, with the plugin device's token) and decrypts it
|
|
1013
|
+
* with `file` (`createAttachmentDecryptor`), so no media byte crosses the
|
|
1014
|
+
* shared gateway object. Bounded by `opts.timeoutMs` (default the media
|
|
1015
|
+
* download deadline). Null when the event is unknown, redacted or carries
|
|
1016
|
+
* no media.
|
|
1006
1017
|
*/
|
|
1007
|
-
|
|
1018
|
+
eventMediaInfo(
|
|
1008
1019
|
roomId: string,
|
|
1009
1020
|
eventId: string,
|
|
1010
1021
|
opts?: MediaDownloadDeadline,
|
|
1011
|
-
): Promise<
|
|
1012
|
-
/** Authenticated media download of an `mxc://` URI, as a stream. */
|
|
1013
|
-
downloadMxcMediaStream(
|
|
1014
|
-
mxc: string,
|
|
1015
|
-
opts?: MediaDownloadDeadline,
|
|
1016
|
-
): Promise<ReadableStream<Uint8Array>>;
|
|
1022
|
+
): Promise<EventMediaInfo | null>;
|
|
1017
1023
|
/** Forcibly reset the gateway object like a platform host drain (debug routes only). The call itself rejects. */
|
|
1018
1024
|
debugAbortObject(): Promise<void>;
|
|
1019
1025
|
/** Stop the bot (operator/debug; `ensureStarted`/cron brings it back). */
|
|
@@ -1040,11 +1046,16 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
1040
1046
|
listOutbox(): Promise<OutboxRow[]>;
|
|
1041
1047
|
/**
|
|
1042
1048
|
* Credentials of the oracle's second, crypto-less device for plugins that
|
|
1043
|
-
* run their own Matrix client (editor, flows)
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1049
|
+
* run their own Matrix client (editor, flows) and for the user objects'
|
|
1050
|
+
* own media transfers (channel file uploads, Matrix media downloads).
|
|
1051
|
+
* Logged in once with the account password, kept in the gateway's
|
|
1052
|
+
* storage; concurrent first calls share one login. A caller whose
|
|
1053
|
+
* request the homeserver answered 401 passes the token it used as
|
|
1054
|
+
* `rejected`: when that is still the cached token, the cache is dropped and
|
|
1055
|
+
* the stored token re-validated (`whoami`), else the device is logged in
|
|
1056
|
+
* again with the same device id — once, for every caller waiting.
|
|
1046
1057
|
*/
|
|
1047
|
-
botCredentials(): Promise<BotCredentials>;
|
|
1058
|
+
botCredentials(opts?: { rejected?: string }): Promise<BotCredentials>;
|
|
1048
1059
|
status(): Promise<GatewayStatus>;
|
|
1049
1060
|
/** Send a text message (encrypted when the room is). Returns the event id. */
|
|
1050
1061
|
sendText(
|
|
@@ -1073,12 +1084,18 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
1073
1084
|
* event id. `txnId` pins the transaction id, as for `sendText`: the
|
|
1074
1085
|
* homeserver deduplicates by it per device, so a send retried after a lost
|
|
1075
1086
|
* response returns the original event instead of posting a second one.
|
|
1087
|
+
* `priority: 'background'` (channel text mirrors, channel file events)
|
|
1088
|
+
* goes through the gateway's small background lane, so such sends never
|
|
1089
|
+
* hold more than a few of the send path's places ahead of a reply
|
|
1090
|
+
* (`interactive`, the default). Content carrying an attachment key
|
|
1091
|
+
* (`file.key`) is refused for a room the gateway does not see as
|
|
1092
|
+
* end-to-end encrypted.
|
|
1076
1093
|
*/
|
|
1077
1094
|
sendEvent(
|
|
1078
1095
|
roomId: string,
|
|
1079
1096
|
type: string,
|
|
1080
1097
|
content: JsonString,
|
|
1081
|
-
opts?: { txnId?: string },
|
|
1098
|
+
opts?: { txnId?: string; priority?: 'interactive' | 'background' },
|
|
1082
1099
|
): Promise<string>;
|
|
1083
1100
|
/** Resolve the canonical user↔oracle room for a user DID (alias lookup, cached). */
|
|
1084
1101
|
resolveUserRoom(
|
|
@@ -1103,7 +1120,7 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
1103
1120
|
): Promise<{ eventId: string }>;
|
|
1104
1121
|
/**
|
|
1105
1122
|
* The latest snapshot for `storageKey` as a stream, as stored (see
|
|
1106
|
-
* `
|
|
1123
|
+
* `MediaStream`), or null when none exists.
|
|
1107
1124
|
*/
|
|
1108
1125
|
downloadUserSnapshotStream(
|
|
1109
1126
|
userDid: string,
|
|
@@ -1198,7 +1215,6 @@ export interface OutboxRow {
|
|
|
1198
1215
|
enqueuedAt: number;
|
|
1199
1216
|
}
|
|
1200
1217
|
|
|
1201
|
-
/** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
|
|
1202
1218
|
/**
|
|
1203
1219
|
* Media handed across the Durable Object boundary as a stream, as stored on
|
|
1204
1220
|
* the homeserver: `file` is present when the event carried an encrypted
|
|
@@ -1214,6 +1230,19 @@ export interface MediaStream {
|
|
|
1214
1230
|
filename?: string;
|
|
1215
1231
|
}
|
|
1216
1232
|
|
|
1233
|
+
/**
|
|
1234
|
+
* Where a media event's file lives (`MatrixGatewayObject.eventMediaInfo`):
|
|
1235
|
+
* its `mxc://` URI and, for an encrypted attachment, the `EncryptedFile`
|
|
1236
|
+
* fields to decrypt it with. `size` is the sender's `info.size` when set.
|
|
1237
|
+
*/
|
|
1238
|
+
export interface EventMediaInfo {
|
|
1239
|
+
mxc: string;
|
|
1240
|
+
file?: EncryptedFileInfo;
|
|
1241
|
+
mimetype?: string;
|
|
1242
|
+
filename?: string;
|
|
1243
|
+
size?: number;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1217
1246
|
/** `MediaStream` of a user's snapshot, plus the media event that carries it. */
|
|
1218
1247
|
export interface SnapshotStream extends MediaStream {
|
|
1219
1248
|
eventId: string;
|
|
@@ -1227,6 +1256,7 @@ export interface GroupRoomInfo {
|
|
|
1227
1256
|
groupLane: boolean;
|
|
1228
1257
|
}
|
|
1229
1258
|
|
|
1259
|
+
/** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
|
|
1230
1260
|
export interface GatewayStatus extends BotStatus {
|
|
1231
1261
|
turns: { inFlight: number; waiting: number };
|
|
1232
1262
|
/** Channel memory of group rooms: messages awaiting compaction, summary chunks, pinned facts. */
|