@agent-compose/sdk 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-context.d.ts +1 -1
- package/dist/agent/agent-loop.d.ts +14 -12
- package/dist/agent/pause-client.d.ts +50 -0
- package/dist/agent/pause-client.test.d.ts +1 -0
- package/dist/agent/steer-control.d.ts +22 -6
- package/dist/client.d.ts +12 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +2379 -1463
- package/dist/pause/checkpoint.d.ts +27 -10
- package/dist/pause/manager.d.ts +1 -0
- package/dist/pause/pause-core.d.ts +23 -0
- package/dist/pause/state-dir.d.ts +1 -1
- package/dist/processors/builtins.d.ts +20 -1
- package/dist/processors/gate-pause.d.ts +46 -0
- package/dist/processors/gate-pause.test.d.ts +1 -0
- package/dist/processors/index.d.ts +3 -1
- package/dist/processors/processor.d.ts +13 -0
- package/dist/runtimes/_acp-client.d.ts +140 -0
- package/dist/runtimes/_cli-agent.d.ts +155 -3
- package/dist/runtimes/amp.d.ts +2 -2
- package/dist/runtimes/cli-agent-acp-live.test.d.ts +30 -0
- package/dist/runtimes/cli-agent.test.d.ts +22 -6
- package/dist/runtimes/codex.d.ts +7 -2
- package/dist/runtimes/openai-desktop.js +2365 -1463
- package/dist/runtimes/vercel.js +389 -2
- package/dist/sandbox.d.ts +113 -19
- package/dist/step-invocation/__tests__/background-invoker.test.d.ts +1 -0
- package/dist/step-invocation/index.d.ts +2 -1
- package/dist/step-invocation/invoker.d.ts +36 -0
- package/dist/types/__tests__/environment-build-flag.test.d.ts +1 -0
- package/dist/types/__tests__/workflow-metadata-provider.test.d.ts +1 -0
- package/dist/types/execution-context.d.ts +0 -8
- package/dist/types/protocol.d.ts +32 -1
- package/dist/types/runtime.d.ts +14 -0
- package/dist/types/sandbox-environment.d.ts +6 -1
- package/dist/types/sandbox.d.ts +86 -6
- package/dist/types/workflow-metadata.d.ts +40 -10
- package/dist/types/workflow.d.ts +22 -6
- package/dist/utils/bundler.d.ts +5 -1
- package/dist/workflow-steps/observability.d.ts +28 -2
- package/dist/workflow-steps/types.d.ts +11 -7
- package/dist/workflow-steps/workflow.d.ts +3 -2
- package/package.json +3 -2
- package/src/agent/agent-context.ts +14 -6
- package/src/agent/agent-loop.ts +32 -10
- package/src/agent/pause-client.ts +108 -0
- package/src/agent/run-agent.ts +9 -4
- package/src/agent/steer-control.ts +21 -7
- package/src/client.ts +35 -1
- package/src/index.ts +20 -2
- package/src/pause/checkpoint.ts +33 -14
- package/src/pause/manager.ts +2 -2
- package/src/pause/pause-core.ts +35 -0
- package/src/pause/state-dir.ts +2 -2
- package/src/processors/builtins.ts +44 -1
- package/src/processors/gate-pause.ts +94 -0
- package/src/processors/index.ts +7 -0
- package/src/processors/processor.ts +13 -0
- package/src/runtimes/_acp-client.ts +516 -0
- package/src/runtimes/_cli-agent.ts +416 -3
- package/src/runtimes/claude.ts +31 -3
- package/src/runtimes/codex.ts +21 -1
- package/src/runtimes/vercel.ts +4 -1
- package/src/sandbox.ts +426 -56
- package/src/step-invocation/index.ts +2 -1
- package/src/step-invocation/invoker.ts +195 -84
- package/src/types/execution-context.ts +0 -8
- package/src/types/protocol.ts +27 -1
- package/src/types/runtime.ts +14 -0
- package/src/types/sandbox-environment.ts +12 -1
- package/src/types/sandbox.ts +84 -6
- package/src/types/workflow-metadata.ts +42 -10
- package/src/types/workflow.ts +22 -7
- package/src/utils/bundler.ts +6 -1
- package/src/workflow-steps/observability.ts +51 -5
- package/src/workflow-steps/runner.ts +9 -5
- package/src/workflow-steps/types.ts +11 -7
- package/src/workflow-steps/workflow.ts +3 -2
|
@@ -24,10 +24,16 @@
|
|
|
24
24
|
* values set via `agentc secrets set` are visible to the CLI.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider } from "../index.js";
|
|
27
|
+
import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider, ToolCallGateResult } from "../index.js";
|
|
28
28
|
import { defineRuntime } from "../types/runtime.js";
|
|
29
29
|
import { AsyncQueue } from "../agent/async-queue.js";
|
|
30
30
|
import { formatError } from "../utils/errors.js";
|
|
31
|
+
import { ndJsonStream, type Stream } from "@agentclientprotocol/sdk";
|
|
32
|
+
import { runProcessorChain } from "../processors/runner.js";
|
|
33
|
+
import type { ProcessorContext, ToolCall } from "../processors/processor.js";
|
|
34
|
+
import { RequestContext } from "../request-context/request-context.js";
|
|
35
|
+
import { AcpClientPeer, ACP_PROTOCOL_VERSION } from "./_acp-client.js";
|
|
36
|
+
import { isPauseSignal, boundProcessorPause } from "../pause/pause-core.js";
|
|
31
37
|
|
|
32
38
|
function now(): string { return new Date().toISOString(); }
|
|
33
39
|
|
|
@@ -36,6 +42,47 @@ export function shellQuote(value: string): string {
|
|
|
36
42
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/** Internal sentinel yielded by `sendMessageAcp` to signal the version-check
|
|
46
|
+
* fallback: the ACP handshake didn't negotiate version 1, so `sendMessage`
|
|
47
|
+
* must drive the legacy JSONL path for this (and every later) turn. Not an
|
|
48
|
+
* `AgentMessage` — filtered out before anything reaches the loop. */
|
|
49
|
+
const ACP_FALLBACK = Symbol("acp-fallback");
|
|
50
|
+
|
|
51
|
+
/** Hard deadline (ms) for the ACP `initialize` handshake AND the prompt turn.
|
|
52
|
+
* `peer.initialize()` resolves only when the agent answers; a real CLI that
|
|
53
|
+
* never speaks ACP (or blocks on stdin) would otherwise hang forever and the
|
|
54
|
+
* JSONL fallback would never be reached. On timeout we tear the peer down and
|
|
55
|
+
* route to JSONL exactly like a handshake error. Overridable via the env for
|
|
56
|
+
* ops tuning; defaults sane. */
|
|
57
|
+
export const ACP_HANDSHAKE_TIMEOUT_MS = Number(process.env.AC_ACP_HANDSHAKE_TIMEOUT_MS) || 10_000;
|
|
58
|
+
|
|
59
|
+
/** Readiness gate for the LIVE ACP attempt. The duplex-stdin transport in
|
|
60
|
+
* `spawnAcpProcess` is now real (`commands.spawnDuplex` on the local provider),
|
|
61
|
+
* so the agent's `initialize` request bytes are delivered and the handshake can
|
|
62
|
+
* actually negotiate. The transport is live; this is `true`.
|
|
63
|
+
*
|
|
64
|
+
* Readiness alone is not sufficient — the runner still requires the live
|
|
65
|
+
* provider to expose `commands.spawnDuplex`. A provider without it (the
|
|
66
|
+
* server→sandbox vercel/e2b view never spawns the in-VM CLI, so neither
|
|
67
|
+
* implements duplex) falls back to JSONL cleanly via the capability check in
|
|
68
|
+
* `sendMessage`. */
|
|
69
|
+
export const ACP_TRANSPORT_READY = true;
|
|
70
|
+
|
|
71
|
+
/** Race a promise against the handshake deadline. On timeout, rejects with a
|
|
72
|
+
* marker error so `sendMessageAcp` falls back to JSONL (same as a handshake
|
|
73
|
+
* error). The timer is cleared on settle so it never keeps the process alive. */
|
|
74
|
+
async function withHandshakeTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
75
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
76
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
77
|
+
timer = setTimeout(() => reject(new Error(`ACP handshake timed out after ${ms}ms`)), ms);
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
return await Promise.race([p, timeout]);
|
|
81
|
+
} finally {
|
|
82
|
+
if (timer) clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
39
86
|
/** Per-CLI behaviour. The base owns the lifecycle (init/done/error) and the
|
|
40
87
|
* transport (spawn + JSONL parse); a spec owns the CLI-specific bits. */
|
|
41
88
|
export interface CliAgentSpec {
|
|
@@ -64,15 +111,87 @@ export interface CliAgentSpec {
|
|
|
64
111
|
buildCommand(args: { promptPath: string; sessionId?: string; model?: string; cwd?: string }): string;
|
|
65
112
|
/** Map one parsed JSONL stdout event to `AgentMessage`s. The base emits
|
|
66
113
|
* `init`/`done`/`error` lifecycle itself, so a spec maps only content +
|
|
67
|
-
* usage (text / thinking / tool_use / tool_result / usage).
|
|
114
|
+
* usage (text / thinking / tool_use / tool_result / usage).
|
|
115
|
+
*
|
|
116
|
+
* LEGACY FALLBACK PATH. Retained for a pinned-old CLI that predates ACP
|
|
117
|
+
* support (the `initialize` handshake negotiates a non-1 version → the
|
|
118
|
+
* runner closes the ACP peer and drives this JSONL path instead). Removed
|
|
119
|
+
* only after the last subprocess CLI is on ACP (ADR-0020 increment 5). */
|
|
68
120
|
mapEvent(parsed: Record<string, unknown>): AgentMessage[];
|
|
69
121
|
/** Pull a session/thread id out of a parsed event so the next turn can
|
|
70
|
-
* resume it (codex `thread.started.thread_id`, amp `session_id`).
|
|
122
|
+
* resume it (codex `thread.started.thread_id`, amp `session_id`).
|
|
123
|
+
* LEGACY FALLBACK PATH (see `mapEvent`). */
|
|
71
124
|
extractSessionId(parsed: Record<string, unknown>): string | undefined;
|
|
125
|
+
/** ACP-mode invocation (the Zed `agent_servers` shape). When present, the
|
|
126
|
+
* runner spawns the CLI in ACP agent mode and delegates the whole wire
|
|
127
|
+
* protocol to `AcpClientPeer`; the legacy `promptPayload`/`buildCommand`/
|
|
128
|
+
* `extractSessionId`/`mapEvent` members are used only as the version-mismatch
|
|
129
|
+
* fallback. Absent → the spec is JSONL-only (legacy path always). */
|
|
130
|
+
acp?: { command: string; args: string[]; env?: Record<string, string> };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A spawned ACP-mode CLI: the read side of its stdout and a delivery for its
|
|
134
|
+
* stdin, wrapped as byte web-streams for `ndJsonStream`. Producing this is a
|
|
135
|
+
* below-ACP transport concern (the duplex command primitive) — see
|
|
136
|
+
* `spawnAcpProcess`. */
|
|
137
|
+
interface AcpProcess {
|
|
138
|
+
/** Duplex byte stream: `readable` = subprocess stdout, `writable` = stdin. */
|
|
139
|
+
stream: Stream;
|
|
140
|
+
/** Resolves when the subprocess exits (with its exit code). */
|
|
141
|
+
exited: Promise<{ exitCode: number; stderr: string }>;
|
|
142
|
+
/** Force-terminate the subprocess and release the stream. */
|
|
143
|
+
kill(): void;
|
|
72
144
|
}
|
|
73
145
|
|
|
74
146
|
export class CliAgentRunner implements ModelExecutionContract {
|
|
75
147
|
readonly kind: string;
|
|
148
|
+
/** Whether the live ACP path will actually run for this runner: the spec
|
|
149
|
+
* declares an `acp` invocation, the transport is ready, AND the live provider
|
|
150
|
+
* exposes the duplex-stdin primitive ACP needs. A provider without
|
|
151
|
+
* `commands.spawnDuplex` (the server→sandbox vercel/e2b view) can't carry the
|
|
152
|
+
* JSON-RPC duplex, so the runner falls back to JSONL — and in that state the
|
|
153
|
+
* CLI owns its own tool loop with no pre-tool seam. */
|
|
154
|
+
private get acpCapable(): boolean {
|
|
155
|
+
return this.spec.acp !== undefined
|
|
156
|
+
&& this.acpTransportReady
|
|
157
|
+
&& typeof this.sandbox.commands.spawnDuplex === "function";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** ACP-capable specs gate tools through the shared processor chain (the CLI
|
|
161
|
+
* no longer owns its own unsupervised tool loop). True ONLY when the ACP path
|
|
162
|
+
* will actually run — a JSONL-only spec, an ACP spec while the transport is
|
|
163
|
+
* dormant (`acpTransportReady` false), OR a provider that can't spawn a duplex
|
|
164
|
+
* leaves the CLI in charge with NO pre-tool seam, so this must report false in
|
|
165
|
+
* those states. Otherwise the agent loop would suppress its "processToolCall
|
|
166
|
+
* gating is dormant" warning and a registered deny/gate processor would be
|
|
167
|
+
* silently un-enforced for the agent. */
|
|
168
|
+
get supportsToolCallProcessor(): boolean {
|
|
169
|
+
return this.acpCapable;
|
|
170
|
+
}
|
|
171
|
+
/** Once the handshake has decided ACP vs. legacy for this runner instance,
|
|
172
|
+
* cache it so every later turn skips re-probing. `undefined` = not yet
|
|
173
|
+
* decided. */
|
|
174
|
+
private acpDecision: "acp" | "jsonl" | undefined;
|
|
175
|
+
/** The persistent ACP session, established lazily on the first ACP turn and
|
|
176
|
+
* reused across every later turn (a real ACP agent is a LONG-LIVED JSON-RPC
|
|
177
|
+
* server — one process, one initialize, one session — driven turn-by-turn via
|
|
178
|
+
* `session/prompt`, NOT respawned per turn). `undefined` until the first
|
|
179
|
+
* successful handshake; never re-set after a fallback (we switch to JSONL
|
|
180
|
+
* for the whole runner instead). */
|
|
181
|
+
private acpSession: { proc: AcpProcess; peer: AcpClientPeer; sessionId: string } | undefined;
|
|
182
|
+
/** The CURRENT turn's processor context. The long-lived ACP peer is built
|
|
183
|
+
* once but reused across turns, so it reads the gate context through a
|
|
184
|
+
* provider (`procCtx: () => this.currentAcpProcCtx`) rather than a frozen
|
|
185
|
+
* value — `sendMessageAcp` refreshes this before each prompt so a
|
|
186
|
+
* permission->pause binds to THIS turn's iteration (stable pauseId on
|
|
187
|
+
* resume), not the first turn's. */
|
|
188
|
+
private currentAcpProcCtx: ProcessorContext | undefined;
|
|
189
|
+
/** Per-instance copy of the live-ACP readiness gate (defaults to the module
|
|
190
|
+
* const `ACP_TRANSPORT_READY`). Tests that drive the ACP lifecycle/fallback/
|
|
191
|
+
* pause/parity paths flip it on via `enableAcpForTesting()` so they keep
|
|
192
|
+
* exercising the real `sendMessageAcp` even while the production default is
|
|
193
|
+
* dormant. Not a public API. */
|
|
194
|
+
private acpTransportReady = ACP_TRANSPORT_READY;
|
|
76
195
|
|
|
77
196
|
constructor(
|
|
78
197
|
private readonly sandbox: SandboxProvider,
|
|
@@ -83,10 +202,49 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
83
202
|
this.kind = spec.kind;
|
|
84
203
|
}
|
|
85
204
|
|
|
205
|
+
/** TEST-ONLY: flip the live-ACP readiness gate on for this runner instance so
|
|
206
|
+
* the ACP lifecycle / fallback tests can exercise `sendMessageAcp` while the
|
|
207
|
+
* production default (`ACP_TRANSPORT_READY`) stays dormant. Not part of the
|
|
208
|
+
* public runtime surface. */
|
|
209
|
+
enableAcpForTesting(): void {
|
|
210
|
+
this.acpTransportReady = true;
|
|
211
|
+
}
|
|
212
|
+
|
|
86
213
|
get model(): string | undefined {
|
|
87
214
|
return this.configModel ?? this.options.model ?? this.spec.defaultModel;
|
|
88
215
|
}
|
|
89
216
|
|
|
217
|
+
/** Runtime-owned pre-tool gate. Runs the shared processor chain and maps its
|
|
218
|
+
* verdict to a `ToolCallGateResult` — identical to `ClaudeRunner.gateToolCall`.
|
|
219
|
+
* Invoked by `AcpClientPeer`'s `session/request_permission` handler. A
|
|
220
|
+
* processor that throws `PauseSignal` propagates through unchanged (the
|
|
221
|
+
* handler re-raises it; never swallowed). */
|
|
222
|
+
async gateToolCall(call: ToolCall, ctx: ProcessorContext): Promise<ToolCallGateResult> {
|
|
223
|
+
const verdict = await runProcessorChain(this.options.processors ?? [], (p) => p.processToolCall, call, ctx);
|
|
224
|
+
if (verdict.kind === "continue") return { kind: "allow", call: verdict.value };
|
|
225
|
+
return verdict;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private buildProcCtx(signal: AbortSignal | undefined, iteration: number | undefined): ProcessorContext {
|
|
229
|
+
const agentId = this.options.agentId ?? "agent";
|
|
230
|
+
const iter = iteration ?? 1;
|
|
231
|
+
return {
|
|
232
|
+
requestContext: this.options.requestContext ?? RequestContext.fromReserved({
|
|
233
|
+
teamId: "", runId: "", workflowId: "",
|
|
234
|
+
factoryId: null, apiKeyScopes: [], parentRunId: null,
|
|
235
|
+
}),
|
|
236
|
+
abortSignal: signal ?? new AbortController().signal,
|
|
237
|
+
retryCount: 0,
|
|
238
|
+
agentId,
|
|
239
|
+
iteration: iter,
|
|
240
|
+
// Wire the pause boundary so a permission-gate processor can raise a
|
|
241
|
+
// human-approval `ctx.pause` from inside `gateToolCall` (the ACP
|
|
242
|
+
// `session/request_permission` path). pauseId is keyed on agentId+iteration
|
|
243
|
+
// so it stays stable across the mid-turn pause/resume.
|
|
244
|
+
pause: boundProcessorPause(this.options.pause, { agentId, iteration: iter }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
90
248
|
private installed = false;
|
|
91
249
|
|
|
92
250
|
/** Provision the CLI on demand. A no-op once `bin` is on PATH — the steady
|
|
@@ -115,8 +273,263 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
115
273
|
iteration?: number;
|
|
116
274
|
signal?: AbortSignal;
|
|
117
275
|
}): AsyncGenerator<AgentMessage> {
|
|
276
|
+
// Synthesised init stays runner-side (unchanged loop contract): yield before
|
|
277
|
+
// any spawn/handshake so `lastSessionId` is set even on an early failure.
|
|
118
278
|
yield { type: "init", sessionId: opts.sessionId ?? "", timestamp: now() };
|
|
119
279
|
|
|
280
|
+
// ACP path when the spec declares an `acp` invocation, the live transport is
|
|
281
|
+
// ready, the provider exposes the duplex-stdin primitive (`acpCapable`), AND
|
|
282
|
+
// we haven't already fallen back to JSONL for this runner. A provider without
|
|
283
|
+
// `commands.spawnDuplex` (the server→sandbox vercel/e2b view) goes straight
|
|
284
|
+
// to JSONL — no wasted handshake. The handshake inside `sendMessageAcp` then
|
|
285
|
+
// decides per-process: a non-1 negotiated version, a handshake error, OR the
|
|
286
|
+
// handshake watchdog firing closes the ACP peer and yields `ACP_FALLBACK`
|
|
287
|
+
// so we drive the legacy JSONL path instead — a pinned-old CLI is never
|
|
288
|
+
// stranded, and a non-responding CLI can't hang the run.
|
|
289
|
+
if (this.acpCapable && this.acpDecision !== "jsonl") {
|
|
290
|
+
let fellBack = false;
|
|
291
|
+
try {
|
|
292
|
+
for await (const ev of this.sendMessageAcp(opts)) {
|
|
293
|
+
if (ev === ACP_FALLBACK) { fellBack = true; break; }
|
|
294
|
+
yield ev;
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
// A PauseSignal re-raised from the permission handler is control flow,
|
|
298
|
+
// NOT an error — let it propagate past sendMessage so it unwinds to
|
|
299
|
+
// serveStep and the run_pauses row is written (ADR-0020 Q4 / ADR-0006).
|
|
300
|
+
// Swallowing it here as `{type:"error"}` is the exact bug the design
|
|
301
|
+
// warns against.
|
|
302
|
+
if (isPauseSignal(err)) throw err;
|
|
303
|
+
yield { type: "error", text: formatError(err), timestamp: now() };
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!fellBack) { this.acpDecision = "acp"; return; }
|
|
307
|
+
this.acpDecision = "jsonl";
|
|
308
|
+
// fall through to the legacy path below for THIS turn
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
yield* this.sendMessageJsonl(opts);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Tear down the failed/probed ACP process on a fallback path: group-kill the
|
|
316
|
+
* subprocess (so the npx-spawned grandchild dies too, not just `sh`) and
|
|
317
|
+
* `.catch()` its `exited` promise so the abandoned process can't surface an
|
|
318
|
+
* unhandled rejection once we've stopped awaiting it. A best-effort stdin
|
|
319
|
+
* close is attempted first — a conformant CLI exits cleanly on EOF — but we
|
|
320
|
+
* rely on the group-kill for force-termination of one that won't.
|
|
321
|
+
*/
|
|
322
|
+
private async teardownAcp(proc: AcpProcess, peer: AcpClientPeer): Promise<void> {
|
|
323
|
+
peer.close();
|
|
324
|
+
// EOF on stdin lets a conformant CLI exit on its own; group-kill is the
|
|
325
|
+
// backstop for one that ignores it. Both are best-effort.
|
|
326
|
+
try { await proc.stream.writable.close(); } catch { /* already closing/closed */ }
|
|
327
|
+
proc.kill();
|
|
328
|
+
proc.exited.catch(() => { /* abandoned process — don't leak an unhandled rejection */ });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Establish the persistent ACP session for this runner instance, ONCE,
|
|
333
|
+
* lazily on the first ACP turn: spawn the long-lived ACP agent process, run
|
|
334
|
+
* `initialize`, and open the single `session/new`. The result is cached on the
|
|
335
|
+
* instance and reused by every later turn — a real ACP agent
|
|
336
|
+
* (`npx @agentclientprotocol/codex-acp`) is a long-lived JSON-RPC server that
|
|
337
|
+
* does NOT exit after one turn, so respawning per turn would both hang on
|
|
338
|
+
* `proc.exited` and break multi-turn continuity (each turn would open a fresh
|
|
339
|
+
* session and lose the thread).
|
|
340
|
+
*
|
|
341
|
+
* Returns the cached session, or `null` to signal the version-mismatch /
|
|
342
|
+
* handshake-error / stall fallback — in which case the failed process has
|
|
343
|
+
* already been torn down (group-killed + exited-catch) by `teardownAcp`, and
|
|
344
|
+
* the caller yields `ACP_FALLBACK`. The persistent process is otherwise killed
|
|
345
|
+
* only on the abort signal (wired in `spawnAcpProcess`) and is reclaimed when
|
|
346
|
+
* the sandbox VM is torn down at run-end — we never block on its exit.
|
|
347
|
+
*/
|
|
348
|
+
private async ensureAcpSession(opts: {
|
|
349
|
+
iteration?: number;
|
|
350
|
+
signal?: AbortSignal;
|
|
351
|
+
}): Promise<{ proc: AcpProcess; peer: AcpClientPeer; sessionId: string } | null> {
|
|
352
|
+
if (this.acpSession) return this.acpSession;
|
|
353
|
+
|
|
354
|
+
await this.ensureInstalled();
|
|
355
|
+
|
|
356
|
+
const acp = this.spec.acp!;
|
|
357
|
+
const proc = this.spawnAcpProcess(acp, opts.signal);
|
|
358
|
+
const peer = new AcpClientPeer({
|
|
359
|
+
stream: proc.stream,
|
|
360
|
+
gateToolCall: (call, ctx) => this.gateToolCall(call, ctx),
|
|
361
|
+
// Provider, NOT a frozen value: the peer outlives a single turn, so it
|
|
362
|
+
// must read the CURRENT turn's gate context (refreshed by sendMessageAcp)
|
|
363
|
+
// — otherwise a turn>=2 permission->pause computes its pauseId with the
|
|
364
|
+
// first turn's iteration and the resume can't recompute the same id.
|
|
365
|
+
procCtx: () => this.currentAcpProcCtx ?? this.buildProcCtx(opts.signal, opts.iteration),
|
|
366
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
let negotiated: number;
|
|
370
|
+
try {
|
|
371
|
+
// Watchdog the handshake: `peer.initialize()` resolves ONLY when the agent
|
|
372
|
+
// answers. A CLI that never speaks ACP (or blocks on stdin) would hang
|
|
373
|
+
// here forever and the JSONL fallback would never be reached, so race a
|
|
374
|
+
// hard deadline. A timeout is treated identically to a handshake error.
|
|
375
|
+
({ protocolVersion: negotiated } = await withHandshakeTimeout(peer.initialize(), ACP_HANDSHAKE_TIMEOUT_MS));
|
|
376
|
+
} catch {
|
|
377
|
+
// The process doesn't speak ACP (no valid JSON-RPC response, or it never
|
|
378
|
+
// answered within the deadline) — tear down and fall back.
|
|
379
|
+
await this.teardownAcp(proc, peer);
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
if (negotiated !== ACP_PROTOCOL_VERSION) {
|
|
383
|
+
// A pinned-old CLI negotiating a different wire version: fall back rather
|
|
384
|
+
// than strand it.
|
|
385
|
+
await this.teardownAcp(proc, peer);
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// `session/new` is the second handshake round-trip — watchdog it too, so a
|
|
390
|
+
// CLI that negotiates a version then stalls before opening a session still
|
|
391
|
+
// falls back rather than hanging. This is the ONLY `session/new` for the
|
|
392
|
+
// runner's lifetime; every later turn reuses this session id.
|
|
393
|
+
let sessionId: string;
|
|
394
|
+
try {
|
|
395
|
+
sessionId = await withHandshakeTimeout(peer.startSession(this.options.cwd ?? "/"), ACP_HANDSHAKE_TIMEOUT_MS);
|
|
396
|
+
} catch {
|
|
397
|
+
await this.teardownAcp(proc, peer);
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
this.acpSession = { proc, peer, sessionId };
|
|
402
|
+
return this.acpSession;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* ACP transport, ONE turn. On the FIRST turn it lazily establishes the
|
|
407
|
+
* persistent session (`ensureAcpSession`); every later turn REUSES that same
|
|
408
|
+
* process + peer + session id — `session/prompt` against the live session, no
|
|
409
|
+
* respawn, no new session. On a handshake error / version mismatch / session
|
|
410
|
+
* open failure (first turn only) it yields `ACP_FALLBACK` so `sendMessage`
|
|
411
|
+
* switches to the JSONL path. Never yields a duplicate `init` (the caller
|
|
412
|
+
* already did). CRITICALLY: never `await proc.exited` — the ACP agent is a
|
|
413
|
+
* long-lived server that does not exit after a turn; blocking on its exit
|
|
414
|
+
* would hang the loop forever.
|
|
415
|
+
*/
|
|
416
|
+
private async *sendMessageAcp(opts: {
|
|
417
|
+
prompt: string;
|
|
418
|
+
sessionId?: string;
|
|
419
|
+
iteration?: number;
|
|
420
|
+
signal?: AbortSignal;
|
|
421
|
+
}): AsyncGenerator<AgentMessage | typeof ACP_FALLBACK> {
|
|
422
|
+
// Refresh the per-turn gate context BEFORE prompting. The persistent peer
|
|
423
|
+
// reads it via its `procCtx` provider, so a permission->pause on THIS turn
|
|
424
|
+
// binds to THIS turn's iteration → the pauseId recomputes identically on
|
|
425
|
+
// resume. (Freezing it at peer construction mis-keyed every turn>=2 pause.)
|
|
426
|
+
this.currentAcpProcCtx = this.buildProcCtx(opts.signal, opts.iteration);
|
|
427
|
+
const session = await this.ensureAcpSession(opts);
|
|
428
|
+
if (!session) { yield ACP_FALLBACK; return; }
|
|
429
|
+
const { proc, peer, sessionId } = session;
|
|
430
|
+
|
|
431
|
+
// Prompt-turn watchdog: a turn must make progress within the deadline or we
|
|
432
|
+
// tear down. Unlike the handshake watchdog this does NOT fall back to JSONL
|
|
433
|
+
// (we've already committed to ACP for this runner) — it cancels the stalled
|
|
434
|
+
// turn and surfaces an error. The deadline is reset on every yielded message
|
|
435
|
+
// so a legitimately long, *streaming* turn is never killed; only a turn that
|
|
436
|
+
// goes fully silent (the documented Gemini-style hang, ADR-0020 risks) trips
|
|
437
|
+
// it. `peer.cancel()` sends `session/cancel` AND trips the peer's abort latch
|
|
438
|
+
// so the in-flight `session/prompt` unblocks deterministically rather than
|
|
439
|
+
// hanging on a reply a stalled CLI never sends.
|
|
440
|
+
//
|
|
441
|
+
// We do NOT close the peer here on a clean turn end: the peer wraps the
|
|
442
|
+
// persistent connection, reused by the next turn's `session/prompt`. The
|
|
443
|
+
// persistent process is killed only on the abort signal (wired in
|
|
444
|
+
// `spawnAcpProcess`) or group-killed below on a stall, and otherwise
|
|
445
|
+
// reclaimed when the sandbox VM is torn down at run-end.
|
|
446
|
+
let sawError = false;
|
|
447
|
+
let stalled = false;
|
|
448
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
449
|
+
const armWatchdog = () => {
|
|
450
|
+
if (watchdog) clearTimeout(watchdog);
|
|
451
|
+
watchdog = setTimeout(() => { stalled = true; peer.cancel(); }, ACP_HANDSHAKE_TIMEOUT_MS);
|
|
452
|
+
};
|
|
453
|
+
try {
|
|
454
|
+
armWatchdog();
|
|
455
|
+
for await (const msg of peer.prompt(opts.prompt)) {
|
|
456
|
+
armWatchdog();
|
|
457
|
+
if (msg.type === "error") sawError = true;
|
|
458
|
+
yield msg;
|
|
459
|
+
}
|
|
460
|
+
} finally {
|
|
461
|
+
if (watchdog) clearTimeout(watchdog);
|
|
462
|
+
}
|
|
463
|
+
if (stalled && !sawError) {
|
|
464
|
+
// A wedged CLI can't continue — group-kill the persistent process, drop
|
|
465
|
+
// the cached session (a later turn would only re-stall on the dead peer),
|
|
466
|
+
// and surface the stall as an error.
|
|
467
|
+
await this.teardownAcp(proc, peer);
|
|
468
|
+
this.acpSession = undefined;
|
|
469
|
+
yield { type: "error", text: `${this.spec.kind} prompt turn stalled (no activity for ${ACP_HANDSHAKE_TIMEOUT_MS}ms)`, timestamp: now() };
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!sawError) yield { type: "done", sessionId: peer.currentSessionId ?? sessionId, timestamp: now() };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Spawn the CLI in ACP agent mode and wrap its stdio as an `ndJsonStream`
|
|
478
|
+
* duplex.
|
|
479
|
+
*
|
|
480
|
+
* The real stdin/stdout duplex comes from `commands.spawnDuplex` — the in-VM
|
|
481
|
+
* `child_process` pipe (`makeLocalSandboxProvider`). The runner runs INSIDE
|
|
482
|
+
* the sandbox VM and spawns the CLI via the local provider, so a duplex stdin
|
|
483
|
+
* is a normal pipe and works identically on Vercel/E2B/local (the
|
|
484
|
+
* @vercel/sandbox no-stdin limit is the server→sandbox boundary, not this
|
|
485
|
+
* one). `ndJsonStream(stdin, stdout)` then carries JSON-RPC both ways:
|
|
486
|
+
* outbound `initialize`/`session/*` request frames down stdin, inbound
|
|
487
|
+
* `session/update` notifications + results up stdout.
|
|
488
|
+
*
|
|
489
|
+
* Caller contract: only invoked when `commands.spawnDuplex` is present (the
|
|
490
|
+
* capability check in `sendMessage` gates the whole ACP attempt on it), so a
|
|
491
|
+
* provider without duplex never reaches here — it falls back to JSONL.
|
|
492
|
+
*/
|
|
493
|
+
private spawnAcpProcess(
|
|
494
|
+
acp: { command: string; args: string[]; env?: Record<string, string> },
|
|
495
|
+
signal: AbortSignal | undefined,
|
|
496
|
+
): AcpProcess {
|
|
497
|
+
const spawnDuplex = this.sandbox.commands.spawnDuplex;
|
|
498
|
+
if (!spawnDuplex) {
|
|
499
|
+
// Defensive: the capability check in sendMessage must keep this path off a
|
|
500
|
+
// provider without duplex. Reaching here is a programming error.
|
|
501
|
+
throw new Error("spawnAcpProcess called on a provider without commands.spawnDuplex");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const cmd = `${acp.command} ${acp.args.map(shellQuote).join(" ")}`.trim();
|
|
505
|
+
const agentEnv = { ...acp.env };
|
|
506
|
+
const proc = spawnDuplex.call(this.sandbox.commands, cmd, {
|
|
507
|
+
...(this.options.cwd ? { cwd: this.options.cwd } : {}),
|
|
508
|
+
...(Object.keys(agentEnv).length > 0 ? { envs: agentEnv } : {}),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const kill = () => proc.kill();
|
|
512
|
+
if (signal) {
|
|
513
|
+
if (signal.aborted) kill();
|
|
514
|
+
else signal.addEventListener("abort", kill, { once: true });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return { stream: ndJsonStream(proc.stdin, proc.stdout), exited: proc.exited, kill };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Legacy JSONL transport (the version-mismatch fallback, ADR-0020 increment
|
|
522
|
+
* 5 retires it). Unchanged behaviour: write the prompt to a file, run the
|
|
523
|
+
* one-shot command, line-buffer stdout, `mapEvent` each parsed line, and cap
|
|
524
|
+
* with the runner-synthesised `done`. Does NOT re-yield `init` (the caller
|
|
525
|
+
* already did).
|
|
526
|
+
*/
|
|
527
|
+
private async *sendMessageJsonl(opts: {
|
|
528
|
+
prompt: string;
|
|
529
|
+
sessionId?: string;
|
|
530
|
+
iteration?: number;
|
|
531
|
+
signal?: AbortSignal;
|
|
532
|
+
}): AsyncGenerator<AgentMessage> {
|
|
120
533
|
let sessionId = opts.sessionId;
|
|
121
534
|
let sawError = false;
|
|
122
535
|
try {
|
package/src/runtimes/claude.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { AsyncQueue } from "../agent/async-queue.js";
|
|
|
8
8
|
import { runProcessorChain } from "../processors/runner.js";
|
|
9
9
|
import type { ProcessorContext, ToolCall } from "../processors/processor.js";
|
|
10
10
|
import { RequestContext } from "../request-context/request-context.js";
|
|
11
|
+
import { boundProcessorPause } from "../pause/pause-core.js";
|
|
11
12
|
import { formatError } from "../utils/errors.js";
|
|
12
13
|
|
|
13
14
|
function now(): string { return new Date().toISOString(); }
|
|
@@ -199,12 +200,15 @@ export class ClaudeRunner implements ModelExecutionContract {
|
|
|
199
200
|
toolInput: (pre.tool_input ?? {}) as Record<string, unknown>,
|
|
200
201
|
toolUseId: toolUseId ?? `${pre.tool_name}-${Date.now()}`,
|
|
201
202
|
};
|
|
203
|
+
const gateAgentId = this.options.agentId ?? "agent";
|
|
204
|
+
const gateIteration = opts.iteration ?? 1;
|
|
202
205
|
const gate = await this.gateToolCall(call, {
|
|
203
206
|
requestContext,
|
|
204
207
|
abortSignal: signal ?? opts.signal ?? new AbortController().signal,
|
|
205
208
|
retryCount: 0,
|
|
206
|
-
agentId:
|
|
207
|
-
iteration:
|
|
209
|
+
agentId: gateAgentId,
|
|
210
|
+
iteration: gateIteration,
|
|
211
|
+
pause: boundProcessorPause(this.options.pause, { agentId: gateAgentId, iteration: gateIteration }),
|
|
208
212
|
});
|
|
209
213
|
if (gate.kind === "allow") return { hookSpecificOutput: { hookEventName: pre.hook_event_name, permissionDecision: "allow", updatedInput: gate.call.toolInput } };
|
|
210
214
|
if (gate.kind === "deny") return { hookSpecificOutput: { hookEventName: pre.hook_event_name, permissionDecision: "deny", permissionDecisionReason: gate.reason } };
|
|
@@ -249,6 +253,26 @@ export class ClaudeRunner implements ModelExecutionContract {
|
|
|
249
253
|
}
|
|
250
254
|
const promptForQuery: string | AsyncIterable<SDKUserMessage> = inboxQueue ?? opts.prompt;
|
|
251
255
|
|
|
256
|
+
// Bridge the loop's abort signal into the Agent SDK's cancel knob
|
|
257
|
+
// (`query({ options: { abortController } })`). Without this the `signal` was
|
|
258
|
+
// accepted but never wired into query(), so a loop abort (cancellation, or a
|
|
259
|
+
// sibling agent's pause tearing the loop down) let the in-flight query run to
|
|
260
|
+
// completion — exactly what the loop's abort is supposed to prevent.
|
|
261
|
+
const queryAbort = new AbortController();
|
|
262
|
+
const onLoopAbort = () => queryAbort.abort();
|
|
263
|
+
if (opts.signal) {
|
|
264
|
+
if (opts.signal.aborted) queryAbort.abort();
|
|
265
|
+
else opts.signal.addEventListener("abort", onLoopAbort, { once: true });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// System-prompt append: the platform manual (agentManual, threaded from
|
|
269
|
+
// agent()) + any caller-supplied claudeMdContent. The Agent SDK does NOT
|
|
270
|
+
// auto-load CLAUDE.md from cwd (no settingSources), so without this the
|
|
271
|
+
// agent never reliably sees the manual — including how to pause.
|
|
272
|
+
const systemPromptAppend = [this.config.claudeMdContent, this.options.agentManual]
|
|
273
|
+
.filter((s): s is string => Boolean(s && s.trim()))
|
|
274
|
+
.join("\n\n");
|
|
275
|
+
|
|
252
276
|
try {
|
|
253
277
|
let emittedAssistantText = false;
|
|
254
278
|
for await (const message of query({
|
|
@@ -262,11 +286,12 @@ export class ClaudeRunner implements ModelExecutionContract {
|
|
|
262
286
|
thinking: resolveThinking(this.model, this.config.thinking),
|
|
263
287
|
effort: this.config.effort,
|
|
264
288
|
cwd: this.options.cwd,
|
|
289
|
+
abortController: queryAbort,
|
|
265
290
|
env: { ...process.env, ...(this.config.env ?? {}) },
|
|
266
291
|
pathToClaudeCodeExecutable: this.config.pathToClaudeCodeExecutable
|
|
267
292
|
?? process.env.CLAUDE_CODE_EXECUTABLE
|
|
268
293
|
?? DEFAULT_CLAUDE_PATH,
|
|
269
|
-
...(
|
|
294
|
+
...(systemPromptAppend ? { systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: systemPromptAppend } } : {}),
|
|
270
295
|
// Turn skills ON (and auto-add the `Skill` tool). The agent-env
|
|
271
296
|
// bakes the `/ac:*` skills; without this the Agent SDK leaves
|
|
272
297
|
// them un-enabled and the agent can't invoke them.
|
|
@@ -314,6 +339,9 @@ export class ClaudeRunner implements ModelExecutionContract {
|
|
|
314
339
|
// Belt-and-braces — close on error/abort too so the dangling
|
|
315
340
|
// iterable doesn't leak the inboxStream consumer.
|
|
316
341
|
inboxQueue?.close();
|
|
342
|
+
// Detach the abort bridge (no-op if it already fired, {once:true}) so a
|
|
343
|
+
// completed turn doesn't leak a listener on the long-lived loop signal.
|
|
344
|
+
opts.signal?.removeEventListener("abort", onLoopAbort);
|
|
317
345
|
}
|
|
318
346
|
}
|
|
319
347
|
}
|
package/src/runtimes/codex.ts
CHANGED
|
@@ -19,10 +19,30 @@ import { formatError } from "../utils/errors.js";
|
|
|
19
19
|
|
|
20
20
|
function now(): string { return new Date().toISOString(); }
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/** Pinned codex-acp adapter version. The README warns "breaking changes
|
|
23
|
+
* likely", so we pin exact (not a range) and bump deliberately. `CODEX_PATH`
|
|
24
|
+
* overrides the bundled `@openai/codex` the adapter drives underneath. */
|
|
25
|
+
const CODEX_ACP_ADAPTER = "@agentclientprotocol/codex-acp@0.1.0";
|
|
26
|
+
|
|
27
|
+
/** Exported for the fixture-based parity tests (ADR-0020): the legacy JSONL
|
|
28
|
+
* `mapEvent` is the golden the ACP normaliser is asserted equal to. Not part
|
|
29
|
+
* of the public runtime surface — `createCodexRuntime` stays the entry point. */
|
|
30
|
+
export const codexSpec: CliAgentSpec = {
|
|
23
31
|
kind: "codex",
|
|
24
32
|
authEnv: "CODEX_API_KEY",
|
|
25
33
|
bin: "codex",
|
|
34
|
+
// ACP-mode invocation (ADR-0020 increment 1). `codex` has no native `--acp`
|
|
35
|
+
// flag; the adapter (a Rust binary shipped via npm) speaks ACP and drives a
|
|
36
|
+
// compatible bundled `@openai/codex`. The runner attempts this first and
|
|
37
|
+
// falls back to the legacy `codex exec --json` JSONL spec below if the
|
|
38
|
+
// version-1 handshake fails.
|
|
39
|
+
acp: {
|
|
40
|
+
command: "npx",
|
|
41
|
+
args: ["-y", CODEX_ACP_ADAPTER],
|
|
42
|
+
// CODEX_PATH lets an operator point the adapter at a specific codex binary
|
|
43
|
+
// (e.g. a sandbox-baked one) instead of the adapter's bundled copy.
|
|
44
|
+
...(process.env.CODEX_PATH ? { env: { CODEX_PATH: process.env.CODEX_PATH } } : {}),
|
|
45
|
+
},
|
|
26
46
|
// Global npm install; symlink onto PATH only if the global bin dir isn't
|
|
27
47
|
// already there (so a non-login `sh -c` can find it).
|
|
28
48
|
install: 'sudo npm install -g @openai/codex && (command -v codex >/dev/null 2>&1 || sudo ln -sf "$(npm prefix -g)/bin/codex" /usr/local/bin/codex)',
|
package/src/runtimes/vercel.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { codingTools, type CodingTool } from "../tools/index.js";
|
|
|
10
10
|
import { runProcessorChain } from "../processors/runner.js";
|
|
11
11
|
import type { ProcessorContext, ToolCall } from "../processors/processor.js";
|
|
12
12
|
import { RequestContext } from "../request-context/request-context.js";
|
|
13
|
+
import { boundProcessorPause } from "../pause/pause-core.js";
|
|
13
14
|
import { formatError } from "../utils/errors.js";
|
|
14
15
|
|
|
15
16
|
type AiToolSet = Record<string, ReturnType<typeof tool<Record<string, unknown>, string>>>;
|
|
@@ -153,12 +154,14 @@ export class VercelRunner implements ModelExecutionContract {
|
|
|
153
154
|
teamId: "", runId: "", workflowId: "",
|
|
154
155
|
factoryId: null, apiKeyScopes: [], parentRunId: null,
|
|
155
156
|
});
|
|
157
|
+
const gateAgentId = this.options.agentId ?? "agent";
|
|
156
158
|
const gate = await this.gateToolCall(call, {
|
|
157
159
|
requestContext,
|
|
158
160
|
abortSignal: execOpts.abortSignal ?? signal ?? new AbortController().signal,
|
|
159
161
|
retryCount: 0,
|
|
160
|
-
agentId:
|
|
162
|
+
agentId: gateAgentId,
|
|
161
163
|
iteration,
|
|
164
|
+
pause: boundProcessorPause(this.options.pause, { agentId: gateAgentId, iteration }),
|
|
162
165
|
});
|
|
163
166
|
if (gate.kind === "deny") return gate.reason;
|
|
164
167
|
if (gate.kind === "abort") throw new Error(gate.reason);
|