@noetic-tools/types 2.1.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.
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  export * from './errors/noetic-config-error.js';
6
6
  export * from './errors/noetic-error.js';
7
7
  export * from './schemas/item.js';
8
+ export * from './schemas/workflow.js';
8
9
  export * from './types/channel.js';
9
10
  export * from './types/common.js';
10
11
  export * from './types/context.js';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  export * from './errors/noetic-config-error.js';
6
6
  export * from './errors/noetic-error.js';
7
7
  export * from './schemas/item.js';
8
+ export * from './schemas/workflow.js';
8
9
  export * from './types/channel.js';
9
10
  export * from './types/common.js';
10
11
  export * from './types/context.js';
@@ -0,0 +1,297 @@
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
+ import type { SubHarnessKind } from '../types/sub-harness.js';
13
+ interface UntilAnyPredicate {
14
+ kind: 'any';
15
+ predicates: UntilPredicate[];
16
+ }
17
+ interface UntilAllPredicate {
18
+ kind: 'all';
19
+ predicates: UntilPredicate[];
20
+ }
21
+ /** @public Discriminated union of named until predicates usable in JSON workflows. */
22
+ export type UntilPredicate = {
23
+ kind: 'maxSteps';
24
+ n: number;
25
+ } | {
26
+ kind: 'maxCost';
27
+ usd: number;
28
+ } | {
29
+ kind: 'maxDuration';
30
+ ms: number;
31
+ } | {
32
+ kind: 'noToolCalls';
33
+ } | {
34
+ kind: 'outputContains';
35
+ marker: string;
36
+ } | {
37
+ kind: 'outputEquals';
38
+ sentinel: string;
39
+ } | {
40
+ kind: 'converged';
41
+ threshold?: number;
42
+ } | UntilAnyPredicate | UntilAllPredicate;
43
+ /** @public Zod schema validating a named `until` predicate (including `any`/`all` combinators). */
44
+ export declare const UntilPredicateSchema: z.ZodType<UntilPredicate>;
45
+ /** @public ESM literal enum for merge strategies. */
46
+ export declare const MergeStrategy: {
47
+ readonly Last: 'last';
48
+ readonly First: 'first';
49
+ readonly Concat: 'concat';
50
+ };
51
+ /** @public Named merge strategy for fork nodes. */
52
+ export type MergeStrategy = (typeof MergeStrategy)[keyof typeof MergeStrategy];
53
+ /** @public Zod schema for the named merge strategy used by `fork` nodes. */
54
+ export declare const MergeStrategySchema: z.ZodEnum<{
55
+ concat: "concat";
56
+ first: "first";
57
+ last: "last";
58
+ }>;
59
+ declare const ModelParamsSchema: z.ZodObject<{
60
+ temperature: z.ZodOptional<z.ZodNumber>;
61
+ topP: z.ZodOptional<z.ZodNumber>;
62
+ maxTokens: z.ZodOptional<z.ZodNumber>;
63
+ stopSequences: z.ZodOptional<z.ZodArray<z.ZodString>>;
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
+ exponential: "exponential";
88
+ fixed: "fixed";
89
+ linear: "linear";
90
+ }>;
91
+ initialDelay: z.ZodNumber;
92
+ maxDelay: z.ZodOptional<z.ZodNumber>;
93
+ }, z.core.$strip>;
94
+ declare const HarnessSettingsSchema: z.ZodObject<{
95
+ model: z.ZodOptional<z.ZodString>;
96
+ permissionMode: z.ZodOptional<z.ZodEnum<{
97
+ acceptEdits: "acceptEdits";
98
+ bypassPermissions: "bypassPermissions";
99
+ default: "default";
100
+ plan: "plan";
101
+ }>>;
102
+ maxTurns: z.ZodOptional<z.ZodNumber>;
103
+ allowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
104
+ extra: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
105
+ }, z.core.$strip>;
106
+ declare const HarnessSessionPolicySchema: z.ZodObject<{
107
+ reuse: z.ZodOptional<z.ZodString>;
108
+ onComplete: z.ZodOptional<z.ZodEnum<{
109
+ destroy: "destroy";
110
+ detach: "detach";
111
+ stop: "stop";
112
+ }>>;
113
+ }, z.core.$strip>;
114
+ interface WorkflowNodeBase {
115
+ id: string;
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
+ }
130
+ export interface LlmWorkflowNode extends WorkflowNodeBase {
131
+ kind: 'llm';
132
+ model?: string;
133
+ instructions: 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>[];
142
+ params?: z.infer<typeof ModelParamsSchema>;
143
+ /** Streaming output-codec reference, resolved from `HydrationContext.uiLibraries`. */
144
+ output?: OutputCodecRef;
145
+ }
146
+ export interface ToolWorkflowNode extends WorkflowNodeBase {
147
+ kind: 'tool';
148
+ toolName: string;
149
+ args?: Record<string, unknown>;
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
+ }
168
+ export interface BranchRoute {
169
+ match: string;
170
+ target: WorkflowNode;
171
+ }
172
+ export interface BranchWorkflowNode extends WorkflowNodeBase {
173
+ kind: 'branch';
174
+ routes: BranchRoute[];
175
+ default?: WorkflowNode;
176
+ }
177
+ export interface ForkWorkflowNode extends WorkflowNodeBase {
178
+ kind: 'fork';
179
+ mode: 'race' | 'all' | 'settle';
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;
196
+ merge?: MergeStrategy;
197
+ concurrency?: number;
198
+ }
199
+ export interface SpawnWorkflowNode extends WorkflowNodeBase {
200
+ kind: 'spawn';
201
+ child: WorkflowNode;
202
+ timeout?: number;
203
+ /**
204
+ * Memory layers the child runs with, by registered name (resolved from
205
+ * `HydrationContext.layers`, like `provide`). Omit to inherit the parent's
206
+ * layers — the default spawn behaviour. Naming layers here REPLACES the
207
+ * inherited set for the child, so list every layer the child needs.
208
+ */
209
+ layers?: string[];
210
+ }
211
+ export interface ProvideWorkflowNode extends WorkflowNodeBase {
212
+ kind: 'provide';
213
+ child: WorkflowNode;
214
+ layers: string[];
215
+ }
216
+ export interface LoopWorkflowNode extends WorkflowNodeBase {
217
+ kind: 'loop';
218
+ body: WorkflowNode;
219
+ until: UntilPredicate;
220
+ maxIterations?: number;
221
+ }
222
+ export interface SequenceWorkflowNode extends WorkflowNodeBase {
223
+ kind: 'sequence';
224
+ steps: WorkflowNode[];
225
+ }
226
+ export interface EveryWorkflowNode extends WorkflowNodeBase {
227
+ kind: 'every';
228
+ step: WorkflowNode;
229
+ ms: number;
230
+ onError?: 'continue' | 'fail';
231
+ }
232
+ /**
233
+ * A node that delegates a turn to a coding-agent harness. `kind` is the harness
234
+ * id (e.g. `claude-code`); the hydrator resolves the matching adapter from the
235
+ * workflow's harness registry.
236
+ */
237
+ export interface SubHarnessWorkflowNode extends WorkflowNodeBase {
238
+ kind: SubHarnessKind;
239
+ prompt: string;
240
+ instructions?: string;
241
+ settings?: z.infer<typeof HarnessSettingsSchema>;
242
+ session?: z.infer<typeof HarnessSessionPolicySchema>;
243
+ }
244
+ /**
245
+ * A node that runs another workflow document as a sub-step. The document is
246
+ * either inline or a named reference resolved from `HydrationContext.workflows`
247
+ * at execution time. Supply exactly one of `document` / `ref`.
248
+ */
249
+ export interface SubflowWorkflowNode extends WorkflowNodeBase {
250
+ kind: 'subflow';
251
+ /** Inline sub-workflow. Mutually exclusive with `ref`. */
252
+ document?: WorkflowDocument;
253
+ /** Named sub-workflow resolved from `HydrationContext.workflows`. Mutually exclusive with `document`. */
254
+ ref?: string;
255
+ /** Literal input passed to the sub-workflow; defaults to the node's runtime input. */
256
+ input?: string;
257
+ }
258
+ /** @public Discriminated union of all JSON-serialisable workflow node kinds. */
259
+ export type WorkflowNode = LlmWorkflowNode | ToolWorkflowNode | RunWorkflowNode | BranchWorkflowNode | ForkWorkflowNode | SpawnWorkflowNode | ProvideWorkflowNode | LoopWorkflowNode | SequenceWorkflowNode | EveryWorkflowNode | SubflowWorkflowNode | SubHarnessWorkflowNode;
260
+ /** @public Zod schema validating a single `WorkflowNode` (any JSON-safe kind). */
261
+ export declare const WorkflowNodeSchema: z.ZodType<WorkflowNode>;
262
+ /** @public Top-level envelope for a JSON workflow definition. */
263
+ export interface WorkflowDocument {
264
+ version: 1;
265
+ root: WorkflowNode;
266
+ }
267
+ /** @public Zod schema validating a complete `WorkflowDocument`. */
268
+ export declare const WorkflowDocumentSchema: z.ZodType<WorkflowDocument>;
269
+ /** Validates a candidate workflow document. Throws `ZodError` on invalid input. */
270
+ export declare function validateWorkflow(input: unknown): WorkflowDocument;
271
+ /** Walks a workflow tree depth-first, yielding each node. */
272
+ export declare function walkWorkflow(node: WorkflowNode): Iterable<WorkflowNode>;
273
+ /** A flattened, JSON-safe view of a workflow tree: its nodes and parent→child edges. */
274
+ export interface WorkflowGraph {
275
+ nodes: Array<{
276
+ id: string;
277
+ kind: WorkflowNode['kind'];
278
+ }>;
279
+ edges: Array<{
280
+ from: string;
281
+ to: string;
282
+ }>;
283
+ }
284
+ /**
285
+ * Flattens a workflow tree into a node + edge list — the static "potential
286
+ * paths" of the DAG, suitable for attaching to a trace span so observers can
287
+ * reconstruct the declared graph independent of which branches actually ran.
288
+ * `subflow` refs are leaves: their target documents resolve at hydration time.
289
+ */
290
+ export declare function workflowGraph(root: WorkflowNode): WorkflowGraph;
291
+ /**
292
+ * Returns the maximum depth of a workflow tree.
293
+ * Leaf nodes (`llm`, `tool`) have depth 0. Structural nodes add +1.
294
+ * A `subflow` ref counts as a leaf — depth does not see through named refs.
295
+ */
296
+ export declare function workflowDepth(node: WorkflowNode): number;
297
+ export {};
@@ -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
@@ -177,6 +177,11 @@ export interface ContextHarness {
177
177
  disposeLayers(layers: MemoryLayer[], ctx: Context): Promise<void>;
178
178
  checkpoint(ctx: Context): Promise<void>;
179
179
  restore(executionId: string, opts?: RestoreContextOptions): Promise<Context | null>;
180
+ /**
181
+ * Cancel an execution: abort `ctx` and every live descendant context, then
182
+ * run memory-layer teardown (`onComplete` with `outcome: 'aborted'`, then
183
+ * `dispose`) bottom-up. A no-op on an already-cancelled context.
184
+ */
180
185
  cancel(ctx: Context, reason?: string): Promise<void>;
181
186
  /** Trace exporter spans are flushed to. Defaults to a no-op exporter. */
182
187
  readonly traceExporter: TraceExporter;
@@ -242,6 +247,18 @@ export interface Context<TMemory = ContextMemory, TState = unknown> {
242
247
  readonly completionValue: unknown;
243
248
  readonly aborted: boolean;
244
249
  readonly abortReason?: string;
250
+ /**
251
+ * Cancel this context and everything running beneath it. The first call wins
252
+ * (later calls are no-ops, so `abortReason` is stable), and it cascades
253
+ * *down* the execution tree: every live fork path and spawn child is aborted
254
+ * too. It never travels up — aborting a child leaves the parent running.
255
+ *
256
+ * Aborting rejects operations blocked on the context (channel `recv` waiters,
257
+ * parked back-pressure senders) with `cancelled`, cuts short the in-flight
258
+ * model call or sub-harness turn, and makes the next step boundary throw
259
+ * `cancelled`. Memory-layer teardown is NOT run — use
260
+ * `harness.cancel(ctx, reason)` for that.
261
+ */
245
262
  abort(reason?: string): void;
246
263
  }
247
264
  export {};
@@ -251,6 +251,16 @@ export interface AgentHarnessContract<TParams extends Record<string, unknown> =
251
251
  * decorated context is silently replaced by a bare one on resume.
252
252
  */
253
253
  restore(executionId: string, opts?: RestoreContextOptions): Promise<Context | null>;
254
+ /**
255
+ * Cancel an execution. Aborts `ctx` and — cascading down the execution tree —
256
+ * every live fork path and spawn child, then runs memory-layer teardown
257
+ * (`onComplete` with `outcome: 'aborted'`, then `dispose`) bottom-up.
258
+ *
259
+ * Blocked channel operations reject with `cancelled`, the in-flight model
260
+ * call and sub-harness turn are cut short, and the next step boundary throws
261
+ * `cancelled`. Idempotent: cancelling an already-cancelled context is a
262
+ * no-op. Use `ctx.abort()` when you want the signal without layer teardown.
263
+ */
254
264
  cancel(ctx: Context, reason?: string): Promise<void>;
255
265
  /** Trace exporter spans are flushed to. Defaults to a no-op exporter. */
256
266
  readonly traceExporter: TraceExporter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/types",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "Foundational type contracts and primitives shared across the Noetic framework (items, LLM config, adapters, steering, errors).",
6
6
  "license": "Apache-2.0",