@noetic-tools/core 5.4.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 (35) hide show
  1. package/dist/builders/acp-agent-tool.d.ts +67 -0
  2. package/dist/builders/acp-agent-tool.js +87 -0
  3. package/dist/builders/dynamic-workflow.js +2 -2
  4. package/dist/builders/step-builders.d.ts +42 -42
  5. package/dist/builders/step-builders.js +38 -62
  6. package/dist/builders/workflow-hydrator.d.ts +8 -4
  7. package/dist/builders/workflow-hydrator.js +16 -24
  8. package/dist/builders/workflow-step.d.ts +3 -3
  9. package/dist/builders/workflow-step.js +1 -1
  10. package/dist/harness/agent-harness.d.ts +36 -7
  11. package/dist/harness/agent-harness.js +49 -6
  12. package/dist/harness/deps/types.d.ts +1 -1
  13. package/dist/index.d.ts +8 -4
  14. package/dist/index.js +3 -3
  15. package/dist/interpreter/acp-events.d.ts +67 -0
  16. package/dist/interpreter/acp-events.js +256 -0
  17. package/dist/interpreter/acp-history.d.ts +22 -0
  18. package/dist/interpreter/acp-history.js +58 -0
  19. package/dist/interpreter/collect-tools.js +3 -6
  20. package/dist/interpreter/execute-acp-agent.d.ts +10 -0
  21. package/dist/interpreter/execute-acp-agent.js +505 -0
  22. package/dist/interpreter/execute.js +4 -11
  23. package/dist/interpreter/structured-output.d.ts +1 -1
  24. package/dist/interpreter/structured-output.js +1 -1
  25. package/dist/portable/builders.d.ts +2 -0
  26. package/dist/portable/builders.js +3 -1
  27. package/dist/runtime/acp-session-store.d.ts +75 -0
  28. package/dist/runtime/acp-session-store.js +0 -0
  29. package/dist/schemas/workflow.d.ts +1 -1
  30. package/package.json +3 -3
  31. package/schema/noetic-workflow.schema.json +215 -249
  32. package/dist/interpreter/execute-sub-harness.d.ts +0 -8
  33. package/dist/interpreter/execute-sub-harness.js +0 -222
  34. package/dist/interpreter/sub-harness-events.d.ts +0 -48
  35. package/dist/interpreter/sub-harness-events.js +0 -208
