@ixo/oracle-runtime-workers 0.37.2 → 0.39.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.
Files changed (111) hide show
  1. package/package.json +4 -4
  2. package/src/attachments/channel-links.ts +74 -7
  3. package/src/attachments/channel.ts +15 -4
  4. package/src/attachments/content-blocks.ts +19 -16
  5. package/src/attachments/download.ts +173 -50
  6. package/src/attachments/index.ts +6 -5
  7. package/src/attachments/pipeline.ts +117 -49
  8. package/src/attachments/resize.ts +142 -0
  9. package/src/attachments/retention.ts +26 -70
  10. package/src/attachments/room-media.ts +643 -0
  11. package/src/attachments/route.ts +47 -4
  12. package/src/attachments/sandbox-archive.ts +15 -4
  13. package/src/attachments/sanitize.ts +24 -13
  14. package/src/attachments/view.ts +82 -38
  15. package/src/channels/replies.ts +8 -4
  16. package/src/core/capability-expiry.ts +62 -0
  17. package/src/core/capability-router.ts +126 -95
  18. package/src/core/context-budget.ts +116 -4
  19. package/src/core/domain-context/document.ts +74 -2
  20. package/src/core/domain-context/index.ts +1 -0
  21. package/src/core/domain-context/resolver.ts +9 -6
  22. package/src/core/domain-context/turn-context.ts +62 -13
  23. package/src/core/domain-context/types.ts +17 -0
  24. package/src/core/env.ts +21 -2
  25. package/src/core/index.ts +4 -0
  26. package/src/core/llm.ts +29 -0
  27. package/src/core/main-agent-types.ts +53 -4
  28. package/src/core/main-agent.ts +228 -125
  29. package/src/core/manifest.ts +109 -24
  30. package/src/core/mcp-result.ts +56 -0
  31. package/src/core/meta-tools.ts +258 -110
  32. package/src/core/middlewares/attachment-recall.ts +502 -0
  33. package/src/core/middlewares/capability-gate.ts +77 -28
  34. package/src/core/middlewares/context-guard.ts +154 -21
  35. package/src/core/middlewares/index.ts +5 -0
  36. package/src/core/middlewares/result-cap.ts +1 -1
  37. package/src/core/middlewares/summarization.ts +9 -0
  38. package/src/core/middlewares/tool-marks.ts +11 -2
  39. package/src/core/middlewares/turn-boundary.ts +22 -12
  40. package/src/core/operating-guides.ts +126 -0
  41. package/src/core/plugins/skills/skills-tools.ts +6 -31
  42. package/src/core/plugins/skills/skills.plugin.ts +4 -3
  43. package/src/core/plugins/weather/weather.plugin.ts +1 -0
  44. package/src/core/prompt-composer.ts +188 -111
  45. package/src/core/read-result-tool.ts +48 -14
  46. package/src/core/registries.ts +13 -5
  47. package/src/core/state.ts +4 -3
  48. package/src/core/sub-agent-fallback.ts +1 -0
  49. package/src/core/subagent-as-tool.ts +4 -1
  50. package/src/core/tool-access.ts +3 -32
  51. package/src/core/wrap-plugin-tool.ts +2 -1
  52. package/src/do/contracts.ts +64 -24
  53. package/src/do/room-mirror.ts +144 -20
  54. package/src/do/transcript.ts +41 -12
  55. package/src/do/turn-attachments.ts +45 -6
  56. package/src/do/turn-metadata.ts +16 -4
  57. package/src/do/user-oracle-do.ts +639 -152
  58. package/src/index.ts +1 -0
  59. package/src/llm/provider-error.ts +5 -2
  60. package/src/llm/stream-liveness.ts +3 -2
  61. package/src/matrix/gateway-do.ts +164 -102
  62. package/src/matrix/ingest.ts +12 -3
  63. package/src/matrix/media-deadline.ts +5 -47
  64. package/src/memory/session-history-indexer.ts +60 -14
  65. package/src/plugin-api/oracle-plugin.ts +4 -5
  66. package/src/plugin-api/types.ts +32 -3
  67. package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
  68. package/src/plugins/agui/agui.plugin.ts +1 -0
  69. package/src/plugins/attachments/attachments.plugin.ts +3 -3
  70. package/src/plugins/attachments/view-attachment-tool.ts +6 -4
  71. package/src/plugins/compact-schema.ts +80 -0
  72. package/src/plugins/composio/composio.plugin.ts +1 -0
  73. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
  74. package/src/plugins/editor/editor.plugin.ts +4 -0
  75. package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
  76. package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
  77. package/src/plugins/flows/flows.plugin.ts +37 -6
  78. package/src/plugins/flows/prompts.ts +1 -1
  79. package/src/plugins/flows/tools/authoring.ts +5 -7
  80. package/src/plugins/flows/tools/discovery.ts +1 -4
  81. package/src/plugins/flows/tools/forms.ts +2 -8
  82. package/src/plugins/flows/tools/linkage.ts +1 -4
  83. package/src/plugins/flows/tools/settings.ts +1 -4
  84. package/src/plugins/flows/translator.ts +16 -5
  85. package/src/plugins/flows/types.ts +27 -48
  86. package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
  87. package/src/plugins/index.ts +2 -2
  88. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
  89. package/src/plugins/ixo-chain/kinds/core.ts +2 -2
  90. package/src/plugins/ixo-chain/kinds/types.ts +34 -5
  91. package/src/plugins/ixo-chain/queries.ts +10 -1
  92. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
  93. package/src/plugins/matrix-group-chats/index.ts +1 -0
  94. package/src/plugins/memory/memory-tools.ts +4 -4
  95. package/src/plugins/memory/memory.plugin.ts +27 -1
  96. package/src/plugins/memory/oracle-document-tools.ts +24 -49
  97. package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
  98. package/src/plugins/portal/portal.plugin.ts +1 -0
  99. package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
  100. package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
  101. package/src/plugins/tasks/manifest.ts +1 -0
  102. package/src/plugins/tasks/tasks-tools.ts +14 -19
  103. package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
  104. package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
  105. package/src/plugins/vfs/vfs-tools.ts +6 -6
  106. package/src/plugins/vfs/vfs.plugin.ts +4 -5
  107. package/src/plugins/yoma/yoma.plugin.ts +1 -0
  108. package/src/sqlite/inline-payloads.ts +121 -0
  109. package/src/sqlite/serialization.ts +12 -0
  110. package/src/sqlite/sqlite-saver.ts +258 -42
  111. package/src/tasks/schedule-input.ts +5 -22
