@zerowidth/workbench-sdk 2.1.2 → 2.2.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/nodes/json-to-xml/json-to-xml.config.json +2 -2
- package/nodes/json-to-yaml/json-to-yaml.config.json +2 -2
- package/nodes/json-to-yaml/json-to-yaml.tests.json +9 -0
- package/nodes/message/message.config.json +1 -1
- package/nodes/message/message.process.js +25 -2
- package/nodes/message/message.tests.json +85 -0
- package/nodes/object-merge/object-merge.config.json +2 -2
- package/nodes/object-merge/object-merge.process.js +6 -2
- package/nodes/object-merge/object-merge.tests.json +12 -0
- package/nodes/prompt-injection-guard/prompt-injection-guard.config.json +39 -0
- package/nodes/prompt-injection-guard/prompt-injection-guard.process.js +269 -0
- package/nodes/prompt-injection-guard/prompt-injection-guard.tests.json +205 -0
- package/nodes/string-template/string-template.config.json +2 -2
- package/nodes/string-template/string-template.process.js +33 -5
- package/nodes/string-template/string-template.tests.json +52 -0
- package/nodes/system-prompt/system-prompt.config.json +1 -1
- package/nodes/system-prompt/system-prompt.process.js +25 -2
- package/nodes/system-prompt/system-prompt.tests.json +113 -0
- package/package.json +1 -1
- package/src/index.js +112 -5
- package/src/utilities/mcp.js +25 -5
package/src/index.js
CHANGED
|
@@ -197,6 +197,26 @@ export default class Workbench {
|
|
|
197
197
|
this.flowTimeout = null;
|
|
198
198
|
this.timeline = [];
|
|
199
199
|
|
|
200
|
+
// Idle watchdog state (see run()). `touchActivity()` stamps
|
|
201
|
+
// progress; a sub-engine created by this engine gets
|
|
202
|
+
// `_notifyParentActivity` wired so descendant progress keeps the
|
|
203
|
+
// whole ancestor chain alive.
|
|
204
|
+
this.idleWatchdog = null;
|
|
205
|
+
this._lastActivityAt = 0;
|
|
206
|
+
this._notifyParentActivity = null;
|
|
207
|
+
|
|
208
|
+
// Token deltas are progress too — the LLM integrations call
|
|
209
|
+
// config.onNodeUpdate directly, so wrap it once here to stamp
|
|
210
|
+
// activity before forwarding to the consumer's handler (if any).
|
|
211
|
+
// A model streaming a long completion is alive, not idle.
|
|
212
|
+
{
|
|
213
|
+
const consumerOnNodeUpdate = this.config.onNodeUpdate || null;
|
|
214
|
+
this.config.onNodeUpdate = (event) => {
|
|
215
|
+
this.touchActivity();
|
|
216
|
+
return consumerOnNodeUpdate ? consumerOnNodeUpdate(event) : undefined;
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
200
220
|
// Initialize ErrorManager for centralized error handling
|
|
201
221
|
this.errorManager = new ErrorManager({
|
|
202
222
|
onError: this.config.onError || null,
|
|
@@ -429,8 +449,11 @@ export default class Workbench {
|
|
|
429
449
|
startTime: new Date().toISOString()
|
|
430
450
|
};
|
|
431
451
|
const startDate = new Date();
|
|
432
|
-
|
|
452
|
+
|
|
433
453
|
try {
|
|
454
|
+
// Every node-execution boundary is progress for the idle
|
|
455
|
+
// watchdog — entry here, plus the success/error exits below.
|
|
456
|
+
this.touchActivity();
|
|
434
457
|
if(this.config.onNodeStart) {
|
|
435
458
|
await this.config.onNodeStart({
|
|
436
459
|
nodeId: node.id,
|
|
@@ -474,7 +497,8 @@ export default class Workbench {
|
|
|
474
497
|
timelineEntry.durationMs = endDate - startDate;
|
|
475
498
|
timelineEntry.status = 'success';
|
|
476
499
|
this.timeline.push(timelineEntry);
|
|
477
|
-
|
|
500
|
+
this.touchActivity();
|
|
501
|
+
|
|
478
502
|
if(this.config.onNodeComplete) {
|
|
479
503
|
await this.config.onNodeComplete({
|
|
480
504
|
nodeId: node.id,
|
|
@@ -494,7 +518,8 @@ export default class Workbench {
|
|
|
494
518
|
timelineEntry.status = 'error';
|
|
495
519
|
timelineEntry.errorMessage = error.message;
|
|
496
520
|
this.timeline.push(timelineEntry);
|
|
497
|
-
|
|
521
|
+
this.touchActivity();
|
|
522
|
+
|
|
498
523
|
// Update execution context for ErrorManager
|
|
499
524
|
this.errorManager.updateExecutionContext({
|
|
500
525
|
timeline: this.timeline,
|
|
@@ -1230,6 +1255,22 @@ export default class Workbench {
|
|
|
1230
1255
|
* @param {number} timeout - Maximum execution time in milliseconds
|
|
1231
1256
|
* @returns {Object} The final output from output nodes
|
|
1232
1257
|
*/
|
|
1258
|
+
/**
|
|
1259
|
+
* Stamp forward progress for the idle watchdog (see run()). Called
|
|
1260
|
+
* from every node-execution boundary, every tool-call dispatch and
|
|
1261
|
+
* settle, and every streaming token delta. Chains upward: when this
|
|
1262
|
+
* engine is a sub-engine (macro / imported flow), the parent wired
|
|
1263
|
+
* `_notifyParentActivity` at construction so descendant progress
|
|
1264
|
+
* keeps every ancestor's watchdog fed — a parent awaiting a hard-
|
|
1265
|
+
* working sub-agent is not idle.
|
|
1266
|
+
*/
|
|
1267
|
+
touchActivity() {
|
|
1268
|
+
this._lastActivityAt = Date.now();
|
|
1269
|
+
if (typeof this._notifyParentActivity === 'function') {
|
|
1270
|
+
this._notifyParentActivity();
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1233
1274
|
async run(inputData, timeout = 60000) {
|
|
1234
1275
|
this.logDebug(`Starting flow execution with timeout: ${timeout}ms`);
|
|
1235
1276
|
this.logDebug(`Input data:`, JSON.stringify(inputData, null, 2));
|
|
@@ -1283,6 +1324,36 @@ export default class Workbench {
|
|
|
1283
1324
|
}
|
|
1284
1325
|
}, timeout);
|
|
1285
1326
|
|
|
1327
|
+
// Idle watchdog (opt-in via config.idleTimeoutMs). The flow
|
|
1328
|
+
// timeout above is a wall-clock cap on the WHOLE run — one budget
|
|
1329
|
+
// that a multi-round agent legitimately eats with many quick tool
|
|
1330
|
+
// calls. The watchdog instead bounds time-without-progress: every
|
|
1331
|
+
// node boundary / tool dispatch / tool settle / token delta calls
|
|
1332
|
+
// touchActivity(), and only silence longer than idleTimeoutMs
|
|
1333
|
+
// aborts. Hosts pair a short idle bound (e.g. 60s) with a long
|
|
1334
|
+
// wall-clock cap so busy flows finish and hung flows still die
|
|
1335
|
+
// fast. Uses the same hasTimedOut + abort path as the flow
|
|
1336
|
+
// timeout so downstream status handling is identical.
|
|
1337
|
+
const idleTimeoutMs = typeof this.config.idleTimeoutMs === 'number' && this.config.idleTimeoutMs > 0
|
|
1338
|
+
? this.config.idleTimeoutMs
|
|
1339
|
+
: null;
|
|
1340
|
+
if (idleTimeoutMs !== null) {
|
|
1341
|
+
this._lastActivityAt = Date.now();
|
|
1342
|
+
const checkEveryMs = Math.min(1000, Math.max(50, Math.floor(idleTimeoutMs / 4)));
|
|
1343
|
+
this.idleWatchdog = setInterval(() => {
|
|
1344
|
+
if (Date.now() - this._lastActivityAt <= idleTimeoutMs) return;
|
|
1345
|
+
this.logDebug("Flow execution idle-timed out (no progress)");
|
|
1346
|
+
this.hasTimedOut = true;
|
|
1347
|
+
if (!this.abortController.signal.aborted) {
|
|
1348
|
+
this.abortController.abort(new Error(
|
|
1349
|
+
`Flow execution timed out after ${idleTimeoutMs}ms without progress (idle timeout)`,
|
|
1350
|
+
));
|
|
1351
|
+
}
|
|
1352
|
+
clearInterval(this.idleWatchdog);
|
|
1353
|
+
this.idleWatchdog = null;
|
|
1354
|
+
}, checkEveryMs);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1286
1357
|
let inputsMissingValues = [];
|
|
1287
1358
|
|
|
1288
1359
|
try {
|
|
@@ -1544,6 +1615,7 @@ export default class Workbench {
|
|
|
1544
1615
|
|
|
1545
1616
|
this.logDebug("Flow execution complete. Final outputs:", finalOutputs);
|
|
1546
1617
|
clearTimeout(this.flowTimeout);
|
|
1618
|
+
if (this.idleWatchdog) { clearInterval(this.idleWatchdog); this.idleWatchdog = null; }
|
|
1547
1619
|
|
|
1548
1620
|
return {
|
|
1549
1621
|
outputs: finalOutputs,
|
|
@@ -1555,6 +1627,7 @@ export default class Workbench {
|
|
|
1555
1627
|
} catch (error) {
|
|
1556
1628
|
// Clear the timeout on error
|
|
1557
1629
|
clearTimeout(this.flowTimeout);
|
|
1630
|
+
if (this.idleWatchdog) { clearInterval(this.idleWatchdog); this.idleWatchdog = null; }
|
|
1558
1631
|
|
|
1559
1632
|
// If this is a timeout error, add it to the timeline
|
|
1560
1633
|
if (error.errorType === 'timeout') {
|
|
@@ -1583,6 +1656,7 @@ export default class Workbench {
|
|
|
1583
1656
|
throw error;
|
|
1584
1657
|
} finally {
|
|
1585
1658
|
clearTimeout(this.flowTimeout);
|
|
1659
|
+
if (this.idleWatchdog) { clearInterval(this.idleWatchdog); this.idleWatchdog = null; }
|
|
1586
1660
|
this._abortTeardown?.();
|
|
1587
1661
|
// Restore the inherited signal so a reused engine doesn't treat this run's
|
|
1588
1662
|
// (possibly aborted) signal as an ancestor on the next run().
|
|
@@ -1673,6 +1747,12 @@ export default class Workbench {
|
|
|
1673
1747
|
|
|
1674
1748
|
// Share executed states with internal engine to prevent duplicate execution
|
|
1675
1749
|
internalEngine.executedNodeStates = this.executedNodeStates;
|
|
1750
|
+
|
|
1751
|
+
// Chain idle-watchdog activity upward: a sub-engine making
|
|
1752
|
+
// progress means this engine (awaiting it) is not idle. Without
|
|
1753
|
+
// this, a parent with idleTimeoutMs would kill a hard-working
|
|
1754
|
+
// sub-agent after one idle window of parent-level silence.
|
|
1755
|
+
internalEngine._notifyParentActivity = () => this.touchActivity();
|
|
1676
1756
|
|
|
1677
1757
|
// Initialize the internal engine
|
|
1678
1758
|
await internalEngine.initialize();
|
|
@@ -2176,7 +2256,13 @@ export default class Workbench {
|
|
|
2176
2256
|
toolSchemas.push(tool);
|
|
2177
2257
|
toolNodeMap[tool.name] = { node: pluginNode, type: 'mcp', mcpToolName: tool.name };
|
|
2178
2258
|
toolRunners[tool.name] = async (args) => {
|
|
2179
|
-
|
|
2259
|
+
// Tool identity and tool arguments stay separate — spreading
|
|
2260
|
+
// args next to `name` clobbered any tool argument that was
|
|
2261
|
+
// itself named `name` (caliper_datasets_create et al).
|
|
2262
|
+
return await callMCPTool(
|
|
2263
|
+
{ name: tool.name, arguments: args },
|
|
2264
|
+
{ url, token },
|
|
2265
|
+
);
|
|
2180
2266
|
};
|
|
2181
2267
|
}
|
|
2182
2268
|
this.logDebug(`Loaded ${tools.length} MCP tools from "${integrationName}"`);
|
|
@@ -2310,6 +2396,8 @@ export default class Workbench {
|
|
|
2310
2396
|
await this.config.onNodeError({
|
|
2311
2397
|
nodeId: toolNodeInfo.node.id,
|
|
2312
2398
|
nodeType: toolNodeInfo.node.type,
|
|
2399
|
+
toolName,
|
|
2400
|
+
toolCallId: tool_call.id,
|
|
2313
2401
|
error: parseError
|
|
2314
2402
|
});
|
|
2315
2403
|
}
|
|
@@ -2333,11 +2421,20 @@ export default class Workbench {
|
|
|
2333
2421
|
// multi-round tool-calling LLMs look like a single
|
|
2334
2422
|
// long "LLM node running…" entry — the waterfall has
|
|
2335
2423
|
// no visibility into the tool-call cycles inside.
|
|
2424
|
+
//
|
|
2425
|
+
// `toolName` / `toolCallId` ride on every tool-call
|
|
2426
|
+
// event: nodeId/nodeType identify the integration node
|
|
2427
|
+
// (shared by every tool on that MCP server), so without
|
|
2428
|
+
// the model-invoked tool name a live UI can't label the
|
|
2429
|
+
// chip, and without the call id it can't pair start →
|
|
2430
|
+
// complete/error across a multi-round loop.
|
|
2336
2431
|
if (toolNodeInfo?.node && this.config.onNodeStart) {
|
|
2337
2432
|
try {
|
|
2338
2433
|
await this.config.onNodeStart({
|
|
2339
2434
|
nodeId: toolNodeInfo.node.id,
|
|
2340
2435
|
nodeType: toolNodeInfo.node.type,
|
|
2436
|
+
toolName,
|
|
2437
|
+
toolCallId: tool_call.id,
|
|
2341
2438
|
inputs: toolArguments,
|
|
2342
2439
|
});
|
|
2343
2440
|
} catch (_hookErr) {
|
|
@@ -2345,9 +2442,14 @@ export default class Workbench {
|
|
|
2345
2442
|
}
|
|
2346
2443
|
}
|
|
2347
2444
|
|
|
2348
|
-
// Execute the tool runner with error handling
|
|
2445
|
+
// Execute the tool runner with error handling. Dispatch
|
|
2446
|
+
// and settle both stamp the idle watchdog — MCP tools
|
|
2447
|
+
// call out directly (no _executeNodeCore boundary), so
|
|
2448
|
+
// without these a tool-heavy round would read as silence.
|
|
2449
|
+
this.touchActivity();
|
|
2349
2450
|
try {
|
|
2350
2451
|
const toolResult = await toolRunners[toolName](toolArguments);
|
|
2452
|
+
this.touchActivity();
|
|
2351
2453
|
|
|
2352
2454
|
// Success - push result
|
|
2353
2455
|
tool_results.push({
|
|
@@ -2380,6 +2482,8 @@ export default class Workbench {
|
|
|
2380
2482
|
await this.config.onNodeComplete({
|
|
2381
2483
|
nodeId: toolNodeInfo.node.id,
|
|
2382
2484
|
nodeType: toolNodeInfo.node.type,
|
|
2485
|
+
toolName,
|
|
2486
|
+
toolCallId: tool_call.id,
|
|
2383
2487
|
inputs: toolArguments,
|
|
2384
2488
|
outputs: { result: toolResult },
|
|
2385
2489
|
});
|
|
@@ -2391,6 +2495,7 @@ export default class Workbench {
|
|
|
2391
2495
|
|
|
2392
2496
|
} catch (executionError) {
|
|
2393
2497
|
// Tool execution failed - create error result
|
|
2498
|
+
this.touchActivity();
|
|
2394
2499
|
this.logDebug(`Tool execution failed for ${toolName}:`, executionError.message);
|
|
2395
2500
|
|
|
2396
2501
|
// Create timeline entry for the execution error
|
|
@@ -2413,6 +2518,8 @@ export default class Workbench {
|
|
|
2413
2518
|
await this.config.onNodeError({
|
|
2414
2519
|
nodeId: toolNodeInfo.node.id,
|
|
2415
2520
|
nodeType: toolNodeInfo.node.type,
|
|
2521
|
+
toolName,
|
|
2522
|
+
toolCallId: tool_call.id,
|
|
2416
2523
|
error: executionError
|
|
2417
2524
|
});
|
|
2418
2525
|
}
|
package/src/utilities/mcp.js
CHANGED
|
@@ -86,17 +86,37 @@ function parseMcpResponse(response) {
|
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Call an MCP tool.
|
|
89
|
-
*
|
|
89
|
+
*
|
|
90
|
+
* Tool identity and tool arguments are carried as SEPARATE fields —
|
|
91
|
+
* never merged into one flat object. The previous flat-object contract
|
|
92
|
+
* ({ ...toolArgs, name: toolName }) made a tool argument named `name`
|
|
93
|
+
* indistinguishable from the tool's own name: it was overwritten at
|
|
94
|
+
* the call site, then stripped here, so tools with a top-level `name`
|
|
95
|
+
* parameter received `name: undefined`. Any unexpected top-level key
|
|
96
|
+
* throws so a legacy-shape caller fails loudly instead of silently
|
|
97
|
+
* dropping arguments.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} call - { name: string, arguments?: Object } — the
|
|
100
|
+
* tool's name and the model-provided arguments, forwarded verbatim.
|
|
90
101
|
* @param {Object} options - { url, token }
|
|
91
102
|
* @returns {Object} The result of the tool call
|
|
92
103
|
*/
|
|
93
|
-
export async function callMCPTool(
|
|
104
|
+
export async function callMCPTool(call, { url, token } = {}) {
|
|
94
105
|
if (!url) throw new Error('No MCP URL provided');
|
|
95
106
|
|
|
96
|
-
const toolName =
|
|
107
|
+
const { name: toolName, arguments: toolArgs } = call ?? {};
|
|
97
108
|
if (!toolName) throw new Error('No tool name provided for MCP call');
|
|
98
109
|
|
|
99
|
-
const
|
|
110
|
+
const extraKeys = Object.keys(call).filter(
|
|
111
|
+
(k) => k !== 'name' && k !== 'arguments',
|
|
112
|
+
);
|
|
113
|
+
if (extraKeys.length > 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`callMCPTool takes { name, arguments } — unexpected top-level keys: ` +
|
|
116
|
+
`${extraKeys.join(', ')}. Tool arguments belong under "arguments".`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
const id = uuidv4();
|
|
101
121
|
|
|
102
122
|
try {
|
|
@@ -108,7 +128,7 @@ export async function callMCPTool(args, { url, token } = {}) {
|
|
|
108
128
|
method: 'tools/call',
|
|
109
129
|
params: {
|
|
110
130
|
name: toolName,
|
|
111
|
-
arguments: toolArgs,
|
|
131
|
+
arguments: toolArgs ?? {},
|
|
112
132
|
},
|
|
113
133
|
},
|
|
114
134
|
{
|