@noetic-tools/core 2.0.0 → 2.1.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.
@@ -1,5 +1,5 @@
1
1
  import type { ContextMemory } from '@noetic-tools/memory';
2
- import type { Context, Lazy, ModelParams, RetryPolicy, StepLLM, StepRun, StepSubHarness, StepTool, SubHarness, SubHarnessSessionPolicy, SubHarnessSettings, SubprocessAdapter, Tool } from '@noetic-tools/types';
2
+ import type { Context, Lazy, ModelParams, RetryPolicy, ServerToolSpec, StepLLM, StepRun, StepSubHarness, StepTool, SubHarness, SubHarnessSessionPolicy, SubHarnessSettings, SubprocessAdapter, Tool } from '@noetic-tools/types';
3
3
  import type { ZodType } from 'zod';
4
4
  interface StepRunOpts<TMemory, I, O> {
5
5
  id: string;
@@ -18,8 +18,12 @@ interface StepLLMOpts<TMemory, O> {
18
18
  model: Lazy<string, TMemory>;
19
19
  /** Optional instructions; eager string or `(ctx) => string | undefined` getter. */
20
20
  instructions?: Lazy<string | undefined, TMemory>;
21
- /** Optional tools; eager array or `(ctx) => Tool[] | undefined` getter. */
22
- tools?: Lazy<Tool[] | undefined, TMemory>;
21
+ /**
22
+ * Optional tools; eager array or `(ctx) => (...)[] | undefined` getter. Each
23
+ * entry is either a client `Tool` or an inline `ServerToolSpec` (an OpenRouter
24
+ * server tool the provider executes, e.g. web search/fetch).
25
+ */
26
+ tools?: Lazy<(Tool | ServerToolSpec)[] | undefined, TMemory>;
23
27
  output?: ZodType<O>;
24
28
  params?: ModelParams;
25
29
  emit?: boolean | ((eventType: string, data: Record<string, unknown>) => boolean);
@@ -6,7 +6,7 @@
6
6
  * ones — they register in the step registry, carry retry policies, etc.
7
7
  */
8
8
  import type { ContextMemory, MemoryLayer } from '@noetic-tools/memory';
9
- import type { ExecuteStepFn, Step, SubHarness, SubHarnessKind, Tool } from '@noetic-tools/types';
9
+ import type { ExecuteStepFn, Step, SubHarness, SubHarnessKind, SubprocessAdapter, Tool } from '@noetic-tools/types';
10
10
  import type { WorkflowDocument, WorkflowNode } from '../schemas/workflow.js';
11
11
  /** @public Context required to hydrate a JSON workflow into live steps. */
12
12
  export interface HydrationContext {
@@ -15,6 +15,12 @@ export interface HydrationContext {
15
15
  layers?: ReadonlyMap<string, MemoryLayer>;
16
16
  /** SubHarness adapters keyed by harness id, resolving `claude-code`/`codex`/… nodes. */
17
17
  subHarnesses?: ReadonlyMap<SubHarnessKind, SubHarness>;
18
+ /**
19
+ * Resolves a named subprocess adapter ref declared on a `run` node's
20
+ * `subprocess` field. When a node omits the ref, the step falls back to
21
+ * `ctx.subprocess` at execution time.
22
+ */
23
+ resolveSubprocess?: (ref: string) => SubprocessAdapter | undefined;
18
24
  }
19
25
  /**
20
26
  * Hydrates a single `WorkflowNode` into a live `Step`.
@@ -5,7 +5,8 @@
5
5
  * `loop`, etc.) so hydrated steps are indistinguishable from programmatic
6
6
  * ones — they register in the step registry, carry retry policies, etc.
7
7
  */
8
- import { frameworkCast, NoeticConfigError } from '@noetic-tools/types';
8
+ import { frameworkCast, isServerToolSpec, NoeticConfigError } from '@noetic-tools/types';
9
+ import { DetachedHandleImpl } from '../runtime/detached-handle.js';
9
10
  import { all, any } from '../until/combinators.js';
10
11
  import { until } from '../until/predicates.js';
11
12
  import { branch, fork } from './control-flow-builders.js';
@@ -52,12 +53,34 @@ function hydrateLlmNode(node, ctx) {
52
53
  if (node.kind !== 'llm') {
53
54
  return frameworkCast(undefined);
54
55
  }
55
- const resolvedTools = resolveTools(node.tools, ctx.tools);
56
+ // Every `tools` entry is an object `{ type, parameters? }`. Partition by the
57
+ // `type` value: a reserved `openrouter:*` server-tool literal is a SERVER
58
+ // tool (flows through unchanged); any other `type` is a CLIENT tool whose
59
+ // `type` is the registered tool NAME (resolved from the registry; a client
60
+ // entry's `parameters` is ignored). Both kinds are combined back into one
61
+ // `tools` array; the interpreter partitions them again at execution (server
62
+ // specs bypass the client-tool machinery and reach the model call's
63
+ // server-tool channel).
64
+ const toolNames = [];
65
+ const serverSpecs = [];
66
+ for (const entry of node.tools ?? []) {
67
+ if (isServerToolSpec(entry)) {
68
+ serverSpecs.push(entry);
69
+ }
70
+ else {
71
+ toolNames.push(entry.type);
72
+ }
73
+ }
74
+ const resolvedTools = resolveTools(toolNames, ctx.tools);
75
+ const combined = [
76
+ ...resolvedTools,
77
+ ...serverSpecs,
78
+ ];
56
79
  return step.llm({
57
80
  id: node.id,
58
81
  model: node.model ?? 'openai/gpt-4o',
59
82
  instructions: node.instructions,
60
- tools: resolvedTools.length > 0 ? resolvedTools : undefined,
83
+ tools: combined.length > 0 ? combined : undefined,
61
84
  params: node.params,
62
85
  });
63
86
  }
@@ -106,6 +129,31 @@ function hydrateToolNode(node, ctx) {
106
129
  },
107
130
  }));