@@ -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
+ }
@@ -1,8 +1,10 @@
1
1
  import { ToolMessage } from '@langchain/core/messages';
2
+ import { Command, ReducedValue, StateSchema } from '@langchain/langgraph';
2
3
  import { type AgentMiddleware, createMiddleware } from 'langchain';
3
4
  import { z } from 'zod';
4
5
  import type { Logger, PluginManifest } from '../../plugin-api/types';
5
6
  import { describeRequirements, type CapabilityRequirement } from '../manifest';
7
+ import type { GateLoadResult } from '../meta-tools';
6
8
  import { NOOP_LOGGER } from '../utils';
7
9
 
8
10
  type Visibility = NonNullable<PluginManifest['visibility']>;
@@ -10,6 +12,43 @@ type Visibility = NonNullable<PluginManifest['visibility']>;
10
12
  /** `response_metadata` key the gate sets on its refusals (never sent to a provider). */
11
13
  const REFUSED = 'capabilityGateRefused';
12
14
 
15
+ const pluginSetUpdate = z
16
+ .union([z.array(z.string()), z.object({ replace: z.array(z.string()) })])
17
+ .optional();
18
+
19
+ /**
20
+ * What a write to a channel of plugin names does to it: a list adds to the set
21
+ * (`load_capability`, the editor context; two loads in one model step both
22
+ * land), `{ replace }` sets it (a turn that unloads idle plugins, a summary).
23
+ */
24
+ function nextPluginSet(
25
+ current: string[] | undefined,
26
+ update: z.infer<typeof pluginSetUpdate>,
27
+ ): string[] | undefined {
28
+ if (update === undefined) return current;
29
+ if (!Array.isArray(update)) return update.replace;
30
+ return Array.from(new Set([...(current ?? []), ...update]));
31
+ }
32
+
33
+ /**
34
+ * The graph channels of the loaded plugins, of the plugins whose usage the
35
+ * thread's messages carry, and of the plugins whose operating guides the
36
+ * system prompt carries (`operating-guides.ts`; last value wins). `createAgent`
37
+ * builds its channels from the middlewares' state schemas: a reducer declared
38
+ * anywhere else never runs.
39
+ */
40
+ const capabilityGateState = new StateSchema({
41
+ loadedPlugins: new ReducedValue(z.array(z.string()).optional(), {
42
+ inputSchema: pluginSetUpdate,
43
+ reducer: nextPluginSet,
44
+ }),
45
+ capabilityUsage: new ReducedValue(z.array(z.string()).optional(), {
46
+ inputSchema: pluginSetUpdate,
47
+ reducer: nextPluginSet,
48
+ }),
49
+ promptGuides: z.array(z.string()).optional(),
50
+ });
51
+
13
52
  /**
14
53
  * A ToolMessage the gate produced instead of running the tool. The tool never
15
54
  * ran, so it is not a failure: the repetition guard must let the same call
@@ -32,15 +71,10 @@ export interface CapabilityGateMiddlewareOptions {
32
71
  * middleware does not need access to the registries at runtime.
33
72
  */
