@noetic-tools/core 5.3.0 → 5.4.1

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 (94) 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 +12 -1
  4. package/dist/adapters/openrouter.js +79 -5
  5. package/dist/builders/acp-agent-tool.d.ts +67 -0
  6. package/dist/builders/acp-agent-tool.js +87 -0
  7. package/dist/builders/control-flow-builders.d.ts +13 -13
  8. package/dist/builders/control-flow-builders.js +11 -11
  9. package/dist/{patterns → builders}/dynamic-workflow.d.ts +1 -1
  10. package/dist/{patterns → builders}/dynamic-workflow.js +20 -14
  11. package/dist/builders/every.d.ts +14 -14
  12. package/dist/builders/every.js +16 -16
  13. package/dist/builders/layer-provides-builders.d.ts +1 -1
  14. package/dist/builders/layer-provides-builders.js +1 -1
  15. package/dist/builders/provide-builder.d.ts +5 -12
  16. package/dist/builders/provide-builder.js +9 -10
  17. package/dist/builders/spawn-builder.d.ts +0 -2
  18. package/dist/builders/spawn-builder.js +2 -3
  19. package/dist/builders/step-builders.d.ts +103 -92
  20. package/dist/builders/step-builders.js +146 -163
  21. package/dist/builders/tool-builder.d.ts +18 -22
  22. package/dist/builders/tool-builder.js +6 -4
  23. package/dist/builders/workflow-hydrator.d.ts +11 -7
  24. package/dist/builders/workflow-hydrator.js +75 -87
  25. package/dist/builders/workflow-step.d.ts +15 -27
  26. package/dist/builders/workflow-step.js +14 -19
  27. package/dist/conditions/conditions.d.ts +2 -2
  28. package/dist/conditions/conditions.js +1 -1
  29. package/dist/harness/agent-harness.d.ts +89 -46
  30. package/dist/harness/agent-harness.js +93 -39
  31. package/dist/harness/deps/types.d.ts +1 -1
  32. package/dist/harness/model-call.d.ts +1 -1
  33. package/dist/harness/model-call.js +23 -18
  34. package/dist/index.d.ts +27 -35
  35. package/dist/index.js +13 -29
  36. package/dist/interpreter/acp-events.d.ts +67 -0
  37. package/dist/interpreter/acp-events.js +256 -0
  38. package/dist/interpreter/acp-history.d.ts +22 -0
  39. package/dist/interpreter/acp-history.js +58 -0
  40. package/dist/interpreter/action-types.d.ts +1 -1
  41. package/dist/interpreter/collect-tools.js +10 -13
  42. package/dist/interpreter/execute-acp-agent.d.ts +10 -0
  43. package/dist/interpreter/execute-acp-agent.js +505 -0
  44. package/dist/interpreter/execute-action.d.ts +5 -5
  45. package/dist/interpreter/execute-action.js +284 -218
  46. package/dist/interpreter/execute-control.d.ts +9 -9
  47. package/dist/interpreter/execute-control.js +27 -27
  48. package/dist/interpreter/execute.d.ts +1 -1
  49. package/dist/interpreter/execute.js +29 -35
  50. package/dist/interpreter/step-dispatcher.d.ts +1 -1
  51. package/dist/interpreter/step-dispatcher.js +3 -3
  52. package/dist/interpreter/structured-output.d.ts +14 -0
  53. package/dist/interpreter/structured-output.js +40 -0
  54. package/dist/interpreter/typeguards.d.ts +1 -1
  55. package/dist/interpreter/typeguards.js +1 -1
  56. package/dist/portable/builders.d.ts +5 -3
  57. package/dist/portable/builders.js +8 -5
  58. package/dist/portable/context-layers.d.ts +1 -1
  59. package/dist/portable/context-layers.js +1 -1
  60. package/dist/runtime/acp-session-store.d.ts +75 -0
  61. package/dist/runtime/acp-session-store.js +0 -0
  62. package/dist/runtime/broadcaster-utils.d.ts +1 -1
  63. package/dist/runtime/broadcaster-utils.js +1 -1
  64. package/dist/runtime/context-impl.d.ts +8 -10
  65. package/dist/runtime/context-impl.js +8 -12
  66. package/dist/runtime/durable/step-ledger.d.ts +2 -2
  67. package/dist/runtime/durable/step-ledger.js +3 -3
  68. package/dist/runtime/session-runner.d.ts +12 -1
  69. package/dist/runtime/session-runner.js +33 -1
  70. package/dist/runtime/tool-context.js +0 -2
  71. package/dist/schemas/workflow.d.ts +3 -3
  72. package/dist/schemas/workflow.js +2 -2
  73. package/dist/types/checkpoint.d.ts +11 -2
  74. package/dist/types/checkpoint.js +10 -1
  75. package/dist/{runtime → util}/detached-handle.js +1 -1
  76. package/package.json +6 -4
  77. package/schema/noetic-workflow.schema.json +239 -260
  78. package/dist/builders/context-option.d.ts +0 -23
  79. package/dist/builders/context-option.js +0 -22
  80. package/dist/deprecated.d.ts +0 -30
  81. package/dist/deprecated.js +0 -28
  82. package/dist/interpreter/execute-sub-harness.d.ts +0 -8
  83. package/dist/interpreter/execute-sub-harness.js +0 -241
  84. package/dist/interpreter/sub-harness-events.d.ts +0 -48
  85. package/dist/interpreter/sub-harness-events.js +0 -208
  86. package/dist/patterns/interview.d.ts +0 -53
  87. package/dist/patterns/interview.js +0 -113
  88. package/dist/patterns/plans.d.ts +0 -44
  89. package/dist/patterns/plans.js +0 -125
  90. package/dist/patterns/ralph-wiggum.d.ts +0 -19
  91. package/dist/patterns/ralph-wiggum.js +0 -37
  92. package/dist/patterns/react.d.ts +0 -19
  93. package/dist/patterns/react.js +0 -41
  94. /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];