108
131
  }
132
+ function hydrateRunNode(node, ctx) {
133
+ if (node.kind !== 'run') {
134
+ return frameworkCast(undefined);
135
+ }
136
+ const code = node.execute;
137
+ const subprocessRef = node.subprocess;
138
+ return frameworkCast(step.run({
139
+ id: node.id,
140
+ retry: node.retry,
141
+ execute: async (input, execCtx) => {
142
+ const adapter = resolveSubprocessAdapter({
143
+ ref: subprocessRef,
144
+ hydrationCtx: ctx,
145
+ execCtx,
146
+ nodeId: node.id,
147
+ });
148
+ return runCodeViaSubprocess({
149
+ adapter,
150
+ nodeId: node.id,
151
+ code,
152
+ input: stringifyResult(input),
153
+ });
154
+ },
155
+ }));
156
+ }
109
157
  function hydrateBranchNode(node, ctx) {
110
158
  if (node.kind !== 'branch') {
111
159
  return frameworkCast(undefined);
@@ -137,14 +185,33 @@ function hydrateForkNode(node, ctx) {
137
185
  if (node.kind !== 'fork') {
138
186
  return frameworkCast(undefined);
139
187
  }
140
- const hydratedPaths = node.paths.map((p) => hydrateNode(p, ctx));
188
+ // Static fork: paths are known at hydration time and feed the optimizer.
189
+ // Dynamic fork (`each`): paths are produced per fork-input at runtime, one
190
+ // child per array item, so they cannot be pre-computed or optimized.
191
+ const dynamic = node.each !== undefined;
192
+ const eachTemplate = node.each;
193
+ const staticPaths = dynamic ? [] : (node.paths ?? []).map((p) => hydrateNode(p, ctx));
194
+ const pathsFactory = (input) => {
195
+ if (!dynamic || !eachTemplate) {
196
+ return staticPaths;
197
+ }
198
+ const items = selectArray(input, node.over, node.id);
199
+ return items.map((item, i) => buildPerItemStep({
200
+ forkId: node.id,
201
+ eachTemplate,
202
+ item,
203
+ index: i,
204
+ ctx,
205
+ }));
206
+ };
207
+ const optimizable = dynamic ? undefined : frameworkCast(staticPaths);
141
208
  if (node.mode === 'race') {
142
209
  return fork({
143
210
  id: node.id,
144
211
  mode: 'race',
145
- paths: () => hydratedPaths,
212
+ paths: pathsFactory,
146
213
  concurrency: node.concurrency,
147
- _optimizable: frameworkCast(hydratedPaths),
214
+ _optimizable: optimizable,
148
215
  });
149
216
  }
150
217
  const mergeFn = buildMerge(node.merge ?? 'last');
@@ -152,22 +219,22 @@ function hydrateForkNode(node, ctx) {
152
219
  return fork({
153
220
  id: node.id,
154
221
  mode: 'settle',
155
- paths: () => hydratedPaths,
222
+ paths: pathsFactory,
156
223
  merge: (results) => {
157
224
  const values = results.filter((r) => r.status === 'fulfilled').map((r) => r.value ?? '');
158
225
  return mergeFn(values);
159
226
  },
160
227
  concurrency: node.concurrency,
161
- _optimizable: frameworkCast(hydratedPaths),
228
+ _optimizable: optimizable,
162
229
  });
163
230
  }
164
231
  return fork({
165
232
  id: node.id,
166
233
  mode: 'all',
167
- paths: () => hydratedPaths,
234
+ paths: pathsFactory,
168
235
  merge: mergeFn,
169
236
  concurrency: node.concurrency,
170
- _optimizable: frameworkCast(hydratedPaths),
237
+ _optimizable: optimizable,
171
238
  });
172
239
  }
173
240
  function hydrateSpawnNode(node, ctx) {
@@ -281,6 +348,7 @@ function hydrateSubHarnessNode(node, ctx) {
281
348
  const NODE_HYDRATORS = {
282
349
  llm: hydrateLlmNode,
283
350
  tool: hydrateToolNode,
351
+ run: hydrateRunNode,
284
352
  branch: hydrateBranchNode,
285
353
  fork: hydrateForkNode,
286
354
  spawn: hydrateSpawnNode,
@@ -332,6 +400,148 @@ function stringifyResult(value) {
332
400
  return JSON.stringify(value);
333
401
  }
334
402
  //#endregion
403
+ //#region Dynamic Fork Helpers
404
+ /**
405
+ * Parses the fork input (a JSON string) and locates the array to fan out over.
406
+ * When `over` is set, reads that property off the parsed object; otherwise the
407
+ * parsed value itself must be an array.
408
+ */
409
+ function selectArray(input, over, nodeId) {
410
+ let parsed;
411
+ try {
412
+ parsed = JSON.parse(input);
413
+ }
414
+ catch {
415
+ throw new NoeticConfigError({
416
+ code: 'INVALID_FORK_INPUT',
417
+ message: `Dynamic fork '${nodeId}' could not parse its input as JSON.`,
418
+ hint: 'A dynamic fork (with `each`) expects its input to be a JSON array (or a JSON object when `over` is set).',
419
+ });
420
+ }
421
+ const candidate = over === undefined ? parsed : frameworkCast(parsed)?.[over];
422
+ if (!Array.isArray(candidate)) {
423
+ throw new NoeticConfigError({
424
+ code: 'INVALID_FORK_INPUT',
425
+ message: `Dynamic fork '${nodeId}' did not resolve an array${over ? ` at key '${over}'` : ''}.`,
426
+ hint: over
427
+ ? `Ensure the input JSON object has an array at '${over}'.`
428
+ : 'Ensure the input is a JSON array, or set `over` to select an array property.',
429
+ });
430
+ }
431
+ return candidate;
432
+ }
433
+ /**
434
+ * Builds one fork path for a single dynamic-fork item. The item is injected as
435
+ * the body's input (forks pass the same fork-input to every path), and the
436
+ * template's node ids are suffixed with `-${i}` so each instantiation has
437
+ * unique ids for tracing and step-registry uniqueness.
438
+ */
439
+ function buildPerItemStep(opts) {
440
+ const { forkId, eachTemplate, item, index, ctx } = opts;
441
+ const hydratedEach = hydrateNode(suffixNodeIds(eachTemplate, `-${index}`), ctx);
442
+ return frameworkCast(step.run({
443
+ id: `${forkId}-item-${index}`,
444
+ execute: async (_input, execCtx) => {
445
+ const itemInput = JSON.stringify(item);
446
+ return stringifyResult(await ctx.executeStep(hydratedEach, itemInput, execCtx));
447
+ },
448
+ }));
449
+ }
450
+ /** Keys whose values are opaque data bags, never child workflow nodes. */
451
+ const NON_NODE_KEYS = new Set([
452
+ 'args',
453
+ 'params',
454
+ 'parameters',
455
+ ]);
456
+ /**
457
+ * Deep-clones a workflow node template, appending `suffix` to the `id` of every
458
+ * nested node so a per-item instantiation has globally-unique step ids.
459
+ */
460
+ function suffixNodeIds(node, suffix) {
461
+ const clone = structuredClone(node);
462
+ const walk = (value) => {
463
+ if (!value || typeof value !== 'object') {
464
+ return;
465
+ }
466
+ if (Array.isArray(value)) {
467
+ for (const entry of value) {
468
+ walk(entry);
469
+ }
470
+ return;
471
+ }
472
+ const record = frameworkCast(value);
473
+ if (typeof record.kind === 'string' && typeof record.id === 'string') {
474
+ record.id = `${record.id}${suffix}`;
475
+ }
476
+ for (const [key, child] of Object.entries(record)) {
477
+ if (NON_NODE_KEYS.has(key)) {
478
+ continue;
479
+ }
480
+ walk(child);
481
+ }
482
+ };
483
+ walk(clone);
484
+ return clone;
485
+ }
486
+ //#endregion
487
+ //#region Run Node Helpers
488
+ /**
489
+ * Resolves the subprocess adapter for a `run` node. When the node names an
490
+ * adapter ref, the host must supply a `resolveSubprocess` resolver; otherwise
491
+ * the step falls back to the harness adapter on the execution context.
492
+ */
493
+ function resolveSubprocessAdapter(opts) {
494
+ const { ref, hydrationCtx, execCtx, nodeId } = opts;
495
+ if (ref === undefined) {
496
+ return execCtx.subprocess;
497
+ }
498
+ const resolved = hydrationCtx.resolveSubprocess?.(ref);
499
+ if (!resolved) {
500
+ throw new NoeticConfigError({
501
+ code: 'UNKNOWN_SUBPROCESS_REFERENCE',
502
+ message: `Subprocess adapter '${ref}' referenced in run node '${nodeId}' could not be resolved.`,
503
+ hint: 'Pass a `resolveSubprocess(ref)` mapping in the HydrationContext, or omit `subprocess` to use the harness default (ctx.subprocess).',
504
+ });
505
+ }
506
+ return resolved;
507
+ }
508
+ /**
509
+ * Dispatches a `run` node's code string through a subprocess adapter: ships the
510
+ * code plus the JSON-stringified input as a process request (input on stdin),
511
+ * waits for the handle to settle, and returns the captured stdout as the step
512
+ * output. A non-zero exit / failed handle surfaces as a thrown error.
513
+ *
514
+ * The code is never eval'd in-process (Cloudflare Workers forbid eval). The
515
+ * adapter owns running the code and capturing stdout into `handle.metadata.result`.
516
+ */
517
+ async function runCodeViaSubprocess(opts) {
518
+ const { adapter, nodeId, code, input } = opts;
519
+ const request = {
520
+ kind: 'process',
521
+ command: 'node',
522
+ args: [
523
+ '-e',
524
+ code,
525
+ ],
526
+ stdin: input,
527
+ metadata: {
528
+ noeticRun: true,
529
+ stepId: nodeId,
530
+ code,
531
+ input,
532
+ },
533
+ };
534
+ const spawnPromise = adapter.spawn(request);
535
+ const handle = new DetachedHandleImpl({
536
+ id: `run-${nodeId}`,
537
+ stepId: nodeId,
538
+ adapter,
539
+ spawnPromise,
540
+ });
541
+ const result = await handle.await();
542
+ return stringifyResult(result);
543
+ }
544
+ //#endregion
335
545
  //#region Public API
336
546
  /**
337
547
  * Hydrates a single `WorkflowNode` into a live `Step`.
@@ -1,4 +1,5 @@
1
1
  import { frameworkCast, NoeticConfigError } from '@noetic-tools/types';
2
+ import { serverTool } from '@openrouter/agent';
2
3
  import { z } from 'zod';
3
4
  import { convertTools, executeToolCall, extractOutputItems, extractSystemInstruction, extractUsage, itemsToInput, sanitizeToolNameForWire, } from '../adapters/openrouter.js';
4
5
  import { isFunctionCall } from '../interpreter/typeguards.js';
@@ -188,15 +189,29 @@ function prepareModelRequest(request, agentName) {
188
189
  .join('\n\n') || undefined;
189
190
  const broadcaster = getBroadcaster(request.ctx);
190
191
  const filteredTools = filterAllowedTools(request);
192
+ // Client (function) tools convert to SDK tool definitions; OpenRouter server
193
+ // tools (web_search/web_fetch) are wrapped via the SDK's `serverTool()` so
194
+ // its convertToolsToAPIFormat accepts them — a raw `{type}` entry crashes it.
195
+ // `parameters` keys stay camelCase: the SDK's outbound zod schema strips
196
+ // unknown keys, so snake_case would be silently dropped.
197
+ const clientSdkTools = filteredTools && filteredTools.length > 0
198
+ ? convertTools({
199
+ tools: filteredTools,
200
+ })
201
+ : [];
202
+ const serverSdkTools = (request._serverTools ?? []).map((s) => serverTool(frameworkCast({
203
+ type: s.type,
204
+ parameters: s.parameters,
205
+ })));
206
+ const sdkTools = [
207
+ ...clientSdkTools,
208
+ ...frameworkCast(serverSdkTools),
209
+ ];
191
210
  return {
192
211
  instructions,
193
212
  remaining,
194
213
  broadcaster,
195
- sdkTools: filteredTools && filteredTools.length > 0
196
- ? convertTools({
197
- tools: filteredTools,
198
- })
199
- : undefined,
214
+ sdkTools: sdkTools.length > 0 ? sdkTools : undefined,
200
215
  emitIfAllowed(eventType, data) {
201
216
  if (!shouldEmit(request.emit, eventType, data)) {
202
217
  return;
package/dist/index.d.ts CHANGED
@@ -115,7 +115,9 @@ export { MergeStrategySchema, UntilPredicateSchema, validateWorkflow, WorkflowDo
115
115
  export type { Channel, ChannelHandle, ExternalChannel } from '@noetic-tools/types';
116
116
  /** @public */
117
117
  /** @public */
118
- export type { LLMResponse, LlmProviderConfig, ModelParams, RetryPolicy, StepMeta, TokenUsage, Tool, ToolMemoryDeclaration, } from '@noetic-tools/types';
118
+ export type { LLMResponse, LlmProviderConfig, ModelParams, RetryPolicy, ServerToolSpec, StepMeta, TokenUsage, Tool, ToolMemoryDeclaration, } from '@noetic-tools/types';
119
+ /** @public */
120
+ export { isServerToolSpec } from '@noetic-tools/types';
119
121
  /** @public */
120
122
  /** @public */
121
123
  /** @public */
package/dist/index.js CHANGED
@@ -98,6 +98,8 @@ export { defaultItemSchemaRegistry, ItemSchema, ItemSchemaRegistry } from '@noet
98
98
  /** @public */
99
99
  export { MergeStrategySchema, UntilPredicateSchema, validateWorkflow, WorkflowDocumentSchema, WorkflowNodeSchema, walkWorkflow, workflowDepth, } from './schemas/workflow.js';
100
100
  /** @public */
101
+ export { isServerToolSpec } from '@noetic-tools/types';
102
+ /** @public */
101
103
  export { getToolCwd, setToolCwd, snapshotCwdState } from './runtime/cwd-helpers.js';
102
104
  /** @public */
103
105
  export { DetachedStatus } from '@noetic-tools/types';
@@ -1,3 +1,3 @@
1
1
  export type { ContextMemory, ExecutionContext, MemoryConfig, MemoryLayer, } from '@noetic-tools/memory';
2
- export type { AgentHarnessContract, Context, ContextRerenderRequest, ExecuteStepFn, FunctionCallItem, Item, ProjectionPolicy, RecallLayerOutput, RetryPolicy, StepLLM, StepMeta, StepProvide, StepRun, StepSpawn, StepTool, Tool, } from '@noetic-tools/types';
3
- export { SteeringAction } from '@noetic-tools/types';
2
+ export type { AgentHarnessContract, Context, ContextRerenderRequest, ExecuteStepFn, FunctionCallItem, Item, ProjectionPolicy, RecallLayerOutput, RetryPolicy, ServerToolSpec, StepLLM, StepMeta, StepProvide, StepRun, StepSpawn, StepTool, Tool, } from '@noetic-tools/types';
3
+ export { isServerToolSpec, SteeringAction } from '@noetic-tools/types';
@@ -1 +1 @@
1
- export { SteeringAction } from '@noetic-tools/types';
1
+ export { isServerToolSpec, SteeringAction } from '@noetic-tools/types';
@@ -1,4 +1,4 @@
1
- import { frameworkCast } from '@noetic-tools/types';
1
+ import { frameworkCast, isServerToolSpec } from '@noetic-tools/types';
2
2
  /**
3
3
  * Recursively walks a step tree and collects all Tool instances
4
4
  * declared on LLM steps. Used to build the unified tool set
@@ -31,9 +31,14 @@ function walkStep(step, out) {
31
31
  // Skip function-form tools: they're resolved per execution from `ctx`
32
32
  // and cannot contribute to the pre-computed unified set. Consumers that
33
33
  // need those tools in the pool should register them via
34
- // `AgentHarness.tools` instead.
34
+ // `AgentHarness.tools` instead. Inline server-tool specs (web search/
35
+ // fetch) are not client `Tool`s — they never enter the unified pool.
35
36
  if (Array.isArray(step.tools)) {
36
- out.push(...step.tools);
37
+ for (const entry of step.tools) {
38
+ if (!isServerToolSpec(entry)) {
39
+ out.push(entry);
40
+ }
41
+ }
37
42
  }
38
43
  return;
39
44
  case 'run':
@@ -4,7 +4,7 @@
4
4
  import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
5
5
  import { ZodError } from 'zod';
6
6
  import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, resolveLayerTools, returnLayers, snapshotCwdState, spawnLayers, } from './action-deps.js';
7
- import { SteeringAction } from './action-types.js';
7
+ import { isServerToolSpec, SteeringAction } from './action-types.js';
8
8
  import { cloneWithGuard } from './clone-guard.js';
9
9
  import { collectAllTools, deduplicateTools } from './collect-tools.js';
10
10
  import { trackUsage } from './message-helpers.js';
@@ -180,6 +180,26 @@ export async function executeLLM(step, input, ctx, layers) {
180
180
  }
181
181
  const resolvedInstructions = await resolveLazy(step.instructions, ctx);
182
182
  const resolvedStepTools = await resolveLazy(step.tools, ctx);
183
+ // `step.tools` is heterogeneous: client `Tool`s plus inline OpenRouter
184
+ // server-tool specs (web search/fetch). Partition them — client tools flow
185
+ // through the unified-tool / allowedToolNames / steering machinery as before;
186
+ // server-tool specs bypass it entirely and are stamped onto the model request
187
+ // (`_serverTools`) for the model caller to wrap via the SDK's `serverTool()`.
188
+ // `undefined` (unrestricted) is preserved as-is; only a present array is
189
+ // partitioned so the `[] = opt-out` semantics of step.tools stay intact.
190
+ let clientStepTools;
191
+ const serverToolSpecs = [];
192
+ if (resolvedStepTools !== undefined) {
193
+ clientStepTools = [];
194
+ for (const entry of resolvedStepTools) {
195
+ if (isServerToolSpec(entry)) {
196
+ serverToolSpecs.push(entry);
197
+ }
198
+ else {
199
+ clientStepTools.push(entry);
200
+ }
201
+ }
202
+ }
183
203
  // Append user input — through layer pipeline if layers exist, otherwise direct.
184
204
  // The append pipeline may request a context re-render (e.g. a layer that
185
205
  // expanded an input reference into new content); applied after recall below.
@@ -196,7 +216,7 @@ export async function executeLLM(step, input, ctx, layers) {
196
216
  baseCtx.itemLog.append(createMessage(input, 'user'));
197
217
  }
198
218
  }
199
- const { tools: resolvedTools, allowedToolNames } = resolveToolsAndRestrictions(resolvedStepTools, layers, baseCtx);
219
+ const { tools: resolvedTools, allowedToolNames } = resolveToolsAndRestrictions(clientStepTools, layers, baseCtx);
200
220
  // Resolve the projection policy (step override > harness default > fallback)
201
221
  // and split the context budget across layers via allocateBudgets.
202
222
  const policy = step.projection ?? baseCtx.harness.config.projection ?? DEFAULT_PROJECTION;
@@ -281,6 +301,7 @@ export async function executeLLM(step, input, ctx, layers) {
281
301
  ...projectedHistoryItems,
282
302
  ];
283
303
  }
304
+ const _serverTools = serverToolSpecs.length > 0 ? serverToolSpecs : undefined;
284
305
  const request = resolvedTools
285
306
  ? {
286
307
  model: resolvedModel,
@@ -290,6 +311,7 @@ export async function executeLLM(step, input, ctx, layers) {
290
311
  params: step.params,
291
312
  outputSchema: step.output,
292
313
  emit: step.emit,
314
+ _serverTools,
293
315
  ctx: baseCtx,
294
316
  layers,
295
317
  allowedToolNames,
@@ -303,6 +325,7 @@ export async function executeLLM(step, input, ctx, layers) {
303
325
  params: step.params,
304
326
  outputSchema: step.output,
305
327
  emit: step.emit,
328
+ _serverTools,
306
329
  nodeId: step.id,
307
330
  parentSpan: baseCtx.span,
308
331
  };
@@ -2,9 +2,11 @@
2
2
  * Zod schemas for JSON-serialisable workflow definitions.
3
3
  *
4
4
  * A `WorkflowDocument` is a portable, JSON-safe representation of a noetic
5
- * step tree. It covers every step kind except `run` (which carries arbitrary
6
- * closures). The hydrator in `builders/workflow-hydrator.ts` converts a
7
- * validated document back into live `Step` objects via the existing builders.
5
+ * step tree. It covers every step kind. The `run` node carries its body as a
6
+ * code STRING (not an in-process closure) dispatched through a subprocess
7
+ * adapter, keeping the document JSON-safe. The hydrator in
8
+ * `builders/workflow-hydrator.ts` converts a validated document back into live
9
+ * `Step` objects via the existing builders.
8
10
  */
9
11
  import type { SubHarnessKind } from '@noetic-tools/types';
10
12
  import { z } from 'zod';
@@ -60,6 +62,35 @@ declare const ModelParamsSchema: z.ZodObject<{
60
62
  maxTokens: z.ZodOptional<z.ZodNumber>;
61
63
  stopSequences: z.ZodOptional<z.ZodArray<z.ZodString>>;
62
64
  }, z.core.$strip>;
65
+ /**
66
+ * A uniform tool entry on an `llm` node. Every entry is an object keyed by
67
+ * `type`:
68
+ * - A CLIENT tool is `{ type: "<registered-tool-name>" }`. The hydrator
69
+ * resolves `type` against the tool registry; `parameters` (if present) is
70
+ * ignored — client tools receive their call args from the model at runtime.
71
+ * - A SERVER tool is `{ type: "openrouter:web_search" | "openrouter:web_fetch",
72
+ * parameters?: {...} }`. The provider executes it; `parameters` keys are
73
+ * camelCase (e.g. `maxResults`, `searchContextSize`) — the SDK re-serialises
74
+ * them and silently drops unknown keys.
75
+ *
76
+ * Server vs client is decided by the `type` value (a reserved `openrouter:*`
77
+ * server-tool literal vs an arbitrary tool name), not by the entry's shape.
78
+ */
79
+ declare const LlmToolEntrySchema: z.ZodObject<{
80
+ type: z.ZodString;
81
+ parameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
82
+ }, z.core.$strip>;
83
+ /** Retry policy for a `run` node, mirroring `RetryPolicy` in `@noetic-tools/types`. */
84
+ declare const RetryPolicySchema: z.ZodObject<{
85
+ maxAttempts: z.ZodNumber;
86
+ backoff: z.ZodEnum<{
87
+ fixed: "fixed";
88
+ linear: "linear";
89
+ exponential: "exponential";
90
+ }>;
91
+ initialDelay: z.ZodNumber;
92
+ maxDelay: z.ZodOptional<z.ZodNumber>;
93
+ }, z.core.$strip>;
63
94
  declare const HarnessSettingsSchema: z.ZodObject<{
64
95
  model: z.ZodOptional<z.ZodString>;
65
96
  permissionMode: z.ZodOptional<z.ZodEnum<{
@@ -87,7 +118,14 @@ export interface LlmWorkflowNode extends WorkflowNodeBase {
87
118
  kind: 'llm';
88
119
  model?: string;
89
120
  instructions: string;
90
- tools?: string[];
121
+ /**
122
+ * Tool entries. Every entry is an object `{ type, parameters? }`. A CLIENT
123
+ * tool is `{ type: "<registered-tool-name>" }` (resolved from the registry);
124
+ * a SERVER tool is `{ type: "openrouter:web_search" | "openrouter:web_fetch",
125
+ * parameters?: {...} }` (executed by the provider). Client vs server is
126
+ * decided by the `type` value.
127
+ */
128
+ tools?: z.infer<typeof LlmToolEntrySchema>[];
91
129
  params?: z.infer<typeof ModelParamsSchema>;
92
130
  }
93
131
  export interface ToolWorkflowNode extends WorkflowNodeBase {
@@ -95,6 +133,23 @@ export interface ToolWorkflowNode extends WorkflowNodeBase {
95
133
  toolName: string;
96
134
  args?: Record<string, unknown>;
97
135
  }
136
+ /**
137
+ * A node that runs a serialised code body. Unlike the programmatic `step.run`
138
+ * (which carries a closure), the JSON form carries `execute` as a code STRING.
139
+ * The code is never eval'd in-process (Cloudflare Workers forbid eval); it is
140
+ * dispatched through a subprocess adapter (`ctx.subprocess`, or a named adapter
141
+ * resolved by ref). Execution therefore requires a subprocess adapter capable
142
+ * of running the code and returning its stdout.
143
+ */
144
+ export interface RunWorkflowNode extends WorkflowNodeBase {
145
+ kind: 'run';
146
+ /** Source code executed in the subprocess. Receives the step input on stdin. */
147
+ execute: string;
148
+ /** Optional retry policy applied to the step. */
149
+ retry?: z.infer<typeof RetryPolicySchema>;
150
+ /** Named subprocess adapter ref; defaults to `ctx.subprocess` when omitted. */
151
+ subprocess?: string;
152
+ }
98
153
  export interface BranchRoute {
99
154
  match: string;
100
155
  target: WorkflowNode;
@@ -107,7 +162,22 @@ export interface BranchWorkflowNode extends WorkflowNodeBase {
107
162
  export interface ForkWorkflowNode extends WorkflowNodeBase {
108
163
  kind: 'fork';
109
164
  mode: 'race' | 'all' | 'settle';
110
- paths: WorkflowNode[];
165
+ /**
166
+ * Static fan-out: one child per entry. Mutually exclusive with `each` —
167
+ * supply exactly one.
168
+ */
169
+ paths?: WorkflowNode[];
170
+ /**
171
+ * Dynamic fan-out: instantiate this body template once per item of a
172
+ * runtime-produced array (data-dependent N). Mutually exclusive with `paths`.
173
+ */
174
+ each?: WorkflowNode;
175
+ /**
176
+ * Selector key into the fork input (parsed as JSON) locating the array to
177
+ * fan out over. When omitted, the input string itself is parsed as a JSON
178
+ * array. Only meaningful with `each`.
179
+ */
180
+ over?: string;
111
181
  merge?: MergeStrategy;
112
182
  concurrency?: number;
113
183
  }
@@ -150,7 +220,7 @@ export interface SubHarnessWorkflowNode extends WorkflowNodeBase {
150
220
  session?: z.infer<typeof HarnessSessionPolicySchema>;
151
221
  }
152
222
  /** @public Discriminated union of all JSON-serialisable workflow node kinds. */
153
- export type WorkflowNode = LlmWorkflowNode | ToolWorkflowNode | BranchWorkflowNode | ForkWorkflowNode | SpawnWorkflowNode | ProvideWorkflowNode | LoopWorkflowNode | SequenceWorkflowNode | EveryWorkflowNode | SubHarnessWorkflowNode;
223
+ export type WorkflowNode = LlmWorkflowNode | ToolWorkflowNode | RunWorkflowNode | BranchWorkflowNode | ForkWorkflowNode | SpawnWorkflowNode | ProvideWorkflowNode | LoopWorkflowNode | SequenceWorkflowNode | EveryWorkflowNode | SubHarnessWorkflowNode;
154
224
  /** @public Zod schema validating a single `WorkflowNode` (any JSON-safe kind). */
155
225
  export declare const WorkflowNodeSchema: z.ZodType<WorkflowNode>;
156
226
  /** @public Top-level envelope for a JSON workflow definition. */
@@ -2,9 +2,11 @@
2
2
  * Zod schemas for JSON-serialisable workflow definitions.
3
3
  *
4
4
  * A `WorkflowDocument` is a portable, JSON-safe representation of a noetic
5
- * step tree. It covers every step kind except `run` (which carries arbitrary
6
- * closures). The hydrator in `builders/workflow-hydrator.ts` converts a
7
- * validated document back into live `Step` objects via the existing builders.
5
+ * step tree. It covers every step kind. The `run` node carries its body as a
6
+ * code STRING (not an in-process closure) dispatched through a subprocess
7
+ * adapter, keeping the document JSON-safe. The hydrator in
8
+ * `builders/workflow-hydrator.ts` converts a validated document back into live
9
+ * `Step` objects via the existing builders.
8
10
  */
9
11
  import { z } from 'zod';
10
12
  //#endregion
@@ -85,6 +87,39 @@ const ModelParamsSchema = z.object({
85
87
  stopSequences: z.array(z.string()).optional(),
86
88
  });
87
89
  //#endregion
90
+ //#region Tool Entries
91
+ /**
92
+ * A uniform tool entry on an `llm` node. Every entry is an object keyed by
93
+ * `type`:
94
+ * - A CLIENT tool is `{ type: "<registered-tool-name>" }`. The hydrator
95
+ * resolves `type` against the tool registry; `parameters` (if present) is
96
+ * ignored — client tools receive their call args from the model at runtime.
97
+ * - A SERVER tool is `{ type: "openrouter:web_search" | "openrouter:web_fetch",
98
+ * parameters?: {...} }`. The provider executes it; `parameters` keys are
99
+ * camelCase (e.g. `maxResults`, `searchContextSize`) — the SDK re-serialises
100
+ * them and silently drops unknown keys.
101
+ *
102
+ * Server vs client is decided by the `type` value (a reserved `openrouter:*`
103
+ * server-tool literal vs an arbitrary tool name), not by the entry's shape.
104
+ */
105
+ const LlmToolEntrySchema = z.object({
106
+ type: z.string().min(1),
107
+ parameters: z.record(z.string(), z.unknown()).optional(),
108
+ });
109
+ //#endregion
110
+ //#region Retry Policy
111
+ /** Retry policy for a `run` node, mirroring `RetryPolicy` in `@noetic-tools/types`. */
112
+ const RetryPolicySchema = z.object({
113
+ maxAttempts: z.number().int().positive(),
114
+ backoff: z.enum([
115
+ 'fixed',
116
+ 'linear',
117
+ 'exponential',
118
+ ]),
119
+ initialDelay: z.number().nonnegative(),
120
+ maxDelay: z.number().nonnegative().optional(),
121
+ });
122
+ //#endregion
88
123
  //#region SubHarness Settings
89
124
  const HarnessSettingsSchema = z.object({
90
125
  model: z.string().optional(),
@@ -121,7 +156,7 @@ const LlmNodeSchema = z.object({
121
156
  ...SHARED_FIELDS,
122
157
  model: z.string().optional(),
123
158
  instructions: z.string(),
124
- tools: z.array(z.string()).optional(),
159
+ tools: z.array(LlmToolEntrySchema).optional(),
125
160
  params: ModelParamsSchema.optional(),
126
161
  });
127
162
  const ToolNodeSchema = z.object({
@@ -130,6 +165,13 @@ const ToolNodeSchema = z.object({
130
165
  toolName: z.string().min(1),
131
166
  args: z.record(z.string(), z.unknown()).optional(),
132
167
  });
168
+ const RunNodeSchema = z.object({
169
+ kind: z.literal('run'),
170
+ ...SHARED_FIELDS,
171
+ execute: z.string().min(1),
172
+ retry: RetryPolicySchema.optional(),
173
+ subprocess: z.string().min(1).optional(),
174
+ });
133
175
  const BranchRouteSchema = z.object({
134
176
  match: z.string().min(1),
135
177
  target: z.lazy(() => WorkflowNodeSchema),
@@ -140,7 +182,8 @@ const BranchNodeSchema = z.object({
140
182
  routes: z.array(BranchRouteSchema).min(1),
141
183
  default: WorkflowNodeRef.optional(),
142
184
  });
143
- const ForkNodeSchema = z.object({
185
+ const ForkNodeSchema = z
186
+ .object({
144
187
  kind: z.literal('fork'),
145
188
  ...SHARED_FIELDS,
146
189
  mode: z.enum([
@@ -148,9 +191,14 @@ const ForkNodeSchema = z.object({
148
191
  'all',
149
192
  'settle',
150
193
  ]),
151
- paths: z.array(WorkflowNodeRef).min(1),
194
+ paths: z.array(WorkflowNodeRef).min(1).optional(),
195
+ each: WorkflowNodeRef.optional(),
196
+ over: z.string().min(1).optional(),
152
197
  merge: MergeStrategySchema.optional(),
153
198
  concurrency: z.number().int().positive().optional(),
199
+ })
200
+ .refine((node) => (node.paths === undefined) !== (node.each === undefined), {
201
+ message: "fork node requires exactly one of 'paths' (static) or 'each' (dynamic).",
154
202
  });
155
203
  const SpawnNodeSchema = z.object({
156
204
  kind: z.literal('spawn'),
@@ -208,6 +256,7 @@ export const WorkflowNodeSchema = z
208
256
  .discriminatedUnion('kind', [
209
257
  LlmNodeSchema,
210
258
  ToolNodeSchema,
259
+ RunNodeSchema,
211
260
  BranchNodeSchema,
212
261
  ForkNodeSchema,
213
262
  SpawnNodeSchema,
@@ -243,7 +292,12 @@ function childNodes(node) {
243
292
  case 'sequence':
244
293
  return node.steps;
245
294
  case 'fork':
246
- return node.paths;
295
+ if (node.each) {
296
+ return [
297
+ node.each,
298
+ ];
299
+ }
300
+ return node.paths ?? [];
247
301
  case 'spawn':
248
302
  case 'provide':
249
303
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "Step primitives, interpreter, runtime, and memory layer contract for Noetic agents.",
6
6
  "license": "Apache-2.0",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@noetic-tools/memory": "^1.0.1",
70
- "@noetic-tools/types": "^1.1.0",
70
+ "@noetic-tools/types": "^1.2.0",
71
71
  "@openrouter/agent": "^0.6.0",
72
72
  "zod": "^4.0.0"
73
73
  },
@@ -41,7 +41,24 @@
41
41
  "tools": {
42
42
  "type": "array",
43
43
  "items": {
44
- "type": "string"
44
+ "type": "object",
45
+ "properties": {
46
+ "type": {
47
+ "type": "string",
48
+ "minLength": 1
49
+ },
50
+ "parameters": {
51
+ "type": "object",
52
+ "propertyNames": {
53
+ "type": "string"
54
+ },
55
+ "additionalProperties": {}
56
+ }
57
+ },
58
+ "required": [
59
+ "type"
60
+ ],
61
+ "additionalProperties": false
45
62
  }
46
63
  },
47
64
  "params": {
@@ -108,6 +125,65 @@
108
125
  ],
109
126
  "additionalProperties": false
110
127
  },
128
+ {
129
+ "type": "object",
130
+ "properties": {
131
+ "kind": {
132
+ "type": "string",
133
+ "const": "run"
134
+ },
135
+ "id": {
136
+ "type": "string",
137
+ "minLength": 1
138
+ },
139
+ "execute": {
140
+ "type": "string",
141
+ "minLength": 1
142
+ },
143
+ "retry": {
144
+ "type": "object",
145
+ "properties": {
146
+ "maxAttempts": {
147
+ "type": "integer",
148
+ "exclusiveMinimum": 0,
149
+ "maximum": 9007199254740991
150
+ },
151
+ "backoff": {
152
+ "type": "string",
153
+ "enum": [
154
+ "fixed",
155
+ "linear",
156
+ "exponential"
157
+ ]
158
+ },
159
+ "initialDelay": {
160
+ "type": "number",
161
+ "minimum": 0
162
+ },
163
+ "maxDelay": {
164
+ "type": "number",
165
+ "minimum": 0
166
+ }
167
+ },
168
+ "required": [
169
+ "maxAttempts",
170
+ "backoff",
171
+ "initialDelay"
172
+ ],
173
+ "additionalProperties": false
174
+ },
175
+ "subprocess": {
176
+ "type": "string",
177
+ "minLength": 1
178
+ }
179
+ },
180
+ "required": [
181
+ "kind",
182
+ "id",
183
+ "execute"
184
+ ],
185
+ "additionalProperties": false
186
+ },
111
187
  {
112
188
  "type": "object",
113
189
  "properties": {
@@ -177,6 +253,13 @@
177
253
  "$ref": "#/$defs/WorkflowNode"
178
254
  }
179
255
  },
256
+ "each": {
257
+ "$ref": "#/$defs/WorkflowNode"
258
+ },
259
+ "over": {
260
+ "type": "string",
261
+ "minLength": 1
262
+ },
180
263
  "merge": {
181
264
  "type": "string",
182
265
  "enum": [
@@ -194,8 +277,7 @@
194
277
  "required": [
195
278
  "kind",
196
279
  "id",
197
- "mode",
198
- "paths"
280
+ "mode"
199
281
  ],
200
282
  "additionalProperties": false
201
283
  },