@@ -0,0 +1,505 @@
1
+ /**
2
+ * ACP step handler: drives an external coding agent over the Agent Client
3
+ * Protocol for one prompt turn and folds its output back into the Noetic
4
+ * execution context — the ACP analogue of `executeCallModel`.
5
+ *
6
+ * Core reaches the agent only through the `@noetic-tools/types` contract, so no
7
+ * protocol library or agent SDK enters its dependency graph.
8
+ */
9
+ import { createMessage, extractAssistantText, frameworkCast, NoeticConfigError, NoeticErrorImpl, SteeringAction, } from '@noetic-tools/types';
10
+ import { AcpEventBridge } from './acp-events.js';
11
+ import { withHistoryPrompt } from './acp-history.js';
12
+ import { resolveLazy } from './execute-action.js';
13
+ import { trackUsage } from './message-helpers.js';
14
+ import { parseStructuredOutput } from './structured-output.js';
15
+ import { isContextImpl, isFunctionCall, isMutableContext } from './typeguards.js';
16
+ //#region Helpers
17
+ /**
18
+ * The cross-step session store, hung off the concrete `AgentHarness` and
19
+ * reached via `frameworkCast`, mirroring how the interpreter reaches
20
+ * `layerStateStore`.
21
+ */
22
+ function sessionStore(ctx) {
23
+ return frameworkCast(ctx.harness).acpSessions;
24
+ }
25
+ /**
26
+ * The root of the context chain. Run-scoped sessions are filed under it so
27
+ * concurrent runs on one harness cannot see or tear down each other's agents.
28
+ */
29
+ function rootContextId(ctx) {
30
+ let current = ctx;
31
+ while (current.parent) {
32
+ current = frameworkCast(current.parent);
33
+ }
34
+ return current.id;
35
+ }
36
+ function abortSignalOf(ctx) {
37
+ return isContextImpl(ctx) ? ctx.abortSignal : undefined;
38
+ }
39
+ async function resolveAgent(step, ctx) {
40
+ const resolved = await resolveLazy(step.agent, ctx);
41
+ if (!resolved) {
42
+ throw new NoeticConfigError({
43
+ code: 'MISSING_ACP_AGENT',
44
+ message: `step.acpAgent(${JSON.stringify(step.id)}) resolved no agent adapter.`,
45
+ hint: 'Pass an agent factory result, e.g. agent: claudeCode() from @noetic-tools/acp.',
46
+ });
47
+ }
48
+ return resolved;
49
+ }
50
+ /**
51
+ * Build the turn's prompt content. Plain text comes first (seeded with the
52
+ * conversation so far on a fresh session), then any explicit content blocks.
53
+ */
54
+ function buildContent(opts) {
55
+ const content = [];
56
+ if (opts.text.length > 0) {
57
+ content.push({
58
+ type: 'text',
59
+ text: opts.text,
60
+ });
61
+ }
62
+ if (opts.blocks) {
63
+ content.push(...opts.blocks);
64
+ }
65
+ return content;
66
+ }
67
+ function toLlmResponse(result) {
68
+ return {
69
+ items: result.items,
70
+ usage: {
71
+ inputTokens: result.usage?.input ?? 0,
72
+ outputTokens: result.usage?.output ?? 0,
73
+ cachedTokens: result.usage?.cached,
74
+ },
75
+ cost: result.cost,
76
+ };
77
+ }
78
+ function applyTurnResult(ctx, result) {
79
+ const toolCalls = [];
80
+ for (const item of result.items) {
81
+ ctx.itemLog.append(item);
82
+ if (isFunctionCall(item)) {
83
+ toolCalls.push(item);
84
+ }
85
+ }
86
+ const llmResponse = toLlmResponse(result);
87
+ trackUsage(ctx, llmResponse);
88
+ const meta = {
89
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
90
+ usage: result.usage ? llmResponse.usage : undefined,
91
+ cost: result.cost,
92
+ responseItems: result.items,
93
+ acpStopReason: result.stopReason,
94
+ };
95
+ if (isMutableContext(ctx)) {
96
+ ctx.lastStepMeta = meta;
97
+ }
98
+ }
99
+ /**
100
+ * Turn a stop reason into the step's outcome. A refusal and a cancellation are
101
+ * genuine failures with dedicated error kinds; a token or turn cap is a normal
102
+ * return whose reason is recorded on `lastStepMeta` for the caller to inspect.
103
+ */
104
+ function assertTurnSucceeded(stepId, result, ctx) {
105
+ if (result.stopReason === 'cancelled') {
106
+ throw new NoeticErrorImpl({
107
+ kind: 'cancelled',
108
+ reason: ctx.abortReason ?? 'the ACP agent reported a cancelled turn',
109
+ });
110
+ }
111
+ if (result.stopReason === 'refusal') {
112
+ throw new NoeticErrorImpl({
113
+ kind: 'model_refused',
114
+ stepId,
115
+ refusal: result.text.length > 0 ? result.text : 'the ACP agent refused to continue',
116
+ });
117
+ }
118
+ }
119
+ //#endregion
120
+ //#region Client host
121
+ /**
122
+ * Bridge the agent's permission requests into the steering pipeline.
123
+ *
124
+ * Steering is a **veto** tier: `beforeToolCall` returns `allow` both when a rule
125
+ * explicitly permits the call *and* when no steering hook exists at all, so
126
+ * treating `allow` as decisive would silently grant every permission. Only a
127
+ * non-allow decision is acted on; anything else abstains and the next tier
128
+ * (the step's handler, then the policy default) decides.
129
+ */
130
+ function buildSteerer(ctx, layers) {
131
+ if (!layers || layers.length === 0) {
132
+ return undefined;
133
+ }
134
+ return async (request) => {
135
+ const decision = await ctx.harness.beforeToolCall(layers, request.toolCall.title ?? request.toolCall.toolCallId, request.toolCall.rawInput, ctx);
136
+ if (decision.action === SteeringAction.Allow) {
137
+ return undefined;
138
+ }
139
+ return {
140
+ decision: 'deny',
141
+ reason: decision.guidance ?? `steering ${decision.action}`,
142
+ };
143
+ };
144
+ }
145
+ /**
146
+ * Point an existing host at the step that is about to take a turn.
147
+ *
148
+ * A connection outlives the step that opened it, so everything per-turn —
149
+ * permissions, steering, the async handler, the event sink — must be rebound
150
+ * before each turn. Without this, a session shared by several steps answers
151
+ * every later step with the FIRST step's policy and streams its output to the
152
+ * first step's (already finalized) event bridge.
153
+ */
154
+ function bindHostToStep(host, opts) {
155
+ host.permissions = opts.step.permissions;
156
+ host.steerPermission = buildSteerer(opts.ctx, opts.layers);
157
+ // Bind the executing context into the handler so the protocol client never
158
+ // has to know about `Context` — it just asks a question and gets an answer.
159
+ const handler = opts.step.onPermissionRequest;
160
+ host.onPermissionRequest = handler
161
+ ? (request) => handler(request, opts.ctx, {
162
+ agentId: opts.agentId,
163
+ stepId: opts.step.id,
164
+ })
165
+ : undefined;
166
+ host.onSessionUpdate = opts.onUpdate;
167
+ host.onClientActivity = opts.onActivity;
168
+ }
169
+ function buildHost(opts) {
170
+ const host = {
171
+ cwd: opts.cwd,
172
+ fs: opts.ctx.fs,
173
+ shell: opts.ctx.shell,
174
+ threadId: opts.ctx.threadId,
175
+ signal: abortSignalOf(opts.ctx),
176
+ // Connection-level: ACP negotiates the capability set once, in `initialize`.
177
+ capabilities: opts.step.clientCapabilities,
178
+ onSessionUpdate: opts.onUpdate,
179
+ onClientActivity: opts.onActivity,
180
+ };
181
+ bindHostToStep(host, opts);
182
+ return host;
183
+ }
184
+ /**
185
+ * The capability set as the wire sees it, so two spellings of the same policy
186
+ * compare equal. Matches the defaults `clientCapabilitiesFor` applies in the
187
+ * protocol client — an omitted field and an explicitly-`true` one are the same
188
+ * request, and field order is not a difference at all.
189
+ */
190
+ function normalizedCapabilities(config) {
191
+ return JSON.stringify({
192
+ readTextFile: config?.readTextFile !== false,
193
+ writeTextFile: config?.writeTextFile !== false,
194
+ terminal: config?.terminal !== false,
195
+ allowAnyPath: config?.allowAnyPath === true,
196
+ additionalDirectories: [
197
+ ...(config?.additionalDirectories ?? []),
198
+ ].sort(),
199
+ });
200
+ }
201
+ /**
202
+ * `cwd` and `mcpServers` are fixed when the session is created, so a step
203
+ * joining an existing one cannot change them either.
204
+ *
205
+ * These used to be dropped in silence while a mismatched `clientCapabilities`
206
+ * threw — so a step that named different MCP servers simply got none of them,
207
+ * with no diagnostic. Connection-scoped settings should all fail the same way.
208
+ */
209
+ function assertSessionScopedSettingsMatch(step, live, cwd, servers) {
210
+ const conflict = (setting, hint) => new NoeticConfigError({
211
+ code: 'ACP_SESSION_SETTING_CONFLICT',
212
+ message: `step.acpAgent(${JSON.stringify(step.id)}) reuses session '${step.session?.reuse}' but requests a different ${setting} than the session was created with.`,
213
+ hint,
214
+ });
215
+ if (cwd !== live.cwd) {
216
+ throw conflict('cwd', 'A session is created in one working directory. Give every step sharing a `session.reuse` key the same `cwd`, or use a separate session.');
217
+ }
218
+ // Only a step that asks for servers can conflict; omitting them means
219
+ // "whatever the session already has".
220
+ if (servers && JSON.stringify(servers) !== JSON.stringify(live.mcpServers ?? [])) {
221
+ throw conflict('set of MCP servers', 'MCP servers are attached when the session is created. Give every step sharing a `session.reuse` key the same `mcpServers`, or use a separate session.');
222
+ }
223
+ }
224
+ /**
225
+ * `clientCapabilities` is negotiated once per connection, so a step joining an
226
+ * existing session cannot change it. Silently ignoring the request would hand
227
+ * back a session with different permissions than the step asked for, so this
228
+ * is a configuration error instead.
229
+ *
230
+ * Compared field by field after normalisation: comparing raw JSON made
231
+ * `{a,b}` conflict with `{b,a}`, and an omitted capability conflict with the
232
+ * default it resolves to — telling an author to make two identical policies
233
+ * identical.
234
+ */
235
+ function assertCapabilitiesCompatible(step, live) {
236
+ const requested = step.clientCapabilities;
237
+ if (requested === undefined) {
238
+ return;
239
+ }
240
+ if (normalizedCapabilities(requested) === normalizedCapabilities(live.host.capabilities)) {
241
+ return;
242
+ }
243
+ throw new NoeticConfigError({
244
+ code: 'ACP_SESSION_CAPABILITY_CONFLICT',
245
+ message: `step.acpAgent(${JSON.stringify(step.id)}) reuses session '${step.session?.reuse}' but requests different clientCapabilities than the connection was opened with.`,
246
+ hint: 'ACP negotiates client capabilities once per connection. Give every step sharing a `session.reuse` key the same `clientCapabilities`, or use a separate session.',
247
+ });
248
+ }
249
+ /** Keeping a connection is always explicit; the default closes it with the step. */
250
+ function keepAliveOf(policy) {
251
+ return policy?.keepAlive ?? 'step';
252
+ }
253
+ /**
254
+ * Sharing a connection only means something if the connection outlives the
255
+ * step. Rather than silently upgrading the scope, say so — the step is asking
256
+ * for two different things and has only named one.
257
+ */
258
+ function assertReuseIsKeptAlive(step) {
259
+ const policy = step.session;
260
+ if (!policy?.reuse || keepAliveOf(policy) !== 'step') {
261
+ return;
262
+ }
263
+ throw new NoeticConfigError({
264
+ code: 'ACP_REUSE_WITHOUT_KEEPALIVE',
265
+ message: `step.acpAgent(${JSON.stringify(step.id)}) sets session.reuse '${policy.reuse}' but leaves keepAlive at 'step', so the connection closes before any other step can share it.`,
266
+ hint: "Add `keepAlive: 'run'` to share the connection for the rest of the run, or `keepAlive: 'harness'` to keep it past the run and close it yourself with `harness.closeAcpSessions()`.",
267
+ });
268
+ }
269
+ async function openSession(opts) {
270
+ const store = sessionStore(opts.baseCtx);
271
+ const keepAlive = keepAliveOf(opts.step.session);
272
+ const reuseKey = opts.step.session?.reuse;
273
+ const parts = {
274
+ keepAlive,
275
+ rootId: rootContextId(opts.baseCtx),
276
+ reuseKey,
277
+ };
278
+ // Registered under a key even without `session.reuse`: anything kept past
279
+ // its step has to be reachable, or nothing can ever close it.
280
+ const key = store.keyFor(parts);
281
+ const { live, fresh } = await store.acquire(key, parts, async () => {
282
+ const connection = await opts.agent.connect({
283
+ host: opts.host,
284
+ signal: abortSignalOf(opts.baseCtx),
285
+ });
286
+ // From here on the connection owns a live agent (usually a child process).
287
+ // Anything that throws before the caller can take responsibility for it
288
+ // must close it first — otherwise the agent outlives the step and, because
289
+ // its stdio keeps the event loop alive, the host never exits.
290
+ try {
291
+ const loadId = opts.step.session?.load;
292
+ const session = loadId
293
+ ? await connection.loadSession({
294
+ sessionId: loadId,
295
+ cwd: opts.cwd,
296
+ mcpServers: opts.servers,
297
+ })
298
+ : await connection.newSession({
299
+ cwd: opts.cwd,
300
+ mcpServers: opts.servers,
301
+ });
302
+ return {
303
+ connection,
304
+ session,
305
+ host: opts.host,
306
+ agentId: opts.agent.agentId,
307
+ keepAlive,
308
+ cwd: opts.cwd,
309
+ mcpServers: opts.servers,
310
+ };
311
+ }
312
+ catch (e) {
313
+ await connection.close().catch(() => undefined);
314
+ throw e;
315
+ }
316
+ });
317
+ if (!fresh) {
318
+ assertSessionScopedSettingsMatch(opts.step, live, opts.cwd, opts.servers);
319
+ if (live.agentId !== opts.agent.agentId) {
320
+ throw new NoeticConfigError({
321
+ code: 'ACP_SESSION_AGENT_CONFLICT',
322
+ message: `step.acpAgent(${JSON.stringify(opts.step.id)}) reuses session '${reuseKey}', which was opened by the '${live.agentId}' agent, with a '${opts.agent.agentId}' agent.`,
323
+ hint: 'A reused session is one live connection to one agent. Give every step sharing a `session.reuse` key the same agent, or use separate keys.',
324
+ });
325
+ }
326
+ assertCapabilitiesCompatible(opts.step, live);
327
+ // Point the existing connection at THIS step before it takes a turn.
328
+ bindHostToStep(live.host, opts.binding);
329
+ }
330
+ return {
331
+ live,
332
+ key,
333
+ keepAlive,
334
+ fresh,
335
+ };
336
+ }
337
+ /**
338
+ * Tear the connection down per policy. Nothing is kept unless the step asked
339
+ * for it: only `keepAlive: 'run' | 'harness'` survives the step, and the run
340
+ * scope is collected by the harness when the root run finishes.
341
+ */
342
+ async function finalizeSession(resolution, baseCtx) {
343
+ if (resolution.keepAlive !== 'step') {
344
+ return;
345
+ }
346
+ // Teardown must not fail a turn whose work already landed in the item log —
347
+ // the stdio transport throws from `close()` if the child ever errored.
348
+ await resolution.live.connection.close().catch(() => undefined);
349
+ sessionStore(baseCtx).forget(resolution.key);
350
+ }
351
+ //#endregion
352
+ /**
353
+ * Release a connection this step opened, before surfacing a failure. A reused
354
+ * one is left intact for a later step to retry against.
355
+ */
356
+ async function discardIfFresh(resolution, baseCtx) {
357
+ if (!resolution.fresh) {
358
+ return;
359
+ }
360
+ await resolution.live.connection.close().catch(() => undefined);
361
+ sessionStore(baseCtx).forget(resolution.key);
362
+ }
363
+ /**
364
+ * Apply the step's mode and model before its turn. Part of setting the turn up,
365
+ * so a failure tears down exactly like a failed turn rather than leaving a
366
+ * fresh connection holding a live agent.
367
+ */
368
+ async function applySessionSelection(step, ctx, resolution, baseCtx) {
369
+ try {
370
+ const mode = await resolveLazy(step.mode, ctx);
371
+ if (mode) {
372
+ await resolution.live.session.setMode(mode);
373
+ }
374
+ const model = await resolveLazy(step.model, ctx);
375
+ if (model) {
376
+ await resolution.live.session.setModel(model);
377
+ }
378
+ }
379
+ catch (e) {
380
+ await discardIfFresh(resolution, baseCtx);
381
+ throw e;
382
+ }
383
+ }
384
+ /** Turn a failed turn into the step's typed error, after closing the bracket. */
385
+ function turnFailure(step, cause, baseCtx) {
386
+ if (cause instanceof NoeticErrorImpl) {
387
+ return cause;
388
+ }
389
+ if (baseCtx.aborted) {
390
+ return new NoeticErrorImpl({
391
+ kind: 'cancelled',
392
+ reason: baseCtx.abortReason ?? 'context aborted',
393
+ });
394
+ }
395
+ return new NoeticErrorImpl({
396
+ kind: 'step_failed',
397
+ stepId: step.id,
398
+ cause: cause instanceof Error ? cause : new Error(String(cause)),
399
+ retriesExhausted: false,
400
+ });
401
+ }
402
+ //#region Public API
403
+ export async function executeAcpAgent(step, input, ctx, layers) {
404
+ const baseCtx = frameworkCast(ctx);
405
+ assertReuseIsKeptAlive(step);
406
+ const agent = await resolveAgent(step, ctx);
407
+ const resolvedPrompt = await resolveLazy(step.prompt, ctx);
408
+ const blocks = await resolveLazy(step.content, ctx);
409
+ const promptText = resolvedPrompt && resolvedPrompt.length > 0
410
+ ? resolvedPrompt
411
+ : typeof input === 'string'
412
+ ? input
413
+ : '';
414
+ if (promptText.trim() === '' && (!blocks || blocks.length === 0)) {
415
+ throw new NoeticConfigError({
416
+ code: 'MISSING_PROMPT',
417
+ message: `step.acpAgent(${JSON.stringify(step.id)}) resolved an empty prompt.`,
418
+ hint: 'Provide a non-empty `prompt` or `content`, or pass a string input to the step.',
419
+ });
420
+ }
421
+ const cwd = (await resolveLazy(step.cwd, ctx)) ?? baseCtx.cwdState.cwd;
422
+ const servers = await resolveLazy(step.mcpServers, ctx);
423
+ // Capture the conversation so far BEFORE appending this turn's prompt, so a
424
+ // fresh session can be seeded with it and the agent is not left guessing at
425
+ // what earlier steps established.
426
+ const priorHistory = [
427
+ ...baseCtx.itemLog.items,
428
+ ];
429
+ if (promptText.length > 0) {
430
+ baseCtx.itemLog.append(createMessage(promptText, 'user'));
431
+ }
432
+ const bridge = new AcpEventBridge(step, agent.agentId, baseCtx);
433
+ const binding = {
434
+ step,
435
+ ctx: baseCtx,
436
+ agentId: agent.agentId,
437
+ layers,
438
+ onUpdate: (notification) => {
439
+ bridge.forward(notification);
440
+ },
441
+ onActivity: (activity) => {
442
+ bridge.forwardActivity(activity);
443
+ },
444
+ };
445
+ const host = buildHost({
446
+ ...binding,
447
+ cwd,
448
+ });
449
+ const resolution = await openSession({
450
+ step,
451
+ agent,
452
+ baseCtx,
453
+ cwd,
454
+ host,
455
+ binding,
456
+ servers: servers
457
+ ? [
458
+ ...servers,
459
+ ]
460
+ : undefined,
461
+ });
462
+ await applySessionSelection(step, ctx, resolution, baseCtx);
463
+ bridge.begin();
464
+ let result;
465
+ try {
466
+ result = await resolution.live.session.prompt({
467
+ content: buildContent({
468
+ // History seeds only a freshly opened session; a reused one already
469
+ // owns its conversation on the agent side.
470
+ text: resolution.fresh
471
+ ? withHistoryPrompt({
472
+ prompt: promptText,
473
+ history: priorHistory,
474
+ })
475
+ : promptText,
476
+ blocks,
477
+ }),
478
+ // Per-turn signal, so a session reused across turns is cancelled by the
479
+ // context running the CURRENT turn rather than the one that opened it.
480
+ signal: abortSignalOf(baseCtx),
481
+ });
482
+ }
483
+ catch (e) {
484
+ // Close the turn on the event surface before rethrowing. A consumer driving
485
+ // a UI off `getFullStream()` saw `response.created` and would otherwise
486
+ // wait forever for a completion that never comes — the stream just stops.
487
+ bridge.abort(e);
488
+ await discardIfFresh(resolution, baseCtx);
489
+ throw turnFailure(step, e, baseCtx);
490
+ }
491
+ bridge.finalize(result);
492
+ applyTurnResult(baseCtx, result);
493
+ await finalizeSession(resolution, baseCtx);
494
+ assertTurnSucceeded(step.id, result, baseCtx);
495
+ const lastText = result.text.length > 0 ? result.text : extractAssistantText(result.items);
496
+ if (step.output) {
497
+ return parseStructuredOutput({
498
+ schema: step.output,
499
+ rawText: lastText,
500
+ stepId: step.id,
501
+ });
502
+ }
503
+ return frameworkCast(lastText);
504
+ }
505
+ //#endregion
@@ -2,9 +2,9 @@ import { frameworkCast, NoeticErrorImpl } from '@noetic-tools/types';
2
2
  import { emitFrameworkEvent, getBroadcaster, shouldEmit } from '../runtime/broadcaster-utils.js';
