@noetic-tools/core 2.0.0 → 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.
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Action step handlers: run, llm, spawn, provide, tool.
3
3
  */
4
- import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
4
+ import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, isOutputCodec, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
5
5
  import { ZodError } from 'zod';
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';
6
+ import { emitToolUi } from '../runtime/tool-ui.js';
7
+ import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, resolveLayerTools, returnLayers, shouldEmit, snapshotCwdState, spawnLayers, } from './action-deps.js';
8
+ import { isServerToolSpec, SteeringAction } from './action-types.js';
8
9
  import { cloneWithGuard } from './clone-guard.js';
9
10
  import { collectAllTools, deduplicateTools } from './collect-tools.js';
10
11
  import { trackUsage } from './message-helpers.js';
@@ -164,6 +165,97 @@ async function runInputPipeline({ ctx, layers, input, }) {
164
165
  }
165
166
  return rerenderRequests;
166
167
  }
168
+ /**
169
+ * Classify a step's `output` into the JSON-schema vs streaming-codec modes.
170
+ *
171
+ * An `OutputCodec` (e.g. OpenUI Lang) carries its own system-prompt fragment
172
+ * and is NOT a JSON schema — its `instructions` fold into the system prompt and
173
+ * it is kept off `outputSchema` (which drives JSON-schema formatting).
174
+ */
175
+ function resolveOutputMode(output, baseInstructions) {
176
+ // A generic type predicate does not reliably narrow a `ZodType<O> |
177
+ // OutputCodec<O>` union through a free type parameter, so bridge the two
178
+ // branches with the sanctioned framework cast — `isOutputCodec` guarantees
179
+ // the discriminant at runtime.
180
+ const isCodec = isOutputCodec(output);
181
+ const outputCodec = isCodec ? frameworkCast(output) : undefined;
182
+ const outputSchema = isCodec ? undefined : frameworkCast(output);
183
+ const codecInstructions = outputCodec?.instructions;
184
+ const resolvedInstructions = codecInstructions !== undefined
185
+ ? [
186
+ baseInstructions,
187
+ codecInstructions,
188
+ ]
189
+ .filter((s) => s !== undefined)
190
+ .join('\n\n')
191
+ : baseInstructions;
192
+ return {
193
+ outputCodec,
194
+ outputSchema,
195
+ resolvedInstructions,
196
+ };
197
+ }
198
+ /**
199
+ * Drive an `OutputCodec` with the finished assistant text: emit its `openui.*`
200
+ * framework events (gated by `step.emit`) and return the codec's typed value.
201
+ * True per-delta streaming lives in the model caller; here the statements are
202
+ * delivered at turn finalization, which the transport still forwards.
203
+ */
204
+ function finalizeCodecOutput(codec, lastText, ctx, emit) {
205
+ const session = codec.start();
206
+ const broadcaster = getBroadcaster(ctx);
207
+ const agentName = ctx.harness.config.name;
208
+ session.push(lastText, (eventType, data) => {
209
+ if (shouldEmit(emit, eventType, data)) {
210
+ emitFrameworkEvent({
211
+ broadcaster,
212
+ agentName,
213
+ eventType,
214
+ data,
215
+ });
216
+ }
217
+ });
218
+ return session.finish(lastText);
219
+ }
220
+ /** JSON-parse + Zod-validate the assistant text, raising `llm_parse_error`. */
221
+ function parseSchemaOutput(schema, lastText, stepId) {
222
+ try {
223
+ return schema.parse(JSON.parse(lastText));
224
+ }
225
+ catch (e) {
226
+ if (e instanceof SyntaxError || e instanceof ZodError) {
227
+ throw new NoeticErrorImpl({
228
+ kind: 'llm_parse_error',
229
+ stepId,
230
+ raw: lastText,
231
+ schema,
232
+ zodError: e instanceof ZodError
233
+ ? e
234
+ : new ZodError([
235
+ {
236
+ code: 'custom',
237
+ message: `Invalid JSON: ${e.message}`,
238
+ path: [],
239
+ },
240
+ ]),
241
+ });
242
+ }
243
+ throw e;
244
+ }
245
+ }
246
+ /**
247
+ * Turn the finished assistant text into the step's typed output: a streaming
248
+ * codec, a Zod schema, or raw text passthrough.
249
+ */
250
+ function finalizeStepOutput(params) {
251
+ if (params.outputCodec) {
252
+ return finalizeCodecOutput(params.outputCodec, params.lastText, params.ctx, params.emit);
253
+ }
254
+ if (params.outputSchema) {
255
+ return parseSchemaOutput(params.outputSchema, params.lastText, params.stepId);
256
+ }
257
+ return frameworkCast(params.lastText);
258
+ }
167
259
  export async function executeLLM(step, input, ctx, layers) {
168
260
  const baseCtx = frameworkCast(ctx);
169
261
  const hasLayers = layers !== undefined && layers.length > 0;
@@ -178,8 +270,29 @@ export async function executeLLM(step, input, ctx, layers) {
178
270
  hint: "Ensure `model` (or your `(ctx) => string` getter) returns a non-empty identifier, e.g. 'anthropic/claude-sonnet-4-20250514'.",
179
271
  });
