@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,322 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InitializeResponse,
|
|
3
|
+
PromptRequest,
|
|
4
|
+
RequestPermissionRequest,
|
|
5
|
+
RequestPermissionResponse,
|
|
6
|
+
} from "@agentclientprotocol/sdk";
|
|
7
|
+
import type { AgentCapabilities, AgentInput, PermissionMode } from "../../../protocol/index.ts";
|
|
8
|
+
import { AsyncQueue } from "../../../protocol/index.ts";
|
|
9
|
+
import type { AgentExecuteOptions, PermissionRequestHandler, RawAgentEvent } from "../base.ts";
|
|
10
|
+
import { BaseAgent } from "../base.ts";
|
|
11
|
+
import { configFingerprint } from "../config-fingerprint.ts";
|
|
12
|
+
import { SessionStore } from "../session-store.ts";
|
|
13
|
+
import { AcpClient, type AcpClientOptions, type AcpLaunch } from "./client.ts";
|
|
14
|
+
import {
|
|
15
|
+
CANCELLED_OUTCOME,
|
|
16
|
+
autoDecide,
|
|
17
|
+
chooseOutcome,
|
|
18
|
+
permissionToolCall,
|
|
19
|
+
toAcpMcpServers,
|
|
20
|
+
toPromptBlocks,
|
|
21
|
+
} from "./mappings.ts";
|
|
22
|
+
|
|
23
|
+
const CAPABILITIES: AgentCapabilities = {
|
|
24
|
+
multiTurn: true,
|
|
25
|
+
// The engine always *accepts* resumeSessionId; whether context actually
|
|
26
|
+
// survives depends on the target agent's `sessionCapabilities.resume`
|
|
27
|
+
// (falls back to a fresh session when unsupported).
|
|
28
|
+
sessionResume: true,
|
|
29
|
+
// v1 exposes model selection only through agent-defined config options;
|
|
30
|
+
// there is no portable model parameter to pass through yet.
|
|
31
|
+
modelSwitch: "unsupported",
|
|
32
|
+
thinkingLevels: false,
|
|
33
|
+
images: true,
|
|
34
|
+
mcpServers: true,
|
|
35
|
+
permissionRequests: true,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** ACP protocol major version this harness speaks. */
|
|
39
|
+
const PROTOCOL_VERSION = 1;
|
|
40
|
+
|
|
41
|
+
/** Idle time before an unused agent subprocess is shut down. */
|
|
42
|
+
const IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
43
|
+
|
|
44
|
+
/** How long a cancelled prompt may take to resolve before the turn is failed. */
|
|
45
|
+
const CANCEL_GRACE_MS = 10_000;
|
|
46
|
+
|
|
47
|
+
/** Deadline for `initialize` on a freshly spawned agent (mirrors codex startup). */
|
|
48
|
+
const INITIALIZE_TIMEOUT_MS = 15_000;
|
|
49
|
+
|
|
50
|
+
/** Deadline for session lifecycle requests (`session/new`, `session/resume`). */
|
|
51
|
+
const SESSION_REQUEST_TIMEOUT_MS = 30_000;
|
|
52
|
+
|
|
53
|
+
export interface AcpAgentOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the ACP agent subprocess to launch. Operator-level only — never
|
|
56
|
+
* sourced from the wire (the command is arbitrary code execution).
|
|
57
|
+
*/
|
|
58
|
+
resolveLaunch: () => Promise<AcpLaunch | undefined> | AcpLaunch | undefined;
|
|
59
|
+
/** `clientInfo.name` advertised to agents. */
|
|
60
|
+
clientName?: string;
|
|
61
|
+
idleTimeoutMs?: number;
|
|
62
|
+
/** Grace period for a `session/cancel` to resolve the in-flight prompt. */
|
|
63
|
+
cancelGraceMs?: number;
|
|
64
|
+
/** Injectable client factory for deterministic tests. */
|
|
65
|
+
createClient?: (options: AcpClientOptions) => Promise<AcpClient>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Permission context of the turn currently running on a session — set and
|
|
69
|
+
* cleared as ONE value so stale partial state (an authorization bypass) is
|
|
70
|
+
* unrepresentable. `undefined` between turns → the auto policy rejects. */
|
|
71
|
+
interface TurnPermissions {
|
|
72
|
+
broker?: PermissionRequestHandler;
|
|
73
|
+
mode?: PermissionMode;
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface AcpSession {
|
|
78
|
+
client: AcpClient;
|
|
79
|
+
acpSessionId: string;
|
|
80
|
+
cwd: string;
|
|
81
|
+
envFingerprint: string;
|
|
82
|
+
mcpFingerprint: string;
|
|
83
|
+
launchFingerprint: string;
|
|
84
|
+
/** From `initialize`: whether the agent accepts image content blocks. */
|
|
85
|
+
imagesSupported: boolean;
|
|
86
|
+
turn?: TurnPermissions;
|
|
87
|
+
idleTimer?: ReturnType<typeof setTimeout>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function acpSessionCompatible(
|
|
91
|
+
existing: AcpSession | undefined,
|
|
92
|
+
options: AgentExecuteOptions,
|
|
93
|
+
launchFingerprint: string,
|
|
94
|
+
): boolean {
|
|
95
|
+
return Boolean(
|
|
96
|
+
existing &&
|
|
97
|
+
!existing.client.closed &&
|
|
98
|
+
existing.cwd === options.cwd &&
|
|
99
|
+
existing.envFingerprint === configFingerprint(options.env) &&
|
|
100
|
+
existing.mcpFingerprint === configFingerprint(options.mcpServers) &&
|
|
101
|
+
existing.launchFingerprint === launchFingerprint &&
|
|
102
|
+
(!options.resumeSessionId || options.resumeSessionId === existing.acpSessionId),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Answer an agent permission request against the session's current turn. */
|
|
107
|
+
export async function answerPermission(
|
|
108
|
+
session: AcpSession,
|
|
109
|
+
request: RequestPermissionRequest,
|
|
110
|
+
): Promise<RequestPermissionResponse> {
|
|
111
|
+
if (request.sessionId !== session.acpSessionId) return CANCELLED_OUTCOME;
|
|
112
|
+
const turn = session.turn;
|
|
113
|
+
const asks = (turn?.mode ?? "default") === "default";
|
|
114
|
+
if (!turn?.broker || !asks) return autoDecide(request, turn?.mode);
|
|
115
|
+
const decision = await turn.broker(permissionToolCall(request), { signal: turn.signal });
|
|
116
|
+
if (decision.decision === "cancel") return CANCELLED_OUTCOME;
|
|
117
|
+
return chooseOutcome(request.options, decision.decision === "allow");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ACP harness: drives any Agent Client Protocol v1 agent as a long-lived
|
|
122
|
+
* subprocess (one per logical session, reused across turns). The launch
|
|
123
|
+
* command comes from the operator's `resolveLaunch`; `initialize` negotiates
|
|
124
|
+
* the protocol version and gates optional features (resume, images) on the
|
|
125
|
+
* agent's advertised capabilities. The turn's stopReason arrives as the
|
|
126
|
+
* `session/prompt` *response*, so `execute` re-injects it into the raw stream
|
|
127
|
+
* as a synthetic `session/prompt_result` event for the adapter.
|
|
128
|
+
*/
|
|
129
|
+
export class AcpAgent extends BaseAgent {
|
|
130
|
+
readonly harness = "acp" as const;
|
|
131
|
+
readonly capabilities = CAPABILITIES;
|
|
132
|
+
private readonly sessions = new SessionStore<AcpSession>();
|
|
133
|
+
|
|
134
|
+
constructor(private readonly agentOptions: AcpAgentOptions) {
|
|
135
|
+
super();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async resolveSession(options: AgentExecuteOptions): Promise<AcpSession> {
|
|
139
|
+
const launch = await this.agentOptions.resolveLaunch();
|
|
140
|
+
if (!launch) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"acp harness is not configured: resolveLaunch returned no agent launch command",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const launchFingerprint = configFingerprint({
|
|
146
|
+
command: launch.command,
|
|
147
|
+
args: launch.args?.join(" ") ?? "",
|
|
148
|
+
...launch.env,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const existing = this.sessions.get(options.sessionId);
|
|
152
|
+
let resumeTarget = options.resumeSessionId;
|
|
153
|
+
if (existing && !existing.client.closed) {
|
|
154
|
+
if (acpSessionCompatible(existing, options, launchFingerprint)) {
|
|
155
|
+
this.sessions.clearIdle(existing);
|
|
156
|
+
return existing;
|
|
157
|
+
}
|
|
158
|
+
// Preserve context within the same cwd/env boundary when the agent can
|
|
159
|
+
// resume (mirrors the claude-code/codex restart policy).
|
|
160
|
+
if (
|
|
161
|
+
resumeTarget === undefined &&
|
|
162
|
+
existing.cwd === options.cwd &&
|
|
163
|
+
existing.envFingerprint === configFingerprint(options.env)
|
|
164
|
+
) {
|
|
165
|
+
resumeTarget = existing.acpSessionId;
|
|
166
|
+
}
|
|
167
|
+
this.sessions.close(options.sessionId);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const clientOptions: AcpClientOptions = {
|
|
171
|
+
launch,
|
|
172
|
+
cwd: options.cwd,
|
|
173
|
+
env: options.env,
|
|
174
|
+
clientName: this.agentOptions.clientName,
|
|
175
|
+
};
|
|
176
|
+
const client = this.agentOptions.createClient
|
|
177
|
+
? await this.agentOptions.createClient(clientOptions)
|
|
178
|
+
: await AcpClient.create(clientOptions);
|
|
179
|
+
client.onClose(() => this.sessions.dropIfCurrent(options.sessionId, client));
|
|
180
|
+
|
|
181
|
+
let init: InitializeResponse;
|
|
182
|
+
let acpSessionId: string | undefined;
|
|
183
|
+
try {
|
|
184
|
+
init = await client.request(
|
|
185
|
+
"initialize",
|
|
186
|
+
{ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} },
|
|
187
|
+
INITIALIZE_TIMEOUT_MS,
|
|
188
|
+
);
|
|
189
|
+
const mcpServers = toAcpMcpServers(options.mcpServers);
|
|
190
|
+
if (resumeTarget && init.agentCapabilities?.sessionCapabilities?.resume) {
|
|
191
|
+
try {
|
|
192
|
+
await client.request(
|
|
193
|
+
"session/resume",
|
|
194
|
+
{ sessionId: resumeTarget, cwd: options.cwd, mcpServers },
|
|
195
|
+
SESSION_REQUEST_TIMEOUT_MS,
|
|
196
|
+
);
|
|
197
|
+
acpSessionId = resumeTarget;
|
|
198
|
+
} catch {
|
|
199
|
+
// Unknown/expired session id — fall through to a fresh one.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!acpSessionId) {
|
|
203
|
+
const created = await client.request(
|
|
204
|
+
"session/new",
|
|
205
|
+
{ cwd: options.cwd, mcpServers },
|
|
206
|
+
SESSION_REQUEST_TIMEOUT_MS,
|
|
207
|
+
);
|
|
208
|
+
acpSessionId = created.sessionId;
|
|
209
|
+
}
|
|
210
|
+
if (!acpSessionId) throw new Error("acp agent returned no session id");
|
|
211
|
+
} catch (error) {
|
|
212
|
+
client.close();
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const session: AcpSession = {
|
|
217
|
+
client,
|
|
218
|
+
acpSessionId,
|
|
219
|
+
cwd: options.cwd,
|
|
220
|
+
envFingerprint: configFingerprint(options.env),
|
|
221
|
+
mcpFingerprint: configFingerprint(options.mcpServers),
|
|
222
|
+
launchFingerprint,
|
|
223
|
+
imagesSupported: Boolean(init.agentCapabilities?.promptCapabilities?.image),
|
|
224
|
+
};
|
|
225
|
+
client.setPermissionHandler((request) => answerPermission(session, request));
|
|
226
|
+
this.sessions.set(options.sessionId, session);
|
|
227
|
+
return session;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async *execute(
|
|
231
|
+
input: AgentInput,
|
|
232
|
+
options: AgentExecuteOptions,
|
|
233
|
+
): AsyncIterableIterator<RawAgentEvent> {
|
|
234
|
+
const controller = this.trackTurn(options.sessionId, options.signal);
|
|
235
|
+
const queue = new AsyncQueue<RawAgentEvent>();
|
|
236
|
+
const unsubscribe: Array<() => void> = [];
|
|
237
|
+
let cancelTimer: ReturnType<typeof setTimeout> | undefined;
|
|
238
|
+
let session: AcpSession | undefined;
|
|
239
|
+
try {
|
|
240
|
+
session = await this.resolveSession(options);
|
|
241
|
+
session.turn = {
|
|
242
|
+
broker: options.onPermissionRequest,
|
|
243
|
+
mode: options.permissionMode,
|
|
244
|
+
signal: controller.signal,
|
|
245
|
+
};
|
|
246
|
+
options.onNativeSession?.(session.acpSessionId, {
|
|
247
|
+
resumed: session.acpSessionId === options.resumeSessionId,
|
|
248
|
+
});
|
|
249
|
+
const { client, acpSessionId } = session;
|
|
250
|
+
|
|
251
|
+
unsubscribe.push(
|
|
252
|
+
client.onSessionUpdate((notification) => {
|
|
253
|
+
if (notification.sessionId !== acpSessionId) return;
|
|
254
|
+
queue.push({ method: "session/update", params: notification });
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
unsubscribe.push(
|
|
258
|
+
client.onClose((err) => queue.fail(err ?? new Error("acp agent exited mid-turn"))),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const onAbort = () => {
|
|
262
|
+
// ACP semantics: session/cancel makes the in-flight prompt resolve
|
|
263
|
+
// with stopReason=cancelled. Bound the wait for misbehaving agents.
|
|
264
|
+
void client.notify("session/cancel", { sessionId: acpSessionId });
|
|
265
|
+
cancelTimer = setTimeout(
|
|
266
|
+
() => queue.fail(new Error("acp agent ignored session/cancel")),
|
|
267
|
+
this.agentOptions.cancelGraceMs ?? CANCEL_GRACE_MS,
|
|
268
|
+
);
|
|
269
|
+
cancelTimer.unref?.();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (controller.signal.aborted) {
|
|
273
|
+
// Aborted while the session was being set up (a listener added now
|
|
274
|
+
// would never fire) — never start the prompt at all.
|
|
275
|
+
queue.push({ method: "session/prompt_result", params: { stopReason: "cancelled" } });
|
|
276
|
+
queue.end();
|
|
277
|
+
} else {
|
|
278
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
279
|
+
const prompt: PromptRequest = {
|
|
280
|
+
sessionId: acpSessionId,
|
|
281
|
+
prompt: toPromptBlocks(input, session.imagesSupported),
|
|
282
|
+
};
|
|
283
|
+
client.request("session/prompt", prompt).then(
|
|
284
|
+
(response) => {
|
|
285
|
+
queue.push({ method: "session/prompt_result", params: response });
|
|
286
|
+
queue.end();
|
|
287
|
+
},
|
|
288
|
+
(error: unknown) => {
|
|
289
|
+
queue.push({ method: "error", params: { message: String(error) } });
|
|
290
|
+
queue.end();
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for await (const event of queue) {
|
|
296
|
+
yield event;
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
if (cancelTimer) clearTimeout(cancelTimer);
|
|
300
|
+
for (const off of unsubscribe) off();
|
|
301
|
+
if (session) session.turn = undefined;
|
|
302
|
+
this.endTurn(options.sessionId, controller);
|
|
303
|
+
if (session && !session.client.closed) {
|
|
304
|
+
this.sessions.armIdle(
|
|
305
|
+
options.sessionId,
|
|
306
|
+
session,
|
|
307
|
+
this.agentOptions.idleTimeoutMs ?? IDLE_TIMEOUT_MS,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
override async release(sessionId: string): Promise<void> {
|
|
314
|
+
await super.release(sessionId);
|
|
315
|
+
this.sessions.close(sessionId);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
override async terminateAll(): Promise<void> {
|
|
319
|
+
await super.terminateAll();
|
|
320
|
+
this.sessions.closeAll();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContentBlock,
|
|
3
|
+
PromptResponse,
|
|
4
|
+
SessionNotification,
|
|
5
|
+
SessionUpdate,
|
|
6
|
+
ToolCallContent,
|
|
7
|
+
ToolCallUpdate,
|
|
8
|
+
} from "@agentclientprotocol/sdk";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_TOKEN_USAGE,
|
|
11
|
+
type ReasoningPart,
|
|
12
|
+
STOP_REASONS,
|
|
13
|
+
type StopReason,
|
|
14
|
+
type StreamContext,
|
|
15
|
+
type TextPart,
|
|
16
|
+
type ToolPart,
|
|
17
|
+
completeToolPart,
|
|
18
|
+
createReasoningPart,
|
|
19
|
+
createTextPart,
|
|
20
|
+
createToolPart,
|
|
21
|
+
setToolMeta,
|
|
22
|
+
startToolPart,
|
|
23
|
+
} from "../../../protocol/index.ts";
|
|
24
|
+
import type { AdapterEvent, EventTransformer, TransformResult } from "../types.ts";
|
|
25
|
+
import { isRecord, toToolLocations, toolKindOf } from "./mappings.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parses ACP v1 raw events into AdapterEvents. The mapping is nearly identity:
|
|
29
|
+
* our part vocabulary (ToolKind, tool status, locations) was aligned to ACP up
|
|
30
|
+
* front (DESIGN D2), so chunks append to text/reasoning parts and
|
|
31
|
+
* `tool_call`/`tool_call_update` upsert tool parts by `toolCallId`. Everything
|
|
32
|
+
* arrives through the SDK connection, which zod-validates inbound payloads —
|
|
33
|
+
* so the SDK's own types ARE the raw shapes (no defensive re-modelling).
|
|
34
|
+
*
|
|
35
|
+
* Raw events are the shapes AcpAgent yields:
|
|
36
|
+
* - `{method: "session/update", params: SessionNotification}`
|
|
37
|
+
* - `{method: "session/prompt_result", params: PromptResponse}` (synthetic —
|
|
38
|
+
* ACP returns the turn's stopReason as the prompt *response*, not as a
|
|
39
|
+
* notification, so the agent re-injects it into the event stream)
|
|
40
|
+
* - `{method: "error", params: {message}}` (synthetic, prompt failure)
|
|
41
|
+
*
|
|
42
|
+
* v1 `messageId` on chunks is optional: when present, a change opens a new
|
|
43
|
+
* wire message; when absent the whole turn is one message (the processor
|
|
44
|
+
* synthesizes the boundary).
|
|
45
|
+
*/
|
|
46
|
+
type RawEvent =
|
|
47
|
+
| { method: "session/update"; params: SessionNotification }
|
|
48
|
+
| { method: "session/prompt_result"; params: Partial<PromptResponse> }
|
|
49
|
+
| { method: "error"; params: { message?: string } };
|
|
50
|
+
|
|
51
|
+
function stopReasonOf(value: string | undefined): StopReason | undefined {
|
|
52
|
+
return value && (STOP_REASONS as readonly string[]).includes(value)
|
|
53
|
+
? (value as StopReason)
|
|
54
|
+
: undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Flatten a tool call's content blocks (plus rawOutput) into one output string. */
|
|
58
|
+
function toolOutput(content: ToolCallContent[] | null | undefined, rawOutput: unknown): string {
|
|
59
|
+
const texts = (content ?? [])
|
|
60
|
+
.map((item) =>
|
|
61
|
+
item.type === "content" && item.content.type === "text" ? item.content.text : "",
|
|
62
|
+
)
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
if (texts.length) return texts.join("\n");
|
|
65
|
+
return rawOutput !== undefined && rawOutput !== null ? JSON.stringify(rawOutput) : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type StreamSlot = "text" | "reasoning";
|
|
69
|
+
|
|
70
|
+
export class AcpTransformer implements EventTransformer<unknown> {
|
|
71
|
+
private readonly ctx: StreamContext;
|
|
72
|
+
private readonly toolParts = new Map<string, ToolPart>();
|
|
73
|
+
private readonly streamParts = new Map<StreamSlot, TextPart | ReasoningPart>();
|
|
74
|
+
/** messageId of the currently open wire message (undefined = implicit single message). */
|
|
75
|
+
private currentMessageId?: string;
|
|
76
|
+
private messageOpen = false;
|
|
77
|
+
|
|
78
|
+
private stopReason?: StopReason;
|
|
79
|
+
private finishReason?: string;
|
|
80
|
+
private error?: string;
|
|
81
|
+
|
|
82
|
+
constructor(ctx: { sessionId: string }) {
|
|
83
|
+
this.ctx = { sessionId: ctx.sessionId, messageId: "" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
process(raw: unknown): AdapterEvent[] {
|
|
87
|
+
const event = raw as RawEvent;
|
|
88
|
+
switch (event.method) {
|
|
89
|
+
case "session/update":
|
|
90
|
+
return this.onUpdate(event.params.update);
|
|
91
|
+
case "session/prompt_result":
|
|
92
|
+
this.stopReason = stopReasonOf(event.params.stopReason);
|
|
93
|
+
this.finishReason = event.params.stopReason;
|
|
94
|
+
return this.finalizeStreamingParts();
|
|
95
|
+
case "error":
|
|
96
|
+
this.error = event.params.message ?? "acp agent error";
|
|
97
|
+
this.finishReason = "error";
|
|
98
|
+
// A failed prompt still closes any parts left streaming.
|
|
99
|
+
return this.finalizeStreamingParts();
|
|
100
|
+
default:
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
finish(): TransformResult {
|
|
106
|
+
// ACP v1 has no per-turn token usage (the end-turn-token-usage RFD is
|
|
107
|
+
// still draft); `usage_update` is a context-window gauge, not a bill.
|
|
108
|
+
return {
|
|
109
|
+
usage: { ...DEFAULT_TOKEN_USAGE },
|
|
110
|
+
stopReason: this.stopReason,
|
|
111
|
+
finishReason: this.finishReason,
|
|
112
|
+
error: this.error,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private onUpdate(update: SessionUpdate): AdapterEvent[] {
|
|
117
|
+
switch (update.sessionUpdate) {
|
|
118
|
+
case "agent_message_chunk":
|
|
119
|
+
return this.chunk("text", update.content, update.messageId);
|
|
120
|
+
case "agent_thought_chunk":
|
|
121
|
+
return this.chunk("reasoning", update.content, update.messageId);
|
|
122
|
+
case "tool_call":
|
|
123
|
+
return this.toolCall(update);
|
|
124
|
+
case "tool_call_update":
|
|
125
|
+
return this.toolCallUpdate(update);
|
|
126
|
+
case "usage_update": {
|
|
127
|
+
// ACP's stable context gauge maps 1:1 onto session.usage. ACP cost is
|
|
128
|
+
// {amount, currency}; ours is a bare USD number — map USD only.
|
|
129
|
+
const usd = update.cost?.currency === "USD" ? update.cost.amount : undefined;
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
kind: "usage",
|
|
133
|
+
used: update.used,
|
|
134
|
+
size: update.size,
|
|
135
|
+
...(usd !== undefined && { cost: usd }),
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
default:
|
|
140
|
+
// plan / available_commands / current_mode / config_option /
|
|
141
|
+
// session_info / usage updates have no part representation yet;
|
|
142
|
+
// `includeRaw` consumers still see them.
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private chunk(
|
|
148
|
+
slot: StreamSlot,
|
|
149
|
+
content: ContentBlock,
|
|
150
|
+
messageId: string | null | undefined,
|
|
151
|
+
): AdapterEvent[] {
|
|
152
|
+
if (content.type !== "text" || !content.text) return [];
|
|
153
|
+
const events: AdapterEvent[] = [...this.rolloverMessage(messageId ?? undefined)];
|
|
154
|
+
let part = this.streamParts.get(slot);
|
|
155
|
+
if (!part) {
|
|
156
|
+
part =
|
|
157
|
+
slot === "text"
|
|
158
|
+
? createTextPart(this.ctx, "", true)
|
|
159
|
+
: createReasoningPart(this.ctx, "", true);
|
|
160
|
+
this.streamParts.set(slot, part);
|
|
161
|
+
events.push({ kind: "part-open", part });
|
|
162
|
+
}
|
|
163
|
+
part.text += content.text;
|
|
164
|
+
events.push(
|
|
165
|
+
slot === "text"
|
|
166
|
+
? { kind: "text-delta", partId: part.id, text: content.text }
|
|
167
|
+
: { kind: "reasoning-delta", partId: part.id, text: content.text },
|
|
168
|
+
);
|
|
169
|
+
return events;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private toolCall(update: ToolCallUpdate): AdapterEvent[] {
|
|
173
|
+
if (this.toolParts.has(update.toolCallId)) return this.toolCallUpdate(update); // duplicate open
|
|
174
|
+
const part = this.openTool(update);
|
|
175
|
+
const events: AdapterEvent[] = [{ kind: "part-open", part }];
|
|
176
|
+
if (this.applyToolUpdate(part, update)) events.push({ kind: "part-update", part });
|
|
177
|
+
return events;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private toolCallUpdate(update: ToolCallUpdate): AdapterEvent[] {
|
|
181
|
+
// Update before (or without) its tool_call — open a part so nothing is lost.
|
|
182
|
+
const part = this.toolParts.get(update.toolCallId) ?? this.openTool(update);
|
|
183
|
+
this.applyToolUpdate(part, update);
|
|
184
|
+
return [{ kind: "part-update", part }];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private openTool(update: ToolCallUpdate): ToolPart {
|
|
188
|
+
const part = createToolPart(
|
|
189
|
+
this.ctx,
|
|
190
|
+
update.toolCallId,
|
|
191
|
+
update.name ?? update.title ?? "tool",
|
|
192
|
+
isRecord(update.rawInput) ? update.rawInput : {},
|
|
193
|
+
{
|
|
194
|
+
kind: toolKindOf(update.kind),
|
|
195
|
+
title: update.title ?? undefined,
|
|
196
|
+
locations: toToolLocations(update.locations),
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
this.toolParts.set(update.toolCallId, part);
|
|
200
|
+
return part;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Fold an update's meta/input/status into the part; true when it just completed. */
|
|
204
|
+
private applyToolUpdate(part: ToolPart, update: ToolCallUpdate): boolean {
|
|
205
|
+
setToolMeta(part, {
|
|
206
|
+
kind: toolKindOf(update.kind),
|
|
207
|
+
title: update.title ?? undefined,
|
|
208
|
+
locations: toToolLocations(update.locations),
|
|
209
|
+
});
|
|
210
|
+
const terminal = part.state.status === "completed" || part.state.status === "failed";
|
|
211
|
+
if (terminal) return false;
|
|
212
|
+
if (isRecord(update.rawInput)) {
|
|
213
|
+
if (part.state.status === "pending") startToolPart(part, update.rawInput);
|
|
214
|
+
else part.state.input = update.rawInput;
|
|
215
|
+
}
|
|
216
|
+
if (update.status !== "completed" && update.status !== "failed") return false;
|
|
217
|
+
completeToolPart(part, {
|
|
218
|
+
output: toolOutput(update.content, update.rawOutput),
|
|
219
|
+
isError: update.status === "failed",
|
|
220
|
+
});
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A new v1 `messageId` closes the current wire message and opens the next. */
|
|
225
|
+
private rolloverMessage(messageId: string | undefined): AdapterEvent[] {
|
|
226
|
+
if (!messageId) {
|
|
227
|
+
// No id: everything belongs to one implicit message; open it lazily so
|
|
228
|
+
// the assistant role is explicit (parts still work without it).
|
|
229
|
+
if (this.messageOpen) return [];
|
|
230
|
+
this.messageOpen = true;
|
|
231
|
+
return [{ kind: "message-start", role: "assistant" }];
|
|
232
|
+
}
|
|
233
|
+
if (messageId === this.currentMessageId) return [];
|
|
234
|
+
const events: AdapterEvent[] = [];
|
|
235
|
+
if (this.messageOpen) {
|
|
236
|
+
events.push(...this.finalizeStreamingParts(), { kind: "message-end" });
|
|
237
|
+
}
|
|
238
|
+
this.currentMessageId = messageId;
|
|
239
|
+
this.messageOpen = true;
|
|
240
|
+
this.streamParts.clear();
|
|
241
|
+
events.push({ kind: "message-start", role: "assistant" });
|
|
242
|
+
return events;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Mark still-streaming text/reasoning parts done (turn or message boundary). */
|
|
246
|
+
private finalizeStreamingParts(): AdapterEvent[] {
|
|
247
|
+
const events: AdapterEvent[] = [];
|
|
248
|
+
for (const part of this.streamParts.values()) {
|
|
249
|
+
if (part.state === "streaming") {
|
|
250
|
+
part.state = "done";
|
|
251
|
+
events.push({ kind: "part-update", part });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return events;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function createAcpTransformer(ctx: { sessionId: string }): EventTransformer {
|
|
259
|
+
return new AcpTransformer(ctx);
|
|
260
|
+
}
|