@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.
- package/package.json +4 -4
- 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/channels/replies.ts +8 -4
- package/src/core/capability-expiry.ts +62 -0
- package/src/core/capability-router.ts +126 -95
- package/src/core/context-budget.ts +116 -4
- package/src/core/domain-context/document.ts +74 -2
- package/src/core/domain-context/index.ts +1 -0
- package/src/core/domain-context/resolver.ts +9 -6
- package/src/core/domain-context/turn-context.ts +62 -13
- package/src/core/domain-context/types.ts +17 -0
- package/src/core/env.ts +21 -2
- package/src/core/index.ts +4 -0
- package/src/core/llm.ts +29 -0
- package/src/core/main-agent-types.ts +53 -4
- package/src/core/main-agent.ts +228 -125
- package/src/core/manifest.ts +109 -24
- package/src/core/mcp-result.ts +56 -0
- package/src/core/meta-tools.ts +258 -110
- package/src/core/middlewares/attachment-recall.ts +502 -0
- package/src/core/middlewares/capability-gate.ts +77 -28
- package/src/core/middlewares/context-guard.ts +154 -21
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/middlewares/result-cap.ts +1 -1
- package/src/core/middlewares/summarization.ts +9 -0
- package/src/core/middlewares/tool-marks.ts +11 -2
- package/src/core/middlewares/turn-boundary.ts +22 -12
- package/src/core/operating-guides.ts +126 -0
- package/src/core/plugins/skills/skills-tools.ts +6 -31
- package/src/core/plugins/skills/skills.plugin.ts +4 -3
- package/src/core/plugins/weather/weather.plugin.ts +1 -0
- package/src/core/prompt-composer.ts +188 -111
- package/src/core/read-result-tool.ts +48 -14
- package/src/core/registries.ts +13 -5
- package/src/core/state.ts +4 -3
- package/src/core/sub-agent-fallback.ts +1 -0
- package/src/core/subagent-as-tool.ts +4 -1
- package/src/core/tool-access.ts +3 -32
- package/src/core/wrap-plugin-tool.ts +2 -1
- package/src/do/contracts.ts +64 -24
- package/src/do/room-mirror.ts +144 -20
- package/src/do/transcript.ts +41 -12
- package/src/do/turn-attachments.ts +45 -6
- package/src/do/turn-metadata.ts +16 -4
- package/src/do/user-oracle-do.ts +639 -152
- package/src/index.ts +1 -0
- 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/memory/session-history-indexer.ts +60 -14
- package/src/plugin-api/oracle-plugin.ts +4 -5
- package/src/plugin-api/types.ts +32 -3
- package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
- package/src/plugins/agui/agui.plugin.ts +1 -0
- package/src/plugins/attachments/attachments.plugin.ts +3 -3
- package/src/plugins/attachments/view-attachment-tool.ts +6 -4
- package/src/plugins/compact-schema.ts +80 -0
- package/src/plugins/composio/composio.plugin.ts +1 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
- package/src/plugins/editor/editor.plugin.ts +4 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
- package/src/plugins/flows/flows.plugin.ts +37 -6
- package/src/plugins/flows/prompts.ts +1 -1
- package/src/plugins/flows/tools/authoring.ts +5 -7
- package/src/plugins/flows/tools/discovery.ts +1 -4
- package/src/plugins/flows/tools/forms.ts +2 -8
- package/src/plugins/flows/tools/linkage.ts +1 -4
- package/src/plugins/flows/tools/settings.ts +1 -4
- package/src/plugins/flows/translator.ts +16 -5
- package/src/plugins/flows/types.ts +27 -48
- package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
- package/src/plugins/index.ts +2 -2
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
- package/src/plugins/ixo-chain/kinds/core.ts +2 -2
- package/src/plugins/ixo-chain/kinds/types.ts +34 -5
- package/src/plugins/ixo-chain/queries.ts +10 -1
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
- package/src/plugins/matrix-group-chats/index.ts +1 -0
- package/src/plugins/memory/memory-tools.ts +4 -4
- package/src/plugins/memory/memory.plugin.ts +27 -1
- package/src/plugins/memory/oracle-document-tools.ts +24 -49
- package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
- package/src/plugins/portal/portal.plugin.ts +1 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
- package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
- package/src/plugins/tasks/manifest.ts +1 -0
- package/src/plugins/tasks/tasks-tools.ts +14 -19
- package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
- package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
- package/src/plugins/vfs/vfs-tools.ts +6 -6
- package/src/plugins/vfs/vfs.plugin.ts +4 -5
- package/src/plugins/yoma/yoma.plugin.ts +1 -0
- package/src/sqlite/inline-payloads.ts +121 -0
- package/src/sqlite/serialization.ts +12 -0
- package/src/sqlite/sqlite-saver.ts +258 -42
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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}
|
|
219
|
+
`[CapabilityGateMiddleware] refused a call to ${name} and loaded capability '${plugin}'`,
|
|
182
220
|
);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
});
|