180
272
  }
181
- const resolvedInstructions = await resolveLazy(step.instructions, ctx);
273
+ const baseInstructions = await resolveLazy(step.instructions, ctx);
274
+ const { outputCodec, outputSchema, resolvedInstructions } = resolveOutputMode(step.output, baseInstructions);
182
275
  const resolvedStepTools = await resolveLazy(step.tools, ctx);
276
+ // `step.tools` is heterogeneous: client `Tool`s plus inline OpenRouter
277
+ // server-tool specs (web search/fetch). Partition them — client tools flow
278
+ // through the unified-tool / allowedToolNames / steering machinery as before;
279
+ // server-tool specs bypass it entirely and are stamped onto the model request
280
+ // (`_serverTools`) for the model caller to wrap via the SDK's `serverTool()`.
281
+ // `undefined` (unrestricted) is preserved as-is; only a present array is
282
+ // partitioned so the `[] = opt-out` semantics of step.tools stay intact.
283
+ let clientStepTools;
284
+ const serverToolSpecs = [];
285
+ if (resolvedStepTools !== undefined) {
286
+ clientStepTools = [];
287
+ for (const entry of resolvedStepTools) {
288
+ if (isServerToolSpec(entry)) {
289
+ serverToolSpecs.push(entry);
290
+ }
291
+ else {
292
+ clientStepTools.push(entry);
293
+ }
294
+ }
295
+ }
183
296
  // Append user input — through layer pipeline if layers exist, otherwise direct.
184
297
  // The append pipeline may request a context re-render (e.g. a layer that
185
298
  // expanded an input reference into new content); applied after recall below.
@@ -196,7 +309,7 @@ export async function executeLLM(step, input, ctx, layers) {
196
309
  baseCtx.itemLog.append(createMessage(input, 'user'));
197
310
  }
198
311
  }
199
- const { tools: resolvedTools, allowedToolNames } = resolveToolsAndRestrictions(resolvedStepTools, layers, baseCtx);
312
+ const { tools: resolvedTools, allowedToolNames } = resolveToolsAndRestrictions(clientStepTools, layers, baseCtx);
200
313
  // Resolve the projection policy (step override > harness default > fallback)
201
314
  // and split the context budget across layers via allocateBudgets.
202
315
  const policy = step.projection ?? baseCtx.harness.config.projection ?? DEFAULT_PROJECTION;
@@ -281,6 +394,7 @@ export async function executeLLM(step, input, ctx, layers) {
281
394
  ...projectedHistoryItems,
282
395
  ];
283
396
  }
397
+ const _serverTools = serverToolSpecs.length > 0 ? serverToolSpecs : undefined;
284
398
  const request = resolvedTools
