@noetic-tools/core 5.2.0 → 5.4.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.
Files changed (92) hide show
  1. package/README.md +8 -6
  2. package/dist/adapters/in-memory-subprocess/step-completion.js +1 -1
  3. package/dist/adapters/openrouter.d.ts +20 -2
  4. package/dist/adapters/openrouter.js +102 -6
  5. package/dist/builders/control-flow-builders.d.ts +13 -13
  6. package/dist/builders/control-flow-builders.js +11 -11
  7. package/dist/{patterns → builders}/dynamic-workflow.d.ts +1 -1
  8. package/dist/{patterns → builders}/dynamic-workflow.js +18 -12
  9. package/dist/builders/every.d.ts +14 -14
  10. package/dist/builders/every.js +16 -16
  11. package/dist/builders/layer-provides-builders.d.ts +1 -1
  12. package/dist/builders/layer-provides-builders.js +1 -1
  13. package/dist/builders/provide-builder.d.ts +5 -12
  14. package/dist/builders/provide-builder.js +9 -10
  15. package/dist/builders/spawn-builder.d.ts +0 -2
  16. package/dist/builders/spawn-builder.js +2 -3
  17. package/dist/builders/step-builders.d.ts +66 -55
  18. package/dist/builders/step-builders.js +118 -111
  19. package/dist/builders/tool-builder.d.ts +18 -22
  20. package/dist/builders/tool-builder.js +6 -4
  21. package/dist/builders/workflow-hydrator.d.ts +3 -3
  22. package/dist/builders/workflow-hydrator.js +59 -63
  23. package/dist/builders/workflow-step.d.ts +13 -25
  24. package/dist/builders/workflow-step.js +13 -18
  25. package/dist/conditions/conditions.d.ts +2 -2
  26. package/dist/conditions/conditions.js +1 -1
  27. package/dist/harness/agent-harness.d.ts +58 -40
  28. package/dist/harness/agent-harness.js +125 -51
  29. package/dist/harness/deps/context.d.ts +1 -1
  30. package/dist/harness/deps/context.js +1 -1
  31. package/dist/harness/deps/interpreter.d.ts +1 -0
  32. package/dist/harness/deps/interpreter.js +1 -0
  33. package/dist/harness/deps/types.d.ts +2 -2
  34. package/dist/harness/model-call.d.ts +1 -1
  35. package/dist/harness/model-call.js +45 -23
  36. package/dist/index.d.ts +23 -35
  37. package/dist/index.js +10 -26
  38. package/dist/interpreter/action-deps.d.ts +2 -2
  39. package/dist/interpreter/action-deps.js +1 -1
  40. package/dist/interpreter/action-types.d.ts +1 -1
  41. package/dist/interpreter/collect-tools.js +7 -7
  42. package/dist/interpreter/context-assembly.d.ts +50 -0
  43. package/dist/interpreter/context-assembly.js +547 -0
  44. package/dist/interpreter/execute-action.d.ts +5 -5
  45. package/dist/interpreter/execute-action.js +337 -208
  46. package/dist/interpreter/execute-control.d.ts +9 -9
  47. package/dist/interpreter/execute-control.js +27 -27
  48. package/dist/interpreter/execute-sub-harness.d.ts +1 -1
  49. package/dist/interpreter/execute-sub-harness.js +7 -26
  50. package/dist/interpreter/execute.d.ts +1 -1
  51. package/dist/interpreter/execute.js +26 -25
  52. package/dist/interpreter/step-dispatcher.d.ts +1 -1
  53. package/dist/interpreter/step-dispatcher.js +3 -3
  54. package/dist/interpreter/structured-output.d.ts +14 -0
  55. package/dist/interpreter/structured-output.js +40 -0
  56. package/dist/interpreter/typeguards.d.ts +1 -1
  57. package/dist/interpreter/typeguards.js +1 -1
  58. package/dist/observability/genai-attributes.d.ts +20 -0
  59. package/dist/observability/genai-attributes.js +20 -0
  60. package/dist/portable/builders.d.ts +3 -3
  61. package/dist/portable/builders.js +6 -5
  62. package/dist/portable/context-layers.d.ts +1 -1
  63. package/dist/portable/context-layers.js +1 -1
  64. package/dist/runtime/broadcaster-utils.d.ts +1 -1
  65. package/dist/runtime/broadcaster-utils.js +1 -1
  66. package/dist/runtime/context-impl.d.ts +8 -10
  67. package/dist/runtime/context-impl.js +8 -12
  68. package/dist/runtime/durable/step-ledger.d.ts +2 -2
  69. package/dist/runtime/durable/step-ledger.js +3 -3
  70. package/dist/runtime/session-runner.d.ts +12 -1
  71. package/dist/runtime/session-runner.js +33 -1
  72. package/dist/runtime/tool-context.js +0 -2
  73. package/dist/schemas/workflow.d.ts +3 -3
  74. package/dist/schemas/workflow.js +2 -2
  75. package/dist/types/checkpoint.d.ts +11 -2
  76. package/dist/types/checkpoint.js +10 -1
  77. package/dist/{runtime → util}/detached-handle.js +1 -1
  78. package/package.json +6 -4
  79. package/schema/noetic-workflow.schema.json +24 -11
  80. package/dist/builders/context-option.d.ts +0 -23
  81. package/dist/builders/context-option.js +0 -22
  82. package/dist/deprecated.d.ts +0 -30
  83. package/dist/deprecated.js +0 -28
  84. package/dist/patterns/interview.d.ts +0 -53
  85. package/dist/patterns/interview.js +0 -113
  86. package/dist/patterns/plans.d.ts +0 -44
  87. package/dist/patterns/plans.js +0 -125
  88. package/dist/patterns/ralph-wiggum.d.ts +0 -19
  89. package/dist/patterns/ralph-wiggum.js +0 -37
  90. package/dist/patterns/react.d.ts +0 -19
  91. package/dist/patterns/react.js +0 -41
  92. /package/dist/{runtime → util}/detached-handle.d.ts +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @noetic-tools/core
