@robota-sdk/agent-transport-acp 3.0.0-beta.96 → 3.0.1
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/CHANGELOG.md +362 -0
- package/README.md +7 -3
- package/dist/node/index.cjs +3 -3
- package/dist/node/index.d.cts +39 -7
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +39 -7
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +3 -3
- package/dist/node/index.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/acp-editor-files.test.ts +236 -0
- package/src/__tests__/acp-session-load.test.ts +649 -0
- package/src/__tests__/acp-test-harness.ts +9 -1
- package/src/acp-agent.ts +180 -42
- package/src/acp-editor-files.ts +87 -0
- package/src/acp-session-catalog.ts +65 -0
- package/src/acp-tool-calls.ts +21 -11
- package/src/acp-transport.ts +5 -0
- package/src/acp-turn.ts +2 -2
- package/src/acp-types.ts +39 -5
- package/src/index.ts +2 -0
package/src/acp-agent.ts
CHANGED
|
@@ -6,13 +6,21 @@ import { isAbsolute, resolve } from 'node:path';
|
|
|
6
6
|
import { agent, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
|
|
7
7
|
import { OWNER_DRIVER_ID } from '@robota-sdk/agent-interface-session';
|
|
8
8
|
|
|
9
|
+
import { editorFileAccess } from './acp-editor-files.js';
|
|
9
10
|
import { promptText } from './acp-prompt-text.js';
|
|
11
|
+
import { availableCommands, replayHistory } from './acp-session-catalog.js';
|
|
10
12
|
import { runCommandTurn, runModelTurn } from './acp-turn.js';
|
|
11
13
|
import { createUpdateStream } from './acp-updates.js';
|
|
12
14
|
|
|
13
15
|
import type { IAcpTurnContext } from './acp-turn.js';
|
|
14
16
|
import type { IAcpTransportOptions, IAcpTransportSession } from './acp-types.js';
|
|
15
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
AgentApp,
|
|
19
|
+
AgentContext,
|
|
20
|
+
ClientCapabilities,
|
|
21
|
+
McpServer,
|
|
22
|
+
PromptResponse,
|
|
23
|
+
} from '@agentclientprotocol/sdk';
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* JSON-RPC server-error code for a workspace the host has not admitted. Not -32000: ACP assigns
|
|
@@ -29,6 +37,15 @@ export interface IAcpAgent {
|
|
|
29
37
|
readonly app: AgentApp;
|
|
30
38
|
/** Cancel the running prompt, if there is one. */
|
|
31
39
|
cancelActive(): void;
|
|
40
|
+
/** Tell the host the editor's file system is gone, if it was handed one. */
|
|
41
|
+
withdrawEditorFiles(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What `session/new` and `session/load` both carry that decides whether the editor may have the session. */
|
|
45
|
+
interface ISessionRequest {
|
|
46
|
+
readonly cwd: string;
|
|
47
|
+
readonly mcpServers: readonly McpServer[];
|
|
48
|
+
readonly additionalDirectories?: readonly string[] | null;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
function sameDirectory(requested: string, served: string): boolean {
|
|
@@ -41,6 +58,10 @@ function sameDirectory(requested: string, served: string): boolean {
|
|
|
41
58
|
}
|
|
42
59
|
}
|
|
43
60
|
|
|
61
|
+
function asError(error: unknown): Error {
|
|
62
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
export function createAcpAgent(
|
|
45
66
|
session: IAcpTransportSession,
|
|
46
67
|
options: IAcpTransportOptions,
|
|
@@ -48,63 +69,169 @@ export function createAcpAgent(
|
|
|
48
69
|
const driverId = options.driverId ?? OWNER_DRIVER_ID;
|
|
49
70
|
const report = (error: Error, context: string): void => options.onDeliveryError?.(error, context);
|
|
50
71
|
let boundSessionId: string | undefined;
|
|
51
|
-
/** A `session/new`
|
|
72
|
+
/** A `session/new` or `session/load` is still settling which session this connection serves. */
|
|
52
73
|
let binding = false;
|
|
53
74
|
let active: { cancel(): void } | undefined;
|
|
54
75
|
let toolCalls = 0;
|
|
76
|
+
const usedToolCallIds = new Set<string>();
|
|
77
|
+
const nextToolCallId = (preferredId?: string): string => {
|
|
78
|
+
let id = preferredId;
|
|
79
|
+
while (id === undefined || usedToolCallIds.has(id)) id = `tool-${(toolCalls += 1)}`;
|
|
80
|
+
usedToolCallIds.add(id);
|
|
81
|
+
return id;
|
|
82
|
+
};
|
|
83
|
+
/** The command list the editor last received, so an unchanged list is not sent again. */
|
|
84
|
+
let advertised: string | undefined;
|
|
85
|
+
|
|
86
|
+
/** Refuse what the editor may not ask for; the same rules hold for a new and a loaded session. */
|
|
87
|
+
const admit = (params: ISessionRequest): void => {
|
|
88
|
+
if (!isAbsolute(params.cwd) || !sameDirectory(params.cwd, options.cwd)) {
|
|
89
|
+
throw RequestError.invalidParams(
|
|
90
|
+
undefined,
|
|
91
|
+
`cwd must be the directory this agent was started in: ${options.cwd}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
// Servers or roots named by the editor would widen what the session can reach.
|
|
95
|
+
if (params.mcpServers.length > 0) {
|
|
96
|
+
throw RequestError.invalidParams(undefined, 'mcpServers are not accepted by this agent');
|
|
97
|
+
}
|
|
98
|
+
if ((params.additionalDirectories ?? []).length > 0) {
|
|
99
|
+
throw RequestError.invalidParams(
|
|
100
|
+
undefined,
|
|
101
|
+
'additionalDirectories are not accepted by this agent',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const admission = options.admitWorkspace();
|
|
105
|
+
if (!admission.admitted) {
|
|
106
|
+
throw new RequestError(WORKSPACE_NOT_ADMITTED, admission.message, {
|
|
107
|
+
reason: 'workspace-not-trusted',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Wait for the attached session to name itself; a session that failed to start is a fixed error. */
|
|
113
|
+
const sessionId = async (context: string): Promise<string> => {
|
|
114
|
+
try {
|
|
115
|
+
// A session that builds itself in the background cannot name itself until it is ready.
|
|
116
|
+
await session.whenInitialized?.();
|
|
117
|
+
return session.getStatusSnapshot().sessionId;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
report(asError(error), context);
|
|
120
|
+
// The cause can name host paths or settings; the editor only needs to know it failed.
|
|
121
|
+
throw new RequestError(INTERNAL_ERROR, 'The session could not be started');
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Tell the editor which commands it may offer, when they differ from what it last received. The
|
|
127
|
+
* update names the session, so it goes out only once the editor holds that id.
|
|
128
|
+
*/
|
|
129
|
+
const advertiseCommands = (client: AgentContext, id: string): void => {
|
|
130
|
+
if (session.listCommands === undefined && session.listSkills === undefined) return;
|
|
131
|
+
let commands: ReturnType<typeof availableCommands>;
|
|
132
|
+
try {
|
|
133
|
+
commands = availableCommands(session);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
report(asError(error), 'available_commands_update');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const serialized = JSON.stringify(commands);
|
|
139
|
+
if (serialized === advertised) return;
|
|
140
|
+
advertised = serialized;
|
|
141
|
+
client
|
|
142
|
+
.notify('session/update', {
|
|
143
|
+
sessionId: id,
|
|
144
|
+
update: { sessionUpdate: 'available_commands_update', availableCommands: commands },
|
|
145
|
+
})
|
|
146
|
+
.catch((error: unknown) => report(asError(error), 'available_commands_update'));
|
|
147
|
+
};
|
|
148
|
+
/** After the response that gives the editor the session id has been written. */
|
|
149
|
+
const advertiseAfterReply = (client: AgentContext, id: string): void => {
|
|
150
|
+
setImmediate(() => advertiseCommands(client, id));
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The file system the editor offered in `initialize`. */
|
|
154
|
+
let editorFs: ClientCapabilities['fs'];
|
|
155
|
+
let editorFilesOffered = false;
|
|
156
|
+
/** Hand the host the editor's file system for the bound session, if the editor offered one. */
|
|
157
|
+
const offerEditorFiles = (client: AgentContext): void => {
|
|
158
|
+
const access = editorFileAccess(editorFs, client, () => boundSessionId);
|
|
159
|
+
if (access === undefined) return;
|
|
160
|
+
editorFilesOffered = true;
|
|
161
|
+
options.onEditorFileAccess?.(access);
|
|
162
|
+
};
|
|
55
163
|
|
|
56
164
|
const app = agent({ name: options.agentInfo.name })
|
|
57
|
-
.onRequest('initialize', () =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
165
|
+
.onRequest('initialize', ({ params }) => {
|
|
166
|
+
editorFs = params.clientCapabilities?.fs;
|
|
167
|
+
return {
|
|
168
|
+
// Always the version this agent speaks: the client disconnects if it cannot use it.
|
|
169
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
170
|
+
agentCapabilities: {
|
|
171
|
+
loadSession:
|
|
172
|
+
options.loadSession !== undefined && session.getMessagesDisplay !== undefined,
|
|
173
|
+
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
|
174
|
+
},
|
|
175
|
+
agentInfo: { name: options.agentInfo.name, version: options.agentInfo.version },
|
|
176
|
+
};
|
|
177
|
+
})
|
|
178
|
+
.onRequest('session/new', async ({ params, client }) => {
|
|
67
179
|
if (boundSessionId !== undefined || binding) {
|
|
68
180
|
throw RequestError.invalidParams(undefined, 'one session per connection');
|
|
69
181
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// Servers or roots named by the editor would widen what the session can reach.
|
|
77
|
-
if (params.mcpServers.length > 0) {
|
|
78
|
-
throw RequestError.invalidParams(undefined, 'mcpServers are not accepted by this agent');
|
|
182
|
+
admit(params);
|
|
183
|
+
binding = true;
|
|
184
|
+
try {
|
|
185
|
+
boundSessionId = await sessionId('session/new');
|
|
186
|
+
} finally {
|
|
187
|
+
binding = false;
|
|
79
188
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
189
|
+
offerEditorFiles(client);
|
|
190
|
+
advertiseAfterReply(client, boundSessionId);
|
|
191
|
+
return { sessionId: boundSessionId };
|
|
192
|
+
})
|
|
193
|
+
.onRequest('session/load', async ({ params, client }) => {
|
|
194
|
+
const load = options.loadSession;
|
|
195
|
+
if (load === undefined || session.getMessagesDisplay === undefined) {
|
|
196
|
+
throw RequestError.methodNotFound('session/load');
|
|
85
197
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
});
|
|
198
|
+
if (binding) throw RequestError.invalidParams(undefined, 'a session is already being opened');
|
|
199
|
+
// A load replaces this connection's session, so it may not pull the session out from under a turn.
|
|
200
|
+
if (active !== undefined) {
|
|
201
|
+
throw RequestError.invalidParams(undefined, 'a prompt is running');
|
|
91
202
|
}
|
|
203
|
+
admit(params);
|
|
92
204
|
binding = true;
|
|
93
205
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
206
|
+
let outcome: Awaited<ReturnType<typeof load>>;
|
|
207
|
+
try {
|
|
208
|
+
outcome = await load(params.sessionId);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
report(asError(error), 'session/load');
|
|
211
|
+
throw new RequestError(INTERNAL_ERROR, 'The session could not be loaded');
|
|
212
|
+
}
|
|
213
|
+
if (!outcome.loaded) throw RequestError.invalidParams(undefined, outcome.message);
|
|
214
|
+
boundSessionId = await sessionId('session/load');
|
|
215
|
+
offerEditorFiles(client);
|
|
216
|
+
// The editor sees the conversation it reopened before the load answers.
|
|
217
|
+
const updates = createUpdateStream(client, boundSessionId, report);
|
|
218
|
+
try {
|
|
219
|
+
replayHistory(session.getMessagesDisplay(), updates, nextToolCallId);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
report(asError(error), 'session/load');
|
|
222
|
+
// The session is loaded; only its history could not be shown, and the cause stays local.
|
|
223
|
+
throw new RequestError(INTERNAL_ERROR, 'The session history could not be shown');
|
|
224
|
+
}
|
|
225
|
+
await updates.drained();
|
|
101
226
|
} finally {
|
|
102
227
|
binding = false;
|
|
103
228
|
}
|
|
104
|
-
|
|
229
|
+
advertised = undefined;
|
|
230
|
+
advertiseAfterReply(client, boundSessionId);
|
|
231
|
+
return {};
|
|
105
232
|
})
|
|
106
233
|
.onRequest('session/prompt', async ({ params, signal, client }): Promise<PromptResponse> => {
|
|
107
|
-
if (params.sessionId !== boundSessionId) {
|
|
234
|
+
if (params.sessionId !== boundSessionId || binding) {
|
|
108
235
|
throw RequestError.invalidParams(undefined, 'unknown session');
|
|
109
236
|
}
|
|
110
237
|
if (active !== undefined) {
|
|
@@ -132,7 +259,7 @@ export function createAcpAgent(
|
|
|
132
259
|
driverId,
|
|
133
260
|
surface: options.surface ?? 'unknown',
|
|
134
261
|
cwd: options.cwd,
|
|
135
|
-
nextToolCallId
|
|
262
|
+
nextToolCallId,
|
|
136
263
|
wasCancelled: () => cancelled,
|
|
137
264
|
report,
|
|
138
265
|
};
|
|
@@ -142,11 +269,22 @@ export function createAcpAgent(
|
|
|
142
269
|
: await runModelTurn(turn, text.full);
|
|
143
270
|
} finally {
|
|
144
271
|
active = undefined;
|
|
272
|
+
// A command or a model turn can change the catalog (a plugin reload, a preset): the editor
|
|
273
|
+
// gets the new list, and an unchanged one is not sent again.
|
|
274
|
+
advertiseCommands(client, params.sessionId);
|
|
145
275
|
}
|
|
146
276
|
})
|
|
147
277
|
.onNotification('session/cancel', ({ params }) => {
|
|
148
278
|
if (params.sessionId === boundSessionId) active?.cancel();
|
|
149
279
|
});
|
|
150
280
|
|
|
151
|
-
return {
|
|
281
|
+
return {
|
|
282
|
+
app,
|
|
283
|
+
cancelActive: () => active?.cancel(),
|
|
284
|
+
withdrawEditorFiles: () => {
|
|
285
|
+
if (!editorFilesOffered) return;
|
|
286
|
+
editorFilesOffered = false;
|
|
287
|
+
options.onEditorFileAccess?.(undefined);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
152
290
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor's file system, as the editor offered it in `initialize`: reads and writes the host's
|
|
3
|
+
* file tools may inspect once the session is bound. ACP v1 carries only a pathname, not a stable
|
|
4
|
+
* rooted file capability, so this adapter deliberately cannot claim the authority required to
|
|
5
|
+
* carry contained Read, Write or Edit operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isAbsolute } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { RequestError } from '@agentclientprotocol/sdk';
|
|
11
|
+
|
|
12
|
+
import type { IAcpEditorFileAccess } from './acp-types.js';
|
|
13
|
+
import type { AgentContext, ClientCapabilities, SendRequestOptions } from '@agentclientprotocol/sdk';
|
|
14
|
+
|
|
15
|
+
function cancellable(signal: AbortSignal | undefined): SendRequestOptions | undefined {
|
|
16
|
+
return signal === undefined ? undefined : { cancellationSignal: signal };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A JSON-RPC error names its kind in `message` and, from an SDK client, the cause in `data.details`. */
|
|
20
|
+
function editorError(error: unknown): Error {
|
|
21
|
+
if (!(error instanceof RequestError)) return error instanceof Error ? error : new Error(String(error));
|
|
22
|
+
const data: unknown = error.data;
|
|
23
|
+
const details =
|
|
24
|
+
typeof data === 'object' && data !== null && 'details' in data && typeof data.details === 'string'
|
|
25
|
+
? data.details
|
|
26
|
+
: undefined;
|
|
27
|
+
return new Error(details === undefined ? error.message : `${error.message}: ${details}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function absolute(path: string): void {
|
|
31
|
+
// ACP names files by absolute path; a relative one would be resolved by the editor, not the tool.
|
|
32
|
+
if (!isAbsolute(path)) throw new Error(`the editor is given absolute paths only: ${path}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The access the editor offered, for the bound session; `undefined` when it offered neither read nor
|
|
37
|
+
* write. `sessionId` is read at each request, so the access follows the session the connection serves.
|
|
38
|
+
*/
|
|
39
|
+
export function editorFileAccess(
|
|
40
|
+
fs: ClientCapabilities['fs'],
|
|
41
|
+
client: AgentContext,
|
|
42
|
+
sessionId: () => string | undefined,
|
|
43
|
+
): IAcpEditorFileAccess | undefined {
|
|
44
|
+
const reads = fs?.readTextFile === true;
|
|
45
|
+
const writes = fs?.writeTextFile === true;
|
|
46
|
+
if (!reads && !writes) return undefined;
|
|
47
|
+
const bound = (): string => {
|
|
48
|
+
const id = sessionId();
|
|
49
|
+
if (id === undefined) throw new Error('the editor has no session to read or write for');
|
|
50
|
+
return id;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
...(reads
|
|
54
|
+
? {
|
|
55
|
+
readTextFile: async (path: string, signal?: AbortSignal): Promise<string> => {
|
|
56
|
+
absolute(path);
|
|
57
|
+
try {
|
|
58
|
+
const reply = await client.request(
|
|
59
|
+
'fs/read_text_file',
|
|
60
|
+
{ sessionId: bound(), path },
|
|
61
|
+
cancellable(signal),
|
|
62
|
+
);
|
|
63
|
+
return reply.content;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw editorError(error);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
...(writes
|
|
71
|
+
? {
|
|
72
|
+
writeTextFile: async (path: string, content: string, signal?: AbortSignal): Promise<void> => {
|
|
73
|
+
absolute(path);
|
|
74
|
+
try {
|
|
75
|
+
await client.request(
|
|
76
|
+
'fs/write_text_file',
|
|
77
|
+
{ sessionId: bound(), path, content },
|
|
78
|
+
cancellable(signal),
|
|
79
|
+
);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw editorError(error);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** What an editor learns about the session besides a turn: the commands it can offer, and the history of a loaded session. */
|
|
2
|
+
|
|
3
|
+
import { acpToolEndStatus, acpToolKind, acpToolStartTitle } from './acp-tool-mapping.js';
|
|
4
|
+
|
|
5
|
+
import type { IAcpUpdateStream } from './acp-updates.js';
|
|
6
|
+
import type { AvailableCommand } from '@agentclientprotocol/sdk';
|
|
7
|
+
import type { IHistoryDisplaySegment, ISessionCommands } from '@robota-sdk/agent-interface-session';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The slash commands an editor may offer: the runtime's commands, then the user-invocable skills
|
|
11
|
+
* whose names no command already uses. A command a client implements itself (its runner is the
|
|
12
|
+
* client) is not offered, because an editor cannot run it and the runtime would not.
|
|
13
|
+
*/
|
|
14
|
+
export function availableCommands(
|
|
15
|
+
session: Partial<Pick<ISessionCommands, 'listCommands' | 'listSkills'>>,
|
|
16
|
+
): AvailableCommand[] {
|
|
17
|
+
const offered: AvailableCommand[] = [];
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
const offer = (name: string, description: string, hint: string | undefined): void => {
|
|
20
|
+
if (names.has(name)) return;
|
|
21
|
+
names.add(name);
|
|
22
|
+
offered.push({ name, description, ...(hint ? { input: { hint } } : {}) });
|
|
23
|
+
};
|
|
24
|
+
for (const command of session.listCommands?.() ?? []) {
|
|
25
|
+
if (command.runner !== 'runtime') continue;
|
|
26
|
+
offer(command.name, command.description, command.argumentHint);
|
|
27
|
+
}
|
|
28
|
+
for (const skill of session.listSkills?.() ?? []) {
|
|
29
|
+
if (skill.userInvocable) offer(skill.name, skill.description, skill.argumentHint);
|
|
30
|
+
}
|
|
31
|
+
return offered;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A loaded session's history as the editor would have seen it live: what the person typed, what the
|
|
36
|
+
* agent answered, and each finished tool call with how it ended. A call keeps its own id unless that
|
|
37
|
+
* id already named a row of this replay (a provider can reuse ids across turns), because the editor
|
|
38
|
+
* would merge the two rows into one. The connection's allocator also keeps later live calls distinct.
|
|
39
|
+
*/
|
|
40
|
+
export function replayHistory(
|
|
41
|
+
segments: readonly IHistoryDisplaySegment[],
|
|
42
|
+
updates: IAcpUpdateStream,
|
|
43
|
+
nextToolCallId: (preferredId?: string) => string,
|
|
44
|
+
): void {
|
|
45
|
+
for (const segment of segments) {
|
|
46
|
+
if (segment.type === 'text') {
|
|
47
|
+
if (segment.content === '') continue;
|
|
48
|
+
updates.send({
|
|
49
|
+
sessionUpdate: segment.role === 'user' ? 'user_message_chunk' : 'agent_message_chunk',
|
|
50
|
+
content: { type: 'text', text: segment.content },
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const { tool } = segment;
|
|
55
|
+
if (tool.internal) continue;
|
|
56
|
+
const toolCallId = nextToolCallId(tool.executionId);
|
|
57
|
+
updates.send({
|
|
58
|
+
sessionUpdate: 'tool_call',
|
|
59
|
+
toolCallId,
|
|
60
|
+
title: acpToolStartTitle(tool),
|
|
61
|
+
kind: acpToolKind(tool.toolName),
|
|
62
|
+
status: acpToolEndStatus(tool),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/acp-tool-calls.ts
CHANGED
|
@@ -20,8 +20,8 @@ export interface IAcpToolCallTracker {
|
|
|
20
20
|
|
|
21
21
|
export interface IAcpToolCallDeps {
|
|
22
22
|
readonly updates: IAcpUpdateStream;
|
|
23
|
-
/**
|
|
24
|
-
readonly nextToolCallId: () => string;
|
|
23
|
+
/** Allocate a new row id, preserving a preferred execution id only when no earlier row used it. */
|
|
24
|
+
readonly nextToolCallId: (preferredId?: string) => string;
|
|
25
25
|
/** The served directory, for paths the session reports relative to it. */
|
|
26
26
|
readonly cwd: string;
|
|
27
27
|
}
|
|
@@ -34,6 +34,7 @@ interface IOpenCall {
|
|
|
34
34
|
|
|
35
35
|
/** A permission request's row, held until the call it names starts or is refused. */
|
|
36
36
|
interface IAwaitedCall {
|
|
37
|
+
readonly id: string;
|
|
37
38
|
readonly rawInput: unknown;
|
|
38
39
|
/** A background agent's call can run after this prompt, so its row closes with the answer. */
|
|
39
40
|
readonly background: boolean;
|
|
@@ -71,7 +72,7 @@ export function createToolCallTracker(deps: IAcpToolCallDeps): IAcpToolCallTrack
|
|
|
71
72
|
// An internal signal tool is not a call the person made or should see.
|
|
72
73
|
if (tool.internal) return;
|
|
73
74
|
const asked = take(tool.executionId);
|
|
74
|
-
const id =
|
|
75
|
+
const id = asked?.id ?? deps.nextToolCallId(tool.executionId);
|
|
75
76
|
open.push({ id, toolName: tool.toolName, executionId: tool.executionId });
|
|
76
77
|
const call = {
|
|
77
78
|
toolCallId: id,
|
|
@@ -99,14 +100,15 @@ export function createToolCallTracker(deps: IAcpToolCallDeps): IAcpToolCallTrack
|
|
|
99
100
|
return;
|
|
100
101
|
}
|
|
101
102
|
// A refused call ends with no start; when its permission was asked, that row is the call.
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const asked = take(tool.executionId);
|
|
104
|
+
if (asked !== undefined) {
|
|
105
|
+
updates.send({ sessionUpdate: 'tool_call_update', toolCallId: asked.id, status });
|
|
104
106
|
return;
|
|
105
107
|
}
|
|
106
108
|
// Otherwise give the editor the call, finished, rather than an update for nothing.
|
|
107
109
|
updates.send({
|
|
108
110
|
sessionUpdate: 'tool_call',
|
|
109
|
-
toolCallId:
|
|
111
|
+
toolCallId: deps.nextToolCallId(tool.executionId),
|
|
110
112
|
title: acpToolStartTitle(tool),
|
|
111
113
|
kind: acpToolKind(tool.toolName),
|
|
112
114
|
status,
|
|
@@ -116,14 +118,22 @@ export function createToolCallTracker(deps: IAcpToolCallDeps): IAcpToolCallTrack
|
|
|
116
118
|
requested(event) {
|
|
117
119
|
const { executionId } = event;
|
|
118
120
|
if (executionId === undefined) {
|
|
119
|
-
const call = permissionToolCall(
|
|
121
|
+
const call = permissionToolCall(
|
|
122
|
+
event,
|
|
123
|
+
deps.nextToolCallId(`permission-${event.id}`),
|
|
124
|
+
deps.cwd,
|
|
125
|
+
);
|
|
120
126
|
unnamed.set(event.id, call.toolCallId);
|
|
121
127
|
return call;
|
|
122
128
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
// A repeated request and the call's own start/end keep the row allocated for this call.
|
|
130
|
+
const running = open.find((call) => call.executionId === executionId);
|
|
131
|
+
const waiting = awaited.get(executionId);
|
|
132
|
+
const id = running?.id ?? waiting?.id ?? deps.nextToolCallId(executionId);
|
|
133
|
+
const call = permissionToolCall(event, id, deps.cwd);
|
|
134
|
+
if (running === undefined && waiting === undefined) {
|
|
126
135
|
awaited.set(executionId, {
|
|
136
|
+
id,
|
|
127
137
|
rawInput: call.rawInput,
|
|
128
138
|
background: event.requester !== undefined,
|
|
129
139
|
});
|
|
@@ -147,7 +157,7 @@ export function createToolCallTracker(deps: IAcpToolCallDeps): IAcpToolCallTrack
|
|
|
147
157
|
|
|
148
158
|
finish() {
|
|
149
159
|
finished = true;
|
|
150
|
-
for (const
|
|
160
|
+
for (const call of awaited.values()) close(call.id, call.background && call.allowed === true);
|
|
151
161
|
for (const row of unnamed.values()) close(row, false);
|
|
152
162
|
awaited.clear();
|
|
153
163
|
unnamed.clear();
|
package/src/acp-transport.ts
CHANGED
|
@@ -40,6 +40,7 @@ export function createAcpTransport(options: IAcpTransportOptions): IAcpTransport
|
|
|
40
40
|
let carrier: IStdioCarrier | null = null;
|
|
41
41
|
let connection: AgentConnection | null = null;
|
|
42
42
|
let cancelActive: (() => void) | null = null;
|
|
43
|
+
let withdrawEditorFiles: (() => void) | null = null;
|
|
43
44
|
let resolveClose: (() => void) | null = null;
|
|
44
45
|
let rejectClose: ((error: Error) => void) | null = null;
|
|
45
46
|
let closePromise: Promise<void> = Promise.resolve();
|
|
@@ -53,6 +54,8 @@ export function createAcpTransport(options: IAcpTransportOptions): IAcpTransport
|
|
|
53
54
|
};
|
|
54
55
|
const release = (): void => {
|
|
55
56
|
try {
|
|
57
|
+
// The host's tools go back to the disk before the connection that carried their access closes.
|
|
58
|
+
withdrawEditorFiles?.();
|
|
56
59
|
cancelActive?.();
|
|
57
60
|
connection?.close();
|
|
58
61
|
} finally {
|
|
@@ -60,6 +63,7 @@ export function createAcpTransport(options: IAcpTransportOptions): IAcpTransport
|
|
|
60
63
|
carrier = null;
|
|
61
64
|
connection = null;
|
|
62
65
|
cancelActive = null;
|
|
66
|
+
withdrawEditorFiles = null;
|
|
63
67
|
}
|
|
64
68
|
};
|
|
65
69
|
const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>
|
|
@@ -84,6 +88,7 @@ export function createAcpTransport(options: IAcpTransportOptions): IAcpTransport
|
|
|
84
88
|
try {
|
|
85
89
|
const acpAgent = createAcpAgent(session, options);
|
|
86
90
|
cancelActive = acpAgent.cancelActive;
|
|
91
|
+
withdrawEditorFiles = acpAgent.withdrawEditorFiles;
|
|
87
92
|
// The SDK destroys what it reads when its connection closes; it reads the bridge, never the
|
|
88
93
|
// process input, and a closed or failed end ends the transport instead of leaving it idle.
|
|
89
94
|
const attached = attachStdioCarrier(options.input, options.output, {
|
package/src/acp-turn.ts
CHANGED
|
@@ -31,8 +31,8 @@ export interface IAcpTurnContext {
|
|
|
31
31
|
readonly surface: ISubmitOptions['surface'];
|
|
32
32
|
/** The served directory; paths the session reports relative to it are made absolute for the editor. */
|
|
33
33
|
readonly cwd: string;
|
|
34
|
-
/**
|
|
35
|
-
readonly nextToolCallId: () => string;
|
|
34
|
+
/** Allocate a new row id, preserving a preferred execution id only when no earlier row used it. */
|
|
35
|
+
readonly nextToolCallId: (preferredId?: string) => string;
|
|
36
36
|
/** Whether the editor sent `session/cancel` for this prompt. */
|
|
37
37
|
readonly wasCancelled: () => boolean;
|
|
38
38
|
readonly report: (error: Error, context: string) => void;
|
package/src/acp-types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Public contract of the ACP agent transport: its session role set, host-injected ports and options. */
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
+
ISessionCommands,
|
|
5
|
+
ISessionConversationRead,
|
|
4
6
|
ISessionEvents,
|
|
5
7
|
ISessionPromptResolution,
|
|
6
8
|
ISessionStatusRead,
|
|
@@ -14,7 +16,8 @@ import type { Readable, Writable } from 'node:stream';
|
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* The session roles ACP uses: submit and abort a turn, stream its events, answer its permission
|
|
17
|
-
* prompts, and name the session.
|
|
19
|
+
* prompts, and name the session. Optional catalog reads let the editor offer commands; optional
|
|
20
|
+
* history reads enable a host load port to replay a stored session. A full interactive session is assignable.
|
|
18
21
|
*/
|
|
19
22
|
export interface IAcpTransportSession
|
|
20
23
|
extends
|
|
@@ -22,28 +25,44 @@ export interface IAcpTransportSession
|
|
|
22
25
|
ISessionTurnControl,
|
|
23
26
|
ISessionEvents,
|
|
24
27
|
ISessionPromptResolution,
|
|
25
|
-
ISessionStatusRead
|
|
28
|
+
ISessionStatusRead,
|
|
29
|
+
Partial<Pick<ISessionCommands, 'listCommands' | 'listSkills'>>,
|
|
30
|
+
Partial<Pick<ISessionConversationRead, 'getMessagesDisplay'>> {
|
|
26
31
|
/**
|
|
27
32
|
* Resolves once the session can name itself, and rejects if it failed to start. A session that
|
|
28
33
|
* builds itself in the background (`InteractiveSession`) has no id before then, so `session/new`
|
|
29
|
-
*
|
|
34
|
+
* and `session/load` wait on this. A session that is ready at construction can omit it.
|
|
30
35
|
*/
|
|
31
36
|
whenInitialized?(): Promise<void>;
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The editor's pathname file system, as the editor offered it (`clientCapabilities.fs`): a method
|
|
41
|
+
* it did not offer is absent. Paths are absolute. ACP v1 does not bind the path to stable rooted
|
|
42
|
+
* authority, so contained host file tools must not treat this as `IEditorFileAccess.pathAuthority`.
|
|
43
|
+
*/
|
|
44
|
+
export interface IAcpEditorFileAccess {
|
|
45
|
+
readTextFile?(path: string, signal?: AbortSignal): Promise<string>;
|
|
46
|
+
writeTextFile?(path: string, content: string, signal?: AbortSignal): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
/** The host's answer to whether the workspace may serve an editor; a refusal carries what to tell the user. */
|
|
35
50
|
export type TAcpWorkspaceAdmission =
|
|
36
51
|
{ readonly admitted: true } | { readonly admitted: false; readonly message: string };
|
|
37
52
|
|
|
53
|
+
/** The host's answer to a `session/load`; a refusal carries what to tell the editor. */
|
|
54
|
+
export type TAcpSessionLoad =
|
|
55
|
+
{ readonly loaded: true } | { readonly loaded: false; readonly message: string };
|
|
56
|
+
|
|
38
57
|
export interface IAcpTransportOptions {
|
|
39
58
|
/** Process-owned byte streams carrying newline-delimited JSON-RPC: the editor writes `input`, reads `output`. */
|
|
40
59
|
readonly input: Readable;
|
|
41
60
|
readonly output: Writable;
|
|
42
61
|
/** Sent to the editor in the `initialize` reply. */
|
|
43
62
|
readonly agentInfo: { readonly name: string; readonly version: string };
|
|
44
|
-
/** The directory the attached session is rooted at; `session/new` must name
|
|
63
|
+
/** The directory the attached session is rooted at; `session/new` and `session/load` must name it. */
|
|
45
64
|
readonly cwd: string;
|
|
46
|
-
/** The host's trust check, asked when the editor creates the session. */
|
|
65
|
+
/** The host's trust check, asked when the editor creates or loads the session. */
|
|
47
66
|
readonly admitWorkspace: () => TAcpWorkspaceAdmission;
|
|
48
67
|
/**
|
|
49
68
|
* Runs a `/command` prompt and returns the text to show. Supplied by the host because command
|
|
@@ -52,6 +71,21 @@ export interface IAcpTransportOptions {
|
|
|
52
71
|
* cancels the prompt or the connection closes; pass it on so what the command starts stops with it.
|
|
53
72
|
*/
|
|
54
73
|
readonly executeCommand?: (text: string, signal: AbortSignal) => Promise<string>;
|
|
74
|
+
/**
|
|
75
|
+
* Makes the stored session `sessionId` current, so that the object this transport was attached to
|
|
76
|
+
* answers for it from then on (the host attaches a forwarder, such as a session slot, whose current
|
|
77
|
+
* session the port replaces). It must admit only a session rooted in the served directory, and may
|
|
78
|
+
* refuse with a message for the editor. Supplied by the host because building a session from
|
|
79
|
+
* storage is not this package's; without it or the session's history read, `session/load` is not offered.
|
|
80
|
+
*/
|
|
81
|
+
readonly loadSession?: (sessionId: string) => Promise<TAcpSessionLoad>;
|
|
82
|
+
/**
|
|
83
|
+
* Receives the editor's file system after a new or loaded session binds, when the editor offers reads or
|
|
84
|
+
* writes, and `undefined` when the transport stops. The host decides which of its tools use it;
|
|
85
|
+
* they must apply their own path and permission checks first, because the editor checks nothing
|
|
86
|
+
* for the agent. Never called for an editor that offers no file system.
|
|
87
|
+
*/
|
|
88
|
+
readonly onEditorFileAccess?: (access: IAcpEditorFileAccess | undefined) => void;
|
|
55
89
|
/** Attribution recorded on turns and prompt answers submitted through this transport. Default `'owner'`. */
|
|
56
90
|
readonly driverId?: TDriverId;
|
|
57
91
|
/** Usage surface recorded on turns. Default `'unknown'` (no editor value exists). */
|