@ixo/oracle-runtime-workers 0.35.1 → 0.36.1

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.
@@ -0,0 +1,315 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
2
+ import { z } from 'zod';
3
+ import { CHANNEL_FILENAME_MAX } from '../../channels/contract';
4
+ import { ReplyFileSchema } from '../../delivery/schema';
5
+ import type { ReplyFile } from '../../delivery/types';
6
+ import { tool } from '../../plugin-api/tool-helper';
7
+ import type { Logger, PluginTool } from '../../plugin-api/types';
8
+
9
+ export const SEND_FILE_TOOL = 'send_file';
10
+
11
+ /** The upstream tool that signs a stored file's link. */
12
+ export const PRESIGNED_URL_TOOL = 'artifact_get_presigned_url';
13
+
14
+ /** Upstream tools that return signed links next to a file's metadata. */
15
+ export const URL_RESULT_TOOLS: ReadonlySet<string> = new Set([
16
+ 'artifact_get_by_path',
17
+ 'artifact_list',
18
+ 'artifact_get_by_cid',
19
+ ]);
20
+
21
+ /** Appended to those tools' descriptions on a chat surface. */
22
+ export const CHAT_URL_NOTE =
23
+ 'In this chat the URL fields are removed from the result and `artifact_get_presigned_url` does not exist: deliver a file to the user with `send_file`.';
24
+
25
+ export const SendFileArgs = z.object({
26
+ path: z
27
+ .string()
28
+ .trim()
29
+ .min(1)
30
+ .max(1024)
31
+ .describe(
32
+ 'The stored path of the file, for example `/output/report.docx`.',
33
+ ),
34
+ caption: z
35
+ .string()
36
+ .trim()
37
+ .max(1024)
38
+ .optional()
39
+ .describe('A short line shown with the file, at most 1,024 characters.'),
40
+ });
41
+
42
+ /**
43
+ * What `send_file` sends, by extension, with its MIME type: the types a chat
44
+ * gateway delivers. A gateway refuses any other (the legacy `.doc`, `.xls`
45
+ * and `.ppt` included), so the tool refuses it before signing anything.
46
+ */
47
+ const FILE_MIME: ReadonlyMap<string, string> = new Map([
48
+ ['pdf', 'application/pdf'],
49
+ [
50
+ 'docx',
51
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
52
+ ],
53
+ ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
54
+ [
55
+ 'pptx',
56
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
57
+ ],
58
+ ['txt', 'text/plain'],
59
+ ['jpg', 'image/jpeg'],
60
+ ['jpeg', 'image/jpeg'],
61
+ ['png', 'image/png'],
62
+ ['md', 'text/markdown'],
63
+ ['markdown', 'text/markdown'],
64
+ ['csv', 'text/csv'],
65
+ ]);
66
+
67
+ /** The types in `FILE_MIME`, as the tool description and the prompt name them. */
68
+ export const SENDABLE_FILE_TYPES =
69
+ 'PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx), text, CSV, Markdown, PNG or JPEG';
70
+
71
+ /** The MIME type of a filename `send_file` may send; `undefined` for any other. */
72
+ export function fileMimetype(filename: string): string | undefined {
73
+ const dot = filename.lastIndexOf('.');
74
+ const extension = dot >= 0 ? filename.slice(dot + 1).toLowerCase() : '';
75
+ return FILE_MIME.get(extension);
76
+ }
77
+
78
+ /**
79
+ * The name a file is delivered under: the path's last segment, without
80
+ * control, format (bidi, zero-width) or filesystem-reserved characters, cut
81
+ * to `CHANNEL_FILENAME_MAX` code points with its extension kept.
82
+ */
83
+ export function deliveredFilename(path: string): string {
84
+ const last = path.split(/[/\\]/).filter(Boolean).pop() ?? '';
85
+ const clean = last
86
+ .replace(/[\p{Cc}\p{Cf}:*?"<>|]/gu, '')
87
+ .replace(/\s+/g, ' ')
88
+ .replace(/^[\s.]+/, '')
89
+ .trimEnd();
90
+ const chars = Array.from(clean);
91
+ if (chars.length === 0) return 'file';
92
+ if (chars.length <= CHANNEL_FILENAME_MAX) return clean;
93
+ const dot = clean.lastIndexOf('.');
94
+ const extension = dot > 0 ? Array.from(clean.slice(dot)) : [];
95
+ // An "extension" as long as the limit is not one worth keeping.
96
+ const tail = extension.length < 32 ? extension : [];
97
+ return [...chars.slice(0, CHANNEL_FILENAME_MAX - tail.length), ...tail].join(
98
+ '',
99
+ );
100
+ }
101
+
102
+ function isRecord(value: unknown): value is Record<string, unknown> {
103
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
104
+ }
105
+
106
+ /**
107
+ * An MCP tool's JSON result, however the adapter handed it over: the JSON
108
+ * text, a text content block, a list of blocks, or already an object.
109
+ * `undefined` when it holds no JSON object.
110
+ */
111
+ function resultObject(result: unknown): Record<string, unknown> | undefined {
112
+ if (typeof result === 'string') {
113
+ try {
114
+ const parsed: unknown = JSON.parse(result);
115
+ return isRecord(parsed) ? parsed : undefined;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ if (Array.isArray(result)) {
121
+ for (const block of result) {
122
+ const found = resultObject(block);
123
+ if (found) return found;
124
+ }
125
+ return undefined;
126
+ }
127
+ if (!isRecord(result)) return undefined;
128
+ return typeof result.text === 'string' ? resultObject(result.text) : result;
129
+ }
130
+
131
+ function httpsUrl(value: unknown): string | undefined {
132
+ return typeof value === 'string' &&
133
+ ReplyFileSchema.shape.url.safeParse(value).success
134
+ ? value
135
+ : undefined;
136
+ }
137
+
138
+ function isUrlValue(value: unknown): boolean {
139
+ return typeof value === 'string' && /^https?:\/\//i.test(value);
140
+ }
141
+
142
+ function withoutUrls(value: unknown): unknown {
143
+ if (Array.isArray(value))
144
+ return value
145
+ .filter((item) => !isUrlValue(item))
146
+ .map((item) => withoutUrls(item));
147
+ if (!isRecord(value)) return value;
148
+ return Object.fromEntries(
149
+ Object.entries(value)
150
+ // `expiresIn` is the expiry of the links that just went.
151
+ .filter(
152
+ ([key, item]) =>
153
+ !/url$/i.test(key) && key !== 'expiresIn' && !isUrlValue(item),
154
+ )
155
+ .map(([key, item]) => [key, withoutUrls(item)]),
156
+ );
157
+ }
158
+
159
+ const UNREADABLE_RESULT = JSON.stringify({
160
+ error: 'The result could not be read.',
161
+ });
162
+
163
+ function jsonWithoutUrls(text: string): string {
164
+ try {
165
+ return JSON.stringify(withoutUrls(JSON.parse(text)), null, 2);
166
+ } catch {
167
+ // Links can only be removed from a structured result, so anything else
168
+ // never reaches the model on a chat surface.
169
+ return UNREADABLE_RESULT;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * An artefact lookup's result without its URLs, in the shape it came in. The
175
+ * result is structured data, so this reads fields, not prose: a field whose
176
+ * name ends in `url` goes, and so does any string value that is a link.
177
+ */
178
+ export function stripArtifactUrls(result: unknown): unknown {
179
+ if (typeof result === 'string') return jsonWithoutUrls(result);
180
+ if (Array.isArray(result)) return result.map(stripArtifactUrls);
181
+ if (!isRecord(result)) return result;
182
+ const stripped = withoutUrls(result) as Record<string, unknown>;
183
+ return typeof result.text === 'string'
184
+ ? { ...stripped, text: jsonWithoutUrls(result.text) }
185
+ : stripped;
186
+ }
187
+
188
+ /** The handle `send_file` signs through: the upstream presigned-URL tool. */
189
+ export interface PresignTool {
190
+ invoke(input: unknown): Promise<unknown>;
191
+ }
192
+
193
+ const NOT_FOUND = /not found/i;
194
+
195
+ function notFoundError(path: string): string {
196
+ return `No stored file at \`${path}\`. A file is stored once the sandbox call that wrote it under \`/workspace/data/output/\` has finished. Check the path with \`artifact_list\`, or write the file first, then call \`send_file\` again.`;
197
+ }
198
+
199
+ function unsupportedTypeError(path: string): string {
200
+ return `Cannot send \`${path}\`. This chat can only receive ${SENDABLE_FILE_TYPES} files. Convert the file, or describe its contents instead.`;
201
+ }
202
+
203
+ function failedError(path: string): string {
204
+ return `Could not prepare \`${path}\` for sending. Try once more; if it fails again, tell the user the file could not be sent. Do not offer a link.`;
205
+ }
206
+
207
+ /**
208
+ * Sign the file and build what the plan delivers. Failures come back as the
209
+ * text the model reads, and are logged by kind only: an upstream error is
210
+ * never repeated, here or to the model, because it could quote a signed URL.
211
+ */
212
+ async function signFile(
213
+ presign: PresignTool,
214
+ args: z.infer<typeof SendFileArgs>,
215
+ logger: Pick<Logger, 'warn'>,
216
+ ): Promise<{ file: ReplyFile; path: string } | { error: string }> {
217
+ const failed = (kind: string, error: string): { error: string } => {
218
+ logger.warn(`[send_file] ${args.path}: ${kind}`);
219
+ return { error };
220
+ };
221
+ // Checked before signing: a file of another type is never signed.
222
+ const filename = deliveredFilename(args.path);
223
+ const mimetype = fileMimetype(filename);
224
+ if (!mimetype)
225
+ return failed('unsupported type', unsupportedTypeError(args.path));
226
+ let signed: Record<string, unknown> | undefined;
227
+ try {
228
+ signed = resultObject(await presign.invoke({ path: args.path }));
229
+ } catch (error) {
230
+ return error instanceof Error && NOT_FOUND.test(error.message)
231
+ ? failed('not found', notFoundError(args.path))
232
+ : failed(
233
+ `lookup failed (${error instanceof Error ? error.name : 'unknown'})`,
234
+ failedError(args.path),
235
+ );
236
+ }
237
+ if (typeof signed?.error === 'string')
238
+ return NOT_FOUND.test(signed.error)
239
+ ? failed('not found', notFoundError(args.path))
240
+ : failed('lookup refused', failedError(args.path));
241
+ const url = httpsUrl(signed?.downloadUrl) ?? httpsUrl(signed?.url);
242
+ if (!url) return failed('no usable link returned', failedError(args.path));
243
+
244
+ const { expiresIn, path, size } = signed ?? {};
245
+ return {
246
+ path: typeof path === 'string' && path ? path : args.path,
247
+ file: {
248
+ url,
249
+ filename,
250
+ mimetype,
251
+ ...(typeof size === 'number' ? { bytes: size } : {}),
252
+ ...(args.caption ? { caption: args.caption } : {}),
253
+ ...(typeof expiresIn === 'number'
254
+ ? { expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() }
255
+ : {}),
256
+ },
257
+ };
258
+ }
259
+
260
+ /**
261
+ * A call's `ToolMessage.artifact`: the part's file plus `path`, the store's
262
+ * normalised path, which identifies the file and never leaves the runtime.
263
+ */
264
+ const SentFileSchema = ReplyFileSchema.extend({ path: z.string().min(1) });
265
+
266
+ /** What one call delivers, from its `ToolMessage.artifact`; null for none. */
267
+ export function sentFile(
268
+ artifact: unknown,
269
+ ): { path: string; file: ReplyFile } | null {
270
+ const parsed = SentFileSchema.safeParse(artifact);
271
+ if (!parsed.success) return null;
272
+ const { path, ...file } = parsed.data;
273
+ return { path, file };
274
+ }
275
+
276
+ /**
277
+ * Bound on chat surfaces only, by the sandbox plugin. It carries the signed
278
+ * link in `ToolMessage.artifact`, which the checkpoint keeps and the plan
279
+ * reads, and which no chat model converter sends: the model reads the
280
+ * content, which never holds the link. Runs again after a reset
281
+ * (`effect: 'read'`); a new signature for the same file is fine.
282
+ * `attachments`: whether the surface delivers a file as an attachment.
283
+ */
284
+ export function buildSendFileTool(
285
+ presign: PresignTool,
286
+ attachments: boolean,
287
+ ): PluginTool {
288
+ return tool(
289
+ async (rawArgs, ctx) => {
290
+ const args = SendFileArgs.parse(rawArgs);
291
+ const signed = await signFile(presign, args, ctx.logger);
292
+ if ('error' in signed) return { ok: false, error: signed.error };
293
+ const { filename, mimetype } = signed.file;
294
+ const artifact = { ...signed.file, path: signed.path };
295
+ return new ToolMessage({
296
+ tool_call_id: ctx.toolCallId ?? '',
297
+ name: SEND_FILE_TOOL,
298
+ content: JSON.stringify({
299
+ ok: true,
300
+ filename,
301
+ mimetype,
302
+ delivery:
303
+ 'Attached to your reply: it goes out when the reply does. Do not paste a link.',
304
+ }),
305
+ artifact,
306
+ });
307
+ },
308
+ {
309
+ name: SEND_FILE_TOOL,
310
+ description: `Send the user a file ${attachments ? 'as an attachment in this chat' : 'as a link posted in this room'}. Use it for every file meant for the user; it sends ${SENDABLE_FILE_TYPES} files only. Build the file yourself in the sandbox: do not send a file you downloaded from the internet unless the user asked for that exact file. Not for helper scripts or intermediate files. \`path\` is the stored path, for example \`/output/report.docx\`: save the file under \`/workspace/data/output/\` and let that sandbox call finish first. \`caption\` is an optional short line shown with the file. ${attachments ? 'The user receives the file itself' : 'The link is posted for you'}, so never paste a download link and do not explain how to open it. For several files, call it once per file, in the order they should arrive. If it returns an error, follow it, or tell the user the file could not be sent.`,
311
+ schema: SendFileArgs,
312
+ effect: 'read',
313
+ },
314
+ );
315
+ }
@@ -18,6 +18,8 @@
18
18
  * compress(decompress(x)) round-trips bit-exact at the value level.
19
19
  */
20
20
 
21
+ import { gunzipSync, gzipSync } from 'node:zlib';
22
+
21
23
  /** Blobs below this size gain nothing from gzip (header ≈ 20 bytes). */
22
24
  export const MIN_COMPRESS_BYTES = 256;
23
25
 
@@ -28,41 +30,24 @@ export function isGzip(bytes: Uint8Array): boolean {
28
30
  return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
29
31
  }
30
32
 
31
- async function pipeThrough(
32
- bytes: Uint8Array,
33
- transform: TransformStream<Uint8Array, Uint8Array>,
34
- ): Promise<Uint8Array> {
35
- const source = new ReadableStream<Uint8Array>({
36
- start(controller) {
37
- controller.enqueue(bytes);
38
- controller.close();
39
- },
40
- });
41
- const stream = source.pipeThrough(transform);
42
- const chunks: Uint8Array[] = [];
43
- let total = 0;
44
- const reader = stream.getReader();
45
- for (;;) {
46
- const { done, value } = await reader.read();
47
- if (done) break;
48
- chunks.push(value);
49
- total += value.length;
50
- }
51
- const out = new Uint8Array(total);
52
- let offset = 0;
53
- for (const chunk of chunks) {
54
- out.set(chunk, offset);
55
- offset += chunk.length;
56
- }
57
- return out;
58
- }
59
-
33
+ /**
34
+ * One gzip/gunzip of a whole value, through node:zlib's one-shot calls, so
35
+ * the zlib state lives for the call only. A `CompressionStream` /
36
+ * `DecompressionStream` keeps its zlib state (zlib's defaults: about
37
+ * 256 KiB to deflate, about 40 KiB to inflate) outside the JS heap until
38
+ * the stream is collected, and the saver gzips or gunzips every message
39
+ * row of a thread on each checkpoint read and write. On a deployed oracle a
40
+ * turn on a 200-message, 410 KB thread left 6 MiB of the Durable Object's
41
+ * 128 MB with streams and 50 MiB with one-shot calls (an empty thread: 58).
42
+ * Same gzip format either way, so existing rows read back unchanged.
43
+ */
60
44
  export function gzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
61
- return pipeThrough(bytes, new CompressionStream('gzip'));
45
+ // A copy: an exact-size `Uint8Array`, not a `Buffer` view.
46
+ return Promise.resolve(new Uint8Array(gzipSync(bytes)));
62
47
  }
63
48
 
64
49
  export function gunzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
65
- return pipeThrough(bytes, new DecompressionStream('gzip'));
50
+ return Promise.resolve(new Uint8Array(gunzipSync(bytes)));
66
51
  }
67
52
 
68
53
  /**