@zvada/agent-server 0.2.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/LICENSE +21 -0
- package/README.md +71 -0
- package/package.json +87 -0
- package/src/client/client.ts +589 -0
- package/src/client/index.ts +18 -0
- package/src/client/transports.ts +84 -0
- package/src/core/agents/acp/acp-agent.ts +322 -0
- package/src/core/agents/acp/adapter.ts +260 -0
- package/src/core/agents/acp/client.ts +212 -0
- package/src/core/agents/acp/known-agents.ts +29 -0
- package/src/core/agents/acp/mappings.ts +136 -0
- package/src/core/agents/base.ts +145 -0
- package/src/core/agents/claude-code/adapter.ts +451 -0
- package/src/core/agents/claude-code/claude-agent.ts +235 -0
- package/src/core/agents/claude-code/generator-session.ts +344 -0
- package/src/core/agents/claude-code/options.ts +161 -0
- package/src/core/agents/claude-code/session-manager.ts +159 -0
- package/src/core/agents/codex-app-server/adapter.ts +214 -0
- package/src/core/agents/codex-app-server/client.ts +221 -0
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
- package/src/core/agents/codex-items.ts +122 -0
- package/src/core/agents/codex-sdk/adapter.ts +204 -0
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
- package/src/core/agents/config-fingerprint.ts +19 -0
- package/src/core/agents/error-classifier.ts +68 -0
- package/src/core/agents/registry.ts +40 -0
- package/src/core/agents/session-store.ts +72 -0
- package/src/core/agents/tool-meta.ts +68 -0
- package/src/core/agents/types.ts +54 -0
- package/src/core/index.ts +114 -0
- package/src/core/presets.ts +78 -0
- package/src/core/provision/extract.ts +31 -0
- package/src/core/provision/index.ts +10 -0
- package/src/core/provision/npm.ts +114 -0
- package/src/core/provision/pins.ts +51 -0
- package/src/core/provision/platform.ts +73 -0
- package/src/core/provision/provisioner.ts +478 -0
- package/src/core/proxy/anthropic-proxy.ts +69 -0
- package/src/core/proxy/api-key-store.ts +34 -0
- package/src/core/proxy/index.ts +7 -0
- package/src/core/runtime/agent-runtime.ts +363 -0
- package/src/core/runtime/event-processor.ts +218 -0
- package/src/core/runtime/event-sink.ts +37 -0
- package/src/core/utils/errors.ts +41 -0
- package/src/index.ts +4 -0
- package/src/protocol/async-queue.ts +68 -0
- package/src/protocol/config.ts +100 -0
- package/src/protocol/factories.ts +125 -0
- package/src/protocol/harness.ts +50 -0
- package/src/protocol/ids.ts +53 -0
- package/src/protocol/index.ts +16 -0
- package/src/protocol/lifecycle.ts +309 -0
- package/src/protocol/models.ts +45 -0
- package/src/protocol/part-input.ts +58 -0
- package/src/protocol/parts.ts +60 -0
- package/src/protocol/thinking.ts +32 -0
- package/src/protocol/tokens.ts +39 -0
- package/src/protocol/tool-state.ts +89 -0
- package/src/protocol/wire.ts +313 -0
- package/src/server/acp/binding.ts +163 -0
- package/src/server/acp/translate.ts +160 -0
- package/src/server/agent-server.ts +357 -0
- package/src/server/bin.ts +174 -0
- package/src/server/index.ts +24 -0
- package/src/server/install.ts +51 -0
- package/src/server/session-log.ts +66 -0
- package/src/server/transports.ts +149 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { RunConfigSchema } from "./config.ts";
|
|
3
|
+
import { AgentCapabilitiesSchema } from "./harness.ts";
|
|
4
|
+
import { LifecycleEventSchema, PermissionOutcomeSchema } from "./lifecycle.ts";
|
|
5
|
+
import { AgentInputSchema } from "./part-input.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The standard wire (phase 2): JSON-RPC 2.0 over a newline-delimited JSON
|
|
9
|
+
* stream (stdio, WebSocket, or any duplex byte channel). Requests flow
|
|
10
|
+
* client -> server only; the server pushes `event` notifications. Turns are
|
|
11
|
+
* quick-ack: `turn/start` acknowledges immediately and completion is observed
|
|
12
|
+
* via the `turn.ended` lifecycle event, mirroring the engine's event-driven
|
|
13
|
+
* model (and ACP v2's prompt lifecycle).
|
|
14
|
+
*/
|
|
15
|
+
export const WIRE_PROTOCOL_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
// ---- JSON-RPC 2.0 envelopes --------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const JsonRpcIdSchema = z.union([z.string(), z.number()]);
|
|
20
|
+
export type JsonRpcId = z.infer<typeof JsonRpcIdSchema>;
|
|
21
|
+
|
|
22
|
+
export const JsonRpcErrorSchema = z.object({
|
|
23
|
+
code: z.number().int(),
|
|
24
|
+
message: z.string(),
|
|
25
|
+
data: z.unknown().optional(),
|
|
26
|
+
});
|
|
27
|
+
export type JsonRpcError = z.infer<typeof JsonRpcErrorSchema>;
|
|
28
|
+
|
|
29
|
+
/** JSON-RPC error codes used on this wire (standard + implementation range). */
|
|
30
|
+
export const WIRE_ERROR_CODES = {
|
|
31
|
+
parseError: -32700,
|
|
32
|
+
invalidRequest: -32600,
|
|
33
|
+
methodNotFound: -32601,
|
|
34
|
+
invalidParams: -32602,
|
|
35
|
+
internalError: -32603,
|
|
36
|
+
/** `config.harness` is not registered/available in this server process. */
|
|
37
|
+
harnessNotAvailable: -32000,
|
|
38
|
+
/** The sessionId is unknown to this server process (e.g. it restarted). */
|
|
39
|
+
unknownSession: -32001,
|
|
40
|
+
/** The session already has an active turn — one turn per session at a time. */
|
|
41
|
+
turnActive: -32002,
|
|
42
|
+
/** The server is shutting down and no longer accepts turns. */
|
|
43
|
+
shuttingDown: -32003,
|
|
44
|
+
/** The peer requested a wire version this implementation cannot speak. */
|
|
45
|
+
protocolVersionMismatch: -32004,
|
|
46
|
+
/** A session/close for this session is still in flight; retry after it settles. */
|
|
47
|
+
sessionClosing: -32005,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
// ---- methods -----------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export const WIRE_METHODS = {
|
|
53
|
+
initialize: "initialize",
|
|
54
|
+
turnStart: "turn/start",
|
|
55
|
+
turnCancel: "turn/cancel",
|
|
56
|
+
sessionClose: "session/close",
|
|
57
|
+
permissionRespond: "permission/respond",
|
|
58
|
+
eventsReplay: "events/replay",
|
|
59
|
+
shutdown: "shutdown",
|
|
60
|
+
/** Server -> client notification carrying one sequenced lifecycle event. */
|
|
61
|
+
event: "event",
|
|
62
|
+
} as const;
|
|
63
|
+
export type WireMethod = (typeof WIRE_METHODS)[keyof typeof WIRE_METHODS];
|
|
64
|
+
|
|
65
|
+
export const WireImplementationInfoSchema = z.object({
|
|
66
|
+
name: z.string(),
|
|
67
|
+
version: z.string().optional(),
|
|
68
|
+
});
|
|
69
|
+
export type WireImplementationInfo = z.infer<typeof WireImplementationInfoSchema>;
|
|
70
|
+
|
|
71
|
+
export const InitializeParamsSchema = z.object({
|
|
72
|
+
protocolVersion: z.number().int().positive(),
|
|
73
|
+
client: WireImplementationInfoSchema.optional(),
|
|
74
|
+
});
|
|
75
|
+
export type InitializeParams = z.infer<typeof InitializeParamsSchema>;
|
|
76
|
+
|
|
77
|
+
export const InitializeResultSchema = z.object({
|
|
78
|
+
protocolVersion: z.number().int().positive(),
|
|
79
|
+
server: WireImplementationInfoSchema,
|
|
80
|
+
/** Capabilities per registered harness — only available harnesses appear. */
|
|
81
|
+
harnesses: z.record(z.string(), AgentCapabilitiesSchema),
|
|
82
|
+
});
|
|
83
|
+
export type InitializeResult = z.infer<typeof InitializeResultSchema>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `turn/start` params are a RunRequest whose ids may be omitted — the server
|
|
87
|
+
* mints UUIDv7s for missing ones and echoes the effective ids in the ack.
|
|
88
|
+
* (The bundled client always mints ids itself so it can route events that
|
|
89
|
+
* arrive before the ack's promise continuation runs.)
|
|
90
|
+
*/
|
|
91
|
+
export const TurnStartParamsSchema = z.object({
|
|
92
|
+
sessionId: z.string().optional(),
|
|
93
|
+
turnId: z.string().optional(),
|
|
94
|
+
input: AgentInputSchema,
|
|
95
|
+
config: RunConfigSchema,
|
|
96
|
+
});
|
|
97
|
+
export type TurnStartParams = z.infer<typeof TurnStartParamsSchema>;
|
|
98
|
+
|
|
99
|
+
/** Quick-ack: the turn is accepted and running; completion = `turn.ended`. */
|
|
100
|
+
export const TurnStartResultSchema = z.object({
|
|
101
|
+
sessionId: z.string(),
|
|
102
|
+
turnId: z.string(),
|
|
103
|
+
});
|
|
104
|
+
export type TurnStartResult = z.infer<typeof TurnStartResultSchema>;
|
|
105
|
+
|
|
106
|
+
export const TurnCancelParamsSchema = z.object({ sessionId: z.string() });
|
|
107
|
+
export type TurnCancelParams = z.infer<typeof TurnCancelParamsSchema>;
|
|
108
|
+
|
|
109
|
+
/** `cancelled: false` means the session had no active turn (idempotent). */
|
|
110
|
+
export const TurnCancelResultSchema = z.object({ cancelled: z.boolean() });
|
|
111
|
+
export type TurnCancelResult = z.infer<typeof TurnCancelResultSchema>;
|
|
112
|
+
|
|
113
|
+
export const SessionCloseParamsSchema = z.object({ sessionId: z.string() });
|
|
114
|
+
export type SessionCloseParams = z.infer<typeof SessionCloseParamsSchema>;
|
|
115
|
+
|
|
116
|
+
/** `closed: false` means the server did not know the session (idempotent). */
|
|
117
|
+
export const SessionCloseResultSchema = z.object({ closed: z.boolean() });
|
|
118
|
+
export type SessionCloseResult = z.infer<typeof SessionCloseResultSchema>;
|
|
119
|
+
|
|
120
|
+
export const PermissionRespondParamsSchema = z.object({
|
|
121
|
+
sessionId: z.string(),
|
|
122
|
+
requestId: z.string(),
|
|
123
|
+
outcome: PermissionOutcomeSchema,
|
|
124
|
+
});
|
|
125
|
+
export type PermissionRespondParams = z.infer<typeof PermissionRespondParamsSchema>;
|
|
126
|
+
|
|
127
|
+
/** `accepted: false` means the request was unknown or already resolved. */
|
|
128
|
+
export const PermissionRespondResultSchema = z.object({ accepted: z.boolean() });
|
|
129
|
+
export type PermissionRespondResult = z.infer<typeof PermissionRespondResultSchema>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One sequenced event as broadcast on the wire. `seq` is per-session,
|
|
133
|
+
* monotonic, and starts at 1 — a client that has seen nothing replays from 1.
|
|
134
|
+
* The envelope duplicates `sessionId` so consumers can route without parsing
|
|
135
|
+
* the event body.
|
|
136
|
+
*/
|
|
137
|
+
export const WireEventEnvelopeSchema = z.object({
|
|
138
|
+
sessionId: z.string(),
|
|
139
|
+
seq: z.number().int().positive(),
|
|
140
|
+
event: LifecycleEventSchema,
|
|
141
|
+
});
|
|
142
|
+
export type WireEventEnvelope = z.infer<typeof WireEventEnvelopeSchema>;
|
|
143
|
+
|
|
144
|
+
export const EventsReplayParamsSchema = z.object({
|
|
145
|
+
sessionId: z.string(),
|
|
146
|
+
/** Replay buffered events with `seq >= fromSeq`. */
|
|
147
|
+
fromSeq: z.number().int().positive(),
|
|
148
|
+
});
|
|
149
|
+
export type EventsReplayParams = z.infer<typeof EventsReplayParamsSchema>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Replay is served from a bounded per-session ring buffer. When
|
|
153
|
+
* `firstAvailableSeq > fromSeq`, the requested prefix was evicted and the gap
|
|
154
|
+
* is unfillable — the consumer must treat its local view as stale.
|
|
155
|
+
*/
|
|
156
|
+
export const EventsReplayResultSchema = z.object({
|
|
157
|
+
events: z.array(WireEventEnvelopeSchema),
|
|
158
|
+
firstAvailableSeq: z.number().int().positive().nullable(),
|
|
159
|
+
latestSeq: z.number().int().positive().nullable(),
|
|
160
|
+
});
|
|
161
|
+
export type EventsReplayResult = z.infer<typeof EventsReplayResultSchema>;
|
|
162
|
+
|
|
163
|
+
export const ShutdownParamsSchema = z.object({}).optional();
|
|
164
|
+
export const ShutdownResultSchema = z.object({});
|
|
165
|
+
|
|
166
|
+
// ---- codec -------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/** A decoded incoming wire line, discriminated for dispatch. */
|
|
169
|
+
export type WireMessage =
|
|
170
|
+
| { kind: "request"; id: JsonRpcId; method: string; params?: unknown }
|
|
171
|
+
| { kind: "notification"; method: string; params?: unknown }
|
|
172
|
+
/** `id` is null on protocol-level errors answering unparseable requests. */
|
|
173
|
+
| { kind: "response"; id: JsonRpcId | null; result?: unknown; error?: JsonRpcError }
|
|
174
|
+
| { kind: "invalid"; error: string };
|
|
175
|
+
|
|
176
|
+
/** Decode one NDJSON line into a JSON-RPC message (never throws). */
|
|
177
|
+
export function decodeWireMessage(line: string): WireMessage {
|
|
178
|
+
let raw: unknown;
|
|
179
|
+
try {
|
|
180
|
+
raw = JSON.parse(line);
|
|
181
|
+
} catch {
|
|
182
|
+
return { kind: "invalid", error: "not valid JSON" };
|
|
183
|
+
}
|
|
184
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
185
|
+
return { kind: "invalid", error: "not a JSON-RPC object" };
|
|
186
|
+
}
|
|
187
|
+
const msg = raw as Record<string, unknown>;
|
|
188
|
+
if (msg.jsonrpc !== "2.0") return { kind: "invalid", error: "missing jsonrpc: '2.0'" };
|
|
189
|
+
const id = JsonRpcIdSchema.safeParse(msg.id);
|
|
190
|
+
if (typeof msg.method === "string") {
|
|
191
|
+
return id.success
|
|
192
|
+
? { kind: "request", id: id.data, method: msg.method, params: msg.params }
|
|
193
|
+
: { kind: "notification", method: msg.method, params: msg.params };
|
|
194
|
+
}
|
|
195
|
+
if ((id.success || msg.id === null) && ("result" in msg || "error" in msg)) {
|
|
196
|
+
const error = JsonRpcErrorSchema.safeParse(msg.error);
|
|
197
|
+
return {
|
|
198
|
+
kind: "response",
|
|
199
|
+
id: id.success ? id.data : null,
|
|
200
|
+
result: msg.result,
|
|
201
|
+
error: error.success ? error.data : undefined,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return { kind: "invalid", error: "neither request, notification, nor response" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function encodeRequest(id: JsonRpcId, method: string, params?: unknown): string {
|
|
208
|
+
return JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function encodeNotification(method: string, params?: unknown): string {
|
|
212
|
+
return JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function encodeResponse(id: JsonRpcId, result: unknown): string {
|
|
216
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function encodeErrorResponse(
|
|
220
|
+
id: JsonRpcId | null,
|
|
221
|
+
code: number,
|
|
222
|
+
message: string,
|
|
223
|
+
data?: unknown,
|
|
224
|
+
): string {
|
|
225
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message, data } });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- transport port ----------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A duplex line channel the wire runs over. Implementations exist for stdio,
|
|
232
|
+
* WebSocket, and in-memory pairs (tests). Handlers registered after close are
|
|
233
|
+
* never called; `send` after close is a silent no-op.
|
|
234
|
+
*/
|
|
235
|
+
export interface WireTransport {
|
|
236
|
+
send(line: string): void;
|
|
237
|
+
/** Register a line handler; returns an unsubscribe function. */
|
|
238
|
+
onLine(handler: (line: string) => void): () => void;
|
|
239
|
+
/** Register a close handler; returns an unsubscribe function. */
|
|
240
|
+
onClose(handler: (reason?: string) => void): () => void;
|
|
241
|
+
close(): void;
|
|
242
|
+
readonly closed: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Split an inbound transport message into NDJSON lines, defensively: peers
|
|
247
|
+
* may batch several lines per frame, and WebSocket implementations deliver
|
|
248
|
+
* text, Uint8Array, or ArrayBuffer depending on runtime. Blank lines are
|
|
249
|
+
* skipped.
|
|
250
|
+
*/
|
|
251
|
+
export function pushNdjsonLines(push: (line: string) => void, data: unknown): void {
|
|
252
|
+
let text: string;
|
|
253
|
+
if (typeof data === "string") text = data;
|
|
254
|
+
else if (data instanceof Uint8Array) text = new TextDecoder().decode(data);
|
|
255
|
+
else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(new Uint8Array(data));
|
|
256
|
+
else text = String(data);
|
|
257
|
+
for (const line of text.split("\n")) if (line.trim()) push(line);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build a WireTransport from a raw byte-channel binding: the caller supplies
|
|
262
|
+
* the outbound `send`/`close`, and pushes inbound lines / close through the
|
|
263
|
+
* returned handles. Every concrete transport (stdio, WebSocket, memory pair)
|
|
264
|
+
* is this bridge plus platform wiring.
|
|
265
|
+
*/
|
|
266
|
+
export function channelTransport(io: { send(line: string): void; close?(): void }): {
|
|
267
|
+
transport: WireTransport;
|
|
268
|
+
push(line: string): void;
|
|
269
|
+
end(reason?: string): void;
|
|
270
|
+
} {
|
|
271
|
+
const lineHandlers = new Set<(line: string) => void>();
|
|
272
|
+
const closeHandlers = new Set<(reason?: string) => void>();
|
|
273
|
+
let closed = false;
|
|
274
|
+
|
|
275
|
+
const end = (reason?: string) => {
|
|
276
|
+
if (closed) return;
|
|
277
|
+
closed = true;
|
|
278
|
+
for (const handler of [...closeHandlers]) handler(reason);
|
|
279
|
+
lineHandlers.clear();
|
|
280
|
+
closeHandlers.clear();
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const transport: WireTransport = {
|
|
284
|
+
send(line) {
|
|
285
|
+
if (!closed) io.send(line);
|
|
286
|
+
},
|
|
287
|
+
onLine(handler) {
|
|
288
|
+
lineHandlers.add(handler);
|
|
289
|
+
return () => lineHandlers.delete(handler);
|
|
290
|
+
},
|
|
291
|
+
onClose(handler) {
|
|
292
|
+
closeHandlers.add(handler);
|
|
293
|
+
return () => closeHandlers.delete(handler);
|
|
294
|
+
},
|
|
295
|
+
close() {
|
|
296
|
+
if (closed) return;
|
|
297
|
+
io.close?.();
|
|
298
|
+
end("closed locally");
|
|
299
|
+
},
|
|
300
|
+
get closed() {
|
|
301
|
+
return closed;
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
transport,
|
|
307
|
+
push(line) {
|
|
308
|
+
if (closed) return;
|
|
309
|
+
for (const handler of [...lineHandlers]) handler(line);
|
|
310
|
+
},
|
|
311
|
+
end,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Readable, Writable } from "node:stream";
|
|
2
|
+
import type {
|
|
3
|
+
AgentApp,
|
|
4
|
+
AgentConnection,
|
|
5
|
+
RequestPermissionResponse,
|
|
6
|
+
} from "@agentclientprotocol/sdk";
|
|
7
|
+
import type { AgentRuntime, EventSink } from "../../core/index.ts";
|
|
8
|
+
import { generateUUIDv7 } from "../../core/index.ts";
|
|
9
|
+
import type {
|
|
10
|
+
AgentHarness,
|
|
11
|
+
LifecycleEvent,
|
|
12
|
+
PermissionMode,
|
|
13
|
+
PermissionOutcome,
|
|
14
|
+
} from "../../protocol/index.ts";
|
|
15
|
+
import { AcpUpdateTranslator, fromAcpMcpServers, fromPromptBlocks } from "./translate.ts";
|
|
16
|
+
|
|
17
|
+
/** ACP protocol major version this binding speaks. */
|
|
18
|
+
const PROTOCOL_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export interface AcpBindingOptions {
|
|
21
|
+
runtime: AgentRuntime;
|
|
22
|
+
/** The harness this ACP agent fronts (operator-chosen at launch). */
|
|
23
|
+
harness: AgentHarness;
|
|
24
|
+
model?: string;
|
|
25
|
+
/** Defaults to `default` so tool approvals flow to the ACP client. */
|
|
26
|
+
permissionMode?: PermissionMode;
|
|
27
|
+
name?: string;
|
|
28
|
+
version?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface BoundSession {
|
|
32
|
+
cwd: string;
|
|
33
|
+
mcpServers?: ReturnType<typeof fromAcpMcpServers>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The phase-3 ACP *server* binding: exposes the runtime as an Agent Client
|
|
38
|
+
* Protocol v1 agent, so anything that speaks ACP (Zed, another agent-server's
|
|
39
|
+
* `acp` harness, any product) can drive our harnesses with zero custom
|
|
40
|
+
* integration. `session/prompt` runs one engine turn; LifecycleEvents stream
|
|
41
|
+
* out as `session/update`; engine-brokered permissions become
|
|
42
|
+
* `session/request_permission` round-trips (identical option vocabulary).
|
|
43
|
+
* Resume is within-process: an ACP sessionId stays valid for the binding's
|
|
44
|
+
* lifetime (cross-process resume needs host persistence — not offered yet).
|
|
45
|
+
*/
|
|
46
|
+
export async function createAcpAgentApp(options: AcpBindingOptions): Promise<AgentApp> {
|
|
47
|
+
const acp = await import("@agentclientprotocol/sdk");
|
|
48
|
+
const { runtime, harness } = options;
|
|
49
|
+
const sessions = new Map<string, BoundSession>();
|
|
50
|
+
|
|
51
|
+
const requireSession = (sessionId: string): BoundSession => {
|
|
52
|
+
const session = sessions.get(sessionId);
|
|
53
|
+
if (!session) throw acp.RequestError.resourceNotFound(sessionId);
|
|
54
|
+
return session;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return acp
|
|
58
|
+
.agent({ name: options.name ?? "agent-server" })
|
|
59
|
+
.onRequest("initialize", (ctx) => ({
|
|
60
|
+
protocolVersion: Math.min(ctx.params.protocolVersion, PROTOCOL_VERSION),
|
|
61
|
+
agentInfo: { name: options.name ?? "agent-server", version: options.version ?? "0.0.0" },
|
|
62
|
+
agentCapabilities: {
|
|
63
|
+
promptCapabilities: { image: true },
|
|
64
|
+
sessionCapabilities: { resume: {}, close: {} },
|
|
65
|
+
},
|
|
66
|
+
authMethods: [],
|
|
67
|
+
}))
|
|
68
|
+
.onRequest("session/new", (ctx) => {
|
|
69
|
+
const sessionId = generateUUIDv7();
|
|
70
|
+
sessions.set(sessionId, {
|
|
71
|
+
cwd: ctx.params.cwd,
|
|
72
|
+
mcpServers: fromAcpMcpServers(ctx.params.mcpServers),
|
|
73
|
+
});
|
|
74
|
+
return { sessionId };
|
|
75
|
+
})
|
|
76
|
+
.onRequest("session/resume", (ctx) => {
|
|
77
|
+
const session = requireSession(ctx.params.sessionId);
|
|
78
|
+
session.cwd = ctx.params.cwd;
|
|
79
|
+
if (ctx.params.mcpServers) session.mcpServers = fromAcpMcpServers(ctx.params.mcpServers);
|
|
80
|
+
return {};
|
|
81
|
+
})
|
|
82
|
+
.onRequest("session/close", async (ctx) => {
|
|
83
|
+
requireSession(ctx.params.sessionId);
|
|
84
|
+
await runtime.closeSession(harness, ctx.params.sessionId);
|
|
85
|
+
sessions.delete(ctx.params.sessionId);
|
|
86
|
+
return {};
|
|
87
|
+
})
|
|
88
|
+
.onNotification("session/cancel", (ctx) => {
|
|
89
|
+
if (sessions.has(ctx.params.sessionId)) {
|
|
90
|
+
void runtime.cancel(harness, ctx.params.sessionId);
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.onRequest("session/prompt", async (ctx) => {
|
|
94
|
+
const session = requireSession(ctx.params.sessionId);
|
|
95
|
+
const translator = new AcpUpdateTranslator();
|
|
96
|
+
const sink: EventSink = {
|
|
97
|
+
emit: async (event: LifecycleEvent) => {
|
|
98
|
+
if (event.type === "permission.requested") {
|
|
99
|
+
// Round-trip to the ACP client; its outcome shape matches ours.
|
|
100
|
+
let outcome: PermissionOutcome = { outcome: "cancelled" };
|
|
101
|
+
try {
|
|
102
|
+
const response: RequestPermissionResponse = await ctx.client.request(
|
|
103
|
+
"session/request_permission",
|
|
104
|
+
{
|
|
105
|
+
sessionId: ctx.params.sessionId,
|
|
106
|
+
toolCall: {
|
|
107
|
+
toolCallId: event.toolCall?.toolCallId ?? event.requestId,
|
|
108
|
+
title: event.title,
|
|
109
|
+
status: "pending",
|
|
110
|
+
...(event.toolCall?.kind && { kind: event.toolCall.kind }),
|
|
111
|
+
...(event.toolCall?.rawInput && { rawInput: event.toolCall.rawInput }),
|
|
112
|
+
},
|
|
113
|
+
options: event.options,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
outcome = response.outcome;
|
|
117
|
+
} catch {
|
|
118
|
+
// Client refused/disconnected — fall through to cancelled.
|
|
119
|
+
}
|
|
120
|
+
runtime.respondPermission(ctx.params.sessionId, event.requestId, outcome);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const update of translator.translate(event)) {
|
|
124
|
+
void ctx.client.notify("session/update", {
|
|
125
|
+
sessionId: ctx.params.sessionId,
|
|
126
|
+
update,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const summary = await runtime.run(
|
|
132
|
+
{
|
|
133
|
+
sessionId: ctx.params.sessionId,
|
|
134
|
+
turnId: generateUUIDv7(),
|
|
135
|
+
input: fromPromptBlocks(ctx.params.prompt),
|
|
136
|
+
config: {
|
|
137
|
+
harness,
|
|
138
|
+
cwd: session.cwd,
|
|
139
|
+
model: options.model,
|
|
140
|
+
permissionMode: options.permissionMode ?? "default",
|
|
141
|
+
mcpServers: session.mcpServers,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
sink,
|
|
145
|
+
);
|
|
146
|
+
if (summary.stopReason === "error") {
|
|
147
|
+
throw new Error(summary.error?.message ?? "turn failed");
|
|
148
|
+
}
|
|
149
|
+
return { stopReason: summary.stopReason };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Serve the binding on this process's stdio (what an ACP client spawns). */
|
|
154
|
+
export async function serveAcpStdio(options: AcpBindingOptions): Promise<AgentConnection> {
|
|
155
|
+
const acp = await import("@agentclientprotocol/sdk");
|
|
156
|
+
const app = await createAcpAgentApp(options);
|
|
157
|
+
return app.connect(
|
|
158
|
+
acp.ndJsonStream(
|
|
159
|
+
Writable.toWeb(process.stdout),
|
|
160
|
+
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ContentBlock, McpServer, SessionUpdate } from "@agentclientprotocol/sdk";
|
|
2
|
+
import type { LifecycleEvent, McpServerConfig, PartInput, ToolPart } from "../../protocol/index.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-prompt translator from the engine's LifecycleEvents to ACP v1
|
|
6
|
+
* `session/update` payloads — the exact inverse of the acp harness adapter
|
|
7
|
+
* (the vocabularies were aligned for this: ToolKind, tool status, and
|
|
8
|
+
* permission shapes pass through unchanged).
|
|
9
|
+
*
|
|
10
|
+
* Deliberate v1 gaps: sub-agent parts (`parentToolUseId`) have no ACP slot
|
|
11
|
+
* and are dropped (the parent tool's result carries the outcome);
|
|
12
|
+
* `session.compacted` has no v1 equivalent; `usage_update` requires `size`,
|
|
13
|
+
* so gauges without a window size are skipped.
|
|
14
|
+
*/
|
|
15
|
+
export class AcpUpdateTranslator {
|
|
16
|
+
/** toolCallIds already announced via `tool_call`. */
|
|
17
|
+
private readonly openedTools = new Set<string>();
|
|
18
|
+
/** Text/reasoning characters already emitted per part (dedupe snapshots vs deltas). */
|
|
19
|
+
private readonly sentByPart = new Map<string, number>();
|
|
20
|
+
|
|
21
|
+
translate(event: LifecycleEvent): SessionUpdate[] {
|
|
22
|
+
switch (event.type) {
|
|
23
|
+
case "message.part.delta": {
|
|
24
|
+
if (event.parentToolUseId) return [];
|
|
25
|
+
const { delta } = event;
|
|
26
|
+
if (delta.type === "tool-input-delta") return []; // covered by part snapshots
|
|
27
|
+
this.sentByPart.set(
|
|
28
|
+
event.partId,
|
|
29
|
+
(this.sentByPart.get(event.partId) ?? 0) + delta.text.length,
|
|
30
|
+
);
|
|
31
|
+
return [
|
|
32
|
+
chunk(
|
|
33
|
+
delta.type === "text-delta" ? "agent_message_chunk" : "agent_thought_chunk",
|
|
34
|
+
delta.text,
|
|
35
|
+
event.messageId,
|
|
36
|
+
),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
case "message.part": {
|
|
40
|
+
const part = event.part;
|
|
41
|
+
if (part.parentToolUseId) return [];
|
|
42
|
+
if (part.type === "tool") return this.toolUpdate(part);
|
|
43
|
+
// While a part is still streaming, its snapshots mirror the deltas
|
|
44
|
+
// (adapters mutate in place, so an early snapshot can already carry
|
|
45
|
+
// delta text — emitting both doubles it). Only the final `done`
|
|
46
|
+
// snapshot may contribute an unseen suffix, which is also how fully
|
|
47
|
+
// non-streamed parts (no deltas at all) get emitted exactly once.
|
|
48
|
+
if (part.state !== "done") return [];
|
|
49
|
+
const sent = this.sentByPart.get(part.id) ?? 0;
|
|
50
|
+
if (part.text.length <= sent) return [];
|
|
51
|
+
this.sentByPart.set(part.id, part.text.length);
|
|
52
|
+
return [
|
|
53
|
+
chunk(
|
|
54
|
+
part.type === "text" ? "agent_message_chunk" : "agent_thought_chunk",
|
|
55
|
+
part.text.slice(sent),
|
|
56
|
+
event.messageId,
|
|
57
|
+
),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
case "session.usage":
|
|
61
|
+
if (event.size === undefined) return [];
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
sessionUpdate: "usage_update",
|
|
65
|
+
used: event.used,
|
|
66
|
+
size: event.size,
|
|
67
|
+
...(event.cost !== undefined && {
|
|
68
|
+
cost: { amount: event.cost, currency: "USD" },
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
default:
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private toolUpdate(part: ToolPart): SessionUpdate[] {
|
|
78
|
+
// Statuses and ToolKind are the SDK's own vocabulary (DESIGN D2) — no
|
|
79
|
+
// re-narrowing, no casts.
|
|
80
|
+
const status = part.state.status;
|
|
81
|
+
if (!this.openedTools.has(part.toolCallId)) {
|
|
82
|
+
this.openedTools.add(part.toolCallId);
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
sessionUpdate: "tool_call",
|
|
86
|
+
toolCallId: part.toolCallId,
|
|
87
|
+
title: part.title ?? part.toolName,
|
|
88
|
+
name: part.toolName,
|
|
89
|
+
...(part.kind && { kind: part.kind }),
|
|
90
|
+
status,
|
|
91
|
+
...(part.locations?.length && { locations: part.locations }),
|
|
92
|
+
...(status !== "pending" && { rawInput: part.state.input }),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
const output =
|
|
97
|
+
part.state.status === "failed"
|
|
98
|
+
? part.state.error
|
|
99
|
+
: part.state.status === "completed"
|
|
100
|
+
? part.state.output
|
|
101
|
+
: undefined;
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
sessionUpdate: "tool_call_update",
|
|
105
|
+
toolCallId: part.toolCallId,
|
|
106
|
+
status,
|
|
107
|
+
...(output && {
|
|
108
|
+
content: [{ type: "content", content: { type: "text", text: output } }],
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function chunk(
|
|
116
|
+
kind: "agent_message_chunk" | "agent_thought_chunk",
|
|
117
|
+
text: string,
|
|
118
|
+
messageId: string,
|
|
119
|
+
): SessionUpdate {
|
|
120
|
+
return { sessionUpdate: kind, content: { type: "text", text }, messageId };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** ACP prompt content → engine input (text + base64 images; the rest is dropped). */
|
|
124
|
+
export function fromPromptBlocks(prompt: ContentBlock[]): string | PartInput[] {
|
|
125
|
+
const parts: PartInput[] = [];
|
|
126
|
+
for (const block of prompt) {
|
|
127
|
+
if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
|
|
128
|
+
else if (block.type === "image" && block.data) {
|
|
129
|
+
parts.push({ type: "image", data: block.data, mediaType: block.mimeType });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (parts.length === 1 && parts[0]?.type === "text") return parts[0].text;
|
|
133
|
+
return parts.length ? parts : "";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** ACP `McpServer[]` → engine MCP config map (inverse of the harness-side mapping). */
|
|
137
|
+
export function fromAcpMcpServers(
|
|
138
|
+
servers: McpServer[] | undefined,
|
|
139
|
+
): Record<string, McpServerConfig> | undefined {
|
|
140
|
+
if (!servers?.length) return undefined;
|
|
141
|
+
const out: Record<string, McpServerConfig> = {};
|
|
142
|
+
// Note: the SDK's `type: "acp"` nested-agent variant is silently dropped.
|
|
143
|
+
for (const server of servers) {
|
|
144
|
+
if ("command" in server) {
|
|
145
|
+
out[server.name] = {
|
|
146
|
+
type: "stdio",
|
|
147
|
+
command: server.command,
|
|
148
|
+
args: server.args,
|
|
149
|
+
env: Object.fromEntries((server.env ?? []).map((e) => [e.name, e.value])),
|
|
150
|
+
};
|
|
151
|
+
} else if ("url" in server && (server.type === "http" || server.type === "sse")) {
|
|
152
|
+
out[server.name] = {
|
|
153
|
+
type: server.type,
|
|
154
|
+
url: server.url,
|
|
155
|
+
headers: Object.fromEntries((server.headers ?? []).map((h) => [h.name, h.value])),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return Object.keys(out).length ? out : undefined;
|
|
160
|
+
}
|