3
3
  import { ContextImpl } from '../runtime/context-impl.js';
4
4
  import { DetachedHandleImpl } from '../util/detached-handle.js';
5
+ import { executeAcpAgent } from './execute-acp-agent.js';
5
6
  import { executeCallModel, executeInvokeTool, executeRunCode, executeSpawn, executeWithContext, } from './execute-action.js';
6
7
  import { executeConditional, executeInParallel, executeLoop, executeSchedule, } from './execute-control.js';
7
- import { executeSubHarness } from './execute-sub-harness.js';
8
8
  import { isMutableContext } from './typeguards.js';
9
9
  //#region Constants
10
10
  const MAX_DEPTH = 64;
@@ -35,11 +35,7 @@ function buildSpawnOpts(ctx) {
35
35
  * step kinds carry an `emit` field; every other kind defaults to enabled.
36
36
  */
37
37
  function resolveStepEmit(step) {
38
- if (step.kind === 'callModel' ||
39
- step.kind === 'claude-code' ||
40
- step.kind === 'codex' ||
41
- step.kind === 'opencode' ||
42
- step.kind === 'pi') {
38
+ if (step.kind === 'callModel' || step.kind === 'acp-agent') {
43
39
  return step.emit;
44
40
  }
45
41
  return undefined;
@@ -258,11 +254,8 @@ export async function execute(step, input, ctx) {
258
254
  case 'callModel':
259
255
  result = await executeCallModel(step, input, ctx, baseCtx.layers);
260
256
  break;
261
- case 'claude-code':
262
- case 'codex':
263
- case 'opencode':
264
- case 'pi':
265
- result = await executeSubHarness(step, input, ctx);
257
+ case 'acp-agent':
258
+ result = await executeAcpAgent(step, input, ctx, baseCtx.layers);
266
259
  break;
267
260
  case 'invokeTool':
268
261
  result = await executeInvokeTool(step, input, ctx, baseCtx.harness);
@@ -1,7 +1,7 @@
1
1
  import type { StandardSchemaV1 } from '@noetic-tools/types';
2
2
  /**
3
3
  * JSON-parse + validate assistant text against a Standard Schema, raising
4
- * `model_parse_error`. Shared by the LLM step and sub-harness step handlers.
4
+ * `model_parse_error`. Shared by the LLM step and ACP agent step handlers.
5
5
  *
6
6
  * Zod schemas keep the original `safeParse` error; other vendors' Standard
7
7
  * Schema issues are adapted into a synthetic `ZodError` of `custom` issues so
@@ -1,7 +1,7 @@
1
1
  import { frameworkCast, NoeticErrorImpl, standardIssuesToZodError, validateSchema, } from '@noetic-tools/types';
2
2
  /**
3
3
  * JSON-parse + validate assistant text against a Standard Schema, raising
4
- * `model_parse_error`. Shared by the LLM step and sub-harness step handlers.
4
+ * `model_parse_error`. Shared by the LLM step and ACP agent step handlers.
5
5
  *
6
6
  * Zod schemas keep the original `safeParse` error; other vendors' Standard
7
7
  * Schema issues are adapted into a synthetic `ZodError` of `custom` issues so
@@ -1,4 +1,6 @@
1
1
  /** @public */
2
+ /** @public */
3
+ export { acpAgentTool } from '../builders/acp-agent-tool.js';
2
4
  export { conditional, inParallel } from '../builders/control-flow-builders.js';
3
5
  /** @public */
4
6
  export { layerData, layerFunction } from '../builders/layer-provides-builders.js';
@@ -1,4 +1,6 @@
1
1
  /** @public */
2
+ /** @public */
3
+ export { acpAgentTool } from '../builders/acp-agent-tool.js';
2
4
  export { conditional, inParallel } from '../builders/control-flow-builders.js';
3
5
  /** @public */
4
6
  export { layerData, layerFunction } from '../builders/layer-provides-builders.js';
@@ -6,7 +8,7 @@ export { layerData, layerFunction } from '../builders/layer-provides-builders.js
6
8
  export { loop } from '../builders/loop-builder.js';
7
9
  /** @public */
8
10
  export { spawn } from '../builders/spawn-builder.js';
9
- // The base builders plus the sub-harness `step` namespace, without
11
+ // The base builders plus the ACP `step` namespace, without
10
12
  // `workflow` — the portable surface stays free of the hydrator so
11
13
  // restricted runtimes don't pull it in.
12
14
  /** @public */
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The harness's registry of live ACP connections.
3
+ *
4
+ * Three properties this has to get right, each of which was previously wrong:
5
+ *
6
+ * 1. **Everything kept is registered.** A session whose `keepAlive` outlives
7
+ * its step is only closable if something holds it. Registering only when the
8
+ * step named a `reuse` key meant `keepAlive: 'run'` without a key was held
9
+ * by nobody — not closed at the end of the run, and out of reach of
10
+ * `closeAcpSessions()`. With the stdio transport that is a child process
11
+ * whose stdio keeps the host alive forever.
12
+ *
13
+ * 2. **Ownership is tracked.** A `'run'`-scoped session belongs to the root run
14
+ * that opened it. Sweeping every entry when *any* root run finished let a
15
+ * short request kill a concurrent long one's agent mid-turn, and let two
16
+ * unrelated runs silently share one agent's conversation. `'harness'` scope
17
+ * is deliberately global — surviving runs is its entire purpose.
18
+ *
19
+ * 3. **Opening is atomic per key.** `get` → `await connect()` → `set` leaves a
20
+ * window in which two parallel steps sharing a key both miss, both connect,
21
+ * and the second registration orphans the first — unreachable and never
22
+ * closed. Registering the in-flight promise before awaiting closes it.
23
+ */
24
+ import type { AcpKeepAlive, AcpLiveSession, AcpSessionInfo } from '@noetic-tools/types';
25
+ /** @internal Identifies a session for reuse and for scoped teardown. */
26
+ export interface AcpSessionKeyParts {
27
+ keepAlive: AcpKeepAlive;
28
+ /** Root context of the run taking the turn. */
29
+ rootId: string;
30
+ /** The step's `session.reuse`, when set. */
31
+ reuseKey?: string;
32
+ }
33
+ /** @internal Live ACP connections held by one `AgentHarness`. */
34
+ export declare class AcpSessionStore {
35
+ private readonly settled;
36
+ /** Openers still connecting, so a concurrent step waits rather than racing. */
37
+ private readonly pending;
38
+ private nextAnonymous;
39
+ /**
40
+ * The handle a session is filed under.
41
+ *
42
+ * `'harness'` scope is keyed globally because outliving the run is the point.
43
+ * `'run'` scope is keyed per root so concurrent runs cannot see, share, or
44
+ * tear down each other's agents. A session with no `reuse` key gets a unique
45
+ * one, so it is still registered — and therefore still closable.
46
+ */
47
+ keyFor(parts: AcpSessionKeyParts): string;
48
+ /**
49
+ * Get the session under `key`, opening it if nobody has yet.
50
+ *
51
+ * Deliberately **not** `async`: the lookup and the registration of the
52
+ * in-flight promise must happen in one synchronous turn. An `await` between
53
+ * them — even one resolving immediately — lets a second caller check before
54
+ * the first registers, so both open a connection and one is orphaned,
55
+ * unreachable and never closed.
56
+ */
57
+ acquire(key: string, parts: AcpSessionKeyParts, open: () => Promise<AcpLiveSession>): Promise<{
58
+ live: AcpLiveSession;
59
+ fresh: boolean;
60
+ }>;
61
+ /** Forget a session without closing it — the caller already did. */
62
+ forget(key: string): void;
63
+ /** What is live right now, for a UI or a caller deciding what to cancel. */
64
+ list(): AcpSessionInfo[];
65
+ /** Look up by the handle {@link list} reports, or by a step's reuse key. */
66
+ get(key: string): AcpLiveSession | undefined;
67
+ /**
68
+ * Close every session a finishing root run owns. `'harness'` scope opted out
69
+ * of this collection, and another run's sessions are none of its business.
70
+ */
71
+ closeOwnedBy(rootId: string): Promise<void>;
72
+ /** Close everything, whatever its scope. */
73
+ closeAll(): Promise<void>;
74
+ private closeWhere;
75
+ }
@@ -3,5 +3,5 @@
3
3
  * consumers can validate workflow documents without importing core.
4
4
  * This barrel keeps core's public and intra-core import paths unchanged.
5
5
  */
6
- export type { CallModelWorkflowNode, ConditionalRoute, ConditionalWorkflowNode, InParallelWorkflowNode, InvokeToolWorkflowNode, LoopWorkflowNode, OutputCodecRef, RunCodeWorkflowNode, ScheduleWorkflowNode, SequenceWorkflowNode, SpawnWorkflowNode, SubflowWorkflowNode, SubHarnessWorkflowNode, UntilPredicate, WithContextWorkflowNode, WorkflowDocument, WorkflowGraph, WorkflowNode, } from '@noetic-tools/types';
6
+ export type { AcpAgentWorkflowNode, CallModelWorkflowNode, ConditionalRoute, ConditionalWorkflowNode, InParallelWorkflowNode, InvokeToolWorkflowNode, LoopWorkflowNode, OutputCodecRef, RunCodeWorkflowNode, ScheduleWorkflowNode, SequenceWorkflowNode, SpawnWorkflowNode, SubflowWorkflowNode, UntilPredicate, WithContextWorkflowNode, WorkflowDocument, WorkflowGraph, WorkflowNode, } from '@noetic-tools/types';
7
7
  export { MergeStrategy, MergeStrategySchema, UntilPredicateSchema, validateWorkflow, WorkflowDocumentSchema, WorkflowNodeSchema, walkWorkflow, workflowDepth, workflowGraph, } from '@noetic-tools/types';