@ixo/oracle-runtime-workers 0.35.1 → 0.36.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/artifacts/tool.ts +7 -4
- package/src/attachments/pipeline.ts +3 -1
- package/src/attachments/route.ts +11 -0
- package/src/attachments/view.ts +5 -1
- package/src/channels/contract.ts +3 -2
- package/src/channels/turns.ts +27 -9
- package/src/core/env.ts +4 -2
- package/src/core/llm.ts +70 -1
- package/src/core/main-agent.ts +5 -0
- package/src/core/middlewares/result-cap.ts +2 -0
- package/src/core/middlewares/turn-boundary.ts +14 -0
- package/src/delivery/plan.ts +89 -20
- package/src/delivery/profile.ts +25 -20
- package/src/delivery/prompt.ts +33 -7
- package/src/delivery/schema.ts +69 -5
- package/src/delivery/shaper.ts +329 -87
- package/src/delivery/types.ts +48 -8
- package/src/do/contracts.ts +2 -0
- package/src/do/run-coordinator.ts +13 -3
- package/src/do/run-store.ts +53 -3
- package/src/do/transcript.ts +17 -1
- package/src/do/user-oracle-do.ts +51 -3
- package/src/matrix/reply-parts.ts +9 -1
- package/src/plugins/ixo-chain/names/client.ts +7 -3
- package/src/plugins/sandbox/index.ts +1 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +41 -8
- package/src/plugins/sandbox/send-file.ts +302 -0
- package/src/sqlite/blob-codec.ts +16 -31
|
@@ -0,0 +1,302 @@
|
|
|
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
|
+
/** The types a gateway treats specially; anything else is opaque to it. */
|
|
43
|
+
const FILE_MIME: ReadonlyMap<string, string> = new Map([
|
|
44
|
+
['pdf', 'application/pdf'],
|
|
45
|
+
['doc', 'application/msword'],
|
|
46
|
+
[
|
|
47
|
+
'docx',
|
|
48
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
49
|
+
],
|
|
50
|
+
['xls', 'application/vnd.ms-excel'],
|
|
51
|
+
['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
52
|
+
['ppt', 'application/vnd.ms-powerpoint'],
|
|
53
|
+
[
|
|
54
|
+
'pptx',
|
|
55
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
56
|
+
],
|
|
57
|
+
['txt', 'text/plain'],
|
|
58
|
+
['jpg', 'image/jpeg'],
|
|
59
|
+
['jpeg', 'image/jpeg'],
|
|
60
|
+
['png', 'image/png'],
|
|
61
|
+
['md', 'text/markdown'],
|
|
62
|
+
['markdown', 'text/markdown'],
|
|
63
|
+
['csv', 'text/csv'],
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** The MIME type of a filename, from the allow-list of extensions. */
|
|
67
|
+
export function fileMimetype(filename: string): string {
|
|
68
|
+
const dot = filename.lastIndexOf('.');
|
|
69
|
+
const extension = dot >= 0 ? filename.slice(dot + 1).toLowerCase() : '';
|
|
70
|
+
return FILE_MIME.get(extension) ?? 'application/octet-stream';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The name a file is delivered under: the path's last segment, without
|
|
75
|
+
* control, format (bidi, zero-width) or filesystem-reserved characters, cut
|
|
76
|
+
* to `CHANNEL_FILENAME_MAX` code points with its extension kept.
|
|
77
|
+
*/
|
|
78
|
+
export function deliveredFilename(path: string): string {
|
|
79
|
+
const last = path.split(/[/\\]/).filter(Boolean).pop() ?? '';
|
|
80
|
+
const clean = last
|
|
81
|
+
.replace(/[\p{Cc}\p{Cf}:*?"<>|]/gu, '')
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.replace(/^[\s.]+/, '')
|
|
84
|
+
.trimEnd();
|
|
85
|
+
const chars = Array.from(clean);
|
|
86
|
+
if (chars.length === 0) return 'file';
|
|
87
|
+
if (chars.length <= CHANNEL_FILENAME_MAX) return clean;
|
|
88
|
+
const dot = clean.lastIndexOf('.');
|
|
89
|
+
const extension = dot > 0 ? Array.from(clean.slice(dot)) : [];
|
|
90
|
+
// An "extension" as long as the limit is not one worth keeping.
|
|
91
|
+
const tail = extension.length < 32 ? extension : [];
|
|
92
|
+
return [...chars.slice(0, CHANNEL_FILENAME_MAX - tail.length), ...tail].join(
|
|
93
|
+
'',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
98
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* An MCP tool's JSON result, however the adapter handed it over: the JSON
|
|
103
|
+
* text, a text content block, a list of blocks, or already an object.
|
|
104
|
+
* `undefined` when it holds no JSON object.
|
|
105
|
+
*/
|
|
106
|
+
function resultObject(result: unknown): Record<string, unknown> | undefined {
|
|
107
|
+
if (typeof result === 'string') {
|
|
108
|
+
try {
|
|
109
|
+
const parsed: unknown = JSON.parse(result);
|
|
110
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(result)) {
|
|
116
|
+
for (const block of result) {
|
|
117
|
+
const found = resultObject(block);
|
|
118
|
+
if (found) return found;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (!isRecord(result)) return undefined;
|
|
123
|
+
return typeof result.text === 'string' ? resultObject(result.text) : result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function httpsUrl(value: unknown): string | undefined {
|
|
127
|
+
return typeof value === 'string' &&
|
|
128
|
+
ReplyFileSchema.shape.url.safeParse(value).success
|
|
129
|
+
? value
|
|
130
|
+
: undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isUrlValue(value: unknown): boolean {
|
|
134
|
+
return typeof value === 'string' && /^https?:\/\//i.test(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function withoutUrls(value: unknown): unknown {
|
|
138
|
+
if (Array.isArray(value))
|
|
139
|
+
return value
|
|
140
|
+
.filter((item) => !isUrlValue(item))
|
|
141
|
+
.map((item) => withoutUrls(item));
|
|
142
|
+
if (!isRecord(value)) return value;
|
|
143
|
+
return Object.fromEntries(
|
|
144
|
+
Object.entries(value)
|
|
145
|
+
// `expiresIn` is the expiry of the links that just went.
|
|
146
|
+
.filter(
|
|
147
|
+
([key, item]) =>
|
|
148
|
+
!/url$/i.test(key) && key !== 'expiresIn' && !isUrlValue(item),
|
|
149
|
+
)
|
|
150
|
+
.map(([key, item]) => [key, withoutUrls(item)]),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const UNREADABLE_RESULT = JSON.stringify({
|
|
155
|
+
error: 'The result could not be read.',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function jsonWithoutUrls(text: string): string {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.stringify(withoutUrls(JSON.parse(text)), null, 2);
|
|
161
|
+
} catch {
|
|
162
|
+
// Links can only be removed from a structured result, so anything else
|
|
163
|
+
// never reaches the model on a chat surface.
|
|
164
|
+
return UNREADABLE_RESULT;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An artefact lookup's result without its URLs, in the shape it came in. The
|
|
170
|
+
* result is structured data, so this reads fields, not prose: a field whose
|
|
171
|
+
* name ends in `url` goes, and so does any string value that is a link.
|
|
172
|
+
*/
|
|
173
|
+
export function stripArtifactUrls(result: unknown): unknown {
|
|
174
|
+
if (typeof result === 'string') return jsonWithoutUrls(result);
|
|
175
|
+
if (Array.isArray(result)) return result.map(stripArtifactUrls);
|
|
176
|
+
if (!isRecord(result)) return result;
|
|
177
|
+
const stripped = withoutUrls(result) as Record<string, unknown>;
|
|
178
|
+
return typeof result.text === 'string'
|
|
179
|
+
? { ...stripped, text: jsonWithoutUrls(result.text) }
|
|
180
|
+
: stripped;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The handle `send_file` signs through: the upstream presigned-URL tool. */
|
|
184
|
+
export interface PresignTool {
|
|
185
|
+
invoke(input: unknown): Promise<unknown>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const NOT_FOUND = /not found/i;
|
|
189
|
+
|
|
190
|
+
function notFoundError(path: string): string {
|
|
191
|
+
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.`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function failedError(path: string): string {
|
|
195
|
+
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.`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Sign the file and build what the plan delivers. Failures come back as the
|
|
200
|
+
* text the model reads, and are logged by kind only: an upstream error is
|
|
201
|
+
* never repeated, here or to the model, because it could quote a signed URL.
|
|
202
|
+
*/
|
|
203
|
+
async function signFile(
|
|
204
|
+
presign: PresignTool,
|
|
205
|
+
args: z.infer<typeof SendFileArgs>,
|
|
206
|
+
logger: Pick<Logger, 'warn'>,
|
|
207
|
+
): Promise<{ file: ReplyFile; path: string } | { error: string }> {
|
|
208
|
+
const failed = (kind: string, error: string): { error: string } => {
|
|
209
|
+
logger.warn(`[send_file] ${args.path}: ${kind}`);
|
|
210
|
+
return { error };
|
|
211
|
+
};
|
|
212
|
+
let signed: Record<string, unknown> | undefined;
|
|
213
|
+
try {
|
|
214
|
+
signed = resultObject(await presign.invoke({ path: args.path }));
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return error instanceof Error && NOT_FOUND.test(error.message)
|
|
217
|
+
? failed('not found', notFoundError(args.path))
|
|
218
|
+
: failed(
|
|
219
|
+
`lookup failed (${error instanceof Error ? error.name : 'unknown'})`,
|
|
220
|
+
failedError(args.path),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (typeof signed?.error === 'string')
|
|
224
|
+
return NOT_FOUND.test(signed.error)
|
|
225
|
+
? failed('not found', notFoundError(args.path))
|
|
226
|
+
: failed('lookup refused', failedError(args.path));
|
|
227
|
+
const url = httpsUrl(signed?.downloadUrl) ?? httpsUrl(signed?.url);
|
|
228
|
+
if (!url) return failed('no usable link returned', failedError(args.path));
|
|
229
|
+
|
|
230
|
+
const filename = deliveredFilename(args.path);
|
|
231
|
+
const { expiresIn, path, size } = signed ?? {};
|
|
232
|
+
return {
|
|
233
|
+
path: typeof path === 'string' && path ? path : args.path,
|
|
234
|
+
file: {
|
|
235
|
+
url,
|
|
236
|
+
filename,
|
|
237
|
+
mimetype: fileMimetype(filename),
|
|
238
|
+
...(typeof size === 'number' ? { bytes: size } : {}),
|
|
239
|
+
...(args.caption ? { caption: args.caption } : {}),
|
|
240
|
+
...(typeof expiresIn === 'number'
|
|
241
|
+
? { expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() }
|
|
242
|
+
: {}),
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A call's `ToolMessage.artifact`: the part's file plus `path`, the store's
|
|
249
|
+
* normalised path, which identifies the file and never leaves the runtime.
|
|
250
|
+
*/
|
|
251
|
+
const SentFileSchema = ReplyFileSchema.extend({ path: z.string().min(1) });
|
|
252
|
+
|
|
253
|
+
/** What one call delivers, from its `ToolMessage.artifact`; null for none. */
|
|
254
|
+
export function sentFile(
|
|
255
|
+
artifact: unknown,
|
|
256
|
+
): { path: string; file: ReplyFile } | null {
|
|
257
|
+
const parsed = SentFileSchema.safeParse(artifact);
|
|
258
|
+
if (!parsed.success) return null;
|
|
259
|
+
const { path, ...file } = parsed.data;
|
|
260
|
+
return { path, file };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Bound on chat surfaces only, by the sandbox plugin. It carries the signed
|
|
265
|
+
* link in `ToolMessage.artifact`, which the checkpoint keeps and the plan
|
|
266
|
+
* reads, and which no chat model converter sends: the model reads the
|
|
267
|
+
* content, which never holds the link. Runs again after a reset
|
|
268
|
+
* (`effect: 'read'`); a new signature for the same file is fine.
|
|
269
|
+
* `attachments`: whether the surface delivers a file as an attachment.
|
|
270
|
+
*/
|
|
271
|
+
export function buildSendFileTool(
|
|
272
|
+
presign: PresignTool,
|
|
273
|
+
attachments: boolean,
|
|
274
|
+
): PluginTool {
|
|
275
|
+
return tool(
|
|
276
|
+
async (rawArgs, ctx) => {
|
|
277
|
+
const args = SendFileArgs.parse(rawArgs);
|
|
278
|
+
const signed = await signFile(presign, args, ctx.logger);
|
|
279
|
+
if ('error' in signed) return { ok: false, error: signed.error };
|
|
280
|
+
const { filename, mimetype } = signed.file;
|
|
281
|
+
const artifact = { ...signed.file, path: signed.path };
|
|
282
|
+
return new ToolMessage({
|
|
283
|
+
tool_call_id: ctx.toolCallId ?? '',
|
|
284
|
+
name: SEND_FILE_TOOL,
|
|
285
|
+
content: JSON.stringify({
|
|
286
|
+
ok: true,
|
|
287
|
+
filename,
|
|
288
|
+
mimetype,
|
|
289
|
+
delivery:
|
|
290
|
+
'Attached to your reply: it goes out when the reply does. Do not paste a link.',
|
|
291
|
+
}),
|
|
292
|
+
artifact,
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: SEND_FILE_TOOL,
|
|
297
|
+
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: a Word document, PDF, spreadsheet, slide deck, image, CSV or anything else. 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.`,
|
|
298
|
+
schema: SendFileArgs,
|
|
299
|
+
effect: 'read',
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
}
|
package/src/sqlite/blob-codec.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
50
|
+
return Promise.resolve(new Uint8Array(gunzipSync(bytes)));
|
|
66
51
|
}
|
|
67
52
|
|
|
68
53
|
/**
|