2
2
 
3
- Core primitives for the [Noetic](https://github.com/mattapperson/noetic) agent framework: step types, the interpreter, the runtime, the memory layer contract, and built-in patterns.
3
+ Core primitives for the [Noetic](https://github.com/mattapperson/noetic) agent framework: step types, the interpreter, the runtime, and the context layer contract.
4
4
 
5
5
  ## Install
6
6
 
@@ -10,6 +10,8 @@ npm install @noetic-tools/core
10
10
 
11
11
  `@noetic-tools/core` depends on `@openrouter/agent` (the OpenRouter LLM adapter) and `zod`; your package manager installs both automatically. Runs on Node.js 18+ and Bun.
12
12
 
13
+ Tool and structured-output schemas accept any [Standard Schema v1](https://standardschema.dev/schema) validator. Model-facing JSON Schema resolves through three tiers: Zod's native converter, the [Standard JSON Schema v1](https://standardschema.dev/json-schema) companion trait, then an explicit `inputJsonSchema` / `outputJsonSchema` override. Zod 4.2+, ArkType 2.1.28+, Zod Mini, VineJS, and Sury implement the companion trait; Valibot provides it via `toStandardJsonSchema()` from `@valibot/to-json-schema`.
14
+
13
15
  ## Set your API key
14
16
 
15
17
  Noetic talks to LLMs through [OpenRouter](https://openrouter.ai). Grab a key at [openrouter.ai/keys](https://openrouter.ai/keys) and export it — the runtime reads `OPENROUTER_API_KEY` by default:
@@ -23,20 +25,20 @@ export OPENROUTER_API_KEY=sk-or-...
23
25
  A complete, runnable program. Save it as `agent.mjs` and run `node agent.mjs`:
24
26
 
25
27
  ```ts
26
- import { AgentHarness, step } from '@noetic-tools/core';
28
+ import { AgentHarness, callModel } from '@noetic-tools/core';
27
29
 
28
- // 1. Define a single LLM step. `model` and `instructions` live here.
29
- const greet = step.llm({
30
+ // 1. Define a single model-call step. `model` and `instructions` live here.
31
+ const greet = callModel({
30
32
  id: 'greet',
31
33
  model: 'openai/gpt-4o-mini', // any OpenRouter model id (provider/model)
32
34
  instructions: 'You are a friendly assistant. Greet the user by name in one short sentence.',
33
35
  });
34
36
 
35
- // 2. Create the harness. `name` and `params` are required; `llm` selects the provider.
37
+ // 2. Create the harness. `name` and `params` are required; `callModelDefaults` selects the provider.
36
38
  const harness = new AgentHarness({
37
39
  name: 'greeter',
38
40
  params: {},
39
- llm: { provider: 'openrouter' }, // apiKey defaults to process.env.OPENROUTER_API_KEY
41
+ callModelDefaults: { provider: 'openrouter' }, // apiKey defaults to process.env.OPENROUTER_API_KEY
40
42
  });
41
43
 
42
44
  // 3. Run the step with a context and print the model's reply.
@@ -31,7 +31,7 @@ export async function completeProcessRun({ request, handle, run, handles, active
31
31
  error: serializeError(err),
32
32
  },
33
33
  });
34
- // Same rationale as the success branch above — clear the durable
34
+ // Same rationale as the success path above — clear the durable
35
35
  // manifest so `listLive()` doesn't resurrect this handle on reboot.
36
36
  await clearIfDurable(handle.id);
37
37
  }
@@ -1,5 +1,5 @@
1
1
  import type { ContextLayer } from '@noetic-tools/context';
2
- import type { AgentHarnessContract, Context, Item, LLMResponse, Tool } from '@noetic-tools/types';
2
+ import type { AgentHarnessContract, Context, Item, LLMResponse, StandardSchemaV1, Tool } from '@noetic-tools/types';
3
3
  import type * as OpenRouterAgent from '@openrouter/agent';
4
4
  import type { EmbedFn } from '../types/embed.js';
5
5
  type ProviderOutputItem = OpenRouterAgent.OpenResponsesResult['output'][number];
@@ -23,7 +23,14 @@ export declare function itemsToInput(items: ReadonlyArray<Item>): OpenRouterInpu
23
23
  * when the output array contains no message items.
24
24
  */
25
25
  export declare function extractOutputItems(response: OpenRouterAgent.OpenResponsesResult): Item[];
26
- /** @internal */
26
+ /**
27
+ * @internal
28
+ *
29
+ * A missing `cachedTokens` stays `undefined` rather than collapsing to `0`:
30
+ * context anchoring reads it to decide whether the prompt prefix survived, and
31
+ * "this provider reports no cache figures" must not look like "nothing was
32
+ * cached".
33
+ */
27
34
  export declare function extractUsage(usage: OpenRouterAgent.Usage | null | undefined): LLMResponse['usage'];
28
35
  /**
29
36
  * Provider APIs (Anthropic via OpenRouter) enforce tool names match
@@ -33,6 +40,17 @@ export declare function extractUsage(usage: OpenRouterAgent.Usage | null | undef
33
40
  * to names like `plan/updatePrd`) is preserved.
34
41
  */
35
42
  export declare function sanitizeToolNameForWire(name: string): string;
43
+ /**
44
+ * Resolve the JSON Schema sent to the model. Zod keeps its legacy-compatible
45
+ * fast path. For other validators, an explicit override wins over the Standard
46
+ * JSON Schema v1 companion trait.
47
+ * @internal
48
+ */
49
+ export declare function resolveWireJsonSchema(params: {
50
+ schema: StandardSchemaV1;
51
+ explicitJsonSchema?: Record<string, unknown>;
52
+ what: string;
53
+ }): Record<string, unknown>;
36
54
  /** @internal */
37
55
  export declare function convertTools({ tools }: ConvertToolsParams): SdkTool[];
38
56
  /** @internal */
@@ -1,4 +1,4 @@
1
- import { frameworkCast, isAssistantMessage, NoeticConfigError, SteeringAction, } from '@noetic-tools/types';
1
+ import { frameworkCast, isAssistantMessage, isStandardJsonSchema, isZodSchema, NoeticConfigError, SteeringAction, validateSchema, } from '@noetic-tools/types';
2
2
  import { z } from 'zod';
3
3
  import { buildToolExecutionContext } from '../runtime/tool-context.js';
4
4
  import { emitToolUi } from '../runtime/tool-ui.js';
@@ -153,7 +153,14 @@ export function extractOutputItems(response) {
153
153
  ...items,
154
154
  ];
155
155
  }
156
- /** @internal */
156
+ /**
157
+ * @internal
158
+ *
159
+ * A missing `cachedTokens` stays `undefined` rather than collapsing to `0`:
160
+ * context anchoring reads it to decide whether the prompt prefix survived, and
161
+ * "this provider reports no cache figures" must not look like "nothing was
162
+ * cached".
163
+ */
157
164
  export function extractUsage(usage) {
158
165
  if (!usage) {
159
166
  return {
@@ -165,8 +172,23 @@ export function extractUsage(usage) {
165
172
  inputTokens: usage.inputTokens,
166
173
  outputTokens: usage.outputTokens,
167
174
  cachedTokens: usage.inputTokensDetails?.cachedTokens,
175
+ cacheWriteTokens: cacheWriteTokensOf(usage.inputTokensDetails),
168
176
  };
169
177
  }
178
+ /**
179
+ * OpenRouter reports `cache_write_tokens` alongside `cached_tokens`, but the
180
+ * SDK's `InputTokensDetails` type declares only the latter. Read it off the
181
+ * value rather than the type, and stay `undefined` when it is genuinely absent
182
+ * — a write of zero and no report at all mean different things to the epoch
183
+ * logic (see `noteCacheOutcome`).
184
+ */
185
+ function cacheWriteTokensOf(details) {
186
+ if (typeof details !== 'object' || details === null || !('cacheWriteTokens' in details)) {
187
+ return undefined;
188
+ }
189
+ const value = details.cacheWriteTokens;
190
+ return typeof value === 'number' ? value : undefined;
191
+ }
170
192
  //#endregion
171
193
  //#region Tool Conversion
172
194
  // We construct the SDK tool shape manually and use frameworkCast to bridge
@@ -188,6 +210,72 @@ export function extractUsage(usage) {
188
210
  export function sanitizeToolNameForWire(name) {
189
211
  return name.replace(/[^a-zA-Z0-9_-]/g, '_');
190
212
  }
213
+ /**
214
+ * Resolve the JSON Schema sent to the model. Zod keeps its legacy-compatible
215
+ * fast path. For other validators, an explicit override wins over the Standard
216
+ * JSON Schema v1 companion trait.
217
+ * @internal
218
+ */
219
+ export function resolveWireJsonSchema(params) {
220
+ const { schema, explicitJsonSchema, what } = params;
221
+ if (isZodSchema(schema)) {
222
+ return sanitizeJsonSchema(z.toJSONSchema(schema, {
223
+ target: 'draft-07',
224
+ }));
225
+ }
226
+ if (explicitJsonSchema) {
227
+ return sanitizeJsonSchema(explicitJsonSchema);
228
+ }
229
+ if (isStandardJsonSchema(schema)) {
230
+ try {
231
+ return sanitizeJsonSchema(schema['~standard'].jsonSchema.input({
232
+ target: 'draft-07',
233
+ }));
234
+ }
235
+ catch {
236
+ // Fall through to the actionable configuration error below.
237
+ }
238
+ }
239
+ throw new NoeticConfigError({
240
+ code: 'MISSING_JSON_SCHEMA',
241
+ message: `The ${what} cannot be converted to JSON Schema.`,
242
+ hint: 'Use a schema implementing StandardJSONSchemaV1 or pass `inputJsonSchema` on tool() / `outputJsonSchema` on step.llm().',
243
+ });
244
+ }
245
+ function sanitizeJsonSchema(value) {
246
+ return Object.fromEntries(Object.entries(value)
247
+ .filter(([key]) => !key.startsWith('~'))
248
+ .map(([key, nested]) => [
249
+ key,
250
+ sanitizeJsonSchemaValue(nested),
251
+ ]));
252
+ }
253
+ function sanitizeJsonSchemaValue(value) {
254
+ if (Array.isArray(value)) {
255
+ return value.map(sanitizeJsonSchemaValue);
256
+ }
257
+ if (typeof value === 'object' && value !== null) {
258
+ return sanitizeJsonSchema(frameworkCast(value));
259
+ }
260
+ return value;
261
+ }
262
+ /**
263
+ * The OpenRouter SDK requires a live Zod `inputSchema` and runs
264
+ * `z.toJSONSchema` over it; it never executes these tool objects (Noetic owns
265
+ * its tool loop). For non-Zod inputs, bridge the resolved JSON Schema through
266
+ * a wire-only `z.any().meta(...)` so the SDK emits it unchanged. Noetic, not
267
+ * the SDK, validates tool-call args.
268
+ */
269
+ function inputSchemaForWire(tool) {
270
+ if (isZodSchema(tool.input)) {
271
+ return tool.input;
272
+ }
273
+ return z.any().meta(resolveWireJsonSchema({
274
+ schema: tool.input,
275
+ explicitJsonSchema: tool.inputJsonSchema,
276
+ what: `input schema of tool '${tool.name}'`,
277
+ }));
278
+ }
191
279
  /** @internal */
192
280
  export function convertTools({ tools }) {
193
281
  return tools.map((t) => frameworkCast({
@@ -195,7 +283,7 @@ export function convertTools({ tools }) {
195
283
  function: {
196
284
  name: sanitizeToolNameForWire(t.name),
197
285
  description: t.description,
198
- inputSchema: t.input,
286
+ inputSchema: inputSchemaForWire(t),
199
287
  },
200
288
  }));
201
289
  }
@@ -210,7 +298,7 @@ export async function executeToolCall(params) {
210
298
  // Model sees sanitised tool names (see `sanitizeToolNameForWire`). Match
211
299
  // against both the original and sanitised name so internal identity (e.g.
212
300
  // `plan/updatePrd` used by steering whitelists, skill docs, and the
213
- // `planContext.beforeToolCall` hook) stays intact while the wire name is
301
+ // `plan()` layer's `beforeToolCall` hook) stays intact while the wire name is
214
302
  // provider-compliant.
215
303
  const matchedTool = params.tools.find((t) => t.name === params.toolName || sanitizeToolNameForWire(t.name) === params.toolName);
216
304
  if (!matchedTool) {
@@ -234,6 +322,14 @@ export async function executeToolCall(params) {
234
322
  };
235
323
  }
236
324
  }
325
+ const validated = await validateSchema(matchedTool.input, params.args);
326
+ if (!validated.success) {
327
+ return {
328
+ output: `Error: invalid arguments for tool '${params.toolName}': ${validated.zodError.message}`,
329
+ error: true,
330
+ };
331
+ }
332
+ const parsedArgs = validated.value;
237
333
  const toolCtx = buildToolExecutionContext(params.context, params.harness);
238
334
  const callId = params.callId;
239
335
  const uiBase = callId !== undefined
@@ -241,7 +337,7 @@ export async function executeToolCall(params) {
241
337
  ctx: params.context,
242
338
  tool: matchedTool,
243
339
  callId,
244
- args: params.args,
340
+ args: parsedArgs,
245
341
  }
246
342
  : undefined;
247
343
  if (uiBase) {
@@ -251,7 +347,7 @@ export async function executeToolCall(params) {
251
347
  });
252
348
  }
253
349
  try {
254
- const executionResult = matchedTool.execute(params.args, toolCtx);
350
+ const executionResult = matchedTool.execute(parsedArgs, toolCtx);
255
351
  // Generator tools stream progress; drive them here so tool-UI `progress`
256
352
  // fragments emit per yield (the non-UI case just consumes to the return).
257
353
  let result;
@@ -1,5 +1,5 @@
1
1
  import type { ContextData } from '@noetic-tools/context';
2
- import type { Context, SettleResult, Step, StepBranch, StepForkAll, StepForkRace, StepForkSettle } from '@noetic-tools/types';
2
+ import type { Context, SettleResult, Step, StepConditional, StepInParallelAll, StepInParallelRace, StepInParallelSettle } from '@noetic-tools/types';
3
3
  /**
4
4
  * Creates a parallel execution step that races paths and returns the first result.
5
5
  *
@@ -8,16 +8,16 @@ import type { Context, SettleResult, Step, StepBranch, StepForkAll, StepForkRace
8
8
  * @param opts.mode - Must be `'race'`: first path to resolve wins, others are cancelled.
9
9
  * @param opts.paths - Factory `(input, ctx) => Step[]` generating the parallel paths.
10
10
  * @param opts.concurrency - Optional maximum number of paths to run simultaneously.
11
- * @returns A `StepForkRace` step.
11
+ * @returns A `StepInParallelRace` step.
12
12
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
13
13
  */
14
- export declare function fork<TContext = ContextData, I = unknown, O = unknown>(opts: {
14
+ export declare function inParallel<TContext = ContextData, I = unknown, O = unknown>(opts: {
15
15
  id: string;
16
16
  mode: 'race';
17
17
  paths: (input: I, ctx: Context<TContext>) => Step<TContext, I, O>[];
18
18
  concurrency?: number;
19
19
  _optimizable?: Step<TContext>[];
20
- }): StepForkRace<TContext, I, O>;
20
+ }): StepInParallelRace<TContext, I, O>;
21
21
  /**
22
22
  * Creates a parallel execution step that waits for all paths and merges results.
23
23
  *
@@ -27,18 +27,18 @@ export declare function fork<TContext = ContextData, I = unknown, O = unknown>(o
27
27
  * @param opts.paths - Factory `(input, ctx) => Step[]` generating the parallel paths.
28
28
  * @param opts.merge - Combines all path results into a single output value.
29
29
  * @param opts.concurrency - Optional maximum number of paths to run simultaneously.
30
- * @returns A `StepForkAll` step.
30
+ * @returns A `StepInParallelAll` step.
31
31
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
32
32
  * @throws `NoeticConfigError` with code `MISSING_MERGE_FUNCTION` if `merge` is not provided.
33
33
  */
34
- export declare function fork<TContext = ContextData, I = unknown, O = unknown>(opts: {
34
+ export declare function inParallel<TContext = ContextData, I = unknown, O = unknown>(opts: {
35
35
  id: string;
36
36
  mode: 'all';
37
37
  paths: (input: I, ctx: Context<TContext>) => Step<TContext, I, O>[];
38
38
  merge: (results: O[], ctx: Context<TContext>) => O;
39
39
  concurrency?: number;
40
40
  _optimizable?: Step<TContext>[];
41
- }): StepForkAll<TContext, I, O>;
41
+ }): StepInParallelAll<TContext, I, O>;
42
42
  /**
43
43
  * Creates a parallel execution step that waits for all paths, collecting errors as results.
44
44
  *
@@ -48,18 +48,18 @@ export declare function fork<TContext = ContextData, I = unknown, O = unknown>(o
48
48
  * @param opts.paths - Factory `(input, ctx) => Step[]` generating the parallel paths.
49
49
  * @param opts.merge - Combines settled results (successes and failures) into a single output.
50
50
  * @param opts.concurrency - Optional maximum number of paths to run simultaneously.
51
- * @returns A `StepForkSettle` step.
51
+ * @returns A `StepInParallelSettle` step.
52
52
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
53
53
  * @throws `NoeticConfigError` with code `MISSING_MERGE_FUNCTION` if `merge` is not provided.
54
54
  */
55
- export declare function fork<TContext = ContextData, I = unknown, O = unknown>(opts: {
55
+ export declare function inParallel<TContext = ContextData, I = unknown, O = unknown>(opts: {
56
56
  id: string;
57
57
  mode: 'settle';
58
58
  paths: (input: I, ctx: Context<TContext>) => Step<TContext, I, O>[];
59
59
  merge: (results: SettleResult<O>[], ctx: Context<TContext>) => O;
60
60
  concurrency?: number;
61
61
  _optimizable?: Step<TContext>[];
62
- }): StepForkSettle<TContext, I, O>;
62
+ }): StepInParallelSettle<TContext, I, O>;
63
63
  /**
64
64
  * Creates a conditional routing step that selects the next step at runtime.
65
65
  *
@@ -67,12 +67,12 @@ export declare function fork<TContext = ContextData, I = unknown, O = unknown>(o
67
67
  * @param opts.id - Unique step identifier used in traces and error messages.
68
68
  * @param opts.route - Routing function `(input, ctx) => Step | null`. Returns the next step
69
69
  * to execute, or `null` to skip (pass input through as output). May be async.
70
- * @returns A `StepBranch` step.
70
+ * @returns A `StepConditional` step.
71
71
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
72
72
  * @throws `NoeticConfigError` with code `MISSING_ROUTE_FUNCTION` if `route` is not provided.
73
73
  */
74
- export declare function branch<TContext = ContextData, I = unknown, O = unknown>(opts: {
74
+ export declare function conditional<TContext = ContextData, I = unknown, O = unknown>(opts: {
75
75
  id: string;
76
76
  route: (input: I, ctx: Context<TContext>) => Step<TContext, I, O> | null | Promise<Step<TContext, I, O> | null>;
77
77
  _optimizable?: Step<TContext>[];
78
- }): StepBranch<TContext, I, O>;
78
+ }): StepConditional<TContext, I, O>;
@@ -1,22 +1,22 @@
1
1
  import { frameworkCast, NoeticConfigError } from '@noetic-tools/types';
2
2
  import { getDefaultRegistrar } from '../types/step-registrar.js';
3
- export function fork(opts) {
3
+ export function inParallel(opts) {
4
4
  if (!opts.id || opts.id.trim() === '') {
5
5
  throw new NoeticConfigError({
6
6
  code: 'EMPTY_STEP_ID',
7
- message: 'fork() requires a non-empty id.',
8
- hint: 'Pass a unique string as the id field, e.g. fork({ id: "my-fork", ... }).',
7
+ message: 'inParallel() requires a non-empty id.',
8
+ hint: 'Pass a unique string as the id field, e.g. inParallel({ id: "my-inParallel", ... }).',
9
9
  });
10
10
  }
11
11
  if ((opts.mode === 'all' || opts.mode === 'settle') && !opts.merge) {
12
12
  throw new NoeticConfigError({
13
13
  code: 'MISSING_MERGE_FUNCTION',
14
- message: `fork() mode '${opts.mode}' requires a merge function.`,
14
+ message: `inParallel() mode '${opts.mode}' requires a merge function.`,
15
15
  hint: 'Provide a merge function that combines path results into a single output.',
16
16
  });
17
17
  }
18
18
  const built = frameworkCast({
19
- kind: 'fork',
19
+ kind: 'inParallel',
20
20
  ...opts,
21
21
  });
22
22
  getDefaultRegistrar().register(built);
@@ -29,27 +29,27 @@ export function fork(opts) {
29
29
  * @param opts.id - Unique step identifier used in traces and error messages.
30
30
  * @param opts.route - Routing function `(input, ctx) => Step | null`. Returns the next step
31
31
  * to execute, or `null` to skip (pass input through as output). May be async.
32
- * @returns A `StepBranch` step.
32
+ * @returns A `StepConditional` step.
33
33
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
34
34
  * @throws `NoeticConfigError` with code `MISSING_ROUTE_FUNCTION` if `route` is not provided.
35
35
  */
36
- export function branch(opts) {
36
+ export function conditional(opts) {
37
37
  if (!opts.id || opts.id.trim() === '') {
38
38
  throw new NoeticConfigError({
39
39
  code: 'EMPTY_STEP_ID',
40
- message: 'branch() requires a non-empty id.',
41
- hint: 'Pass a unique string as the id field, e.g. branch({ id: "my-branch", ... }).',
40
+ message: 'conditional() requires a non-empty id.',
41
+ hint: 'Pass a unique string as the id field, e.g. conditional({ id: "my-conditional", ... }).',
42
42
  });
43
43
  }
44
44
  if (!opts.route) {
45
45
  throw new NoeticConfigError({
46
46
  code: 'MISSING_ROUTE_FUNCTION',
47
- message: 'branch() requires a route function.',
47
+ message: 'conditional() requires a route function.',
48
48
  hint: 'Provide a route function that selects the next step at runtime.',
49
49
  });
50
50
  }
51
51
  const built = {
52
- kind: 'branch',
52
+ kind: 'conditional',
53
53
  ...opts,
54
54
  };
55
55
  getDefaultRegistrar().register(built);
@@ -36,7 +36,7 @@ export interface ParseAndRunWorkflowOpts {
36
36
  input?: string;
37
37
  maxDepth?: number;
38
38
  /**
39
- * Context layers the document's `provide` / `spawn` nodes may reference by name.
39
+ * Context layers the document's `withContext` / `spawn` nodes may reference by name.
40
40
  * Without a registry those nodes resolve to NO layers rather than failing, so a
41
41
  * host that runs layer-bearing workflows must pass its layers here.
42
42
  */
@@ -3,10 +3,10 @@
3
3
  * then hydrates and runs it within the same harness session.
4
4
  */
5
5
  import { frameworkCast, NoeticConfigError } from '@noetic-tools/types';
6
- import { step } from '../builders/step-builders.js';
7
- import { hydrateWorkflow } from '../builders/workflow-hydrator.js';
8
6
  import { NoeticAttr } from '../observability/genai-attributes.js';
9
7
  import { WorkflowDocumentSchema, workflowDepth, workflowGraph } from '../schemas/workflow.js';
8
+ import { callModel, runCode } from './step-builders.js';
9
+ import { hydrateWorkflow } from './workflow-hydrator.js';
10
10
  //#endregion
11
11
  //#region Constants
12
12
  const DEFAULT_MAX_DEPTH = 5;
@@ -21,19 +21,25 @@ The workflow document must be valid JSON with this structure:
21
21
  }
22
22
 
23
23
  A WorkflowNode is one of:
24
- - { "kind": "llm", "id": "<unique>", "instructions": "<prompt>", "model": "<optional>", "tools": ["<tool-name>", ...] }
25
- - { "kind": "tool", "id": "<unique>", "toolName": "<name>", "args": { ... } }
26
- - { "kind": "sequence", "id": "<unique>", "steps": [<WorkflowNode>, ...] }
27
- - { "kind": "fork", "id": "<unique>", "mode": "all"|"race"|"settle", "paths": [<WorkflowNode>, ...], "merge": "last"|"first"|"concat" }
28
- - { "kind": "loop", "id": "<unique>", "body": <WorkflowNode>, "until": { "kind": "maxSteps", "n": <number> } }
29
- - { "kind": "branch", "id": "<unique>", "routes": [{ "match": "<substring>", "target": <WorkflowNode> }], "default": <WorkflowNode> }
30
- - { "kind": "spawn", "id": "<unique>", "child": <WorkflowNode> }
24
+ - { "kind": "callModel", "id": "<unique>", "instructions": "<prompt>", "model": "<optional>", "tools": [{ "type": "<tool-name>" }, ...] } (one LLM call)
25
+ - { "kind": "invokeTool", "id": "<unique>", "toolName": "<name>", "args": { ... } } (call one tool directly, no LLM)
26
+ - { "kind": "runCode", "id": "<unique>", "execute": "<source code>" } (runs the code in a subprocess with the step input on stdin; only emit if the task needs deterministic computation AND a subprocess adapter is available)
27
+ - { "kind": "sequence", "id": "<unique>", "steps": [<WorkflowNode>, ...] } (run children in order, threading each output into the next)
28
+ - { "kind": "inParallel", "id": "<unique>", "mode": "all"|"race"|"settle", "paths": [<WorkflowNode>, ...], "merge": "last"|"first"|"concat" } (fan out; supply exactly one of "paths" or "each": <WorkflowNode> — with "each", add "over": "<key>" to fan out over an array in the input JSON)
29
+ - { "kind": "loop", "id": "<unique>", "body": <WorkflowNode>, "until": <UntilPredicate>, "maxIterations": <optional number> } (repeat the body until the predicate holds)
30
+ - { "kind": "conditional", "id": "<unique>", "routes": [{ "match": "<substring>", "target": <WorkflowNode> }], "default": <WorkflowNode> } (route on a substring of the input)
31
+ - { "kind": "spawn", "id": "<unique>", "child": <WorkflowNode>, "timeout": <optional ms> } (run the child in an isolated sub-context)
32
+ - { "kind": "withContext", "id": "<unique>", "child": <WorkflowNode>, "layers": ["<layer-name>", ...] } (run the child with named context layers; only emit if the task names layers that exist, since this planner provides no layer registry and unknown names resolve to NO layers)
33
+ - { "kind": "schedule", "id": "<unique>", "step": <WorkflowNode>, "interval": <ms>, "onError": "continue"|"fail" } (re-runs the step forever on an interval and never returns — only emit for an explicitly daemon-style task)
31
34
  - { "kind": "subflow", "id": "<unique>", "document": { "version": 1, "root": <WorkflowNode> } } (an inline sub-workflow run as one step; only emit the inline form — named refs require a registry this planner does not provide)
32
35
  - { "kind": "claude-code"|"codex"|"opencode"|"pi", "id": "<unique>", "prompt": "<turn prompt>", "settings": { "model": "<optional>", "permissionMode": "<optional>" } }
33
36
 
37
+ Every entry of a callModel node's "tools" is an OBJECT, never a bare string. Use { "type": "<tool-name>" } to let the model call one of the tools listed below. Two provider-executed tools are also available without being listed: { "type": "openrouter:web_search" } and { "type": "openrouter:web_fetch" }, each accepting an optional "parameters" object.
38
+
34
39
  SubHarness nodes (claude-code, codex, opencode, pi) delegate a turn to an external coding agent; only emit one if a matching harness adapter is registered for the workflow.
35
40
 
36
- Until predicates: maxSteps, maxCost, maxDuration, noToolCalls, outputContains, outputEquals, converged, any, all.
41
+ An UntilPredicate (the "until" field of a loop) is one of:
42
+ { "kind": "maxSteps", "n": <positive int> }, { "kind": "maxCost", "usd": <positive number> }, { "kind": "maxDuration", "duration": <ms> }, { "kind": "noToolCalls" }, { "kind": "never" } (never stops — pair with "maxIterations"), { "kind": "outputContains", "marker": "<text>" }, { "kind": "outputEquals", "sentinel": "<text>" }, { "kind": "converged", "threshold": <optional 0-1> }, { "kind": "any", "predicates": [<UntilPredicate>, ...] }, { "kind": "all", "predicates": [<UntilPredicate>, ...] }.
37
43
 
38
44
  Respond with ONLY the JSON document, no markdown fences or explanation.`;
39
45
  //#endregion
@@ -56,7 +62,7 @@ export function dynamicWorkflow(opts) {
56
62
  const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
57
63
  const maxRevisions = opts.maxRevisions ?? DEFAULT_MAX_REVISIONS;
58
64
  const toolMap = buildToolMap(opts.tools);
59
- return step.run({
65
+ return runCode({
60
66
  id: 'dynamic-workflow',
61
67
  execute: async (input, ctx) => {
62
68
  const harness = ctx.harness;
@@ -81,7 +87,7 @@ export function dynamicWorkflow(opts) {
81
87
  const prompt = lastError
82
88
  ? `${basePrompt}\n\nPrevious attempt failed with error: ${lastError}\nPlease fix the workflow and try again.`
83
89
  : basePrompt;
84
- const plannerStep = step.llm({
90
+ const plannerStep = callModel({
85
91
  id: `dynamic-workflow-planner-${revision}`,
86
92
  model,
87
93
  instructions: prompt,
@@ -1,44 +1,44 @@
1
1
  import type { ContextData } from '@noetic-tools/context';
2
- import type { Channel, EveryErrorPolicy, Step, StepEvery } from '@noetic-tools/types';
2
+ import type { Channel, ScheduleErrorPolicy, Step, StepSchedule } from '@noetic-tools/types';
3
3
  /**
4
- * Configuration options accepted by the `every()` builder, excluding the `kind`
5
- * discriminant. `every()` schedules a body step on a fixed-interval, optionally
4
+ * Configuration options accepted by the `schedule()` builder, excluding the `kind`
5
+ * discriminant. `schedule()` schedules a body step on a fixed-interval, optionally
6
6
  * woken early by a channel, and runs until the executing context is cancelled.
7
7
  *
8
8
  * @public
9
9
  */
10
- export interface EveryOptions<TContext = ContextData, I = unknown, O = unknown> {
10
+ export interface ScheduleOptions<TContext = ContextData, I = unknown, O = unknown> {
11
11
  /** Unique step identifier used in traces and error messages. */
12
12
  id: string;
13
13
  /** Body step executed on each iteration. */
14
14
  step: Step<TContext, I, O>;
15
15
  /** Park duration between iterations in milliseconds. Must be >= 0. */
16
- ms: number;
16
+ interval: number;
17
17
  /** Optional channel that wakes the parking interval when any value arrives. */
18
- wakeOn?: Channel<unknown>;
18
+ inbox?: Channel<unknown>;
19
19
  /** Behavior when `step` throws. Defaults to `'continue'`. */
20
- onError?: EveryErrorPolicy;
20
+ onError?: ScheduleErrorPolicy;
21
21
  /** Random jitter applied to the park duration in milliseconds. Must be >= 0. Default 0. */
22
22
  jitter?: number;
23
23
  }
24
24
  /**
25
- * Creates an `every` step that runs a body step on a fixed-interval schedule.
25
+ * Creates a `schedule` step that runs a body step on a fixed-interval schedule.
26
26
  *
27
27
  * The operator runs forever until the executing context is cancelled. After each
28
- * iteration it parks for `ms ± jitter` milliseconds (or until `wakeOn` receives
28
+ * iteration it parks for `interval ± jitter` milliseconds (or until `inbox` receives
29
29
  * a message, whichever comes first).
30
30
  *
31
31
  * @public
32
32
  * @param opts.id - Unique step identifier used in traces and error messages.
33
33
  * @param opts.step - Body step executed on each iteration.
34
- * @param opts.ms - Park duration between iterations in milliseconds.
35
- * @param opts.wakeOn - Optional channel that wakes the parking interval when any value arrives.
34
+ * @param opts.interval - Park duration between iterations in milliseconds.
35
+ * @param opts.inbox - Optional channel that wakes the parking interval when any value arrives.
36
36
  * @param opts.onError - Error policy. `'continue'` (default) records a span event and continues; `'fail'` re-throws.
37
37
  * @param opts.jitter - Random jitter in ms applied to the park duration. Default 0.
38
- * @returns A `StepEvery` step.
38
+ * @returns A `StepSchedule` step.
39
39
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
40
40
  * @throws `NoeticConfigError` with code `MISSING_BODY_STEP` if `step` is not provided.
41
- * @throws `NoeticConfigError` with code `INVALID_INTERVAL_MS` if `ms` is negative or not finite.
41
+ * @throws `NoeticConfigError` with code `INVALID_INTERVAL_MS` if `interval` is negative or not finite.
42
42
  * @throws `NoeticConfigError` with code `INVALID_JITTER` if `jitter` is negative or not finite.
43
43
  */
44
- export declare function every<TContext = ContextData, I = unknown, O = unknown>(opts: EveryOptions<TContext, I, O>): StepEvery<TContext, I, O>;
44
+ export declare function schedule<TContext = ContextData, I = unknown, O = unknown>(opts: ScheduleOptions<TContext, I, O>): StepSchedule<TContext, I, O>;
@@ -1,60 +1,60 @@
1
1
  import { NoeticConfigError } from '@noetic-tools/types';
2
2
  import { getDefaultRegistrar } from '../types/step-registrar.js';
3
3
  /**
4
- * Creates an `every` step that runs a body step on a fixed-interval schedule.
4
+ * Creates a `schedule` step that runs a body step on a fixed-interval schedule.
5
5
  *
6
6
  * The operator runs forever until the executing context is cancelled. After each
7
- * iteration it parks for `ms ± jitter` milliseconds (or until `wakeOn` receives
7
+ * iteration it parks for `interval ± jitter` milliseconds (or until `inbox` receives
8
8
  * a message, whichever comes first).
9
9
  *
10
10
  * @public
11
11
  * @param opts.id - Unique step identifier used in traces and error messages.
12
12
  * @param opts.step - Body step executed on each iteration.
13
- * @param opts.ms - Park duration between iterations in milliseconds.
14
- * @param opts.wakeOn - Optional channel that wakes the parking interval when any value arrives.
13
+ * @param opts.interval - Park duration between iterations in milliseconds.
14
+ * @param opts.inbox - Optional channel that wakes the parking interval when any value arrives.
15
15
  * @param opts.onError - Error policy. `'continue'` (default) records a span event and continues; `'fail'` re-throws.
16
16
  * @param opts.jitter - Random jitter in ms applied to the park duration. Default 0.
17
- * @returns A `StepEvery` step.
17
+ * @returns A `StepSchedule` step.
18
18
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
19
19
  * @throws `NoeticConfigError` with code `MISSING_BODY_STEP` if `step` is not provided.
20
- * @throws `NoeticConfigError` with code `INVALID_INTERVAL_MS` if `ms` is negative or not finite.
20
+ * @throws `NoeticConfigError` with code `INVALID_INTERVAL_MS` if `interval` is negative or not finite.
21
21
  * @throws `NoeticConfigError` with code `INVALID_JITTER` if `jitter` is negative or not finite.
22
22
  */
23
- export function every(opts) {
23
+ export function schedule(opts) {
24
24
  if (!opts.id || opts.id.trim() === '') {
25
25
  throw new NoeticConfigError({
26
26
  code: 'EMPTY_STEP_ID',
27
- message: 'every() requires a non-empty id.',
28
- hint: 'Pass a unique string as the id field, e.g. every({ id: "my-every", ... }).',
27
+ message: 'schedule() requires a non-empty id.',
28
+ hint: 'Pass a unique string as the id field, e.g. schedule({ id: "my-schedule", ... }).',
29
29
  });
30
30
  }
31
31
  if (!opts.step) {
32
32
  throw new NoeticConfigError({
33
33
  code: 'MISSING_BODY_STEP',
34
- message: 'every() requires a body step.',
34
+ message: 'schedule() requires a body step.',
35
35
  hint: 'Provide the step to execute on each scheduled iteration.',
36
36
  });
37
37
  }
38
- if (!Number.isFinite(opts.ms) || opts.ms < 0) {
38
+ if (!Number.isFinite(opts.interval) || opts.interval < 0) {
39
39
  throw new NoeticConfigError({
40
40
  code: 'INVALID_INTERVAL_MS',
41
- message: `every() requires a non-negative finite ms, got ${opts.ms}.`,
41
+ message: `schedule() requires a non-negative finite interval, got ${opts.interval}.`,
42
42
  hint: 'Pass a non-negative number of milliseconds for the park interval.',
43
43
  });
44
44
  }
45
45
  if (opts.jitter !== undefined && (!Number.isFinite(opts.jitter) || opts.jitter < 0)) {
46
46
  throw new NoeticConfigError({
47
47
  code: 'INVALID_JITTER',
48
- message: `every() requires a non-negative finite jitter, got ${opts.jitter}.`,
48
+ message: `schedule() requires a non-negative finite jitter, got ${opts.jitter}.`,
49
49
  hint: 'Pass a non-negative number of milliseconds for jitter, or omit it.',
50
50
  });
51
51
  }
52
52
  const built = {
53
- kind: 'every',
53
+ kind: 'schedule',
54
54
  id: opts.id,
55
55
  step: opts.step,
56
- ms: opts.ms,
57
- wakeOn: opts.wakeOn,
56
+ interval: opts.interval,
57
+ inbox: opts.inbox,
58
58
  onError: opts.onError ?? 'continue',
59
59
  jitter: opts.jitter ?? 0,
60
60
  };
@@ -1 +1 @@
1
- export { layerData, layerFn } from '@noetic-tools/context';
1
+ export { layerData, layerFunction } from '@noetic-tools/context';
@@ -1 +1 @@
1
- export { layerData, layerFn } from '@noetic-tools/context';
1
+ export { layerData, layerFunction } from '@noetic-tools/context';