34
73
  visibilityByToolName: Map<string, Visibility>;
35
- /**
36
- * Plugins the capability router preloaded for this turn. Their on-demand
37
- * tools pass the gate as if loaded, without touching `state.loadedPlugins`.
38
- */
39
- preloadedPlugins?: ReadonlySet<string>;
40
74
  /**
41
75
  * Plugin name → the `manifest.requires` capabilities the user's delegation
42
76
  * does not grant. Such a plugin's tools are hidden and refused whatever
43
- * their visibility, loaded or preloaded.
77
+ * their visibility, loaded or not.
44
78
  */
45
79
  unmetRequirements?: ReadonlyMap<string, readonly CapabilityRequirement[]>;
46
80
  /**
@@ -50,6 +84,12 @@ export interface CapabilityGateMiddlewareOptions {
50
84
  * cannot make it available.
51
85
  */
52
86
  withheldToolNames?: ReadonlySet<string>;
87
+ /**
88
+ * What the model should read about `plugin` once a hidden tool call loaded
89
+ * it (`CapabilityLoader.describeForGate`): usage, tool names, its operating
90
+ * guide. Called once per such call.
91
+ */
92
+ describeLoad: (plugin: string) => GateLoadResult;
53
93
  /** Optional logger; defaults to a no-op. */
54
94
  logger?: Logger;
55
95
  }
