@noetic-tools/types 2.1.1 → 2.3.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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Zod schemas for JSON-serialisable workflow definitions.
3
+ *
4
+ * A `WorkflowDocument` is a portable, JSON-safe representation of a noetic
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
+ * `@noetic-tools/core` (`builders/workflow-hydrator.ts`) converts a validated
9
+ * document back into live `Step` objects via the existing builders.
10
+ */
11
+ import { z } from 'zod';
12
+ //#endregion
13
+ //#region Until Predicate Schemas
14
+ const MaxStepsPredicateSchema = z.object({
15
+ kind: z.literal('maxSteps'),
16
+ n: z.number().int().positive(),
17
+ });
18
+ const MaxCostPredicateSchema = z.object({
19
+ kind: z.literal('maxCost'),
20
+ usd: z.number().positive(),
21
+ });
22
+ const MaxDurationPredicateSchema = z.object({
23
+ kind: z.literal('maxDuration'),
24
+ ms: z.number().positive(),
25
+ });
26
+ const NoToolCallsPredicateSchema = z.object({
27
+ kind: z.literal('noToolCalls'),
28
+ });
29
+ const OutputContainsPredicateSchema = z.object({
30
+ kind: z.literal('outputContains'),
31
+ marker: z.string().min(1),
32
+ });
33
+ const OutputEqualsPredicateSchema = z.object({
34
+ kind: z.literal('outputEquals'),
35
+ sentinel: z.string().min(1),
36
+ });
37
+ const ConvergedPredicateSchema = z.object({
38
+ kind: z.literal('converged'),
39
+ threshold: z.number().min(0).max(1).optional(),
40
+ });
41
+ const UntilPredicateRef = z.lazy(() => UntilPredicateSchema);
42
+ const AnyPredicateSchema = z.object({
43
+ kind: z.literal('any'),
44
+ predicates: z.array(UntilPredicateRef).min(1),
45
+ });
46
+ const AllPredicateSchema = z.object({
47
+ kind: z.literal('all'),
48
+ predicates: z.array(UntilPredicateRef).min(1),
49
+ });
50
+ /** @public Zod schema validating a named `until` predicate (including `any`/`all` combinators). */
51
+ export const UntilPredicateSchema = z
52
+ .union([
53
+ MaxStepsPredicateSchema,
54
+ MaxCostPredicateSchema,
55
+ MaxDurationPredicateSchema,
56
+ NoToolCallsPredicateSchema,
57
+ OutputContainsPredicateSchema,
58
+ OutputEqualsPredicateSchema,
59
+ ConvergedPredicateSchema,
60
+ AnyPredicateSchema,
61
+ AllPredicateSchema,
62
+ ])
63
+ .meta({
64
+ id: 'UntilPredicate',
65
+ title: 'UntilPredicate',
66
+ });
67
+ //#endregion
68
+ //#region Merge Strategy
69
+ /** @public ESM literal enum for merge strategies. */
70
+ export const MergeStrategy = {
71
+ Last: 'last',
72
+ First: 'first',
73
+ Concat: 'concat',
74
+ };
75
+ /** @public Zod schema for the named merge strategy used by `fork` nodes. */
76
+ export const MergeStrategySchema = z.enum([
77
+ 'last',
78
+ 'first',
79
+ 'concat',
80
+ ]);
81
+ //#endregion
82
+ //#region Model Params
83
+ const ModelParamsSchema = z.object({
84
+ temperature: z.number().min(0).optional(),
85
+ topP: z.number().min(0).max(1).optional(),
86
+ maxTokens: z.number().int().positive().optional(),
87
+ stopSequences: z.array(z.string()).optional(),
88
+ });
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
123
+ //#region SubHarness Settings
124
+ const HarnessSettingsSchema = z.object({
125
+ model: z.string().optional(),
126
+ permissionMode: z
127
+ .enum([
128
+ 'default',
129
+ 'plan',
130
+ 'acceptEdits',
131
+ 'bypassPermissions',
132
+ ])
133
+ .optional(),
134
+ maxTurns: z.number().int().positive().optional(),
135
+ allowedTools: z.array(z.string()).optional(),
136
+ extra: z.record(z.string(), z.unknown()).optional(),
137
+ });
138
+ const HarnessSessionPolicySchema = z.object({
139
+ reuse: z.string().min(1).optional(),
140
+ onComplete: z
141
+ .enum([
142
+ 'stop',
143
+ 'detach',
144
+ 'destroy',
145
+ ])
146
+ .optional(),
147
+ });
148
+ //#endregion
149
+ //#region Workflow Node Schema
150
+ const WorkflowNodeRef = z.lazy(() => WorkflowNodeSchema);
151
+ const SHARED_FIELDS = {
152
+ id: z.string().min(1),
153
+ };
154
+ const OutputCodecRefSchema = z.object({
155
+ codec: z.literal('openui'),
156
+ library: z.string().min(1),
157
+ });
158
+ const LlmNodeSchema = z.object({
159
+ kind: z.literal('llm'),
160
+ ...SHARED_FIELDS,
161
+ model: z.string().optional(),
162
+ instructions: z.string(),
163
+ tools: z.array(LlmToolEntrySchema).optional(),
164
+ params: ModelParamsSchema.optional(),
165
+ output: OutputCodecRefSchema.optional(),
166
+ });
167
+ const ToolNodeSchema = z.object({
168
+ kind: z.literal('tool'),
169
+ ...SHARED_FIELDS,
170
+ toolName: z.string().min(1),
171
+ args: z.record(z.string(), z.unknown()).optional(),
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
+ });
180
+ const BranchRouteSchema = z.object({
181
+ match: z.string().min(1),
182
+ target: z.lazy(() => WorkflowNodeSchema),
183
+ });
184
+ const BranchNodeSchema = z.object({
185
+ kind: z.literal('branch'),
186
+ ...SHARED_FIELDS,
187
+ routes: z.array(BranchRouteSchema).min(1),
188
+ default: WorkflowNodeRef.optional(),
189
+ });
190
+ const ForkNodeSchema = z
191
+ .object({
192
+ kind: z.literal('fork'),
193
+ ...SHARED_FIELDS,
194
+ mode: z.enum([
195
+ 'race',
196
+ 'all',
197
+ 'settle',
198
+ ]),
199
+ paths: z.array(WorkflowNodeRef).min(1).optional(),
200
+ each: WorkflowNodeRef.optional(),
201
+ over: z.string().min(1).optional(),
202
+ merge: MergeStrategySchema.optional(),
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).",
207
+ });
208
+ const SpawnNodeSchema = z.object({
209
+ kind: z.literal('spawn'),
210
+ ...SHARED_FIELDS,
211
+ child: WorkflowNodeRef,
212
+ timeout: z.number().positive().optional(),
213
+ layers: z.array(z.string().min(1)).min(1).optional(),
214
+ });
215
+ const ProvideNodeSchema = z.object({
216
+ kind: z.literal('provide'),
217
+ ...SHARED_FIELDS,
218
+ child: WorkflowNodeRef,
219
+ layers: z.array(z.string().min(1)).min(1),
220
+ });
221
+ const LoopNodeSchema = z.object({
222
+ kind: z.literal('loop'),
223
+ ...SHARED_FIELDS,
224
+ body: WorkflowNodeRef,
225
+ until: UntilPredicateSchema,
226
+ maxIterations: z.number().int().positive().optional(),
227
+ });
228
+ const SequenceNodeSchema = z.object({
229
+ kind: z.literal('sequence'),
230
+ ...SHARED_FIELDS,
231
+ steps: z.array(WorkflowNodeRef).min(1),
232
+ });
233
+ const EveryNodeSchema = z.object({
234
+ kind: z.literal('every'),
235
+ ...SHARED_FIELDS,
236
+ step: WorkflowNodeRef,
237
+ ms: z.number().nonnegative(),
238
+ onError: z
239
+ .enum([
240
+ 'continue',
241
+ 'fail',
242
+ ])
243
+ .optional(),
244
+ });
245
+ const WorkflowDocumentRef = z.lazy(() => WorkflowDocumentSchema);
246
+ const SubflowNodeSchema = z
247
+ .object({
248
+ kind: z.literal('subflow'),
249
+ ...SHARED_FIELDS,
250
+ document: WorkflowDocumentRef.optional(),
251
+ ref: z.string().min(1).optional(),
252
+ input: z.string().optional(),
253
+ })
254
+ .refine((node) => (node.document === undefined) !== (node.ref === undefined), {
255
+ message: "subflow node requires exactly one of 'document' (inline) or 'ref' (named).",
256
+ });
257
+ /** Builds the schema for a single harness node kind (`claude-code`, `codex`, …). */
258
+ function subHarnessNodeSchema(kind) {
259
+ return z.object({
260
+ kind: z.literal(kind),
261
+ ...SHARED_FIELDS,
262
+ prompt: z.string().min(1),
263
+ instructions: z.string().optional(),
264
+ settings: HarnessSettingsSchema.optional(),
265
+ session: HarnessSessionPolicySchema.optional(),
266
+ });
267
+ }
268
+ const ClaudeCodeNodeSchema = subHarnessNodeSchema('claude-code');
269
+ const CodexNodeSchema = subHarnessNodeSchema('codex');
270
+ const OpencodeNodeSchema = subHarnessNodeSchema('opencode');
271
+ const PiNodeSchema = subHarnessNodeSchema('pi');
272
+ /** @public Zod schema validating a single `WorkflowNode` (any JSON-safe kind). */
273
+ export const WorkflowNodeSchema = z
274
+ .discriminatedUnion('kind', [
275
+ LlmNodeSchema,
276
+ ToolNodeSchema,
277
+ RunNodeSchema,
278
+ BranchNodeSchema,
279
+ ForkNodeSchema,
280
+ SpawnNodeSchema,
281
+ ProvideNodeSchema,
282
+ LoopNodeSchema,
283
+ SequenceNodeSchema,
284
+ EveryNodeSchema,
285
+ SubflowNodeSchema,
286
+ ClaudeCodeNodeSchema,
287
+ CodexNodeSchema,
288
+ OpencodeNodeSchema,
289
+ PiNodeSchema,
290
+ ])
291
+ .meta({
292
+ id: 'WorkflowNode',
293
+ title: 'WorkflowNode',
294
+ });
295
+ /** @public Zod schema validating a complete `WorkflowDocument`. */
296
+ export const WorkflowDocumentSchema = z
297
+ .object({
298
+ version: z.literal(1),
299
+ root: WorkflowNodeSchema,
300
+ })
301
+ .meta({
302
+ id: 'WorkflowDocument',
303
+ title: 'WorkflowDocument',
304
+ description: 'A portable, JSON-safe representation of a Noetic step tree.',
305
+ });
306
+ //#endregion
307
+ //#region Helpers
308
+ /** Returns the direct child nodes of a workflow node. Leaf nodes return an empty array. */
309
+ function childNodes(node) {
310
+ switch (node.kind) {
311
+ case 'sequence':
312
+ return node.steps;
313
+ case 'fork':
314
+ if (node.each) {
315
+ return [
316
+ node.each,
317
+ ];
318
+ }
319
+ return node.paths ?? [];
320
+ case 'spawn':
321
+ case 'provide':
322
+ return [
323
+ node.child,
324
+ ];
325
+ case 'loop':
326
+ return [
327
+ node.body,
328
+ ];
329
+ case 'every':
330
+ return [
331
+ node.step,
332
+ ];
333
+ case 'branch': {
334
+ const children = node.routes.map((r) => r.target);
335
+ if (node.default) {
336
+ children.push(node.default);
337
+ }
338
+ return children;
339
+ }
340
+ case 'subflow':
341
+ // A named ref is a static leaf: the target document is only known at
342
+ // hydration time, so walk/graph/depth cannot see through it.
343
+ return node.document
344
+ ? [
345
+ node.document.root,
346
+ ]
347
+ : [];
348
+ default:
349
+ return [];
350
+ }
351
+ }
352
+ /** Validates a candidate workflow document. Throws `ZodError` on invalid input. */
353
+ export function validateWorkflow(input) {
354
+ return WorkflowDocumentSchema.parse(input);
355
+ }
356
+ /** Walks a workflow tree depth-first, yielding each node. */
357
+ export function* walkWorkflow(node) {
358
+ yield node;
359
+ for (const child of childNodes(node)) {
360
+ yield* walkWorkflow(child);
361
+ }
362
+ }
363
+ /**
364
+ * Flattens a workflow tree into a node + edge list — the static "potential
365
+ * paths" of the DAG, suitable for attaching to a trace span so observers can
366
+ * reconstruct the declared graph independent of which branches actually ran.
367
+ * `subflow` refs are leaves: their target documents resolve at hydration time.
368
+ */
369
+ export function workflowGraph(root) {
370
+ const nodes = [];
371
+ const edges = [];
372
+ for (const node of walkWorkflow(root)) {
373
+ nodes.push({
374
+ id: node.id,
375
+ kind: node.kind,
376
+ });
377
+ for (const child of childNodes(node)) {
378
+ edges.push({
379
+ from: node.id,
380
+ to: child.id,
381
+ });
382
+ }
383
+ }
384
+ return {
385
+ nodes,
386
+ edges,
387
+ };
388
+ }
389
+ /**
390
+ * Returns the maximum depth of a workflow tree.
391
+ * Leaf nodes (`llm`, `tool`) have depth 0. Structural nodes add +1.
392
+ * A `subflow` ref counts as a leaf — depth does not see through named refs.
393
+ */
394
+ export function workflowDepth(node) {
395
+ const children = childNodes(node);
396
+ if (children.length === 0) {
397
+ return 0;
398
+ }
399
+ return 1 + Math.max(0, ...children.map(workflowDepth));
400
+ }
401
+ //#endregion