@noetic-tools/core 5.4.0 → 5.4.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.
- package/dist/builders/acp-agent-tool.d.ts +67 -0
- package/dist/builders/acp-agent-tool.js +87 -0
- package/dist/builders/dynamic-workflow.js +2 -2
- package/dist/builders/step-builders.d.ts +42 -42
- package/dist/builders/step-builders.js +38 -62
- package/dist/builders/workflow-hydrator.d.ts +8 -4
- package/dist/builders/workflow-hydrator.js +16 -24
- package/dist/builders/workflow-step.d.ts +3 -3
- package/dist/builders/workflow-step.js +1 -1
- package/dist/harness/agent-harness.d.ts +36 -7
- package/dist/harness/agent-harness.js +49 -6
- package/dist/harness/deps/types.d.ts +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +3 -3
- package/dist/interpreter/acp-events.d.ts +67 -0
- package/dist/interpreter/acp-events.js +256 -0
- package/dist/interpreter/acp-history.d.ts +22 -0
- package/dist/interpreter/acp-history.js +58 -0
- package/dist/interpreter/collect-tools.js +3 -6
- package/dist/interpreter/execute-acp-agent.d.ts +10 -0
- package/dist/interpreter/execute-acp-agent.js +505 -0
- package/dist/interpreter/execute.js +4 -11
- package/dist/interpreter/structured-output.d.ts +1 -1
- package/dist/interpreter/structured-output.js +1 -1
- package/dist/portable/builders.d.ts +2 -0
- package/dist/portable/builders.js +3 -1
- package/dist/runtime/acp-session-store.d.ts +75 -0
- package/dist/runtime/acp-session-store.js +0 -0
- package/dist/schemas/workflow.d.ts +1 -1
- package/package.json +3 -3
- package/schema/noetic-workflow.schema.json +215 -249
- package/dist/interpreter/execute-sub-harness.d.ts +0 -8
- package/dist/interpreter/execute-sub-harness.js +0 -222
- package/dist/interpreter/sub-harness-events.d.ts +0 -48
- 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 '
|
|
262
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
|
Binary file
|
|
@@ -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,
|
|
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';
|