@agentgrader/agent-acp 2.0.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.
@@ -0,0 +1,13 @@
1
+ import { AgentAdapter, SandboxHandle, AgentConfig, StepEvent, AgentResult } from '@agentgrader/core';
2
+
3
+ declare class AcpAgentAdapter implements AgentAdapter {
4
+ readonly name = "acp";
5
+ solve(input: {
6
+ prompt: string;
7
+ sandbox: SandboxHandle;
8
+ config: AgentConfig;
9
+ onStep: (step: StepEvent) => void;
10
+ }): Promise<AgentResult>;
11
+ }
12
+
13
+ export { AcpAgentAdapter };
package/dist/index.js ADDED
@@ -0,0 +1,419 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { spawn } from 'child_process';
3
+ import { Writable, Readable } from 'stream';
4
+ import { ndJsonStream, ClientSideConnection, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
5
+
6
+ // src/index.ts
7
+ var DEFAULT_WORKSPACE_ROOT = "/app";
8
+ function resolveAcpSpawn(config) {
9
+ if (!config.acp_command) {
10
+ throw new Error(
11
+ "acp_command is required in agent config when using AcpAgentAdapter (e.g. acp_command: cursor-agent)."
12
+ );
13
+ }
14
+ const explicitArgs = config.acp_args ?? [];
15
+ if (explicitArgs.length > 0) {
16
+ return { command: config.acp_command, args: explicitArgs };
17
+ }
18
+ const parts = config.acp_command.trim().split(/\s+/).filter(Boolean);
19
+ if (parts.length === 0) {
20
+ throw new Error("acp_command must not be empty.");
21
+ }
22
+ return { command: parts[0], args: parts.slice(1) };
23
+ }
24
+ function resolveSandboxPath(path, workspaceRoot) {
25
+ if (path.startsWith("/")) {
26
+ return path;
27
+ }
28
+ return `${workspaceRoot}/${path}`.replace(/\/+/g, "/");
29
+ }
30
+ function shellQuote(value) {
31
+ return `'${value.replace(/'/g, `'\\''`)}'`;
32
+ }
33
+ function sliceFileContent(content, line, limit) {
34
+ if (line == null && limit == null) {
35
+ return content;
36
+ }
37
+ const lines = content.split("\n");
38
+ const start = Math.max((line ?? 1) - 1, 0);
39
+ const end = limit == null ? lines.length : start + limit;
40
+ return lines.slice(start, end).join("\n");
41
+ }
42
+ var SandboxAcpClient = class {
43
+ constructor(sandbox, workspaceRoot, onStep) {
44
+ this.sandbox = sandbox;
45
+ this.workspaceRoot = workspaceRoot;
46
+ this.onStep = onStep;
47
+ }
48
+ sandbox;
49
+ workspaceRoot;
50
+ onStep;
51
+ terminals = /* @__PURE__ */ new Map();
52
+ stepIndex = 0;
53
+ emitStep(partial) {
54
+ const step = {
55
+ index: this.stepIndex++,
56
+ timestamp: Date.now(),
57
+ tokensIn: partial.tokensIn ?? 0,
58
+ tokensOut: partial.tokensOut ?? 0,
59
+ cachedTokens: partial.cachedTokens ?? 0,
60
+ costUsd: partial.costUsd ?? 0,
61
+ kind: partial.kind,
62
+ tool: partial.tool,
63
+ content: partial.content
64
+ };
65
+ this.onStep(step);
66
+ }
67
+ async requestPermission(params) {
68
+ const allowOption = params.options.find((option) => option.kind === "allow_once" || option.kind === "allow_always") ?? params.options[0];
69
+ if (!allowOption) {
70
+ return { outcome: { outcome: "cancelled" } };
71
+ }
72
+ this.emitStep({
73
+ kind: "tool_call",
74
+ tool: "requestPermission",
75
+ content: params.toolCall.title ?? params.toolCall.toolCallId
76
+ });
77
+ return {
78
+ outcome: {
79
+ outcome: "selected",
80
+ optionId: allowOption.optionId
81
+ }
82
+ };
83
+ }
84
+ async sessionUpdate(params) {
85
+ const update = params.update;
86
+ switch (update.sessionUpdate) {
87
+ case "agent_message_chunk":
88
+ this.emitStep({
89
+ kind: "message",
90
+ content: extractTextContent(update)
91
+ });
92
+ break;
93
+ case "agent_thought_chunk":
94
+ this.emitStep({
95
+ kind: "thinking",
96
+ content: extractTextContent(update)
97
+ });
98
+ break;
99
+ case "tool_call":
100
+ this.emitStep({
101
+ kind: "tool_call",
102
+ tool: update.title ?? update.toolCallId,
103
+ content: update.kind ?? String(update.status ?? "")
104
+ });
105
+ break;
106
+ case "tool_call_update":
107
+ this.emitStep({
108
+ kind: "tool_result",
109
+ tool: update.title ?? update.toolCallId,
110
+ content: String(update.status ?? "")
111
+ });
112
+ break;
113
+ }
114
+ }
115
+ async readTextFile(params) {
116
+ const path = resolveSandboxPath(params.path, this.workspaceRoot);
117
+ this.emitStep({
118
+ kind: "tool_call",
119
+ tool: "fs/read_text_file",
120
+ content: path
121
+ });
122
+ try {
123
+ const raw = await this.sandbox.readFile(path);
124
+ const content = sliceFileContent(raw, params.line, params.limit);
125
+ this.emitStep({
126
+ kind: "tool_result",
127
+ tool: "fs/read_text_file",
128
+ content: `${content.length} bytes`
129
+ });
130
+ return { content };
131
+ } catch {
132
+ throw RequestError.resourceNotFound(path);
133
+ }
134
+ }
135
+ async writeTextFile(params) {
136
+ const path = resolveSandboxPath(params.path, this.workspaceRoot);
137
+ this.emitStep({
138
+ kind: "tool_call",
139
+ tool: "fs/write_text_file",
140
+ content: path
141
+ });
142
+ try {
143
+ await this.sandbox.writeFile(path, params.content);
144
+ this.emitStep({
145
+ kind: "tool_result",
146
+ tool: "fs/write_text_file",
147
+ content: "ok"
148
+ });
149
+ return {};
150
+ } catch (err) {
151
+ const message = err instanceof Error ? err.message : String(err);
152
+ throw RequestError.internalError({ path }, message);
153
+ }
154
+ }
155
+ async createTerminal(params) {
156
+ const terminalId = randomUUID();
157
+ const outputFile = `/tmp/acp-term-${terminalId}.out`;
158
+ const exitFile = `/tmp/acp-term-${terminalId}.exit`;
159
+ const pidFile = `/tmp/acp-term-${terminalId}.pid`;
160
+ const cwd = params.cwd ?? this.workspaceRoot;
161
+ const args = (params.args ?? []).map(shellQuote).join(" ");
162
+ const envPrefix = params.env && params.env.length > 0 ? `${params.env.map((entry) => `${entry.name}=${shellQuote(entry.value)}`).join(" ")} ` : "";
163
+ const command = `${envPrefix}${shellQuote(params.command)}${args ? ` ${args}` : ""}`;
164
+ const shellCmd = [
165
+ `cd ${shellQuote(cwd)}`,
166
+ `(${command}) > ${shellQuote(outputFile)} 2>&1; echo $? > ${shellQuote(exitFile)} & echo $! > ${shellQuote(pidFile)}`
167
+ ].join(" && ");
168
+ this.emitStep({
169
+ kind: "tool_call",
170
+ tool: "terminal/create",
171
+ content: params.command
172
+ });
173
+ const spawnResult = await this.sandbox.exec(shellCmd);
174
+ if (spawnResult.exitCode !== 0) {
175
+ throw RequestError.internalError(
176
+ { command: params.command, stderr: spawnResult.stderr },
177
+ `Failed to start terminal command: ${spawnResult.stderr || spawnResult.stdout}`
178
+ );
179
+ }
180
+ this.terminals.set(terminalId, {
181
+ outputFile,
182
+ exitFile,
183
+ pidFile,
184
+ released: false
185
+ });
186
+ this.emitStep({
187
+ kind: "tool_result",
188
+ tool: "terminal/create",
189
+ content: terminalId
190
+ });
191
+ return { terminalId };
192
+ }
193
+ async terminalOutput(params) {
194
+ const terminal = this.getTerminal(params.terminalId);
195
+ const output = await this.readOptionalFile(terminal.outputFile);
196
+ const exitStatus = await this.readExitStatus(terminal);
197
+ this.emitStep({
198
+ kind: "tool_call",
199
+ tool: "terminal/output",
200
+ content: params.terminalId
201
+ });
202
+ return {
203
+ output,
204
+ truncated: false,
205
+ exitStatus
206
+ };
207
+ }
208
+ async waitForTerminalExit(params) {
209
+ const terminal = this.getTerminal(params.terminalId);
210
+ const deadline = Date.now() + 18e4;
211
+ while (Date.now() < deadline) {
212
+ const exitStatus = await this.readExitStatus(terminal);
213
+ if (exitStatus) {
214
+ this.emitStep({
215
+ kind: "tool_result",
216
+ tool: "terminal/wait_for_exit",
217
+ content: String(exitStatus.exitCode ?? "signal")
218
+ });
219
+ return {
220
+ exitCode: exitStatus.exitCode ?? null,
221
+ signal: exitStatus.signal ?? null
222
+ };
223
+ }
224
+ await sleep(100);
225
+ }
226
+ throw RequestError.internalError(
227
+ { terminalId: params.terminalId },
228
+ "Timed out waiting for terminal command to exit."
229
+ );
230
+ }
231
+ async killTerminal(params) {
232
+ const terminal = this.getTerminal(params.terminalId);
233
+ const pid = (await this.readOptionalFile(terminal.pidFile)).trim();
234
+ if (pid) {
235
+ await this.sandbox.exec(`kill -TERM ${shellQuote(pid)} 2>/dev/null || kill -KILL ${shellQuote(pid)} 2>/dev/null || true`);
236
+ }
237
+ this.emitStep({
238
+ kind: "tool_call",
239
+ tool: "terminal/kill",
240
+ content: params.terminalId
241
+ });
242
+ }
243
+ async releaseTerminal(params) {
244
+ const terminal = this.getTerminal(params.terminalId);
245
+ if (terminal.released) {
246
+ return;
247
+ }
248
+ const pid = (await this.readOptionalFile(terminal.pidFile)).trim();
249
+ if (pid) {
250
+ await this.sandbox.exec(`kill -TERM ${shellQuote(pid)} 2>/dev/null || true`);
251
+ }
252
+ terminal.released = true;
253
+ this.terminals.delete(params.terminalId);
254
+ await this.sandbox.exec(
255
+ `rm -f ${shellQuote(terminal.outputFile)} ${shellQuote(terminal.exitFile)} ${shellQuote(terminal.pidFile)}`
256
+ );
257
+ this.emitStep({
258
+ kind: "tool_result",
259
+ tool: "terminal/release",
260
+ content: params.terminalId
261
+ });
262
+ }
263
+ getTerminal(terminalId) {
264
+ const terminal = this.terminals.get(terminalId);
265
+ if (!terminal || terminal.released) {
266
+ throw RequestError.invalidParams({ terminalId }, `Unknown or released terminal: ${terminalId}`);
267
+ }
268
+ return terminal;
269
+ }
270
+ async readOptionalFile(path) {
271
+ try {
272
+ return await this.sandbox.readFile(path);
273
+ } catch {
274
+ return "";
275
+ }
276
+ }
277
+ async readExitStatus(terminal) {
278
+ const exitRaw = (await this.readOptionalFile(terminal.exitFile)).trim();
279
+ if (!exitRaw) {
280
+ return null;
281
+ }
282
+ const exitCode = Number.parseInt(exitRaw, 10);
283
+ return {
284
+ exitCode: Number.isNaN(exitCode) ? null : exitCode,
285
+ signal: null
286
+ };
287
+ }
288
+ };
289
+ function extractTextContent(chunk) {
290
+ if (chunk.content.type === "text") {
291
+ return chunk.content.text;
292
+ }
293
+ return void 0;
294
+ }
295
+ function sleep(ms) {
296
+ return new Promise((resolve) => setTimeout(resolve, ms));
297
+ }
298
+ function killProcess(proc) {
299
+ if (proc.exitCode != null || proc.signalCode != null) {
300
+ return;
301
+ }
302
+ proc.kill("SIGTERM");
303
+ setTimeout(() => {
304
+ if (proc.exitCode == null && proc.signalCode == null) {
305
+ proc.kill("SIGKILL");
306
+ }
307
+ }, 2e3).unref();
308
+ }
309
+ var AcpAgentAdapter = class {
310
+ name = "acp";
311
+ async solve(input) {
312
+ const { prompt, sandbox, config, onStep } = input;
313
+ const { command, args } = resolveAcpSpawn(config);
314
+ const workspaceRoot = config.acp_cwd ?? DEFAULT_WORKSPACE_ROOT;
315
+ const client = new SandboxAcpClient(sandbox, workspaceRoot, onStep);
316
+ let proc;
317
+ let connection;
318
+ try {
319
+ proc = spawn(command, args, {
320
+ stdio: ["pipe", "pipe", "pipe"],
321
+ env: {
322
+ ...process.env,
323
+ ...config.acp_env ?? {}
324
+ }
325
+ });
326
+ const stdin = proc.stdin;
327
+ const stdout = proc.stdout;
328
+ if (!stdin || !stdout) {
329
+ throw new Error(`Failed to open stdio pipes for ACP agent: ${command}`);
330
+ }
331
+ proc.on("error", (err) => {
332
+ onStep({
333
+ index: 0,
334
+ kind: "message",
335
+ timestamp: Date.now(),
336
+ tokensIn: 0,
337
+ tokensOut: 0,
338
+ cachedTokens: 0,
339
+ costUsd: 0,
340
+ content: `ACP subprocess error: ${err.message}`
341
+ });
342
+ });
343
+ const stream = ndJsonStream(
344
+ Writable.toWeb(stdin),
345
+ Readable.toWeb(stdout)
346
+ );
347
+ connection = new ClientSideConnection(() => client, stream);
348
+ await connection.initialize({
349
+ protocolVersion: PROTOCOL_VERSION,
350
+ clientCapabilities: {
351
+ fs: {
352
+ readTextFile: true,
353
+ writeTextFile: true
354
+ },
355
+ terminal: true
356
+ },
357
+ clientInfo: {
358
+ name: "Agentgrader",
359
+ version: "1.0.0"
360
+ }
361
+ });
362
+ const session = await connection.newSession({
363
+ cwd: workspaceRoot,
364
+ mcpServers: []
365
+ });
366
+ const stepTimeoutMs = config.step_timeout_ms ?? 12e4;
367
+ let timedOut = false;
368
+ let promptResponse;
369
+ try {
370
+ promptResponse = await Promise.race([
371
+ connection.prompt({
372
+ sessionId: session.sessionId,
373
+ prompt: [{ type: "text", text: prompt }]
374
+ }),
375
+ new Promise((_, reject) => {
376
+ setTimeout(() => {
377
+ timedOut = true;
378
+ void connection?.cancel({ sessionId: session.sessionId }).catch(() => void 0);
379
+ reject(new Error(`ACP prompt timed out after ${stepTimeoutMs}ms (step_timeout_ms).`));
380
+ }, stepTimeoutMs);
381
+ })
382
+ ]);
383
+ } catch (err) {
384
+ if (timedOut) {
385
+ const finalDiff2 = await sandbox.gitDiff().catch(() => "");
386
+ return {
387
+ finished: false,
388
+ finalDiff: finalDiff2,
389
+ error: err instanceof Error ? err.message : `ACP prompt timed out after ${stepTimeoutMs}ms (step_timeout_ms).`
390
+ };
391
+ }
392
+ throw err;
393
+ }
394
+ const finalDiff = await sandbox.gitDiff();
395
+ const finished = promptResponse.stopReason === "end_turn";
396
+ return {
397
+ finished,
398
+ finalDiff,
399
+ error: finished ? void 0 : `ACP agent stopped with reason: ${promptResponse.stopReason}`
400
+ };
401
+ } catch (err) {
402
+ const finalDiff = await sandbox.gitDiff().catch(() => "");
403
+ return {
404
+ finished: false,
405
+ finalDiff,
406
+ error: err instanceof Error ? err.message : String(err)
407
+ };
408
+ } finally {
409
+ if (connection) {
410
+ await Promise.race([connection.closed, sleep(2e3)]);
411
+ }
412
+ if (proc) {
413
+ killProcess(proc);
414
+ }
415
+ }
416
+ }
417
+ };
418
+
419
+ export { AcpAgentAdapter };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@agentgrader/agent-acp",
3
+ "version": "2.0.0",
4
+ "description": "Agent Client Protocol (ACP) adapter for the Agentgrader framework",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/index.js",
13
+ "types": "./dist/index.d.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup src/index.ts --format esm --dts --clean --treeshake",
21
+ "build:watch": "tsup src/index.ts --format esm --dts --watch"
22
+ },
23
+ "dependencies": {
24
+ "@agentclientprotocol/sdk": "^0.25.1",
25
+ "@agentgrader/core": "^1.3.0",
26
+ "zod": "^3.23.8"
27
+ },
28
+ "devDependencies": {
29
+ "tsup": "^8.5.1"
30
+ },
31
+ "peerDependencies": {
32
+ "@agentgrader/core": "^1.3.0"
33
+ }
34
+ }