@@ -40,6 +40,17 @@ export declare function extractUsage(usage: OpenRouterAgent.Usage | null | undef
40
40
  * to names like `plan/updatePrd`) is preserved.
41
41
  */
42
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>;
43
54
  /** @internal */
44
55
  export declare function convertTools({ tools }: ConvertToolsParams): SdkTool[];
45
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';
@@ -210,6 +210,72 @@ function cacheWriteTokensOf(details) {
210
210
  export function sanitizeToolNameForWire(name) {
211
211
  return name.replace(/[^a-zA-Z0-9_-]/g, '_');
212
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
+ }
213
279
  /** @internal */
214
280
  export function convertTools({ tools }) {
215
281
  return tools.map((t) => frameworkCast({
@@ -217,7 +283,7 @@ export function convertTools({ tools }) {
217
283
  function: {
218
284
  name: sanitizeToolNameForWire(t.name),
219
285
  description: t.description,
220
- inputSchema: t.input,
286
+ inputSchema: inputSchemaForWire(t),
221
287
  },
222
288
  }));
223
289
  }
@@ -232,7 +298,7 @@ export async function executeToolCall(params) {
232
298
  // Model sees sanitised tool names (see `sanitizeToolNameForWire`). Match
233
299
  // against both the original and sanitised name so internal identity (e.g.
234
300
  // `plan/updatePrd` used by steering whitelists, skill docs, and the
235
- // `planContext.beforeToolCall` hook) stays intact while the wire name is
301
+ // `plan()` layer's `beforeToolCall` hook) stays intact while the wire name is
236
302
  // provider-compliant.
237
303
  const matchedTool = params.tools.find((t) => t.name === params.toolName || sanitizeToolNameForWire(t.name) === params.toolName);
238
304
  if (!matchedTool) {
@@ -256,6 +322,14 @@ export async function executeToolCall(params) {
256
322
  };
257
323
  }
258
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;
259
333
  const toolCtx = buildToolExecutionContext(params.context, params.harness);
260
334
  const callId = params.callId;
261
335
  const uiBase = callId !== undefined
@@ -263,7 +337,7 @@ export async function executeToolCall(params) {
263
337
  ctx: params.context,
264
338
  tool: matchedTool,
265
339
  callId,
266
- args: params.args,
340
+ args: parsedArgs,
267
341
  }
268
342
  : undefined;
269
343
  if (uiBase) {
@@ -273,7 +347,7 @@ export async function executeToolCall(params) {
273
347
  });
274
348
  }
275
349
  try {
276
- const executionResult = matchedTool.execute(params.args, toolCtx);
350
+ const executionResult = matchedTool.execute(parsedArgs, toolCtx);
277
351
  // Generator tools stream progress; drive them here so tool-UI `progress`
278
352
  // fragments emit per yield (the non-UI case just consumes to the return).
279
353
  let result;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Exposes an ACP coding agent as a `Tool`, so a `callModel` step can delegate
3
+ * work to it by calling it.
4
+ *
5
+ * `step.acpAgent` puts the agent in the step tree, where the *author* decides
6
+ * when it runs. This puts it in the model's hands instead: the model decides
7
+ * that a task wants a coding agent and calls the tool, the same way it would
8
+ * call any other. Everything else — permissions, session policy, the event
9
+ * bridge, usage accounting — is identical, because the tool runs the very same
10
+ * step underneath.
11
+ */
12
+ import type { AcpAgent, AcpClientCapabilityConfig, AcpMcpServer, AcpPermissionHandler, AcpPermissionPolicy, AcpSessionPolicy, Tool } from '@noetic-tools/types';
13
+ import { z } from 'zod';
14
+ /** @public Options for {@link acpAgentTool}. */
15
+ export interface AcpAgentToolOptions {
16
+ /** The ACP agent adapter to delegate to, e.g. `claudeCode()`. */
17
+ agent: AcpAgent;
18
+ /**
19
+ * Tool name the model calls. Defaults to `delegate_to_<agentId>` with
20
+ * non-identifier characters replaced, e.g. `delegate_to_claude_code`.
21
+ */
22
+ name?: string;
23
+ /**
24
+ * What the model is told the tool does. Defaults to a generic description —
25
+ * override it to steer *when* the model delegates, which matters far more
26
+ * than the default wording.
27
+ */
28
+ description?: string;
29
+ /** Working directory for the agent's session. Defaults to the runtime cwd. */
30
+ cwd?: string;
31
+ /** Session mode to switch to before each delegated turn. */
32
+ mode?: string;
33
+ /** Model to select before each delegated turn. */
34
+ model?: string;
35
+ /** MCP servers to expose to the agent. */
36
+ mcpServers?: ReadonlyArray<AcpMcpServer>;
37
+ /** Declarative answer to the agent's permission requests. Defaults to denying. */
38
+ permissions?: AcpPermissionPolicy;
39
+ /** Async resolver consulted when policy and steering both abstain. */
40
+ onPermissionRequest?: AcpPermissionHandler;
41
+ /** Which client capabilities to advertise to the agent. */
42
+ clientCapabilities?: AcpClientCapabilityConfig;
43
+ /**
44
+ * Session policy for the delegated turns. Give it a `reuse` key with
45
+ * `keepAlive: 'run'` (or `'harness'`) to let the model hold a *conversation*
46
+ * with the agent across several tool calls instead of starting cold each
47
+ * time — usually what you want, since the second call almost always builds
48
+ * on the first.
49
+ */
50
+ session?: AcpSessionPolicy;
51
+ }
52
+ declare const AcpAgentToolInputSchema: z.ZodObject<{
53
+ prompt: z.ZodString;
54
+ }, z.core.$strip>;
55
+ declare const AcpAgentToolOutputSchema: z.ZodObject<{
56
+ text: z.ZodString;
57
+ }, z.core.$strip>;
58
+ /**
59
+ * Build a {@link Tool} that hands a prompt to an ACP coding agent.
60
+ *
61
+ * @public
62
+ * @param opts.agent - The agent adapter, e.g. `claudeCode()` from `@noetic-tools/acp`.
63
+ * @returns A `Tool` accepting `{ prompt }` and returning `{ text }`.
64
+ * @throws `NoeticConfigError` `MISSING_ACP_AGENT` when no adapter is given.
65
+ */
66
+ export declare function acpAgentTool(opts: AcpAgentToolOptions): Tool<typeof AcpAgentToolInputSchema, typeof AcpAgentToolOutputSchema>;
67
+ export {};
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Exposes an ACP coding agent as a `Tool`, so a `callModel` step can delegate
3
+ * work to it by calling it.
4
+ *
5
+ * `step.acpAgent` puts the agent in the step tree, where the *author* decides
6
+ * when it runs. This puts it in the model's hands instead: the model decides
7
+ * that a task wants a coding agent and calls the tool, the same way it would
8
+ * call any other. Everything else — permissions, session policy, the event
9
+ * bridge, usage accounting — is identical, because the tool runs the very same
10
+ * step underneath.
11
+ */
12
+ import { NoeticConfigError } from '@noetic-tools/types';
13
+ import { z } from 'zod';
14
+ import { step } from './step-builders.js';
15
+ import { tool } from './tool-builder.js';
16
+ const AcpAgentToolInputSchema = z.object({
17
+ prompt: z.string().min(1).describe('What the coding agent should do, in plain language.'),
18
+ });
19
+ const AcpAgentToolOutputSchema = z.object({
20
+ text: z.string().describe("The coding agent's final response."),
21
+ });
22
+ //#endregion
23
+ //#region Helpers
24
+ /** `claude-code` → `delegate_to_claude_code`. */
25
+ function defaultToolName(agentId) {
26
+ return `delegate_to_${agentId.replace(/[^a-zA-Z0-9_]/g, '_')}`;
27
+ }
28
+ function defaultDescription(agentId) {
29
+ return [
30
+ `Delegate a coding task to the ${agentId} agent, which works directly in the workspace.`,
31
+ 'Give it a complete, self-contained instruction — it does not see this conversation.',
32
+ 'Prefer it for multi-file edits, running builds or tests, and exploring an unfamiliar codebase.',
33
+ ].join(' ');
34
+ }
35
+ //#endregion
36
+ //#region Public API
37
+ /**
38
+ * Build a {@link Tool} that hands a prompt to an ACP coding agent.
39
+ *
40
+ * @public
41
+ * @param opts.agent - The agent adapter, e.g. `claudeCode()` from `@noetic-tools/acp`.
42
+ * @returns A `Tool` accepting `{ prompt }` and returning `{ text }`.
43
+ * @throws `NoeticConfigError` `MISSING_ACP_AGENT` when no adapter is given.
44
+ */
45
+ export function acpAgentTool(opts) {
46
+ if (!opts.agent) {
47
+ throw new NoeticConfigError({
48
+ code: 'MISSING_ACP_AGENT',
49
+ message: 'acpAgentTool() requires an agent adapter.',
50
+ hint: 'Pass an agent factory result, e.g. agent: claudeCode() from @noetic-tools/acp.',
51
+ });
52
+ }
53
+ const name = opts.name ?? defaultToolName(opts.agent.agentId);
54
+ // Built once, not per call: `step.acpAgent` registers itself in the step
55
+ // registry, so constructing one per invocation would fill the registry with
56
+ // duplicate ids. An empty `prompt` means "use the step's runtime input",
57
+ // which is exactly the tool's argument.
58
+ const delegated = step.acpAgent({
59
+ id: `${name}:step`,
60
+ agent: opts.agent,
61
+ prompt: '',
62
+ cwd: opts.cwd,
63
+ mode: opts.mode,
64
+ model: opts.model,
65
+ mcpServers: opts.mcpServers,
66
+ permissions: opts.permissions,
67
+ onPermissionRequest: opts.onPermissionRequest,
68
+ clientCapabilities: opts.clientCapabilities,
69
+ session: opts.session,
70
+ });
71
+ return tool({
72
+ name,
73
+ description: opts.description ?? defaultDescription(opts.agent.agentId),
74
+ input: AcpAgentToolInputSchema,
75
+ output: AcpAgentToolOutputSchema,
76
+ async execute(args, toolCtx) {
77
+ // Running through the harness on the tool's own context means the
78
+ // delegated turn lands in the same item log, usage totals, and event
79
+ // stream as any other step — it is a real step, not a side channel.
80
+ const text = await toolCtx.harness.run(delegated, args.prompt, toolCtx.ctx);
81
+ return {
82
+ text,
83
+ };
84
+ },
85
+ });
86
+ }
87
+ //#endregion
@@ -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
- - { "kind": "claude-code"|"codex"|"opencode"|"pi", "id": "<unique>", "prompt": "<turn prompt>", "settings": { "model": "<optional>", "permissionMode": "<optional>" } }
35
+ - { "kind": "acp-agent", "id": "<unique>", "agent": "<registered agent id, e.g. claude-code>", "prompt": "<turn prompt>", "mode": "<optional session mode>", "model": "<optional>" }
33
36
 
34
- 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.
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.
35
38
 
36
- Until predicates: maxSteps, maxCost, maxDuration, noToolCalls, outputContains, outputEquals, converged, any, all.
39
+ An acp-agent node delegates a turn to an external coding agent over the Agent Client Protocol; only emit one if a matching agent adapter is registered for the workflow.
40
+
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,