@noetic-tools/core 5.3.0 → 5.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/adapters/in-memory-subprocess/step-completion.js +1 -1
- package/dist/adapters/openrouter.d.ts +12 -1
- package/dist/adapters/openrouter.js +79 -5
- package/dist/builders/acp-agent-tool.d.ts +67 -0
- package/dist/builders/acp-agent-tool.js +87 -0
- package/dist/builders/control-flow-builders.d.ts +13 -13
- package/dist/builders/control-flow-builders.js +11 -11
- package/dist/{patterns → builders}/dynamic-workflow.d.ts +1 -1
- package/dist/{patterns → builders}/dynamic-workflow.js +20 -14
- package/dist/builders/every.d.ts +14 -14
- package/dist/builders/every.js +16 -16
- package/dist/builders/layer-provides-builders.d.ts +1 -1
- package/dist/builders/layer-provides-builders.js +1 -1
- package/dist/builders/provide-builder.d.ts +5 -12
- package/dist/builders/provide-builder.js +9 -10
- package/dist/builders/spawn-builder.d.ts +0 -2
- package/dist/builders/spawn-builder.js +2 -3
- package/dist/builders/step-builders.d.ts +103 -92
- package/dist/builders/step-builders.js +146 -163
- package/dist/builders/tool-builder.d.ts +18 -22
- package/dist/builders/tool-builder.js +6 -4
- package/dist/builders/workflow-hydrator.d.ts +11 -7
- package/dist/builders/workflow-hydrator.js +75 -87
- package/dist/builders/workflow-step.d.ts +15 -27
- package/dist/builders/workflow-step.js +14 -19
- package/dist/conditions/conditions.d.ts +2 -2
- package/dist/conditions/conditions.js +1 -1
- package/dist/harness/agent-harness.d.ts +89 -46
- package/dist/harness/agent-harness.js +93 -39
- package/dist/harness/deps/types.d.ts +1 -1
- package/dist/harness/model-call.d.ts +1 -1
- package/dist/harness/model-call.js +23 -18
- package/dist/index.d.ts +27 -35
- package/dist/index.js +13 -29
- 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/action-types.d.ts +1 -1
- package/dist/interpreter/collect-tools.js +10 -13
- package/dist/interpreter/execute-acp-agent.d.ts +10 -0
- package/dist/interpreter/execute-acp-agent.js +505 -0
- package/dist/interpreter/execute-action.d.ts +5 -5
- package/dist/interpreter/execute-action.js +284 -218
- package/dist/interpreter/execute-control.d.ts +9 -9
- package/dist/interpreter/execute-control.js +27 -27
- package/dist/interpreter/execute.d.ts +1 -1
- package/dist/interpreter/execute.js +29 -35
- package/dist/interpreter/step-dispatcher.d.ts +1 -1
- package/dist/interpreter/step-dispatcher.js +3 -3
- package/dist/interpreter/structured-output.d.ts +14 -0
- package/dist/interpreter/structured-output.js +40 -0
- package/dist/interpreter/typeguards.d.ts +1 -1
- package/dist/interpreter/typeguards.js +1 -1
- package/dist/portable/builders.d.ts +5 -3
- package/dist/portable/builders.js +8 -5
- package/dist/portable/context-layers.d.ts +1 -1
- package/dist/portable/context-layers.js +1 -1
- package/dist/runtime/acp-session-store.d.ts +75 -0
- package/dist/runtime/acp-session-store.js +0 -0
- package/dist/runtime/broadcaster-utils.d.ts +1 -1
- package/dist/runtime/broadcaster-utils.js +1 -1
- package/dist/runtime/context-impl.d.ts +8 -10
- package/dist/runtime/context-impl.js +8 -12
- package/dist/runtime/durable/step-ledger.d.ts +2 -2
- package/dist/runtime/durable/step-ledger.js +3 -3
- package/dist/runtime/session-runner.d.ts +12 -1
- package/dist/runtime/session-runner.js +33 -1
- package/dist/runtime/tool-context.js +0 -2
- package/dist/schemas/workflow.d.ts +3 -3
- package/dist/schemas/workflow.js +2 -2
- package/dist/types/checkpoint.d.ts +11 -2
- package/dist/types/checkpoint.js +10 -1
- package/dist/{runtime → util}/detached-handle.js +1 -1
- package/package.json +6 -4
- package/schema/noetic-workflow.schema.json +239 -260
- package/dist/builders/context-option.d.ts +0 -23
- package/dist/builders/context-option.js +0 -22
- package/dist/deprecated.d.ts +0 -30
- package/dist/deprecated.js +0 -28
- package/dist/interpreter/execute-sub-harness.d.ts +0 -8
- package/dist/interpreter/execute-sub-harness.js +0 -241
- package/dist/interpreter/sub-harness-events.d.ts +0 -48
- package/dist/interpreter/sub-harness-events.js +0 -208
- package/dist/patterns/interview.d.ts +0 -53
- package/dist/patterns/interview.js +0 -113
- package/dist/patterns/plans.d.ts +0 -44
- package/dist/patterns/plans.js +0 -125
- package/dist/patterns/ralph-wiggum.d.ts +0 -19
- package/dist/patterns/ralph-wiggum.js +0 -37
- package/dist/patterns/react.d.ts +0 -19
- package/dist/patterns/react.js +0 -41
- /package/dist/{runtime → util}/detached-handle.d.ts +0 -0
|
@@ -27,7 +27,7 @@ export function deduplicateTools(tools) {
|
|
|
27
27
|
}
|
|
28
28
|
function walkStep(step, out) {
|
|
29
29
|
switch (step.kind) {
|
|
30
|
-
case '
|
|
30
|
+
case 'callModel':
|
|
31
31
|
// Skip function-form tools: they're resolved per execution from `ctx`
|
|
32
32
|
// and cannot contribute to the pre-computed unified set. Consumers that
|
|
33
33
|
// need those tools in the pool should register them via
|
|
@@ -41,30 +41,27 @@ function walkStep(step, out) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
return;
|
|
44
|
-
case '
|
|
45
|
-
case '
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
case '
|
|
49
|
-
case 'codex':
|
|
50
|
-
case 'opencode':
|
|
51
|
-
case 'pi':
|
|
44
|
+
case 'runCode':
|
|
45
|
+
case 'invokeTool':
|
|
46
|
+
// An ACP agent runs its own tools inside the external agent process; it
|
|
47
|
+
// contributes nothing to the host unified tool set.
|
|
48
|
+
case 'acp-agent':
|
|
52
49
|
return;
|
|
53
|
-
case '
|
|
50
|
+
case 'conditional':
|
|
54
51
|
if (step._optimizable) {
|
|
55
52
|
for (const child of step._optimizable) {
|
|
56
53
|
walkStep(child, out);
|
|
57
54
|
}
|
|
58
55
|
}
|
|
59
56
|
return;
|
|
60
|
-
case '
|
|
57
|
+
case 'inParallel':
|
|
61
58
|
if (step._optimizable) {
|
|
62
59
|
for (const child of step._optimizable) {
|
|
63
60
|
walkStep(child, out);
|
|
64
61
|
}
|
|
65
62
|
}
|
|
66
63
|
return;
|
|
67
|
-
case '
|
|
64
|
+
case 'withContext':
|
|
68
65
|
walkStep(step.child, out);
|
|
69
66
|
return;
|
|
70
67
|
case 'spawn':
|
|
@@ -75,7 +72,7 @@ function walkStep(step, out) {
|
|
|
75
72
|
walkStep(child, out);
|
|
76
73
|
}
|
|
77
74
|
return;
|
|
78
|
-
case '
|
|
75
|
+
case 'schedule':
|
|
79
76
|
walkStep(step.step, out);
|
|
80
77
|
return;
|
|
81
78
|
default:
|
|
@@ -0,0 +1,10 @@
|
|
|
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 type { Context, ContextLayer, StepAcpAgent } from '@noetic-tools/types';
|
|
10
|
+
export declare function executeAcpAgent<TContext, I, O>(step: StepAcpAgent<TContext, I, O>, input: I, ctx: Context<TContext>, layers?: ContextLayer[]): Promise<O>;
|
|
@@ -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,8 +2,8 @@
|
|
|
2
2
|
* Action step handlers: run, llm, spawn, provide, tool.
|
|
3
3
|
*/
|
|
4
4
|
import type { ItemSchemaRegistry, LayerStateStore } from './action-deps.js';
|
|
5
|
-
import type { AgentHarnessContract, Context, ContextLayer, ExecuteStepFn,
|
|
6
|
-
export declare function
|
|
5
|
+
import type { AgentHarnessContract, Context, ContextLayer, ExecuteStepFn, StepCallModel, StepInvokeTool, StepRunCode, StepSpawn, StepWithContext } from './action-types.js';
|
|
6
|
+
export declare function executeRunCode<TContext, I, O>(step: StepRunCode<TContext, I, O>, input: I, ctx: Context<TContext>): Promise<O>;
|
|
7
7
|
/**
|
|
8
8
|
* Resolves a `Lazy<T>` step field. If the value is a function, it is invoked
|
|
9
9
|
* with the current context; otherwise the value is returned as-is. Supports
|
|
@@ -14,7 +14,7 @@ export declare function executeRun<TContext, I, O>(step: StepRun<TContext, I, O>
|
|
|
14
14
|
* tools) without baking them in at build time.
|
|
15
15
|
*/
|
|
16
16
|
export declare function resolveLazy<T, TContext>(value: T | ((ctx: Context<TContext>) => T | Promise<T>), ctx: Context<TContext>): Promise<T>;
|
|
17
|
-
export declare function
|
|
17
|
+
export declare function executeCallModel<TContext, I, O>(step: StepCallModel<TContext, I, O>, input: I, ctx: Context<TContext>, layers?: ContextLayer[]): Promise<O>;
|
|
18
18
|
export interface ExecuteSpawnOpts {
|
|
19
19
|
layerStore?: LayerStateStore;
|
|
20
20
|
parentLayers?: ContextLayer[];
|
|
@@ -28,5 +28,5 @@ export declare function executeSpawn<TContext, I, O>(step: StepSpawn<TContext, I
|
|
|
28
28
|
* Unlike spawn, provide does not create a new itemLog or clone state.
|
|
29
29
|
* Events flow through to the parent in real-time.
|
|
30
30
|
*/
|
|
31
|
-
export declare function
|
|
32
|
-
export declare function
|
|
31
|
+
export declare function executeWithContext<TContext, I, O>(step: StepWithContext<TContext, I, O>, input: I, ctx: Context<TContext>, executeStep: ExecuteStepFn): Promise<O>;
|
|
32
|
+
export declare function executeInvokeTool<TContext, I, O>(step: StepInvokeTool<TContext, I, O>, input: I, ctx: Context<TContext>, harness: AgentHarnessContract, layers?: ContextLayer[]): Promise<O>;
|