@zerowidth/workbench-sdk 2.7.1 → 2.8.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/README.md +46 -0
- package/nodes/run-code/run-code.config.json +70 -0
- package/nodes/run-code/run-code.process.js +64 -0
- package/package.json +3 -2
- package/src/index.js +34 -2
- package/src/integrations/code-executor.js +246 -0
- package/src/utilities/loaders.js +7 -0
- package/src/utilities/typers.js +7 -0
package/README.md
CHANGED
|
@@ -540,6 +540,52 @@ Optional store members: `held: true` with `write`/`delete` returning
|
|
|
540
540
|
sub-agent never sees its caller's memory: pass
|
|
541
541
|
`memory.forImport(importId) => ({ instance | path })` to give it its own.
|
|
542
542
|
|
|
543
|
+
### Running Code
|
|
544
|
+
|
|
545
|
+
Attach the **Run Code** tool to a model and it can write and run Python or
|
|
546
|
+
JavaScript: load a CSV, compute something exactly, draw a chart. The engine
|
|
547
|
+
never runs the code itself. You tell it where:
|
|
548
|
+
|
|
549
|
+
```javascript
|
|
550
|
+
// A sandbox service that speaks the HTTP contract below
|
|
551
|
+
await Workbench.create(flow, {
|
|
552
|
+
codeExecutor: { url: "https://sandbox.example.com", apiKey: process.env.SANDBOX_KEY },
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Or your own executor over any sandbox you like
|
|
556
|
+
import { CodeExecutorInterface } from "@zerowidth/workbench-sdk";
|
|
557
|
+
class MySandbox extends CodeExecutorInterface {
|
|
558
|
+
async capabilities() { return { languages: ["python"], sessions: true, files: true }; }
|
|
559
|
+
async openSession() { /* → session id */ }
|
|
560
|
+
async run({ language, code, timeoutMs, session }, { signal }) {
|
|
561
|
+
/* → { stdout, stderr, result?, files?: [{ path, mimeType, data (base64) }], error? } */
|
|
562
|
+
}
|
|
563
|
+
async closeSession(id) { /* … */ }
|
|
564
|
+
}
|
|
565
|
+
await Workbench.create(flow, { codeExecutor: { instance: new MySandbox() } });
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
The HTTP contract is three JSON calls: `POST /sessions` → `{ id }`,
|
|
569
|
+
`POST /run` with `{ language, code, timeoutMs, session? }` → the result above,
|
|
570
|
+
and `DELETE /sessions/:id`. Answer `410` when a session is gone.
|
|
571
|
+
|
|
572
|
+
Each `run()` gets one session, opened on the first call and closed when the
|
|
573
|
+
run ends, so variables and files carry over between the model's calls within
|
|
574
|
+
a run and never into the next run of a reused engine. An imported flow or a
|
|
575
|
+
macro gets a session of its own, the way it gets its own memory; Run Code
|
|
576
|
+
passed into an import as a tool still runs in the caller's session.
|
|
577
|
+
|
|
578
|
+
A failure in the code comes back to the model as output (the traceback is in
|
|
579
|
+
`stderr`) so it can fix it; a time limit, lost session or unreachable sandbox
|
|
580
|
+
comes back as a tool error. The time limit is at most 120 seconds per call.
|
|
581
|
+
Images the code produces (`image/png`, `jpeg`, `gif`, `webp`) reach the model
|
|
582
|
+
as pictures, up to four per call and 5 MB each; every file is listed by path,
|
|
583
|
+
type and size. Printed output and the `result` value are capped at 20,000
|
|
584
|
+
characters each. `signal` is aborted when the run is cancelled or times out.
|
|
585
|
+
|
|
586
|
+
Run the sandbox with no network access and pass it no secrets: the code is
|
|
587
|
+
written by a model.
|
|
588
|
+
|
|
543
589
|
### Custom Node Types
|
|
544
590
|
|
|
545
591
|
Create custom nodes by implementing:
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"display_name": "Run Code",
|
|
3
|
+
"tagline": "Run code in a sandbox",
|
|
4
|
+
"description": "Run Python or JavaScript in a sandbox with no internet access. Variables and files you create stay available to later calls in this run. Print what you want to see: printed output and errors come back to you. A matplotlib figure that is still open when the code finishes comes back as an image. Prefer small steps you can check over one long script.",
|
|
5
|
+
"icon": "code",
|
|
6
|
+
"category": "data",
|
|
7
|
+
"is_constant": false,
|
|
8
|
+
"is_plugin": true,
|
|
9
|
+
"is_output": false,
|
|
10
|
+
"inputs": [
|
|
11
|
+
{
|
|
12
|
+
"name": "code",
|
|
13
|
+
"display_name": "Code",
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "The code to run.",
|
|
16
|
+
"required": true
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "language",
|
|
20
|
+
"display_name": "Language",
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "python or javascript.",
|
|
23
|
+
"default": "python",
|
|
24
|
+
"options": ["python", "javascript"]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"outputs": [
|
|
28
|
+
{
|
|
29
|
+
"name": "stdout",
|
|
30
|
+
"display_name": "Output",
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "What the code printed."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "stderr",
|
|
36
|
+
"display_name": "Errors",
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Warnings and errors, including a traceback when the code failed."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "result",
|
|
42
|
+
"display_name": "Result",
|
|
43
|
+
"type": "any",
|
|
44
|
+
"description": "The value of the last expression, when the sandbox reports one."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "files",
|
|
48
|
+
"display_name": "Files",
|
|
49
|
+
"type": "array",
|
|
50
|
+
"description": "Files the code created or changed: path, type and size, and a link when the host saved them."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "content",
|
|
54
|
+
"display_name": "Content",
|
|
55
|
+
"type": "array",
|
|
56
|
+
"description": "Images the code produced, delivered to the model as pictures."
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"settings": [
|
|
60
|
+
{
|
|
61
|
+
"name": "timeout_seconds",
|
|
62
|
+
"display_name": "Time Limit (seconds)",
|
|
63
|
+
"type": "number",
|
|
64
|
+
"description": "How long one call may run before it is stopped, up to 120.",
|
|
65
|
+
"default": 60
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"timeout": 150000,
|
|
69
|
+
"retry_limit": 0
|
|
70
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Code: the model writes code, a sandbox runs it. The sandbox comes
|
|
3
|
+
* from the host as config.integrations.codeExecutor, scoped to this run:
|
|
4
|
+
* the first call opens a session and every later call reuses it.
|
|
5
|
+
*
|
|
6
|
+
* A failure in the code itself is a result, not an error: the traceback
|
|
7
|
+
* is in stderr and the model's next move is to fix it. Only the sandbox
|
|
8
|
+
* failing (time limit, lost session, unreachable) throws.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const IMAGE = /^image\/(png|jpeg|gif|webp)$/;
|
|
12
|
+
// Inside the node's own 150s timeout, so the sandbox's limit is the one
|
|
13
|
+
// that fires and the model is told which limit it hit.
|
|
14
|
+
const MAX_SECONDS = 120;
|
|
15
|
+
// What the model is shown per call. Larger or later images are still
|
|
16
|
+
// listed in `files`.
|
|
17
|
+
const MAX_IMAGES = 4;
|
|
18
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
export default async ({ inputs, settings, config }) => {
|
|
21
|
+
const executor = config.integrations?.codeExecutor;
|
|
22
|
+
if (!executor) {
|
|
23
|
+
throw new Error("No sandbox is available to run code. Pass config.codeExecutor to the engine.");
|
|
24
|
+
}
|
|
25
|
+
const code = typeof inputs.code === "string" ? inputs.code : "";
|
|
26
|
+
if (!code.trim()) throw new Error("There's no code to run.");
|
|
27
|
+
const language = inputs.language || "python";
|
|
28
|
+
const seconds = Math.min(MAX_SECONDS, Math.max(1, Number(settings?.timeout_seconds) || 60));
|
|
29
|
+
const timeoutMs = seconds * 1000;
|
|
30
|
+
|
|
31
|
+
const out = await executor.run({ language, code, timeoutMs, signal: config.signal });
|
|
32
|
+
|
|
33
|
+
if (out.error && out.error.kind !== "runtime") {
|
|
34
|
+
const reason =
|
|
35
|
+
out.error.kind === "timeout"
|
|
36
|
+
? `The code ran past the ${timeoutMs / 1000}s limit and was stopped.`
|
|
37
|
+
: out.error.kind === "memory"
|
|
38
|
+
? "The code ran out of memory and was stopped."
|
|
39
|
+
: out.error.message;
|
|
40
|
+
const printed = out.stdout ? `\n\nPrinted before it stopped:\n${out.stdout}` : "";
|
|
41
|
+
throw new Error(`${reason}${printed}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const produced = Array.isArray(out.files) ? out.files : [];
|
|
45
|
+
const images = produced.filter((f) => typeof f.data === "string" && IMAGE.test(f.mimeType ?? ""));
|
|
46
|
+
const shown = images.filter((f) => f.data.length * 0.75 <= MAX_IMAGE_BYTES).slice(0, MAX_IMAGES);
|
|
47
|
+
const content = shown.map((f) => ({ type: "image", data: f.data, mimeType: f.mimeType }));
|
|
48
|
+
const notShown = images.length - shown.length;
|
|
49
|
+
// Bytes never ride in `files`: the listing is what the model and the
|
|
50
|
+
// trace see, and images reach the model through `content` instead.
|
|
51
|
+
const files = produced.map(({ data: _data, ...meta }) => meta);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
stdout: out.stdout ?? "",
|
|
55
|
+
stderr:
|
|
56
|
+
(out.stderr ?? "") +
|
|
57
|
+
(notShown
|
|
58
|
+
? `${out.stderr ? "\n" : ""}${notShown} image${notShown > 1 ? "s" : ""} not shown: at most ${MAX_IMAGES} per call, each up to 5 MB. They are still listed in files.`
|
|
59
|
+
: ""),
|
|
60
|
+
result: out.result ?? null,
|
|
61
|
+
files,
|
|
62
|
+
content,
|
|
63
|
+
};
|
|
64
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerowidth/workbench-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"adm-zip": "^0.5.16",
|
|
6
6
|
"ajv": "^8.17.1",
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
"test-flow": "node tests/test.flows.js",
|
|
48
48
|
"test-kb": "node tests/test.kb-search.js && node tests/test.kb-graph.js",
|
|
49
49
|
"test-custom-inference": "node tests/test.custom-inference.js",
|
|
50
|
-
"test-memory": "node tests/test.agent-memory.js"
|
|
50
|
+
"test-memory": "node tests/test.agent-memory.js",
|
|
51
|
+
"test-run-code": "node tests/test.run-code.js"
|
|
51
52
|
},
|
|
52
53
|
"author": "Peter Binggeser @ ZeroWidth, LLC",
|
|
53
54
|
"license": "Apache-2.0",
|
package/src/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { validateKeys, validateFlow, validateInputs } from "./utilities/validato
|
|
|
14
14
|
import { loadCustomTypes, typeCheck, convertType } from "./utilities/typers.js";
|
|
15
15
|
import { createSafeToolName, isRemoteMCPTool, isManualToolNode, mapTypeToJSONSchema, getDirname } from "./utilities/helpers.js";
|
|
16
16
|
import { sanitizeAPICallEvent } from "./utilities/sanitizeAPICall.js";
|
|
17
|
+
import { withOwnCodeSession } from "./integrations/code-executor.js";
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -1186,6 +1187,21 @@ export default class Workbench {
|
|
|
1186
1187
|
* This should be called when the engine is no longer needed to free up memory
|
|
1187
1188
|
* @returns {Promise<void>}
|
|
1188
1189
|
*/
|
|
1190
|
+
/**
|
|
1191
|
+
* Close this engine's code session, if it opened one. Each run() gets
|
|
1192
|
+
* its own, so a reused engine never carries one run's variables and
|
|
1193
|
+
* files into the next. The sandbox would expire it on its own; closing
|
|
1194
|
+
* frees it now.
|
|
1195
|
+
*/
|
|
1196
|
+
async _closeCodeSession() {
|
|
1197
|
+
if (!this.config.integrations?.codeExecutor?.close) return;
|
|
1198
|
+
try {
|
|
1199
|
+
await this.config.integrations.codeExecutor.close();
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
this.logDebug(`Failed to close the code session: ${err.message}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1189
1205
|
async cleanup() {
|
|
1190
1206
|
this.logDebug('Starting cleanup process...');
|
|
1191
1207
|
|
|
@@ -1218,6 +1234,10 @@ export default class Workbench {
|
|
|
1218
1234
|
}
|
|
1219
1235
|
}
|
|
1220
1236
|
|
|
1237
|
+
// run() already closes the code session; this covers an engine
|
|
1238
|
+
// whose run never finished.
|
|
1239
|
+
await this._closeCodeSession();
|
|
1240
|
+
|
|
1221
1241
|
// Clean up any imported engines that were created
|
|
1222
1242
|
// These are stored in the cache when import nodes are processed
|
|
1223
1243
|
const rawStore = this.cache.getRawStore();
|
|
@@ -1661,6 +1681,7 @@ export default class Workbench {
|
|
|
1661
1681
|
// Restore the inherited signal so a reused engine doesn't treat this run's
|
|
1662
1682
|
// (possibly aborted) signal as an ancestor on the next run().
|
|
1663
1683
|
this.config.signal = this._inheritedSignal;
|
|
1684
|
+
await this._closeCodeSession();
|
|
1664
1685
|
}
|
|
1665
1686
|
}
|
|
1666
1687
|
|
|
@@ -1732,8 +1753,9 @@ export default class Workbench {
|
|
|
1732
1753
|
// Create internal zv1 instance
|
|
1733
1754
|
const internalEngine = new Workbench(internalFlow, {
|
|
1734
1755
|
...this.config,
|
|
1735
|
-
// Pass through integrations and other config
|
|
1736
|
-
|
|
1756
|
+
// Pass through integrations and other config. Code runs in a
|
|
1757
|
+
// session of the macro's own: its run ending closes that one, not ours.
|
|
1758
|
+
integrations: withOwnCodeSession(this.config.integrations),
|
|
1737
1759
|
keys: this.config.keys,
|
|
1738
1760
|
debug: this.config.debug,
|
|
1739
1761
|
// Pass tools from parent context
|
|
@@ -3269,3 +3291,13 @@ export {
|
|
|
3269
3291
|
InMemoryMemoryStore,
|
|
3270
3292
|
normalizeMemoryPath,
|
|
3271
3293
|
} from './integrations/memory-store.js';
|
|
3294
|
+
|
|
3295
|
+
// Code execution: pass `config.codeExecutor.url` for a sandbox that
|
|
3296
|
+
// speaks the HTTP contract, or implement CodeExecutorInterface over your
|
|
3297
|
+
// own and pass `config.codeExecutor.instance`. The Run Code tool node
|
|
3298
|
+
// uses it. See integrations/code-executor.js.
|
|
3299
|
+
export {
|
|
3300
|
+
CodeExecutorInterface,
|
|
3301
|
+
HttpCodeExecutor,
|
|
3302
|
+
CodeSessionLostError,
|
|
3303
|
+
} from './integrations/code-executor.js';
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code execution: the contract an executor fulfils, a client for an
|
|
3
|
+
* executor reached over HTTP, and the per-run wrapper the engine hands to
|
|
4
|
+
* the Run Code tool.
|
|
5
|
+
*
|
|
6
|
+
* The engine never runs code itself and never learns which sandbox does.
|
|
7
|
+
* The host passes `config.codeExecutor`:
|
|
8
|
+
*
|
|
9
|
+
* { instance: <CodeExecutorInterface> } — its own executor object
|
|
10
|
+
* { url, apiKey?, headers?, getHeaders? } — an executor that speaks the
|
|
11
|
+
* HTTP contract below
|
|
12
|
+
*
|
|
13
|
+
* HTTP contract (all JSON):
|
|
14
|
+
*
|
|
15
|
+
* POST {url}/sessions → { id }
|
|
16
|
+
* POST {url}/run ← RunRequest → RunResult
|
|
17
|
+
* DELETE {url}/sessions/{id} → 204
|
|
18
|
+
*
|
|
19
|
+
* A session holds variables and files between calls. Sessions are
|
|
20
|
+
* optional: an executor that answers `capabilities.sessions = false`
|
|
21
|
+
* runs every call fresh.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const OUTPUT_CAP = 20000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Thrown when the executor no longer has the session's state (it was
|
|
28
|
+
* restarted, or the call landed somewhere else). Distinct from a failure
|
|
29
|
+
* in the code itself: the model's next step is to run its setup again.
|
|
30
|
+
*/
|
|
31
|
+
export class CodeSessionLostError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super(
|
|
34
|
+
"The sandbox restarted, so variables and files from earlier calls are gone. Run the setup code again before continuing."
|
|
35
|
+
);
|
|
36
|
+
this.name = "CodeSessionLostError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The contract. Implement this over any sandbox and pass it as
|
|
42
|
+
* `config.codeExecutor.instance`.
|
|
43
|
+
*/
|
|
44
|
+
export class CodeExecutorInterface {
|
|
45
|
+
/** @returns {Promise<{ languages: string[], sessions: boolean, files: boolean }>} */
|
|
46
|
+
async capabilities() {
|
|
47
|
+
throw new Error("capabilities() not implemented");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ language: string, code: string, input?: unknown,
|
|
52
|
+
* files?: { path: string, data: string }[], timeoutMs: number,
|
|
53
|
+
* session?: string }} _request file `data` is base64
|
|
54
|
+
* @param {{ signal?: AbortSignal }} [_options] aborted when the run is
|
|
55
|
+
* cancelled or the node times out; stop waiting on the sandbox then
|
|
56
|
+
* @returns {Promise<{ stdout: string, stderr: string, result?: unknown,
|
|
57
|
+
* files?: { path: string, mimeType?: string, data?: string, url?: string, size?: number }[],
|
|
58
|
+
* error?: { kind: "timeout" | "memory" | "runtime" | "unsupported" | "session_lost", message: string },
|
|
59
|
+
* usage?: { wallMs: number, cpuMs?: number } }>}
|
|
60
|
+
*/
|
|
61
|
+
async run(_request, _options) {
|
|
62
|
+
throw new Error("run() not implemented");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @returns {Promise<string>} a session id */
|
|
66
|
+
async openSession() {
|
|
67
|
+
throw new Error("openSession() not implemented");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {string} _id */
|
|
71
|
+
async closeSession(_id) {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An executor reached over HTTP. */
|
|
75
|
+
export class HttpCodeExecutor extends CodeExecutorInterface {
|
|
76
|
+
constructor({ url, apiKey, headers, getHeaders, fetch: fetchImpl, capabilities } = {}) {
|
|
77
|
+
super();
|
|
78
|
+
if (!url) throw new Error("A code executor needs a url.");
|
|
79
|
+
this.url = url.replace(/\/+$/, "");
|
|
80
|
+
this.apiKey = apiKey;
|
|
81
|
+
this.headers = headers ?? {};
|
|
82
|
+
this.getHeaders = getHeaders;
|
|
83
|
+
this.fetch = fetchImpl ?? globalThis.fetch;
|
|
84
|
+
this.declared = capabilities;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async capabilities() {
|
|
88
|
+
return this.declared ?? { languages: ["python", "javascript"], sessions: true, files: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async request(method, path, body, signal) {
|
|
92
|
+
const headers = {
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
...this.headers,
|
|
95
|
+
...(this.getHeaders ? await this.getHeaders() : {}),
|
|
96
|
+
...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}),
|
|
97
|
+
};
|
|
98
|
+
const res = await this.fetch(`${this.url}${path}`, {
|
|
99
|
+
method,
|
|
100
|
+
headers,
|
|
101
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
102
|
+
signal,
|
|
103
|
+
});
|
|
104
|
+
if (res.status === 204) return null;
|
|
105
|
+
const text = await res.text();
|
|
106
|
+
let data = null;
|
|
107
|
+
try {
|
|
108
|
+
data = text ? JSON.parse(text) : null;
|
|
109
|
+
} catch {
|
|
110
|
+
// Fall through with the raw text for the error below.
|
|
111
|
+
}
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
if (res.status === 410 || data?.error?.kind === "session_lost") throw new CodeSessionLostError();
|
|
114
|
+
const message = data?.error?.message ?? data?.error ?? text.slice(0, 300);
|
|
115
|
+
throw new Error(`The code executor refused the request (${res.status}): ${message}`);
|
|
116
|
+
}
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async run(request, { signal } = {}) {
|
|
121
|
+
return this.request("POST", "/run", request, signal);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async openSession() {
|
|
125
|
+
const data = await this.request("POST", "/sessions", {});
|
|
126
|
+
if (!data?.id) throw new Error("The code executor did not return a session id.");
|
|
127
|
+
return data.id;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async closeSession(id) {
|
|
131
|
+
await this.request("DELETE", `/sessions/${encodeURIComponent(id)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const cap = (text) => {
|
|
136
|
+
const value = typeof text === "string" ? text : "";
|
|
137
|
+
return value.length > OUTPUT_CAP
|
|
138
|
+
? `${value.slice(0, OUTPUT_CAP)}\n… (cut: ${value.length - OUTPUT_CAP} more characters)`
|
|
139
|
+
: value;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// `result` is whatever the sandbox reports for the last expression; a
|
|
143
|
+
// big one (a DataFrame, a long list) is capped like printed output.
|
|
144
|
+
const capResult = (value) => {
|
|
145
|
+
if (value === undefined || value === null) return null;
|
|
146
|
+
if (typeof value === "string") return cap(value);
|
|
147
|
+
let json;
|
|
148
|
+
try {
|
|
149
|
+
json = JSON.stringify(value);
|
|
150
|
+
} catch {
|
|
151
|
+
return cap(String(value));
|
|
152
|
+
}
|
|
153
|
+
return json && json.length > OUTPUT_CAP ? cap(json) : value;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One run's view of an executor: opens a session on first use when the
|
|
158
|
+
* executor supports them, reuses it for every later call in the run, and
|
|
159
|
+
* closes it when the run ends. Output is capped here so a print loop
|
|
160
|
+
* cannot flood the conversation, whatever the executor allows.
|
|
161
|
+
*
|
|
162
|
+
* Every engine owns one of these. A sub-engine (an import or a macro)
|
|
163
|
+
* gets its own through forSubEngine(), so its session never shares
|
|
164
|
+
* state with its caller's and closing it leaves the caller's open.
|
|
165
|
+
*/
|
|
166
|
+
export class RunScopedCodeExecutor {
|
|
167
|
+
constructor(executor) {
|
|
168
|
+
this.executor = executor;
|
|
169
|
+
this.sessionId = null;
|
|
170
|
+
this.opening = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async capabilities() {
|
|
174
|
+
return this.executor.capabilities();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A fresh scope over the same executor, for a sub-engine. */
|
|
178
|
+
forSubEngine() {
|
|
179
|
+
return new RunScopedCodeExecutor(this.executor);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async session() {
|
|
183
|
+
const caps = await this.executor.capabilities();
|
|
184
|
+
if (!caps.sessions) return undefined;
|
|
185
|
+
if (this.sessionId) return this.sessionId;
|
|
186
|
+
this.opening ??= this.executor.openSession().then((id) => {
|
|
187
|
+
this.sessionId = id;
|
|
188
|
+
return id;
|
|
189
|
+
});
|
|
190
|
+
try {
|
|
191
|
+
return await this.opening;
|
|
192
|
+
} finally {
|
|
193
|
+
this.opening = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async run({ language, code, input, files, timeoutMs, signal }) {
|
|
198
|
+
const caps = await this.executor.capabilities();
|
|
199
|
+
if (!caps.languages.includes(language)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`This sandbox runs ${caps.languages.join(" and ")}, not ${language}.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const session = await this.session();
|
|
205
|
+
let result;
|
|
206
|
+
try {
|
|
207
|
+
result = await this.executor.run({ language, code, input, files, timeoutMs, session }, { signal });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (err instanceof CodeSessionLostError) this.sessionId = null;
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
if (result?.error?.kind === "session_lost") {
|
|
213
|
+
this.sessionId = null;
|
|
214
|
+
throw new CodeSessionLostError();
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
...result,
|
|
218
|
+
stdout: cap(result?.stdout),
|
|
219
|
+
stderr: cap(result?.stderr),
|
|
220
|
+
result: capResult(result?.result),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async close() {
|
|
225
|
+
const id = this.sessionId;
|
|
226
|
+
this.sessionId = null;
|
|
227
|
+
if (id) await this.executor.closeSession(id);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The integrations a sub-engine runs with: the caller's, except that
|
|
233
|
+
* code runs in a session of its own. Returns the same object when there
|
|
234
|
+
* is no code executor.
|
|
235
|
+
*/
|
|
236
|
+
export function withOwnCodeSession(integrations) {
|
|
237
|
+
if (!integrations?.codeExecutor?.forSubEngine) return integrations;
|
|
238
|
+
return { ...integrations, codeExecutor: integrations.codeExecutor.forSubEngine() };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** From the host's `config.codeExecutor` to the run-scoped executor. */
|
|
242
|
+
export function createCodeExecutor(codeExecutorConfig = {}) {
|
|
243
|
+
if (codeExecutorConfig.instance) return new RunScopedCodeExecutor(codeExecutorConfig.instance);
|
|
244
|
+
if (codeExecutorConfig.url) return new RunScopedCodeExecutor(new HttpCodeExecutor(codeExecutorConfig));
|
|
245
|
+
throw new Error("config.codeExecutor needs either an instance or a url.");
|
|
246
|
+
}
|
package/src/utilities/loaders.js
CHANGED
|
@@ -6,6 +6,7 @@ import { convertImportToNodeType } from "./typers.js";
|
|
|
6
6
|
import { getDirname, isRemoteMCPTool } from "./helpers.js";
|
|
7
7
|
import { isOAuthKey, OAuthRefreshManager } from "./oauth.js";
|
|
8
8
|
import { createMemory, flowUsesMemory } from "../integrations/memory-store.js";
|
|
9
|
+
import { createCodeExecutor } from "../integrations/code-executor.js";
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -244,6 +245,12 @@ export async function loadIntegrations(config, flow = null) {
|
|
|
244
245
|
integrations.memory = createMemory(config.memory ?? {});
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
// Code execution (integrations/code-executor.js): only ever the host's
|
|
249
|
+
// sandbox. There is no built-in fallback that runs code in-process.
|
|
250
|
+
if (config.codeExecutor) {
|
|
251
|
+
integrations.codeExecutor = createCodeExecutor(config.codeExecutor);
|
|
252
|
+
}
|
|
253
|
+
|
|
247
254
|
// Initialize OAuth refresh manager if any OAuth keys are present
|
|
248
255
|
let oauthRefreshManager = null;
|
|
249
256
|
const hasOAuthKeys = Object.values(config.keys || {}).some(key => isOAuthKey(key));
|
package/src/utilities/typers.js
CHANGED
|
@@ -6,6 +6,7 @@ import Workbench from "../index.js";
|
|
|
6
6
|
import { getDirname } from "./helpers.js";
|
|
7
7
|
import { loadTypeConverter } from "./typeConverters.js";
|
|
8
8
|
import { createMemory, flowUsesMemory } from "../integrations/memory-store.js";
|
|
9
|
+
import { withOwnCodeSession } from "../integrations/code-executor.js";
|
|
9
10
|
|
|
10
11
|
const ajv = new Ajv();
|
|
11
12
|
|
|
@@ -338,6 +339,12 @@ export function convertImportToNodeType(importDef) {
|
|
|
338
339
|
importConfig.memory = own;
|
|
339
340
|
}
|
|
340
341
|
|
|
342
|
+
// An imported flow runs code in a session of its own, like memory:
|
|
343
|
+
// it never sees its caller's variables, and its cleanup below closes
|
|
344
|
+
// its session, not the caller's. Run Code attached to the caller and
|
|
345
|
+
// passed in as a tool still runs in the caller's session.
|
|
346
|
+
importConfig.integrations = withOwnCodeSession(importConfig.integrations ?? config.integrations);
|
|
347
|
+
|
|
341
348
|
// If this import accepts plugins, create tool runners for parent context execution
|
|
342
349
|
// Note: We need to access the parent engine (this) to find connected plugins
|
|
343
350
|
let tools = {};
|