@noetic-tools/core 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import type { ContextMemory } from '@noetic-tools/memory';
2
- import type { Context, Lazy, ModelParams, RetryPolicy, StepLLM, StepRun, StepTool, SubprocessAdapter, Tool } from '@noetic-tools/types';
2
+ import type { Context, Lazy, ModelParams, RetryPolicy, StepLLM, StepRun, StepSubHarness, StepTool, SubHarness, SubHarnessSessionPolicy, SubHarnessSettings, SubprocessAdapter, Tool } from '@noetic-tools/types';
3
3
  import type { ZodType } from 'zod';
4
4
  interface StepRunOpts<TMemory, I, O> {
5
5
  id: string;
@@ -29,6 +29,22 @@ interface StepToolOpts<I, O> {
29
29
  tool: Tool<ZodType<I>, ZodType<O>>;
30
30
  args?: Partial<I>;
31
31
  }
32
+ interface StepSubHarnessOpts<TMemory, O> {
33
+ id: string;
34
+ /** The harness adapter created by a `@noetic-tools/sub-harness-*` factory. Eager or `(ctx) => SubHarness`. */
35
+ harness: Lazy<SubHarness, TMemory>;
36
+ /** Turn prompt. Eager string or `(ctx) => string` getter. */
37
+ prompt: Lazy<string, TMemory>;
38
+ /** Shared harness settings (model, permission mode, …). */
39
+ settings?: SubHarnessSettings;
40
+ /** System instructions applied on the first message of a fresh session. */
41
+ instructions?: Lazy<string | undefined, TMemory>;
42
+ /** Optional Zod schema; when set the assistant text is JSON-parsed and validated. */
43
+ output?: ZodType<O>;
44
+ /** Session reuse + teardown policy across steps. */
45
+ session?: SubHarnessSessionPolicy;
46
+ emit?: boolean | ((eventType: string, data: Record<string, unknown>) => boolean);
47
+ }
32
48
  export declare const step: {
33
49
  /**
34
50
  * Creates a pure async computation step.
@@ -75,5 +91,39 @@ export declare const step: {
75
91
  * @throws `NoeticConfigError` with code `MISSING_TOOL` if `tool` is not provided.
76
92
  */
77
93
  tool<TMemory = Readonly<Record<string, Record<string, unknown>>>, I = unknown, O = unknown>(opts: StepToolOpts<I, O>): StepTool<TMemory, I, O>;
94
+ /**
95
+ * Creates a step that delegates a turn to the Claude Code harness.
96
+ *
97
+ * @public
98
+ * @param opts.id - Unique step identifier.
99
+ * @param opts.harness - A `claudeCode(...)` adapter from `@noetic-tools/sub-harness-claude-code`.
100
+ * @param opts.prompt - The turn prompt; eager string or `(ctx) => string` getter.
101
+ * @param opts.settings - Shared harness settings (model, permission mode, …).
102
+ * @param opts.output - Optional Zod schema enabling structured output parsing.
103
+ * @returns A `StepSubHarness` of kind `claude-code`, auto-registered in the step registry.
104
+ * @throws `NoeticConfigError` `EMPTY_STEP_ID` / `MISSING_SUB_HARNESS` / `SUB_HARNESS_KIND_MISMATCH`.
105
+ */
106
+ claudeCode<TMemory = Readonly<Record<string, Record<string, unknown>>>, I = unknown, O = unknown>(opts: StepSubHarnessOpts<TMemory, O>): StepSubHarness<TMemory, I, O>;
107
+ /**
108
+ * Creates a step that delegates a turn to the Codex harness.
109
+ * @public
110
+ * @param opts - See {@link step.claudeCode}; `opts.harness` is a `codex(...)` adapter.
111
+ * @returns A `StepSubHarness` of kind `codex`.
112
+ */
113
+ codex<TMemory = Readonly<Record<string, Record<string, unknown>>>, I = unknown, O = unknown>(opts: StepSubHarnessOpts<TMemory, O>): StepSubHarness<TMemory, I, O>;
114
+ /**
115
+ * Creates a step that delegates a turn to the opencode harness.
116
+ * @public
117
+ * @param opts - See {@link step.claudeCode}; `opts.harness` is an `opencode(...)` adapter.
118
+ * @returns A `StepSubHarness` of kind `opencode`.
119
+ */
120
+ opencode<TMemory = Readonly<Record<string, Record<string, unknown>>>, I = unknown, O = unknown>(opts: StepSubHarnessOpts<TMemory, O>): StepSubHarness<TMemory, I, O>;
121
+ /**
122
+ * Creates a step that delegates a turn to the pi harness.
123
+ * @public
124
+ * @param opts - See {@link step.claudeCode}; `opts.harness` is a `pi(...)` adapter.
125
+ * @returns A `StepSubHarness` of kind `pi`.
126
+ */
127
+ pi<TMemory = Readonly<Record<string, Record<string, unknown>>>, I = unknown, O = unknown>(opts: StepSubHarnessOpts<TMemory, O>): StepSubHarness<TMemory, I, O>;
78
128
  };
79
129
  export {};
@@ -1,6 +1,45 @@
1
1
  import { NoeticConfigError } from '@noetic-tools/types';
2
2
  import { getDefaultRegistrar } from '../types/step-registrar.js';
3
3
  //#endregion
4
+ //#region SubHarness builder helper
5
+ /**
6
+ * Shared construction for every harness step kind. Each `step.<kind>()` is a
7
+ * thin wrapper so the kinds stay individually typed while the validation and
8
+ * registration live in one place.
9
+ */
10
+ function buildSubHarnessStep(kind, builderName, opts) {
11
+ if (!opts.id || opts.id.trim() === '') {
12
+ throw new NoeticConfigError({
13
+ code: 'EMPTY_STEP_ID',
14
+ message: `${builderName}() requires a non-empty id.`,
15
+ hint: `Pass a unique string as the id field, e.g. ${builderName}({ id: "review", ... }).`,
16
+ });
17
+ }
18
+ if (!opts.harness) {
19
+ throw new NoeticConfigError({
20
+ code: 'MISSING_SUB_HARNESS',
21
+ message: `${builderName}() requires a harness adapter.`,
22
+ hint: `Pass a harness factory result, e.g. harness: ${builderName.replace('step.', '')}({ model }).`,
23
+ });
24
+ }
25
+ // Eager adapters are validated now; function-form adapters are validated
26
+ // post-resolution in executeSubHarness so the same SUB_HARNESS_KIND_MISMATCH
27
+ // error surfaces whether the caller passes an adapter or a getter.
28
+ if (typeof opts.harness !== 'function' && opts.harness.harnessId !== kind) {
29
+ throw new NoeticConfigError({
30
+ code: 'SUB_HARNESS_KIND_MISMATCH',
31
+ message: `${builderName}() was given a '${opts.harness.harnessId}' harness.`,
32
+ hint: `Use the matching builder, e.g. step.${opts.harness.harnessId}({ ... }).`,
33
+ });
34
+ }
35
+ const built = {
36
+ kind,
37
+ ...opts,
38
+ };
39
+ getDefaultRegistrar().register(built);
40
+ return built;
41
+ }
42
+ //#endregion
4
43
  //#region Builders
5
44
  export const step = {
6
45
  /**
@@ -114,5 +153,47 @@ export const step = {
114
153
  getDefaultRegistrar().register(built);
115
154
  return built;
116
155
  },
156
+ /**
157
+ * Creates a step that delegates a turn to the Claude Code harness.
158
+ *
159
+ * @public
160
+ * @param opts.id - Unique step identifier.
161
+ * @param opts.harness - A `claudeCode(...)` adapter from `@noetic-tools/sub-harness-claude-code`.
162
+ * @param opts.prompt - The turn prompt; eager string or `(ctx) => string` getter.
163
+ * @param opts.settings - Shared harness settings (model, permission mode, …).
164
+ * @param opts.output - Optional Zod schema enabling structured output parsing.
165
+ * @returns A `StepSubHarness` of kind `claude-code`, auto-registered in the step registry.
166
+ * @throws `NoeticConfigError` `EMPTY_STEP_ID` / `MISSING_SUB_HARNESS` / `SUB_HARNESS_KIND_MISMATCH`.
167
+ */
168
+ claudeCode(opts) {
169
+ return buildSubHarnessStep('claude-code', 'step.claudeCode', opts);
170
+ },
171
+ /**
172
+ * Creates a step that delegates a turn to the Codex harness.
173
+ * @public
174
+ * @param opts - See {@link step.claudeCode}; `opts.harness` is a `codex(...)` adapter.
175
+ * @returns A `StepSubHarness` of kind `codex`.
176
+ */
177
+ codex(opts) {
178
+ return buildSubHarnessStep('codex', 'step.codex', opts);
179
+ },
180
+ /**
181
+ * Creates a step that delegates a turn to the opencode harness.
182
+ * @public
183
+ * @param opts - See {@link step.claudeCode}; `opts.harness` is an `opencode(...)` adapter.
184
+ * @returns A `StepSubHarness` of kind `opencode`.
185
+ */
186
+ opencode(opts) {
187
+ return buildSubHarnessStep('opencode', 'step.opencode', opts);
188
+ },
189
+ /**
190
+ * Creates a step that delegates a turn to the pi harness.
191
+ * @public
192
+ * @param opts - See {@link step.claudeCode}; `opts.harness` is a `pi(...)` adapter.
193
+ * @returns A `StepSubHarness` of kind `pi`.
194
+ */
195
+ pi(opts) {
196
+ return buildSubHarnessStep('pi', 'step.pi', opts);
197
+ },
117
198
  };
118
199
  //#endregion
@@ -6,13 +6,15 @@
6
6
  * ones — they register in the step registry, carry retry policies, etc.
7
7
  */
8
8
  import type { ContextMemory, MemoryLayer } from '@noetic-tools/memory';
9
- import type { ExecuteStepFn, Step, Tool } from '@noetic-tools/types';
9
+ import type { ExecuteStepFn, Step, SubHarness, SubHarnessKind, Tool } from '@noetic-tools/types';
10
10
  import type { WorkflowDocument, WorkflowNode } from '../schemas/workflow.js';
11
11
  /** @public Context required to hydrate a JSON workflow into live steps. */
12
12
  export interface HydrationContext {
13
13
  tools: ReadonlyMap<string, Tool>;
14
14
  executeStep: ExecuteStepFn;
15
15
  layers?: ReadonlyMap<string, MemoryLayer>;
16
+ /** SubHarness adapters keyed by harness id, resolving `claude-code`/`codex`/… nodes. */
17
+ subHarnesses?: ReadonlyMap<SubHarnessKind, SubHarness>;
16
18
  }
17
19
  /**
18
20
  * Hydrates a single `WorkflowNode` into a live `Step`.
@@ -246,6 +246,36 @@ function hydrateEveryNode(node, ctx) {
246
246
  onError: node.onError,
247
247
  });
248
248
  }
249
+ const SUB_HARNESS_BUILDERS = {
250
+ 'claude-code': (opts) => step.claudeCode(opts),
251
+ codex: (opts) => step.codex(opts),
252
+ opencode: (opts) => step.opencode(opts),
253
+ pi: (opts) => step.pi(opts),
254
+ };
255
+ function hydrateSubHarnessNode(node, ctx) {
256
+ if (node.kind !== 'claude-code' &&
257
+ node.kind !== 'codex' &&
258
+ node.kind !== 'opencode' &&
259
+ node.kind !== 'pi') {
260
+ return frameworkCast(undefined);
261
+ }
262
+ const harness = ctx.subHarnesses?.get(node.kind);
263
+ if (!harness) {
264
+ throw new NoeticConfigError({
265
+ code: 'UNKNOWN_SUB_HARNESS_REFERENCE',
266
+ message: `SubHarness '${node.kind}' referenced in workflow node '${node.id}' is not registered.`,
267
+ hint: `Pass harness adapters via HydrationContext.subHarnesses, e.g. new Map([['${node.kind}', ${node.kind.replace(/-([a-z])/g, (_, c) => c.toUpperCase())}({ model })]]).`,
268
+ });
269
+ }
270
+ return SUB_HARNESS_BUILDERS[node.kind]({
271
+ id: node.id,
272
+ harness,
273
+ prompt: node.prompt,
274
+ instructions: node.instructions,
275
+ settings: node.settings,
276
+ session: node.session,
277
+ });
278
+ }
249
279
  //#endregion
250
280
  //#region Handler Registry
251
281
  const NODE_HYDRATORS = {
@@ -258,6 +288,10 @@ const NODE_HYDRATORS = {
258
288
  loop: hydrateLoopNode,
259
289
  sequence: hydrateSequenceNode,
260
290
  every: hydrateEveryNode,
291
+ 'claude-code': hydrateSubHarnessNode,
292
+ codex: hydrateSubHarnessNode,
293
+ opencode: hydrateSubHarnessNode,
294
+ pi: hydrateSubHarnessNode,
261
295
  };
262
296
  //#endregion
263
297
  //#region Helpers
@@ -2,7 +2,7 @@ import { ItemSchemaRegistry } from '@noetic-tools/types';
2
2
  import type { DetachedSpawnOverrides } from './deps/interpreter.js';
3
3
  import type { LayerStateStore, RecallCache } from './deps/memory.js';
4
4
  import type { CheckpointStore, EventBroadcaster } from './deps/runtime.js';
5
- import type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, ContextMemory, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, MemoryLayer, ProjectionPolicy, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StorageAdapter, StreamEvent, StreamingItem, SubprocessAdapter, Tool, TraceExporter, ZodType } from './deps/types.js';
5
+ import type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, ContextMemory, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, MemoryLayer, ProjectionPolicy, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StorageAdapter, StreamEvent, StreamingItem, SubHarnessSession, SubprocessAdapter, Tool, TraceExporter, ZodType } from './deps/types.js';
6
6
  export { createStreamIdleWatchdog } from './model-call.js';
7
7
  interface AgentHarnessOpts<TParams extends Record<string, unknown> = Record<string, unknown>> {
8
8
  name: string;
@@ -95,6 +95,13 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
95
95
  private readonly defaultDeliveryMode;
96
96
  private readonly streamIdleTimeoutMs;
97
97
  private readonly sessions;
98
+ /**
99
+ * @internal Cross-step harness sessions keyed by `step.session.reuse`, kept
100
+ * alive for the life of this harness so reused coding-agent sessions span
101
+ * multiple steps. Reached by the interpreter's harness handler via
102
+ * `frameworkCast`; do not access from outside core.
103
+ */
104
+ readonly subHarnessSessions: Map<string, SubHarnessSession>;
98
105
  readonly layerStateStore: LayerStateStore;
99
106
  /** Per-harness memoization cache for `recallMode: 'eventual'` layers. */
100
107
  readonly recallCache: RecallCache;
@@ -100,6 +100,13 @@ export class AgentHarness {
100
100
  defaultDeliveryMode;
101
101
  streamIdleTimeoutMs;
102
102
  sessions = new Map();
103
+ /**
104
+ * @internal Cross-step harness sessions keyed by `step.session.reuse`, kept
105
+ * alive for the life of this harness so reused coding-agent sessions span
106
+ * multiple steps. Reached by the interpreter's harness handler via
107
+ * `frameworkCast`; do not access from outside core.
108
+ */
109
+ subHarnessSessions = new Map();
103
110
  layerStateStore;
104
111
  /** Per-harness memoization cache for `recallMode: 'eventual'` layers. */
105
112
  recallCache;
@@ -339,6 +346,16 @@ export class AgentHarness {
339
346
  }
340
347
  //#endregion
341
348
  async run(s, input, ctx) {
349
+ // Populate the unified tool pool when an embedder drives a step directly on
350
+ // a bare `createContext()` context (the turn pipeline does this per turn).
351
+ // Without it, steps that resolve tools dynamically from `ctx.unifiedTools`
352
+ // — and any sub-agents spawned from them — would see no tools.
353
+ if (!ctx.unifiedTools && (s !== undefined || this.harnessTools.length > 0)) {
354
+ this.setUnifiedTools(ctx, [
355
+ ...collectAllTools(s),
356
+ ...this.harnessTools,
357
+ ]);
358
+ }
342
359
  return execute(s, input, ctx);
343
360
  }
344
361
  /** Run all layer `init` hooks before the first step executes. Per spec 11,
@@ -1,4 +1,4 @@
1
1
  export type { ContextMemory, ExecutionContext, MemoryLayer, ProjectionPolicy, StorageAdapter, } from '@noetic-tools/memory';
2
- export type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StreamEvent, StreamingItem, SubprocessAdapter, Tool, TraceExporter, } from '@noetic-tools/types';
2
+ export type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StreamEvent, StreamingItem, SubHarnessSession, SubprocessAdapter, Tool, TraceExporter, } from '@noetic-tools/types';
3
3
  export { SteeringAction } from '@noetic-tools/types';
4
4
  export type { ZodType } from 'zod';
package/dist/index.d.ts CHANGED
@@ -108,7 +108,7 @@ export { getRegistry, lookupStep, registerStep } from './runtime/step-registry.j
108
108
  /** @public */
109
109
  export { defaultItemSchemaRegistry, ItemSchema, ItemSchemaRegistry } from '@noetic-tools/types';
110
110
  /** @public */
111
- export type { BranchRoute, BranchWorkflowNode, EveryWorkflowNode, ForkWorkflowNode, LlmWorkflowNode, LoopWorkflowNode, MergeStrategy, ProvideWorkflowNode, SequenceWorkflowNode, SpawnWorkflowNode, ToolWorkflowNode, UntilPredicate, WorkflowDocument, WorkflowNode, } from './schemas/workflow.js';
111
+ export type { BranchRoute, BranchWorkflowNode, EveryWorkflowNode, ForkWorkflowNode, LlmWorkflowNode, LoopWorkflowNode, MergeStrategy, ProvideWorkflowNode, SequenceWorkflowNode, SpawnWorkflowNode, SubHarnessWorkflowNode, ToolWorkflowNode, UntilPredicate, WorkflowDocument, WorkflowNode, } from './schemas/workflow.js';
112
112
  /** @public */
113
113
  export { MergeStrategySchema, UntilPredicateSchema, validateWorkflow, WorkflowDocumentSchema, WorkflowNodeSchema, walkWorkflow, workflowDepth, } from './schemas/workflow.js';
114
114
  /** @public */
@@ -153,7 +153,11 @@ export type { AfterModelCallParams, AfterModelCallResult, BeforeToolCallParams,
153
153
  /** @public */
154
154
  export { LedgerEntryKind, SteeringAction } from '@noetic-tools/types';
155
155
  /** @public */
156
- export type { ExecuteStepFn, SettleResult, Snapshot, Step, StepBranch, StepFork, StepForkAll, StepForkRace, StepForkSettle, StepLLM, StepLoop, StepProvide, StepRun, StepSpawn, StepTool, Until, Verdict, } from '@noetic-tools/types';
156
+ export type { ExecuteStepFn, SettleResult, Snapshot, Step, StepBranch, StepFork, StepForkAll, StepForkRace, StepForkSettle, StepLLM, StepLoop, StepProvide, StepRun, StepSpawn, StepSubHarness, StepTool, Until, Verdict, } from '@noetic-tools/types';
157
+ /** @public */
158
+ export type { SubHarness, SubHarnessBuiltinTool, SubHarnessContinueState, SubHarnessContinueTurnOptions, SubHarnessFinishReason, SubHarnessPromptTurnOptions, SubHarnessResumeState, SubHarnessRunContext, SubHarnessSession, SubHarnessSessionPolicy, SubHarnessSettings, SubHarnessStartOptions, SubHarnessStreamPart, SubHarnessTurnResult, } from '@noetic-tools/types';
159
+ /** @public */
160
+ export { SUB_HARNESS_KINDS, SubHarnessKind, SubHarnessStreamPartSchema } from '@noetic-tools/types';
157
161
  /** @public */
158
162
  export type { FsAdapter, FsStats } from '@noetic-tools/types';
159
163
  /** @public */
package/dist/index.js CHANGED
@@ -108,6 +108,8 @@ export { Slot } from '@noetic-tools/memory';
108
108
  /** @public */
109
109
  export { LedgerEntryKind, SteeringAction } from '@noetic-tools/types';
110
110
  /** @public */
111
+ export { SUB_HARNESS_KINDS, SubHarnessKind, SubHarnessStreamPartSchema } from '@noetic-tools/types';
112
+ /** @public */
111
113
  export { TIMEOUT_ERROR_PREFIX } from '@noetic-tools/types';
112
114
  /** @public Reusable error-serialiser used by custom SubprocessAdapter implementations. */
113
115
  export { serializeError } from './adapters/in-memory-subprocess/metadata.js';
@@ -38,6 +38,12 @@ function walkStep(step, out) {
38
38
  return;
39
39
  case 'run':
40
40
  case 'tool':
41
+ // SubHarness steps run their own built-in tools inside the external agent;
42
+ // they contribute nothing to the host unified tool set.
43
+ case 'claude-code':
44
+ case 'codex':
45
+ case 'opencode':
46
+ case 'pi':
41
47
  return;
42
48
  case 'branch':
43
49
  if (step._optimizable) {
@@ -4,6 +4,16 @@
4
4
  import type { ItemSchemaRegistry, LayerStateStore } from './action-deps.js';
5
5
  import type { AgentHarnessContract, Context, ExecuteStepFn, MemoryLayer, StepLLM, StepProvide, StepRun, StepSpawn, StepTool } from './action-types.js';
6
6
  export declare function executeRun<TMemory, I, O>(step: StepRun<TMemory, I, O>, input: I, ctx: Context<TMemory>): Promise<O>;
7
+ /**
8
+ * Resolves a `Lazy<T>` step field. If the value is a function, it is invoked
9
+ * with the current context; otherwise the value is returned as-is. Supports
10
+ * both sync and async getters.
11
+ *
12
+ * Lazy fields let a step inspect `ctx.harness.config.params`, `ctx.unifiedTools`,
13
+ * or memory layer state to produce per-execution values (model, instructions,
14
+ * tools) without baking them in at build time.
15
+ */
16
+ export declare function resolveLazy<T, TMemory>(value: T | ((ctx: Context<TMemory>) => T | Promise<T>), ctx: Context<TMemory>): Promise<T>;
7
17
  export declare function executeLLM<TMemory, I, O>(step: StepLLM<TMemory, I, O>, input: I, ctx: Context<TMemory>, layers?: MemoryLayer[]): Promise<O>;
8
18
  export interface ExecuteSpawnOpts {
9
19
  layerStore?: LayerStateStore;
@@ -95,7 +95,7 @@ function mergeRecallResults(base, overrides, slotOf) {
95
95
  * or memory layer state to produce per-execution values (model, instructions,
96
96
  * tools) without baking them in at build time.
97
97
  */
98
- async function resolveLazy(value, ctx) {
98
+ export async function resolveLazy(value, ctx) {
99
99
  if (typeof value !== 'function') {
100
100
  return value;
101
101
  }
@@ -411,6 +411,19 @@ async function collectSpawnItems({ layers, parentExecutionCtx, childExecutionCtx
411
411
  });
412
412
  return spawnResults.flatMap((r) => r.items);
413
413
  }
414
+ /** Adds a spawned child's accumulated token/cost usage to its parent. */
415
+ function rollUpSpawnUsage(parent, child) {
416
+ if (!isMutableContext(parent)) {
417
+ return;
418
+ }
419
+ parent.tokens.input += child.tokens.input;
420
+ parent.tokens.output += child.tokens.output;
421
+ parent.tokens.total += child.tokens.total;
422
+ if (child.tokens.cached !== undefined) {
423
+ parent.tokens.cached = (parent.tokens.cached ?? 0) + child.tokens.cached;
424
+ }
425
+ parent.cost += child.cost;
426
+ }
414
427
  export async function executeSpawn(step, input, ctx, executeStep, opts) {
415
428
  const baseCtx = frameworkCast(ctx);
416
429
  const layers = resolveLayersForSpawn(step, opts?.parentLayers);
@@ -441,12 +454,20 @@ export async function executeSpawn(step, input, ctx, executeStep, opts) {
441
454
  itemSchemas: opts?.itemSchemas,
442
455
  });
443
456
  }
444
- // Build unified tool set for child from its step tree + layers
457
+ // Build unified tool set for child from its step tree + layers, plus the
458
+ // parent's unified tools. Sub-agents that read tools dynamically (via
459
+ // `ctx.unifiedTools`) otherwise lose the harness tool pool at the spawn
460
+ // boundary — a child whose `step.llm` resolves `tools` from context would
461
+ // see nothing. Inheriting the parent's tools keeps the harness toolset
462
+ // available across spawns; the child's own step/layer tools take precedence
463
+ // on name collision (dedup keeps the first occurrence), and children that
464
+ // need a restricted set filter explicitly.
445
465
  const childStepTools = collectAllTools(step.child);
446
466
  const childLayerTools = layers.length > 0 ? resolveLayerTools(layers, baseCtx.harness, baseCtx) : [];
447
467
  const childUnifiedTools = deduplicateTools([
448
468
  ...childStepTools,
449
469
  ...childLayerTools,
470
+ ...(baseCtx.unifiedTools ?? []),
450
471
  ]);
451
472
  // Create child context — empty by default, layers provide items via onSpawn.
452
473
  // `id: childId` keeps the ContextImpl's id in sync with the executionId
@@ -468,6 +489,12 @@ export async function executeSpawn(step, input, ctx, executeStep, opts) {
468
489
  try {
469
490
  // Execute the child step
470
491
  const childOutput = await executeStep(step.child, input, frameworkCast(childCtx));
492
+ // Roll the child's token/cost usage up into the parent. A sub-agent's
493
+ // spend is the parent's spend; without this it would be stranded on the
494
+ // child context and invisible to `ctx.tokens` / `ctx.cost` / until.maxCost.
495
+ // Because every spawn boundary rolls up, nested sub-agents propagate to the
496
+ // root recursively.
497
+ rollUpSpawnUsage(baseCtx, childCtx);
471
498
  // Pipeline result through layer onReturn hooks
472
499
  if (!hasLayers || !parentExecutionCtx) {
473
500
  return childOutput;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * SubHarness step handler: drives an external coding-agent harness
3
+ * (Claude Code, Codex, opencode, pi) for one agentic turn and folds its output
4
+ * back into the Noetic execution context — the harness analogue of
5
+ * `executeLLM`.
6
+ */
7
+ import type { Context, StepSubHarness } from '@noetic-tools/types';
8
+ export declare function executeSubHarness<TMemory, I, O>(step: StepSubHarness<TMemory, I, O>, input: I, ctx: Context<TMemory>): Promise<O>;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * SubHarness step handler: drives an external coding-agent harness
3
+ * (Claude Code, Codex, opencode, pi) for one agentic turn and folds its output
4
+ * back into the Noetic execution context — the harness analogue of
5
+ * `executeLLM`.
6
+ */
7
+ import { createMessage, extractAssistantText, frameworkCast, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
8
+ import { ZodError } from 'zod';
9
+ import { resolveLazy } from './execute-action.js';
10
+ import { trackUsage } from './message-helpers.js';
11
+ import { SubHarnessEventBridge } from './sub-harness-events.js';
12
+ import { isFunctionCall, isMutableContext } from './typeguards.js';
13
+ //#endregion
14
+ //#region Helpers
15
+ function sessionStore(ctx) {
16
+ return frameworkCast(ctx.harness).subHarnessSessions;
17
+ }
18
+ async function resolveSubHarness(step, ctx) {
19
+ const resolved = await resolveLazy(step.harness, ctx);
20
+ if (!resolved) {
21
+ throw new NoeticConfigError({
22
+ code: 'MISSING_SUB_HARNESS',
23
+ message: `step.${step.kind}(${JSON.stringify(step.id)}) resolved no harness adapter.`,
24
+ hint: 'Pass a harness factory result, e.g. harness: claudeCode({ model }).',
25
+ });
26
+ }
27
+ if (resolved.harnessId !== step.kind) {
28
+ throw new NoeticConfigError({
29
+ code: 'SUB_HARNESS_KIND_MISMATCH',
30
+ message: `step.${step.kind}(${JSON.stringify(step.id)}) was given a '${resolved.harnessId}' harness.`,
31
+ hint: `Use the matching builder for this adapter, e.g. step.${resolved.harnessId}({ ... }).`,
32
+ });
33
+ }
34
+ return resolved;
35
+ }
36
+ function resolveTurnText(resolvedPrompt, input) {
37
+ if (resolvedPrompt && resolvedPrompt.length > 0) {
38
+ return resolvedPrompt;
39
+ }
40
+ return typeof input === 'string' ? input : '';
41
+ }
42
+ function buildRunContext(ctx) {
43
+ return {
44
+ cwd: ctx.cwdState.cwd,
45
+ fs: ctx.fs,
46
+ shell: ctx.shell,
47
+ subprocess: ctx.subprocess,
48
+ threadId: ctx.threadId,
49
+ };
50
+ }
51
+ function toLlmResponse(result) {
52
+ const usage = result.usage;
53
+ return {
54
+ items: result.items,
55
+ usage: {
56
+ inputTokens: usage?.input ?? 0,
57
+ outputTokens: usage?.output ?? 0,
58
+ cachedTokens: usage?.cached,
59
+ },
60
+ cost: result.cost,
61
+ };
62
+ }
63
+ function applyTurnResult(ctx, result) {
64
+ const toolCalls = [];
65
+ for (const item of result.items) {
66
+ ctx.itemLog.append(item);
67
+ if (isFunctionCall(item)) {
68
+ toolCalls.push(item);
69
+ }
70
+ }
71
+ const llmResponse = toLlmResponse(result);
72
+ trackUsage(ctx, llmResponse);
73
+ const meta = {
74
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
75
+ usage: result.usage ? llmResponse.usage : undefined,
76
+ cost: result.cost,
77
+ responseItems: result.items,
78
+ };
79
+ if (isMutableContext(ctx)) {
80
+ ctx.lastStepMeta = meta;
81
+ }
82
+ }
83
+ async function teardownSession(session, mode) {
84
+ if (mode === 'detach' && session.doDetach) {
85
+ await session.doDetach();
86
+ return;
87
+ }
88
+ if (mode === 'destroy' && session.doDestroy) {
89
+ await session.doDestroy();
90
+ return;
91
+ }
92
+ await session.doStop();
93
+ }
94
+ async function startOrReuseSession(step, harness, ctx, baseCtx, history) {
95
+ const reuseKey = step.session?.reuse;
96
+ const store = sessionStore(baseCtx);
97
+ if (reuseKey) {
98
+ const existing = store.get(reuseKey);
99
+ if (existing) {
100
+ return {
101
+ session: existing,
102
+ reuseKey,
103
+ };
104
+ }
105
+ }
106
+ const session = await harness.doStart({
107
+ settings: step.settings,
108
+ instructions: await resolveLazy(step.instructions, ctx),
109
+ history,
110
+ ctx: buildRunContext(baseCtx),
111
+ });
112
+ if (reuseKey) {
113
+ store.set(reuseKey, session);
114
+ }
115
+ return {
116
+ session,
117
+ reuseKey,
118
+ };
119
+ }
120
+ /**
121
+ * Tear the session down per policy after a successful turn. Reused sessions
122
+ * stay alive by default; an explicit `onComplete` overrides that.
123
+ */
124
+ async function finalizeSession(resolution, policy, baseCtx) {
125
+ const { session, reuseKey } = resolution;
126
+ if (!reuseKey) {
127
+ await teardownSession(session, policy?.onComplete ?? 'stop');
128
+ return;
129
+ }
130
+ const mode = policy?.onComplete;
131
+ if (mode === 'stop' || mode === 'destroy') {
132
+ await teardownSession(session, mode);
133
+ sessionStore(baseCtx).delete(reuseKey);
134
+ return;
135
+ }
136
+ if (mode === 'detach') {
137
+ await teardownSession(session, 'detach');
138
+ }
139
+ // Default for a reused session: keep it alive in the store.
140
+ }
141
+ //#endregion
142
+ //#region Public API
143
+ export async function executeSubHarness(step, input, ctx) {
144
+ const baseCtx = frameworkCast(ctx);
145
+ const harness = await resolveSubHarness(step, ctx);
146
+ const resolvedPrompt = await resolveLazy(step.prompt, ctx);
147
+ const turnText = resolveTurnText(resolvedPrompt, input);
148
+ if (turnText.trim() === '') {
149
+ throw new NoeticConfigError({
150
+ code: 'MISSING_PROMPT',
151
+ message: `step.${step.kind}(${JSON.stringify(step.id)}) resolved an empty prompt.`,
152
+ hint: 'Provide a non-empty `prompt`, or pass a string input to the step.',
153
+ });
154
+ }
155
+ // Capture the conversation so far (from earlier LLM/sub-harness steps and
156
+ // turns) BEFORE appending this turn's prompt, so a fresh session is seeded
157
+ // with full context of the conversation.
158
+ const priorHistory = [
159
+ ...baseCtx.itemLog.items,
160
+ ];
161
+ baseCtx.itemLog.append(createMessage(turnText, 'user'));
162
+ const resolution = await startOrReuseSession(step, harness, ctx, baseCtx, priorHistory);
163
+ const bridge = new SubHarnessEventBridge(step, baseCtx);
164
+ bridge.begin();
165
+ let result;
166
+ try {
167
+ result = await resolution.session.doPromptTurn({
168
+ prompt: turnText,
169
+ emit: (part) => bridge.forward(part),
170
+ });
171
+ }
172
+ catch (e) {
173
+ // Best-effort teardown of a fresh session before surfacing the failure;
174
+ // reused sessions are left intact for a later step to retry against.
175
+ if (!resolution.reuseKey) {
176
+ await teardownSession(resolution.session, 'destroy').catch(() => undefined);
177
+ }
178
+ if (e instanceof NoeticErrorImpl) {
179
+ throw e;
180
+ }
181
+ throw new NoeticErrorImpl({
182
+ kind: 'step_failed',
183
+ stepId: step.id,
184
+ cause: e instanceof Error ? e : new Error(String(e)),
185
+ retriesExhausted: false,
186
+ });
187
+ }
188
+ bridge.finalize(result);
189
+ applyTurnResult(baseCtx, result);
190
+ await finalizeSession(resolution, step.session, baseCtx);
191
+ const lastText = result.text.length > 0 ? result.text : extractAssistantText(result.items);
192
+ if (step.output) {
193
+ try {
194
+ const parsed = JSON.parse(lastText);
195
+ return step.output.parse(parsed);
196
+ }
197
+ catch (e) {
198
+ if (e instanceof SyntaxError || e instanceof ZodError) {
199
+ throw new NoeticErrorImpl({
200
+ kind: 'llm_parse_error',
201
+ stepId: step.id,
202
+ raw: lastText,
203
+ schema: step.output,
204
+ zodError: e instanceof ZodError
205
+ ? e
206
+ : new ZodError([
207
+ {
208
+ code: 'custom',
209
+ message: `Invalid JSON: ${e.message}`,
210
+ path: [],
211
+ },
212
+ ]),
213
+ });
214
+ }
215
+ throw e;
216
+ }
217
+ }
218
+ return frameworkCast(lastText);
219
+ }
220
+ //#endregion