285
399
  ? {
286
400
  model: resolvedModel,
@@ -288,8 +402,9 @@ export async function executeLLM(step, input, ctx, layers) {
288
402
  instructions: resolvedInstructions,
289
403
  tools: resolvedTools,
290
404
  params: step.params,
291
- outputSchema: step.output,
405
+ outputSchema: outputSchema,
292
406
  emit: step.emit,
407
+ _serverTools,
293
408
  ctx: baseCtx,
294
409
  layers,
295
410
  allowedToolNames,
@@ -301,8 +416,9 @@ export async function executeLLM(step, input, ctx, layers) {
301
416
  items: assembledItems,
302
417
  instructions: resolvedInstructions,
303
418
  params: step.params,
304
- outputSchema: step.output,
419
+ outputSchema: outputSchema,
305
420
  emit: step.emit,
421
+ _serverTools,
306
422
  nodeId: step.id,
307
423
  parentSpan: baseCtx.span,
308
424
  };
@@ -353,33 +469,14 @@ export async function executeLLM(step, input, ctx, layers) {
353
469
  await baseCtx.harness.storeLayers(layers, response, baseCtx);
354
470
  }
355
471
  const lastText = extractAssistantText(response.items);
356
- if (step.output) {
357
- try {
358
- const parsed = JSON.parse(lastText);
359
- return step.output.parse(parsed);
360
- }
361
- catch (e) {
362
- if (e instanceof SyntaxError || e instanceof ZodError) {
363
- throw new NoeticErrorImpl({
364
- kind: 'llm_parse_error',
365
- stepId: step.id,
366
- raw: lastText,
367
- schema: step.output,
368
- zodError: e instanceof ZodError
369
- ? e
370
- : new ZodError([
371
- {
372
- code: 'custom',
373
- message: `Invalid JSON: ${e.message}`,
374
- path: [],
375
- },
376
- ]),
377
- });
378
- }
379
- throw e;
380
- }
381
- }
382
- return frameworkCast(lastText);
472
+ return finalizeStepOutput({
473
+ stepId: step.id,
474
+ emit: step.emit,
475
+ outputCodec,
476
+ outputSchema,
477
+ lastText,
478
+ ctx: baseCtx,
479
+ });
383
480
  }
384
481
  // Safety net: the loop above always returns or throws within the body.
385
482
  // This throw is unreachable but protects against future refactors that
@@ -587,21 +684,32 @@ function isAsyncGenerator(value) {
587
684
  async function consumeToolGenerator(params) {
588
685
  const broadcaster = getBroadcaster(params.ctx);
589
686
  const agentName = params.ctx.harness.config.name;
687
+ const events = [];
590
688
  while (true) {
591
689
  const next = await params.generator.next();
592
690
  if (next.done) {
593
691
  return next.value;
594
692
  }
693
+ events.push(next.value);
595
694
  emitFrameworkEvent({
596
695
  broadcaster,
597
696
  agentName,
598
697
  eventType: 'tool_progress',
599
698
  data: {
600
699
  stepId: params.stepId,
601
- toolName: params.toolName,
700
+ toolName: params.tool.name,
602
701
  event: next.value,
603
702
  },
604
703
  });
704
+ // A direct `step.tool` has no model call id — the step id keys its region.
705
+ emitToolUi({
706
+ ctx: params.ctx,
707
+ tool: params.tool,
708
+ callId: params.stepId,
709
+ phase: 'progress',
710
+ args: params.args,
711
+ events,
712
+ });
605
713
  }
606
714
  }
607
715
  export async function executeTool(step, input, ctx, harness, layers) {
@@ -625,20 +733,44 @@ export async function executeTool(step, input, ctx, harness, layers) {
625
733
  });
626
734
  }
627
735
  }
736
+ emitToolUi({
737
+ ctx: baseCtx,
738
+ tool: step.tool,
739
+ callId: step.id,
740
+ phase: 'call',
741
+ args: parseResult.data,
742
+ });
628
743
  try {
629
744
  const toolCtx = buildToolExecutionContext(baseCtx, harness);
630
- const result = step.tool.execute(parseResult.data, toolCtx);
631
- if (isAsyncGenerator(result)) {
632
- return frameworkCast(await consumeToolGenerator({
633
- generator: result,
745
+ const execResult = step.tool.execute(parseResult.data, toolCtx);
746
+ const result = isAsyncGenerator(execResult)
747
+ ? await consumeToolGenerator({
748
+ generator: execResult,
634
749
  stepId: step.id,
635
- toolName: step.tool.name,
750
+ tool: step.tool,
636
751
  ctx: baseCtx,
637
- }));
638
- }
639
- return frameworkCast(await result);
752
+ args: parseResult.data,
753
+ })
754
+ : await execResult;
755
+ emitToolUi({
756
+ ctx: baseCtx,
757
+ tool: step.tool,
758
+ callId: step.id,
759
+ phase: 'result',
760
+ args: parseResult.data,
761
+ output: result,
762
+ });
763
+ return frameworkCast(result);
640
764
  }
