@noetic-tools/core 1.1.1 → 1.1.2

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;
@@ -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
  }
@@ -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
@@ -4,6 +4,7 @@ import { ContextImpl } from '../runtime/context-impl.js';
4
4
  import { DetachedHandleImpl } from '../runtime/detached-handle.js';
5
5
  import { executeLLM, executeProvide, executeRun, executeSpawn, executeTool, } from './execute-action.js';
6
6
  import { executeBranch, executeEvery, executeFork, executeLoop } from './execute-control.js';
7
+ import { executeSubHarness } from './execute-sub-harness.js';
7
8
  import { isMutableContext } from './typeguards.js';
8
9
  //#region Constants
9
10
  const MAX_DEPTH = 64;
@@ -29,6 +30,20 @@ function buildSpawnOpts(ctx) {
29
30
  layerStore: harnessWithLayerStore(baseCtx).layerStateStore,
30
31
  };
31
32
  }
33
+ /**
34
+ * Resolve the per-step framework-event emit option. Both `llm` and the harness
35
+ * step kinds carry an `emit` field; every other kind defaults to enabled.
36
+ */
37
+ function resolveStepEmit(step) {
38
+ if (step.kind === 'llm' ||
39
+ step.kind === 'claude-code' ||
40
+ step.kind === 'codex' ||
41
+ step.kind === 'opencode' ||
42
+ step.kind === 'pi') {
43
+ return step.emit;
44
+ }
45
+ return undefined;
46
+ }
32
47
  /**
33
48
  * Resolve the subprocess adapter that should handle dispatch for this step.
34
49
  * Precedence is `step.subprocess ?? ctx.harness.subprocess`. Per-call
@@ -138,7 +153,7 @@ export async function execute(step, input, ctx) {
138
153
  stepId: step.id,
139
154
  kind: step.kind,
140
155
  };
141
- const emit = step.kind === 'llm' ? step.emit : undefined;
156
+ const emit = resolveStepEmit(step);
142
157
  if (shouldEmit(emit, 'step_started', startedData)) {
143
158
  emitFrameworkEvent({
144
159
  broadcaster,
@@ -166,6 +181,12 @@ export async function execute(step, input, ctx) {
166
181
  case 'llm':
167
182
  result = await executeLLM(step, input, ctx, baseCtx.layers);
168
183
  break;
184
+ case 'claude-code':
185
+ case 'codex':
186
+ case 'opencode':
187
+ case 'pi':
188
+ result = await executeSubHarness(step, input, ctx);
189
+ break;
169
190
  case 'tool':
170
191
  result = await executeTool(step, input, ctx, baseCtx.harness);
171
192
  break;