@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
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP client peer — the JSON-RPC half of a CLI-agent runtime (WS-C / ADR-0020).
|
|
3
|
+
*
|
|
4
|
+
* `CliAgentRunner` spawns an agentic CLI in **ACP agent** mode and delegates the
|
|
5
|
+
* whole wire protocol to this class. We are the ACP *Client*; the CLI is the
|
|
6
|
+
* *Agent*. `AcpClientPeer` owns:
|
|
7
|
+
*
|
|
8
|
+
* - the `ClientSideConnection` (from `@agentclientprotocol/sdk`) over an
|
|
9
|
+
* `ndJsonStream`-framed duplex wired to the subprocess stdio;
|
|
10
|
+
* - the SINGLE `session/update` → `AgentMessage` normaliser (replacing every
|
|
11
|
+
* per-CLI `mapEvent`) — one place, defensive, never throws on an unknown
|
|
12
|
+
* `sessionUpdate` variant (returns `[]`);
|
|
13
|
+
* - the `session/request_permission` handler — builds a `ToolCall`, runs it
|
|
14
|
+
* through the runner's `gateToolCall` (the shared processor chain), and maps
|
|
15
|
+
* the verdict back to a `PermissionOption.optionId`. A `PauseSignal` thrown
|
|
16
|
+
* by a processor MUST propagate (recognised via `isPauseSignal`) — never
|
|
17
|
+
* swallowed by a JSON-RPC try/catch, or the `run_pauses` row is silently
|
|
18
|
+
* lost (ADR-0020 Q4 / ADR-0006);
|
|
19
|
+
* - `session/cancel` driven by the `sendMessage` `AbortSignal`.
|
|
20
|
+
*
|
|
21
|
+
* We advertise NO `fs` / `terminal` client capabilities: the CLI runs in-sandbox
|
|
22
|
+
* with direct access to the factory drive, so we never serve file or terminal
|
|
23
|
+
* ops. We register no handlers for them — the `Client` interface methods are
|
|
24
|
+
* simply absent, so a (conformant) agent won't request them.
|
|
25
|
+
*
|
|
26
|
+
* The wire `protocolVersion` is pinned at integer 1 at `initialize`; the
|
|
27
|
+
* package version is pinned separately. Version negotiation drives the
|
|
28
|
+
* capability fallback that lives in `CliAgentRunner` (a non-1 response → the
|
|
29
|
+
* runner closes this peer and falls back to the legacy JSONL path).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
ClientSideConnection,
|
|
34
|
+
RequestError,
|
|
35
|
+
type Agent,
|
|
36
|
+
type Client,
|
|
37
|
+
type Stream,
|
|
38
|
+
} from "@agentclientprotocol/sdk";
|
|
39
|
+
import type * as acp from "@agentclientprotocol/sdk";
|
|
40
|
+
import type { AgentMessage } from "../types/protocol.js";
|
|
41
|
+
import type { ProcessorContext, ToolCall } from "../processors/processor.js";
|
|
42
|
+
import type { ToolCallGateResult } from "../types/runtime.js";
|
|
43
|
+
import { AsyncQueue } from "../agent/async-queue.js";
|
|
44
|
+
import { isPauseSignal } from "../pause/pause-core.js";
|
|
45
|
+
import { formatError } from "../utils/errors.js";
|
|
46
|
+
|
|
47
|
+
function now(): string { return new Date().toISOString(); }
|
|
48
|
+
|
|
49
|
+
/** The wire protocol version we pin at `initialize`. Bumped only on a breaking
|
|
50
|
+
* ACP wire change — never silently. The package version is pinned separately
|
|
51
|
+
* in package.json; this integer is the real compatibility gate (ADR-0020). */
|
|
52
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
53
|
+
|
|
54
|
+
/** Permission option ids we answer `session/request_permission` with. The agent
|
|
55
|
+
* presents its own `options`; we pick the one whose `kind` matches our verdict.
|
|
56
|
+
* When no matching option is offered we fall back to these (a conformant agent
|
|
57
|
+
* always offers an allow + a reject option). */
|
|
58
|
+
const OPTION_KIND_FOR_VERDICT = {
|
|
59
|
+
allow: ["allow_once", "allow_always"],
|
|
60
|
+
deny: ["reject_once", "reject_always"],
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
/** Collaborators `AcpClientPeer` needs from `CliAgentRunner`, kept as a small
|
|
64
|
+
* injected surface so the peer stays transport-agnostic and unit-testable. */
|
|
65
|
+
export interface AcpClientPeerDeps {
|
|
66
|
+
/** Duplex ACP stream (typically `ndJsonStream(stdinWritable, stdoutReadable)`
|
|
67
|
+
* wired to the subprocess). The peer does not own the subprocess — only the
|
|
68
|
+
* protocol over this stream. */
|
|
69
|
+
stream: Stream;
|
|
70
|
+
/** Run a proposed tool call through the runner's processor chain. Mirrors
|
|
71
|
+
* `ClaudeRunner.gateToolCall`. May throw `PauseSignal` (human-approval
|
|
72
|
+
* processor) — the peer re-raises it, never swallows it. */
|
|
73
|
+
gateToolCall(call: ToolCall, ctx: ProcessorContext): Promise<ToolCallGateResult>;
|
|
74
|
+
/** PROVIDER for the current turn's processor context (requestContext, abort
|
|
75
|
+
* signal, agentId, iteration). A provider — not a frozen value — because the
|
|
76
|
+
* peer is long-lived (persistent session) and reused across turns; it must
|
|
77
|
+
* read the LIVE turn's context so a permission->pause binds to the right
|
|
78
|
+
* iteration. The runner refreshes the backing value before each turn. */
|
|
79
|
+
procCtx: () => ProcessorContext;
|
|
80
|
+
/** Turn-scoped abort signal from `sendMessage`. On abort the peer sends
|
|
81
|
+
* `session/cancel` for the live session. */
|
|
82
|
+
signal?: AbortSignal;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── The single session/update → AgentMessage normaliser ───────────────────────
|
|
86
|
+
|
|
87
|
+
/** Pull readable text out of an ACP `ContentBlock` (text blocks only; image /
|
|
88
|
+
* audio / resource produce no text). */
|
|
89
|
+
function contentBlockText(block: acp.ContentBlock | undefined): string {
|
|
90
|
+
if (!block) return "";
|
|
91
|
+
return block.type === "text" ? block.text : "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Render a tool call's `content[]` (content / diff / terminal blocks) to a
|
|
95
|
+
* readable string for the MVP renderer, and collect structured `diffs`.
|
|
96
|
+
* `terminal` blocks become a reference line (we never resolve `terminalId` —
|
|
97
|
+
* no terminal capability advertised). */
|
|
98
|
+
function renderToolContent(
|
|
99
|
+
content: acp.ToolCallContent[] | null | undefined,
|
|
100
|
+
): { output: string; diffs: { path: string; oldText: string | null; newText: string }[] } {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
const diffs: { path: string; oldText: string | null; newText: string }[] = [];
|
|
103
|
+
for (const block of content ?? []) {
|
|
104
|
+
if (block.type === "content") {
|
|
105
|
+
parts.push(contentBlockText(block.content));
|
|
106
|
+
} else if (block.type === "diff") {
|
|
107
|
+
diffs.push({ path: block.path, oldText: block.oldText ?? null, newText: block.newText });
|
|
108
|
+
const header = block.oldText == null ? `--- (new file)\n+++ ${block.path}` : `--- ${block.path}\n+++ ${block.path}`;
|
|
109
|
+
parts.push(`${header}\n${block.newText}`);
|
|
110
|
+
} else if (block.type === "terminal") {
|
|
111
|
+
parts.push(`[terminal ${block.terminalId}]`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { output: parts.filter((p) => p !== "").join("\n"), diffs };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mapLocations(
|
|
118
|
+
locations: acp.ToolCallLocation[] | null | undefined,
|
|
119
|
+
): { path: string; line?: number }[] | undefined {
|
|
120
|
+
if (!locations || locations.length === 0) return undefined;
|
|
121
|
+
return locations.map((l) => (l.line != null ? { path: l.path, line: l.line } : { path: l.path }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Build the `tool_result` for a terminal (`completed` / `failed`) tool call.
|
|
125
|
+
* Shared by the `tool_call_update` branch (the streaming, two-message shape)
|
|
126
|
+
* AND the `tool_call` start branch (a fast/sync tool that arrives already
|
|
127
|
+
* terminal with content/locations and no follow-up `tool_call_update`) — both
|
|
128
|
+
* must render diffs/locations/output identically (ADR-0020 Q3). */
|
|
129
|
+
function terminalToolResult(args: {
|
|
130
|
+
toolCallId: string;
|
|
131
|
+
status: "completed" | "failed";
|
|
132
|
+
content: acp.ToolCallContent[] | null | undefined;
|
|
133
|
+
locations: acp.ToolCallLocation[] | null | undefined;
|
|
134
|
+
timestamp: string;
|
|
135
|
+
}): AgentMessage {
|
|
136
|
+
const { output, diffs } = renderToolContent(args.content);
|
|
137
|
+
const locations = mapLocations(args.locations);
|
|
138
|
+
return {
|
|
139
|
+
type: "tool_result",
|
|
140
|
+
toolUseId: args.toolCallId,
|
|
141
|
+
output,
|
|
142
|
+
isError: args.status === "failed",
|
|
143
|
+
timestamp: args.timestamp,
|
|
144
|
+
...(diffs.length > 0 ? { diffs } : {}),
|
|
145
|
+
...(locations ? { locations } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Map ONE ACP `session/update` to zero-or-more `AgentMessage`s. The single
|
|
151
|
+
* normaliser (ADR-0020) — defensive: any variant a given CLI never emits is
|
|
152
|
+
* simply absent → `[]`, and an unknown `sessionUpdate` discriminator returns
|
|
153
|
+
* `[]` rather than throwing (mirrors the legacy `mapEvent` `default: return []`).
|
|
154
|
+
*
|
|
155
|
+
* Lifecycle (`init` / `done`) and turn-level errors are synthesised by the
|
|
156
|
+
* runner / `prompt()` result, NOT here. Per-turn token usage rides the
|
|
157
|
+
* `session/prompt` result (`PromptResponse.usage`), not `usage_update` — the
|
|
158
|
+
* `usage_update` session update is a context-window/cost gauge with no per-token
|
|
159
|
+
* fields, so it maps to nothing (ADR-0020 Q1 verified against the live zod types).
|
|
160
|
+
*/
|
|
161
|
+
export function normalizeSessionUpdate(update: acp.SessionUpdate): AgentMessage[] {
|
|
162
|
+
const ts = now();
|
|
163
|
+
switch (update.sessionUpdate) {
|
|
164
|
+
case "agent_message_chunk":
|
|
165
|
+
return [{ type: "text", text: contentBlockText(update.content), timestamp: ts }];
|
|
166
|
+
|
|
167
|
+
case "agent_thought_chunk":
|
|
168
|
+
return [{ type: "thinking", text: contentBlockText(update.content), timestamp: ts }];
|
|
169
|
+
|
|
170
|
+
case "tool_call": {
|
|
171
|
+
// Always emit the tool_use. A `tool_call` start is the symmetric partner
|
|
172
|
+
// of the legacy codex `item.started` → tool_use mapping.
|
|
173
|
+
const messages: AgentMessage[] = [{
|
|
174
|
+
type: "tool_use",
|
|
175
|
+
toolName: update.title || update.kind || "tool",
|
|
176
|
+
toolInput: (update.rawInput ?? {}) as Record<string, unknown>,
|
|
177
|
+
toolUseId: update.toolCallId,
|
|
178
|
+
timestamp: ts,
|
|
179
|
+
}];
|
|
180
|
+
// ACP permits a `tool_call` to arrive ALREADY terminal (a fast/sync tool:
|
|
181
|
+
// status completed|failed carrying content/locations/rawOutput, with NO
|
|
182
|
+
// follow-up `tool_call_update`). The legacy codex path always produces a
|
|
183
|
+
// tool_result, so the ACP path must too — emit the same tool_result the
|
|
184
|
+
// `tool_call_update` terminal branch produces (ADR-0020 Q3).
|
|
185
|
+
if (update.status === "completed" || update.status === "failed") {
|
|
186
|
+
messages.push(terminalToolResult({
|
|
187
|
+
toolCallId: update.toolCallId,
|
|
188
|
+
status: update.status,
|
|
189
|
+
content: update.content,
|
|
190
|
+
locations: update.locations,
|
|
191
|
+
timestamp: ts,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
return messages;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case "tool_call_update": {
|
|
198
|
+
// Only terminal statuses produce a tool_result. pending / in_progress are
|
|
199
|
+
// progress-only deltas merged by toolCallId downstream → no message.
|
|
200
|
+
if (update.status !== "completed" && update.status !== "failed") return [];
|
|
201
|
+
return [terminalToolResult({
|
|
202
|
+
toolCallId: update.toolCallId,
|
|
203
|
+
status: update.status,
|
|
204
|
+
content: update.content,
|
|
205
|
+
locations: update.locations,
|
|
206
|
+
timestamp: ts,
|
|
207
|
+
})];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case "plan":
|
|
211
|
+
return [{
|
|
212
|
+
type: "plan",
|
|
213
|
+
// Each `plan` notification carries the ENTIRE entries array (the agent
|
|
214
|
+
// replaces the whole plan each time); pass it through verbatim. ACP
|
|
215
|
+
// PlanEntryStatus is exactly pending | in_progress | completed.
|
|
216
|
+
entries: update.entries.map((e) => ({ content: e.content, priority: e.priority, status: e.status })),
|
|
217
|
+
timestamp: ts,
|
|
218
|
+
}];
|
|
219
|
+
|
|
220
|
+
// Echoed user input (only during session/load replay — not our stream),
|
|
221
|
+
// slash-command palette, mode switches, context-window gauge, and the
|
|
222
|
+
// experimental plan_update / plan_removed / config / session-info updates
|
|
223
|
+
// are not part of our contract → no message.
|
|
224
|
+
case "user_message_chunk":
|
|
225
|
+
case "available_commands_update":
|
|
226
|
+
case "current_mode_update":
|
|
227
|
+
case "usage_update":
|
|
228
|
+
case "plan_update":
|
|
229
|
+
case "plan_removed":
|
|
230
|
+
case "config_option_update":
|
|
231
|
+
case "session_info_update":
|
|
232
|
+
return [];
|
|
233
|
+
|
|
234
|
+
default:
|
|
235
|
+
// Unknown / future variant — never throw; the contract is defensive.
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── The peer ──────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
export class AcpClientPeer {
|
|
243
|
+
private readonly conn: ClientSideConnection;
|
|
244
|
+
/** Messages mapped from inbound `session/update` notifications, drained by the
|
|
245
|
+
* active `prompt()` call. REPLACED with a fresh queue at the start of every
|
|
246
|
+
* turn: an `AsyncQueue` is single-use (once `close()`d it stays drained), so a
|
|
247
|
+
* long-lived peer reused across turns must hand each turn its own queue or the
|
|
248
|
+
* second turn's updates would be silently dropped. The inbound `sessionUpdate`
|
|
249
|
+
* handler always pushes onto whatever the current queue is. */
|
|
250
|
+
private updates = new AsyncQueue<AgentMessage>();
|
|
251
|
+
/** Live session id (from `session/new` / `session/resume`), needed by
|
|
252
|
+
* `session/cancel`. */
|
|
253
|
+
private sessionId: string | undefined;
|
|
254
|
+
/** Captured `PauseSignal` thrown out of the permission handler. The JSON-RPC
|
|
255
|
+
* layer would otherwise convert the throw into an error response; we instead
|
|
256
|
+
* stash it and re-raise it from `prompt()` so it unwinds to `serveStep`. */
|
|
257
|
+
private pendingPause: unknown = null;
|
|
258
|
+
private cancelled = false;
|
|
259
|
+
/** Trips the in-flight `session/prompt` turn when `cancel()` / `close()` fires.
|
|
260
|
+
* The agent is supposed to answer `cancelled` after `session/cancel`, but a
|
|
261
|
+
* fully-silent / dead CLI may never resolve the prompt request — so the abort
|
|
262
|
+
* latch unblocks `prompt()` deterministically instead of hanging on the
|
|
263
|
+
* outstanding JSON-RPC request. `null` between turns. */
|
|
264
|
+
private abortPrompt: (() => void) | null = null;
|
|
265
|
+
|
|
266
|
+
constructor(private readonly deps: AcpClientPeerDeps) {
|
|
267
|
+
// The client handler. Methods we DON'T define (writeTextFile / readTextFile)
|
|
268
|
+
// and the terminal methods are simply absent — we advertise no fs/terminal
|
|
269
|
+
// capability, so a conformant agent never calls them; if one does the SDK
|
|
270
|
+
// answers method-not-found.
|
|
271
|
+
const handler: Client = {
|
|
272
|
+
sessionUpdate: (params: acp.SessionNotification): void => {
|
|
273
|
+
// Push mapped messages onto the queue. Tolerate updates that race in
|
|
274
|
+
// after cancel (the agent may flush a few before unwinding) — the
|
|
275
|
+
// normaliser handles them the same way.
|
|
276
|
+
for (const msg of normalizeSessionUpdate(params.update)) this.updates.push(msg);
|
|
277
|
+
},
|
|
278
|
+
requestPermission: (params: acp.RequestPermissionRequest) => this.onRequestPermission(params),
|
|
279
|
+
};
|
|
280
|
+
this.conn = new ClientSideConnection((_agent: Agent) => handler, this.deps.stream);
|
|
281
|
+
|
|
282
|
+
// Wire cancel: on abort, send session/cancel for the live session.
|
|
283
|
+
if (this.deps.signal) {
|
|
284
|
+
if (this.deps.signal.aborted) this.cancel();
|
|
285
|
+
else this.deps.signal.addEventListener("abort", () => this.cancel(), { once: true });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Negotiate the wire protocol at `initialize`. Pins `protocolVersion` to
|
|
291
|
+
* integer 1 and advertises NO fs / terminal capabilities. Returns the
|
|
292
|
+
* version the agent negotiated — `CliAgentRunner` compares it to
|
|
293
|
+
* `ACP_PROTOCOL_VERSION` to decide ACP-path vs. legacy-JSONL fallback.
|
|
294
|
+
*/
|
|
295
|
+
async initialize(): Promise<{ protocolVersion: number }> {
|
|
296
|
+
const res = await this.conn.initialize({
|
|
297
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
298
|
+
clientCapabilities: {
|
|
299
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
300
|
+
terminal: false,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
return { protocolVersion: res.protocolVersion };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Start a session and return its id (threaded back onto the synthesised
|
|
308
|
+
* `init` / `done`). Resume is via the CLI's own persisted thread/rollout on
|
|
309
|
+
* the sandbox FS keyed by this id — not an ACP `session/resume` round-trip;
|
|
310
|
+
* the re-issued turn on resume continues that thread (ADR-0020 Q4, validated
|
|
311
|
+
* in the manual live step).
|
|
312
|
+
*/
|
|
313
|
+
async startSession(cwd: string): Promise<string> {
|
|
314
|
+
const res = await this.conn.newSession({ cwd, mcpServers: [] });
|
|
315
|
+
this.sessionId = res.sessionId;
|
|
316
|
+
return res.sessionId;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Drive one `session/prompt` turn, yielding `AgentMessage`s as the agent
|
|
321
|
+
* streams `session/update` notifications. Resolves the generator when the
|
|
322
|
+
* prompt result lands (clean `stopReason`), or yields `{type:"error"}` on a
|
|
323
|
+
* `refusal` stop reason or a JSON-RPC error. A `cancelled` stop reason ends
|
|
324
|
+
* the turn with no error. Re-raises any `PauseSignal` captured by the
|
|
325
|
+
* permission handler so it unwinds past `sendMessage` to `serveStep`.
|
|
326
|
+
*/
|
|
327
|
+
async *prompt(text: string): AsyncGenerator<AgentMessage> {
|
|
328
|
+
if (!this.sessionId) throw new Error("AcpClientPeer.prompt called before startSession");
|
|
329
|
+
|
|
330
|
+
// Fresh updates queue for THIS turn. The peer is long-lived and reused
|
|
331
|
+
// across turns (one persistent ACP process / session), but an `AsyncQueue`
|
|
332
|
+
// is single-use — once closed it stays drained — so the previous turn's
|
|
333
|
+
// queue can't carry this turn's `session/update`s. Swap in a new one and
|
|
334
|
+
// bind a local alias the closures below capture, so closing "the turn's
|
|
335
|
+
// queue" always means this turn's, never a later turn's.
|
|
336
|
+
const updates = new AsyncQueue<AgentMessage>();
|
|
337
|
+
this.updates = updates;
|
|
338
|
+
|
|
339
|
+
// Abort latch: `cancel()` / `close()` trips this to unblock the turn even if
|
|
340
|
+
// the agent never resolves the outstanding `session/prompt` request (a dead
|
|
341
|
+
// or fully-silent CLI). Resolving it closes the updates queue (ending the
|
|
342
|
+
// for-await) and races the prompt result so the await below can't hang.
|
|
343
|
+
const aborted = new Promise<void>((resolve) => {
|
|
344
|
+
this.abortPrompt = () => { resolve(); updates.close(); };
|
|
345
|
+
});
|
|
346
|
+
// If cancel already fired before the prompt started (abort-before-prompt
|
|
347
|
+
// race), trip immediately.
|
|
348
|
+
if (this.cancelled) this.abortPrompt!();
|
|
349
|
+
|
|
350
|
+
// Kick off the prompt; do NOT await yet — drain updates while it runs, then
|
|
351
|
+
// close the queue when the turn resolves so the for-await below terminates.
|
|
352
|
+
const promptPromise = this.conn
|
|
353
|
+
.prompt({ sessionId: this.sessionId, prompt: [{ type: "text", text }] })
|
|
354
|
+
.then(
|
|
355
|
+
(res) => { updates.close(); return { ok: true as const, res }; },
|
|
356
|
+
(err) => { updates.close(); return { ok: false as const, err }; },
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
for await (const msg of updates) {
|
|
360
|
+
yield msg;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A processor inside the permission handler asked for a human decision:
|
|
364
|
+
// re-raise so the pause unwinds to serveStep (ADR-0020 Q4). The outstanding
|
|
365
|
+
// session/prompt is abandoned as the subprocess exits; on resume the loop
|
|
366
|
+
// re-issues the turn against the persisted session.
|
|
367
|
+
if (this.pendingPause !== null) {
|
|
368
|
+
const pause = this.pendingPause;
|
|
369
|
+
this.pendingPause = null;
|
|
370
|
+
this.abortPrompt = null;
|
|
371
|
+
throw pause;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// The turn ended either because the prompt resolved (its `.then` closed the
|
|
375
|
+
// queue) or because the abort latch tripped. Race them so an aborted turn
|
|
376
|
+
// returns cleanly instead of awaiting a JSON-RPC reply the agent may never
|
|
377
|
+
// send. `cancelled` → no error, like a clean cancel stop reason.
|
|
378
|
+
const settled = await Promise.race([
|
|
379
|
+
promptPromise.then((o) => ({ kind: "prompt" as const, o })),
|
|
380
|
+
aborted.then(() => ({ kind: "aborted" as const })),
|
|
381
|
+
]);
|
|
382
|
+
this.abortPrompt = null;
|
|
383
|
+
if (settled.kind === "aborted") return;
|
|
384
|
+
|
|
385
|
+
const outcome = settled.o;
|
|
386
|
+
if (!outcome.ok) {
|
|
387
|
+
yield { type: "error", text: formatError(outcome.err), timestamp: now() };
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const stopReason = outcome.res.stopReason;
|
|
391
|
+
if (stopReason === "refusal") {
|
|
392
|
+
yield { type: "error", text: "agent refused the request", timestamp: now() };
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// end_turn / max_tokens / max_turn_requests → clean end (the runner
|
|
396
|
+
// synthesises `done`). cancelled → end the turn with no error.
|
|
397
|
+
|
|
398
|
+
// Per-turn token usage rides the prompt result, not usage_update (Q1).
|
|
399
|
+
const usage = outcome.res.usage;
|
|
400
|
+
if (usage) {
|
|
401
|
+
yield {
|
|
402
|
+
type: "usage",
|
|
403
|
+
inputTokens: usage.inputTokens,
|
|
404
|
+
outputTokens: usage.outputTokens,
|
|
405
|
+
cacheReadTokens: usage.cachedReadTokens ?? 0,
|
|
406
|
+
cacheCreationTokens: usage.cachedWriteTokens ?? 0,
|
|
407
|
+
durationMs: 0,
|
|
408
|
+
numTurns: 1,
|
|
409
|
+
timestamp: now(),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The live session id, or undefined before `startSession`. Threaded back
|
|
415
|
+
* onto the synthesised `init` / `done` by the runner. */
|
|
416
|
+
get currentSessionId(): string | undefined {
|
|
417
|
+
return this.sessionId;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Send `session/cancel` for the live session (notification, fire-and-forget).
|
|
421
|
+
* Idempotent. The pending `session/prompt` is expected to resolve
|
|
422
|
+
* `cancelled`; any outstanding `session/request_permission` resolves with
|
|
423
|
+
* `outcome:"cancelled"` via the handler's signal check. */
|
|
424
|
+
cancel(): void {
|
|
425
|
+
if (this.cancelled) { this.abortPrompt?.(); return; }
|
|
426
|
+
this.cancelled = true;
|
|
427
|
+
// Send session/cancel when we have a live session; with or without one, trip
|
|
428
|
+
// the abort latch so an in-flight `prompt()` unblocks deterministically
|
|
429
|
+
// rather than waiting on a reply a cancelled/dead agent may never send.
|
|
430
|
+
if (this.sessionId) {
|
|
431
|
+
void this.conn.cancel({ sessionId: this.sessionId }).catch(() => { /* connection may already be closing */ });
|
|
432
|
+
}
|
|
433
|
+
this.abortPrompt?.();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Tear down the connection. Trips the abort latch so any in-flight `prompt()`
|
|
437
|
+
* turn unblocks, and closes the updates queue. Used both on clean turn end and
|
|
438
|
+
* when the runner falls back to the legacy JSONL path after a version
|
|
439
|
+
* mismatch. */
|
|
440
|
+
close(): void {
|
|
441
|
+
// Trip the in-flight turn (closes the updates queue too) so `prompt()`'s
|
|
442
|
+
// `await` can't hang on an outstanding `session/prompt` request.
|
|
443
|
+
this.abortPrompt?.();
|
|
444
|
+
this.updates.close();
|
|
445
|
+
// ClientSideConnection has no explicit close(); ending the stream closes it.
|
|
446
|
+
// Closing the writable is the caller's job (it owns the subprocess), so we
|
|
447
|
+
// only drain our queue here. The connection's `signal` aborts when the
|
|
448
|
+
// stream ends.
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Permission → gate → PermissionOption ────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
// Permission → pause, wired end-to-end (WS-C increment 2b). An ACP
|
|
454
|
+
// `session/request_permission` runs the shared processor chain via
|
|
455
|
+
// `gateToolCall`; a human-approval processor (`humanApproval` / any processor
|
|
456
|
+
// calling `ctx.pause`) raises a `PauseSignal` here. This handler propagates it
|
|
457
|
+
// unswallowed — stashed in `pendingPause` and re-raised from `prompt()` so it
|
|
458
|
+
// unwinds to serveStep and the `run_pauses` row is written (ADR-0020 Q4 /
|
|
459
|
+
// ADR-0006). `ProcessorContext.pause` (processors/processor.ts), wired by the
|
|
460
|
+
// runtime's `buildProcCtx`, is the source. A gate can now allow / deny / abort
|
|
461
|
+
// / PAUSE.
|
|
462
|
+
private async onRequestPermission(
|
|
463
|
+
params: acp.RequestPermissionRequest,
|
|
464
|
+
): Promise<acp.RequestPermissionResponse> {
|
|
465
|
+
// If the turn was cancelled, the protocol REQUIRES we answer cancelled.
|
|
466
|
+
if (this.cancelled || this.deps.signal?.aborted) {
|
|
467
|
+
return { outcome: { outcome: "cancelled" } };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const tc = params.toolCall;
|
|
471
|
+
const call: ToolCall = {
|
|
472
|
+
toolName: tc.title || tc.kind || "tool",
|
|
473
|
+
toolInput: (tc.rawInput ?? {}) as Record<string, unknown>,
|
|
474
|
+
toolUseId: tc.toolCallId,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
let verdict: ToolCallGateResult;
|
|
478
|
+
try {
|
|
479
|
+
// CRITICAL: do NOT wrap this in a catch that swallows everything. A
|
|
480
|
+
// processor requesting human approval throws `PauseSignal`, which is
|
|
481
|
+
// control flow, not an error — it must escape to `prompt()` and unwind to
|
|
482
|
+
// serveStep so the run_pauses row is written (ADR-0020 Q4 / ADR-0006).
|
|
483
|
+
verdict = await this.deps.gateToolCall(call, this.deps.procCtx());
|
|
484
|
+
} catch (err) {
|
|
485
|
+
if (isPauseSignal(err)) {
|
|
486
|
+
// Stash the signal for `prompt()` to re-raise, and answer the still-
|
|
487
|
+
// pending request as cancelled so the agent unwinds its turn while the
|
|
488
|
+
// subprocess is torn down by the pause.
|
|
489
|
+
this.pendingPause = err;
|
|
490
|
+
this.cancel();
|
|
491
|
+
return { outcome: { outcome: "cancelled" } };
|
|
492
|
+
}
|
|
493
|
+
throw err; // genuine error → JSON-RPC error response
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (verdict.kind === "abort") {
|
|
497
|
+
this.cancel();
|
|
498
|
+
return { outcome: { outcome: "cancelled" } };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const wantedKinds =
|
|
502
|
+
verdict.kind === "allow" ? OPTION_KIND_FOR_VERDICT.allow : OPTION_KIND_FOR_VERDICT.deny;
|
|
503
|
+
const option =
|
|
504
|
+
params.options.find((o) => (wantedKinds as readonly string[]).includes(o.kind)) ?? params.options[0];
|
|
505
|
+
if (!option) {
|
|
506
|
+
// No options offered at all — a non-conformant agent. Cancel rather than
|
|
507
|
+
// guess.
|
|
508
|
+
return { outcome: { outcome: "cancelled" } };
|
|
509
|
+
}
|
|
510
|
+
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Re-export RequestError so callers building/handling JSON-RPC errors don't
|
|
515
|
+
// reach into the SDK directly.
|
|
516
|
+
export { RequestError };
|