641
765
  catch (e) {
766
+ emitToolUi({
767
+ ctx: baseCtx,
768
+ tool: step.tool,
769
+ callId: step.id,
770
+ phase: 'error',
771
+ args: parseResult.data,
772
+ error: e,
773
+ });
642
774
  if (e instanceof NoeticErrorImpl) {
643
775
  throw e;
644
776
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tool-authored UI fragment emission.
3
+ *
4
+ * When a tool declares a `ui` block (`ToolUiDeclaration`), the runtime runs its
5
+ * render functions at the call / progress / result / error lifecycle points and
6
+ * forwards each returned `UiFragment` as an `openui.fragment` framework event
7
+ * (namespaced `${agentName}:openui.fragment`). The framework never interprets
8
+ * the fragment source — a UI surface (memory layer + transport) composes it.
9
+ *
10
+ * The `ui` methods are declared bivariantly on `ToolUiDeclaration`, so the
11
+ * erased `Tool` handle the runtime holds can call them with runtime values.
12
+ */
13
+ import type { Context, Tool } from '@noetic-tools/types';
14
+ /** The tool-UI lifecycle point being rendered. */
15
+ export type ToolUiPhase = 'call' | 'progress' | 'result' | 'error';
16
+ /** Parameters for {@link emitToolUi}. */
17
+ export interface EmitToolUiParams {
18
+ ctx: Context;
19
+ tool: Tool;
20
+ callId: string;
21
+ phase: ToolUiPhase;
22
+ /** Parsed tool args (partial at `call` time). */
23
+ args: unknown;
24
+ /** Events seen so far — for `progress`. */
25
+ events?: unknown[];
26
+ /** The tool's return value — for `result`. */
27
+ output?: unknown;
28
+ /** The thrown error — for `error`. */
29
+ error?: unknown;
30
+ }
31
+ /**
32
+ * Render the tool's UI fragment for one lifecycle phase and, when it produces
33
+ * one, emit it as an `openui.fragment` framework event. No-op when the tool
34
+ * declares no `ui` for that phase.
35
+ */
36
+ export declare function emitToolUi(params: EmitToolUiParams): void;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Tool-authored UI fragment emission.
3
+ *
4
+ * When a tool declares a `ui` block (`ToolUiDeclaration`), the runtime runs its
5
+ * render functions at the call / progress / result / error lifecycle points and
6
+ * forwards each returned `UiFragment` as an `openui.fragment` framework event
7
+ * (namespaced `${agentName}:openui.fragment`). The framework never interprets
8
+ * the fragment source — a UI surface (memory layer + transport) composes it.
9
+ *
10
+ * The `ui` methods are declared bivariantly on `ToolUiDeclaration`, so the
11
+ * erased `Tool` handle the runtime holds can call them with runtime values.
12
+ */
13
+ import { frameworkCast } from '@noetic-tools/types';
14
+ import { emitFrameworkEvent, getBroadcaster } from './broadcaster-utils.js';
15
+ const OPENUI_FRAGMENT_EVENT = 'openui.fragment';
16
+ function renderFragment(params) {
17
+ const ui = params.tool.ui;
18
+ if (!ui) {
19
+ return null;
20
+ }
21
+ // The `ui` methods are typed against the tool's own I/O schemas, erased to
22
+ // `ZodTypeAny` on the `Tool` handle the runtime holds. frameworkCast bridges
23
+ // the runtime values (`unknown`) to those erased parameter types.
24
+ const args = frameworkCast(params.args);
25
+ const output = frameworkCast(params.output);
26
+ switch (params.phase) {
27
+ case 'call':
28
+ return ui.call ? ui.call(args) : null;
29
+ case 'progress':
30
+ return ui.progress ? ui.progress(params.events ?? []) : null;
31
+ case 'result':
32
+ return ui.result ? ui.result(output, args) : null;
33
+ case 'error':
34
+ return ui.error ? ui.error(params.error, args) : null;
35
+ }
36
+ }
37
+ /**
38
+ * Render the tool's UI fragment for one lifecycle phase and, when it produces
39
+ * one, emit it as an `openui.fragment` framework event. No-op when the tool
40
+ * declares no `ui` for that phase.
41
+ */
42
+ export function emitToolUi(params) {
43
+ const fragment = renderFragment(params);
44
+ if (fragment === null) {
45
+ return;
46
+ }
47
+ emitFrameworkEvent({
48
+ broadcaster: getBroadcaster(params.ctx),
49
+ agentName: params.ctx.harness.config.name,
50
+ eventType: OPENUI_FRAGMENT_EVENT,
51
+ data: {
52
+ callId: params.callId,
53
+ dialect: fragment.dialect,
54
+ source: fragment.source,
55
+ },
56
+ });
57
+ }
@@ -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<{
@@ -83,18 +114,57 @@ declare const HarnessSessionPolicySchema: z.ZodObject<{
83
114
  interface WorkflowNodeBase {
84
115
  id: string;
85
116
  }
117
+ /**
118
+ * A reference to a streaming `OutputCodec` for an `llm` node's structured
119
+ * output. The hydrator resolves `library` from `HydrationContext.uiLibraries`
120
+ * to a live codec (e.g. `openUi(myLibrary)` from `@noetic-tools/openui`), the
121
+ * same registry-resolution pattern sub-harness nodes use for adapters. Kept a
122
+ * *reference* because a codec is a runtime object, not JSON-expressible.
123
+ */
124
+ export interface OutputCodecRef {
125
+ /** The codec dialect. Only `'openui'` is defined today. */
126
+ codec: 'openui';
127
+ /** Registry key resolved from `HydrationContext.uiLibraries`. */
128
+ library: string;
129
+ }
86
130
  export interface LlmWorkflowNode extends WorkflowNodeBase {
87
131
  kind: 'llm';
88
132
  model?: string;
89
133
  instructions: string;
90
- tools?: string[];
134
+ /**
135
+ * Tool entries. Every entry is an object `{ type, parameters? }`. A CLIENT
136
+ * tool is `{ type: "<registered-tool-name>" }` (resolved from the registry);
137
+ * a SERVER tool is `{ type: "openrouter:web_search" | "openrouter:web_fetch",
138
+ * parameters?: {...} }` (executed by the provider). Client vs server is
139
+ * decided by the `type` value.
140
+ */
141
+ tools?: z.infer<typeof LlmToolEntrySchema>[];
91
142
  params?: z.infer<typeof ModelParamsSchema>;
143
+ /** Streaming output-codec reference, resolved from `HydrationContext.uiLibraries`. */
144
+ output?: OutputCodecRef;
92
145
  }
93
146
  export interface ToolWorkflowNode extends WorkflowNodeBase {
94
147
  kind: 'tool';
95
148
  toolName: string;
96
149
  args?: Record<string, unknown>;
97
150
  }
151
+ /**
152
+ * A node that runs a serialised code body. Unlike the programmatic `step.run`
153
+ * (which carries a closure), the JSON form carries `execute` as a code STRING.
154
+ * The code is never eval'd in-process (Cloudflare Workers forbid eval); it is
155
+ * dispatched through a subprocess adapter (`ctx.subprocess`, or a named adapter
156
+ * resolved by ref). Execution therefore requires a subprocess adapter capable
157
+ * of running the code and returning its stdout.
158
+ */
159
+ export interface RunWorkflowNode extends WorkflowNodeBase {
160
+ kind: 'run';
161
+ /** Source code executed in the subprocess. Receives the step input on stdin. */
162
+ execute: string;
163
+ /** Optional retry policy applied to the step. */
164
+ retry?: z.infer<typeof RetryPolicySchema>;
165
+ /** Named subprocess adapter ref; defaults to `ctx.subprocess` when omitted. */
166
+ subprocess?: string;
167
+ }
98
168
  export interface BranchRoute {
99
169
  match: string;
100
170
  target: WorkflowNode;
@@ -107,7 +177,22 @@ export interface BranchWorkflowNode extends WorkflowNodeBase {
107
177
  export interface ForkWorkflowNode extends WorkflowNodeBase {
108
178
  kind: 'fork';
109
179
  mode: 'race' | 'all' | 'settle';
110
- paths: WorkflowNode[];
180
+ /**
181
+ * Static fan-out: one child per entry. Mutually exclusive with `each` —
182
+ * supply exactly one.
183
+ */
184
+ paths?: WorkflowNode[];
185
+ /**
186
+ * Dynamic fan-out: instantiate this body template once per item of a
187
+ * runtime-produced array (data-dependent N). Mutually exclusive with `paths`.
188
+ */
189
+ each?: WorkflowNode;
190
+ /**
191
+ * Selector key into the fork input (parsed as JSON) locating the array to
192
+ * fan out over. When omitted, the input string itself is parsed as a JSON
193
+ * array. Only meaningful with `each`.
194
+ */
195
+ over?: string;
111
196
  merge?: MergeStrategy;
112
197
  concurrency?: number;
113
198
  }
@@ -150,7 +235,7 @@ export interface SubHarnessWorkflowNode extends WorkflowNodeBase {
150
235
  session?: z.infer<typeof HarnessSessionPolicySchema>;
151
236
  }
152
237
  /** @public Discriminated union of all JSON-serialisable workflow node kinds. */
153
- export type WorkflowNode = LlmWorkflowNode | ToolWorkflowNode | BranchWorkflowNode | ForkWorkflowNode | SpawnWorkflowNode | ProvideWorkflowNode | LoopWorkflowNode | SequenceWorkflowNode | EveryWorkflowNode | SubHarnessWorkflowNode;
238
+ export type WorkflowNode = LlmWorkflowNode | ToolWorkflowNode | RunWorkflowNode | BranchWorkflowNode | ForkWorkflowNode | SpawnWorkflowNode | ProvideWorkflowNode | LoopWorkflowNode | SequenceWorkflowNode | EveryWorkflowNode | SubHarnessWorkflowNode;
154
239
  /** @public Zod schema validating a single `WorkflowNode` (any JSON-safe kind). */
155
240
  export declare const WorkflowNodeSchema: z.ZodType<WorkflowNode>;
156
241
  /** @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(),
@@ -116,13 +151,18 @@ const WorkflowNodeRef = z.lazy(() => WorkflowNodeSchema);
116
151
  const SHARED_FIELDS = {
117
152
  id: z.string().min(1),
118
153
  };
154
+ const OutputCodecRefSchema = z.object({
155
+ codec: z.literal('openui'),
156
+ library: z.string().min(1),
157
+ });
119
158
  const LlmNodeSchema = z.object({
120
159
  kind: z.literal('llm'),
121
160
  ...SHARED_FIELDS,
122
161
  model: z.string().optional(),
123
162
  instructions: z.string(),
124
- tools: z.array(z.string()).optional(),
163
+ tools: z.array(LlmToolEntrySchema).optional(),
125
164
  params: ModelParamsSchema.optional(),
165
+ output: OutputCodecRefSchema.optional(),
126
166
  });
127
167
  const ToolNodeSchema = z.object({
128
168
  kind: z.literal('tool'),
@@ -130,6 +170,13 @@ const ToolNodeSchema = z.object({
130
170
  toolName: z.string().min(1),
131
171
  args: z.record(z.string(), z.unknown()).optional(),
132
172
  });
173
+ const RunNodeSchema = z.object({
174
+ kind: z.literal('run'),
175
+ ...SHARED_FIELDS,
176
+ execute: z.string().min(1),
177
+ retry: RetryPolicySchema.optional(),
178
+ subprocess: z.string().min(1).optional(),
179
+ });
133
180
  const BranchRouteSchema = z.object({
134
181
  match: z.string().min(1),
135
182
  target: z.lazy(() => WorkflowNodeSchema),
@@ -140,7 +187,8 @@ const BranchNodeSchema = z.object({
140
187
  routes: z.array(BranchRouteSchema).min(1),
141
188
  default: WorkflowNodeRef.optional(),
142
189
  });
143
- const ForkNodeSchema = z.object({
190
+ const ForkNodeSchema = z
191
+ .object({
144
192
  kind: z.literal('fork'),
145
193
  ...SHARED_FIELDS,
146
194
  mode: z.enum([
@@ -148,9 +196,14 @@ const ForkNodeSchema = z.object({
148
196
  'all',
149
197
  'settle',
150
198
  ]),
151
- paths: z.array(WorkflowNodeRef).min(1),
199
+ paths: z.array(WorkflowNodeRef).min(1).optional(),
200
+ each: WorkflowNodeRef.optional(),
201
+ over: z.string().min(1).optional(),
152
202
  merge: MergeStrategySchema.optional(),
153
203
  concurrency: z.number().int().positive().optional(),
204
+ })
205
+ .refine((node) => (node.paths === undefined) !== (node.each === undefined), {
206
+ message: "fork node requires exactly one of 'paths' (static) or 'each' (dynamic).",
154
207
  });
155
208
  const SpawnNodeSchema = z.object({
156
209
  kind: z.literal('spawn'),
@@ -208,6 +261,7 @@ export const WorkflowNodeSchema = z
208
261
  .discriminatedUnion('kind', [
209
262
  LlmNodeSchema,
210
263
  ToolNodeSchema,
264
+ RunNodeSchema,
211
265
  BranchNodeSchema,
212
266
  ForkNodeSchema,
213
267
  SpawnNodeSchema,
@@ -243,7 +297,12 @@ function childNodes(node) {
243
297
  case 'sequence':
244
298
  return node.steps;
245
299
  case 'fork':
246
- return node.paths;
300
+ if (node.each) {
301
+ return [
302
+ node.each,
303
+ ];
304
+ }
305
+ return node.paths ?? [];
247
306
  case 'spawn':
248
307
  case 'provide':
249
308
  return [