@@ -74,13 +114,18 @@ export interface CapabilityGateMiddlewareOptions {
74
114
  *
75
115
  * This is capability discovery, not authorization: any non-silent plugin can
76
116
  * be loaded with `load_capability`. What the gate guarantees is that an
77
- * on-demand tool runs only after that load (or the turn's preload) is
78
- * visible in the thread.
117
+ * on-demand tool runs only after that load (or the router's, in the turn's
118
+ * graph input) is visible in the thread.
119
+ *
120
+ * A call to a hidden tool still never runs, whatever its effect: the model
121
+ * has not seen the tool's schema, so its arguments are a guess. Instead the
122
+ * gate loads the capability on that call, so the refusal costs one step, the
123
+ * call again with the schema now in view, not a `load_capability` step too.
79
124
  */
80
125
  export const createCapabilityGateMiddleware = (
81
126
  options: CapabilityGateMiddlewareOptions,
82
127
  ): AgentMiddleware => {
83
- const { pluginByToolName, visibilityByToolName, preloadedPlugins } = options;
128
+ const { pluginByToolName, visibilityByToolName } = options;
84
129
  const logger = options.logger ?? NOOP_LOGGER;
85
130
 
86
131
  /** What the user's authorization lacks for `toolName`'s plugin, or null. */
@@ -102,16 +147,12 @@ export const createCapabilityGateMiddleware = (
102
147
  if (!plugin) return null;
103
148
  const viz = visibilityByToolName.get(toolName) ?? 'on-demand';
104
149
  if (viz === 'always' || viz === 'silent') return null;
105
- if (loaded.has(plugin) || preloadedPlugins?.has(plugin) === true)
106
- return null;
107
- return plugin;
150
+ return loaded.has(plugin) ? null : plugin;
108
151
  };
109
152
 
110
153
  return createMiddleware({
111
154
  name: 'CapabilityGateMiddleware',
112
- stateSchema: z.object({
113
- loadedPlugins: z.array(z.string()).optional(),
114
- }),
155
+ stateSchema: capabilityGateState,
115
156
  // No `contextSchema`: the gate reads only `state.loadedPlugins`, and
116
157
  // declaring one would make LangChain reject every invocation that omits
117
158
  // `context` — the tool wrapper already carries the request context.
@@ -132,10 +173,7 @@ export const createCapabilityGateMiddleware = (
132
173
 
133
174
  if (filtered.length !== request.tools.length) {
134
175
  logger.log(
135
- `[CapabilityGateMiddleware] exposed ${filtered.length}/${request.tools.length} tools; loadedPlugins=${Array.from(loaded).join(',') || '∅'}` +
136
- (preloadedPlugins?.size
137
- ? ` preloadedPlugins=${Array.from(preloadedPlugins).join(',')}`
138
- : ''),
176
+ `[CapabilityGateMiddleware] exposed ${filtered.length}/${request.tools.length} tools; loadedPlugins=${Array.from(loaded).join(',') || '∅'}`,
139
177
  );
140
178
  }
141
179
 
@@ -178,16 +216,27 @@ export const createCapabilityGateMiddleware = (
178
216
  );
179
217
  if (plugin === null) return handler(request);
180
218
  logger.warn(
181
- `[CapabilityGateMiddleware] refused a call to ${name}: capability '${plugin}' is not loaded in this thread`,
219
+ `[CapabilityGateMiddleware] refused a call to ${name} and loaded capability '${plugin}'`,
182
220
  );
183
- return new ToolMessage({
184
- content:
185
- `Tool "${name}" is not available: it belongs to the "${plugin}" capability, which is not loaded in this conversation, so the call was not run. ` +
186
- `If the user's request needs it, call load_capability with names ["${plugin}"] first, then call the tool again.`,
187
- tool_call_id: request.toolCall.id ?? '',
188
- name,
189
- status: 'error',
190
- response_metadata: { [REFUSED]: true },
221
+ const load = options.describeLoad(plugin);
222
+ return new Command({
223
+ update: {
224
+ loadedPlugins: [plugin],
225
+ capabilityUsage: [plugin],
226
+ messages: [
227
+ new ToolMessage({
228
+ content:
229
+ `Tool "${name}" was not run: it belongs to the "${plugin}" capability, which was not loaded. ` +
230
+ `It is loaded now, so the tool is in your tool list from your next step. If the user's request needs it, check the arguments against its schema and call it again.\n\n` +
231
+ load.text,
232
+ tool_call_id: request.toolCall.id ?? '',
233
+ name,
234
+ status: 'error',
235
+ additional_kwargs: load.additional_kwargs,
236
+ response_metadata: { [REFUSED]: true },
237
+ }),
238
+ ],
239
+ },
191
240
  });
192
241
  },
193
242
  });