@zerowidth/workbench-sdk 2.7.0 → 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 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.7.0",
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
- integrations: this.config.integrations,
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
+ }
@@ -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));
@@ -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
 
@@ -229,12 +230,17 @@ export function convertImportToNodeType(importDef) {
229
230
  requestedSnapshot: processedImportDef.requestedSnapshot,
230
231
  inputs: inputNodes.map(node => {
231
232
  if (node.type === 'input-data') {
233
+ // An input with a default doesn't need a connection: left
234
+ // unconnected (or left out by a tool call), it gets the default,
235
+ // the same as running the imported flow on its own.
236
+ const defaultValue = node.settings?.default_value;
232
237
  return {
233
238
  // Use the node's settings.key as the input name to match port connections
234
239
  name: node.settings?.key || 'data',
235
- display_name: `Data: ${node.settings?.key || 'value'}`,
240
+ display_name: node.settings?.display_name || node.settings?.key || 'Data',
236
241
  type: node.settings?.type || 'any',
237
- required: true,
242
+ required: defaultValue === undefined,
243
+ ...(defaultValue !== undefined ? { default: defaultValue } : {}),
238
244
  is_data_input: true,
239
245
  description: node.settings?.description || 'Imported data input'
240
246
  };
@@ -333,6 +339,12 @@ export function convertImportToNodeType(importDef) {
333
339
  importConfig.memory = own;
334
340
  }
335
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
+
336
348
  // If this import accepts plugins, create tool runners for parent context execution
337
349
  // Note: We need to access the parent engine (this) to find connected plugins
338
350
  let tools = {};