@adecore/agents 0.29.2 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/acp-backend.d.ts +59 -0
- package/dist/chat/acp-backend.js +407 -0
- package/dist/chat/acp-protocol.d.ts +131 -0
- package/dist/chat/acp-protocol.js +605 -0
- package/dist/chat/chat-session.d.ts +2 -0
- package/dist/chat/chat-session.js +14 -10
- package/dist/chat/codex-transport.d.ts +4 -27
- package/dist/chat/codex-transport.js +4 -185
- package/dist/chat/fixtures/acp-v1-turn.json +240 -0
- package/dist/chat/index.d.ts +2 -0
- package/dist/chat/index.js +1 -0
- package/dist/chat/json-rpc-transport.d.ts +50 -0
- package/dist/chat/json-rpc-transport.js +203 -0
- package/dist/providers/accounts/accounts.d.ts +10 -6
- package/dist/providers/accounts/accounts.js +19 -19
- package/dist/providers/accounts/service.d.ts +3 -0
- package/dist/providers/accounts/service.js +20 -13
- package/dist/providers/accounts/store.d.ts +4 -5
- package/dist/providers/accounts/store.js +12 -12
- package/dist/providers/acp-provider.d.ts +22 -0
- package/dist/providers/acp-provider.js +45 -0
- package/dist/providers/catalog.js +1 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/registry.d.ts +1 -0
- package/dist/providers/registry.js +4 -0
- package/dist/testing/fake-acp.d.ts +10 -0
- package/dist/testing/fake-acp.js +262 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +476 -0
- package/src/chat/acp-protocol.ts +763 -0
- package/src/chat/chat-session.ts +17 -8
- package/src/chat/codex-transport.ts +6 -207
- package/src/chat/fixtures/acp-v1-turn.json +240 -0
- package/src/chat/index.ts +2 -0
- package/src/chat/json-rpc-transport.ts +253 -0
- package/src/providers/accounts/accounts.ts +28 -25
- package/src/providers/accounts/service.ts +37 -14
- package/src/providers/accounts/store.ts +14 -17
- package/src/providers/acp-provider.ts +73 -0
- package/src/providers/catalog.ts +1 -0
- package/src/providers/index.ts +1 -0
- package/src/providers/registry.ts +5 -0
- package/src/testing/fake-acp.ts +296 -0
- package/src/testing/index.ts +1 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import { isRecord, type ChatFileChange, type RuntimeMode } from '@adecore/agent-contracts';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { MODE_ORDER } from '../modes.ts';
|
|
4
|
+
import type { ApprovalDecision, BackendEvent } from './backend.ts';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The Agent Client Protocol (https://agentclientprotocol.com), version 1, as far as a chat needs it.
|
|
8
|
+
* Every schema is loose: an agent may add fields, and a field or an update this version does not
|
|
9
|
+
* know is ignored rather than refused, as the protocol asks of a client.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
// The code an agent answers `session/new` or `session/load` with when it needs `authenticate` first.
|
|
15
|
+
export const ACP_AUTH_REQUIRED = -32000;
|
|
16
|
+
|
|
17
|
+
const ContentBlockSchema = z.looseObject({ type: z.string(), text: z.string().optional() });
|
|
18
|
+
|
|
19
|
+
const ContentChunkSchema = z.looseObject({ content: ContentBlockSchema, messageId: z.string().nullish() });
|
|
20
|
+
|
|
21
|
+
const ToolCallSchema = z.looseObject({
|
|
22
|
+
toolCallId: z.string(),
|
|
23
|
+
title: z.string().nullish(),
|
|
24
|
+
name: z.string().nullish(),
|
|
25
|
+
kind: z.string().nullish(),
|
|
26
|
+
status: z.string().nullish(),
|
|
27
|
+
content: z.array(z.unknown()).nullish(),
|
|
28
|
+
locations: z.array(z.unknown()).nullish(),
|
|
29
|
+
rawInput: z.unknown().optional(),
|
|
30
|
+
rawOutput: z.unknown().optional()
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const DiffSchema = z.looseObject({ type: z.literal('diff'), path: z.string(), oldText: z.string().nullish(), newText: z.string() });
|
|
34
|
+
|
|
35
|
+
const ToolContentSchema = z.looseObject({ type: z.literal('content'), content: ContentBlockSchema });
|
|
36
|
+
|
|
37
|
+
const LocationSchema = z.looseObject({ path: z.string() });
|
|
38
|
+
|
|
39
|
+
const PlanEntrySchema = z.looseObject({ content: z.string(), status: z.string(), priority: z.string().optional() });
|
|
40
|
+
|
|
41
|
+
const CommandSchema = z.looseObject({ name: z.string() });
|
|
42
|
+
|
|
43
|
+
const UsageSchema = z.looseObject({
|
|
44
|
+
used: z.number(),
|
|
45
|
+
size: z.number(),
|
|
46
|
+
cost: z.looseObject({ amount: z.number(), currency: z.string() }).nullish()
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const PermissionOptionSchema = z.looseObject({ optionId: z.string(), name: z.string(), kind: z.string() });
|
|
50
|
+
|
|
51
|
+
const PermissionRequestSchema = z.looseObject({
|
|
52
|
+
sessionId: z.string(),
|
|
53
|
+
toolCall: ToolCallSchema,
|
|
54
|
+
options: z.array(PermissionOptionSchema)
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }) });
|
|
58
|
+
|
|
59
|
+
const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
|
|
60
|
+
|
|
61
|
+
const ModeStateSchema = z.looseObject({
|
|
62
|
+
currentModeId: z.string(),
|
|
63
|
+
availableModes: z.array(z.unknown())
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
67
|
+
|
|
68
|
+
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string() });
|
|
69
|
+
|
|
70
|
+
const ConfigOptionSchema = z.looseObject({
|
|
71
|
+
id: z.string(),
|
|
72
|
+
name: z.string(),
|
|
73
|
+
type: z.string(),
|
|
74
|
+
category: z.string().nullish(),
|
|
75
|
+
currentValue: z.unknown().optional(),
|
|
76
|
+
options: z.array(z.unknown()).optional()
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Models as agents reported them before the protocol moved them into config options; still sent by some.
|
|
80
|
+
const ModelStateSchema = z.looseObject({
|
|
81
|
+
currentModelId: z.string(),
|
|
82
|
+
availableModels: z.array(z.unknown())
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish() });
|
|
86
|
+
|
|
87
|
+
const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
88
|
+
|
|
89
|
+
const InitializeResponseSchema = z.looseObject({
|
|
90
|
+
protocolVersion: z.number(),
|
|
91
|
+
agentCapabilities: z
|
|
92
|
+
.looseObject({
|
|
93
|
+
loadSession: z.boolean().optional(),
|
|
94
|
+
promptCapabilities: z.looseObject({ image: z.boolean().optional() }).optional(),
|
|
95
|
+
sessionCapabilities: z
|
|
96
|
+
.looseObject({
|
|
97
|
+
resume: z.unknown().optional(),
|
|
98
|
+
additionalDirectories: z.unknown().optional()
|
|
99
|
+
})
|
|
100
|
+
.optional()
|
|
101
|
+
})
|
|
102
|
+
.optional(),
|
|
103
|
+
authMethods: z.array(z.unknown()).optional(),
|
|
104
|
+
agentInfo: z.looseObject({ name: z.string(), title: z.string().nullish(), version: z.string() }).nullish()
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const SessionResponseSchema = z.looseObject({ sessionId: z.string().optional() });
|
|
108
|
+
|
|
109
|
+
/* Parses each item on its own, dropping the ones that do not fit instead of the whole list. */
|
|
110
|
+
function validItems<T>(schema: z.ZodType<T>, items: readonly unknown[] | null | undefined): T[] {
|
|
111
|
+
return (items ?? []).flatMap((item) => {
|
|
112
|
+
const parsed = schema.safeParse(item);
|
|
113
|
+
return parsed.success ? [parsed.data] : [];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface AcpAgentInfo {
|
|
118
|
+
name: string;
|
|
119
|
+
title: string | null;
|
|
120
|
+
version: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* What `initialize` answered, as far as a chat acts on it. */
|
|
124
|
+
export interface AcpAgentCapabilities {
|
|
125
|
+
protocolVersion: number;
|
|
126
|
+
loadSession: boolean;
|
|
127
|
+
resumeSession: boolean;
|
|
128
|
+
additionalDirectories: boolean;
|
|
129
|
+
images: boolean;
|
|
130
|
+
authMethods: string[];
|
|
131
|
+
agent: AcpAgentInfo | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function parseInitialize(result: unknown): AcpAgentCapabilities {
|
|
135
|
+
const parsed = InitializeResponseSchema.safeParse(result);
|
|
136
|
+
if (!parsed.success) {
|
|
137
|
+
throw new Error('initialize failed: the agent answered with something that is not an initialize response');
|
|
138
|
+
}
|
|
139
|
+
const capabilities = parsed.data.agentCapabilities;
|
|
140
|
+
const sessions = capabilities?.sessionCapabilities;
|
|
141
|
+
const agent = parsed.data.agentInfo;
|
|
142
|
+
return {
|
|
143
|
+
protocolVersion: parsed.data.protocolVersion,
|
|
144
|
+
loadSession: capabilities?.loadSession === true,
|
|
145
|
+
resumeSession: sessions?.resume !== undefined && sessions.resume !== null,
|
|
146
|
+
additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
|
|
147
|
+
images: capabilities?.promptCapabilities?.image === true,
|
|
148
|
+
authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
|
|
149
|
+
agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface AcpModel {
|
|
154
|
+
id: string;
|
|
155
|
+
name: string;
|
|
156
|
+
description: string | null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* The models a session offers. `configId` is the config option that picks one, or null where the agent takes `session/set_model`. */
|
|
160
|
+
export interface AcpModelState {
|
|
161
|
+
current: string;
|
|
162
|
+
models: AcpModel[];
|
|
163
|
+
configId: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface AcpMode {
|
|
167
|
+
id: string;
|
|
168
|
+
name: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* The modes a session offers. `configId` is the config option that picks one, or null where the agent takes `session/set_mode`. */
|
|
172
|
+
export interface AcpModeState {
|
|
173
|
+
current: string;
|
|
174
|
+
modes: AcpMode[];
|
|
175
|
+
configId: string | null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* What `session/new`, `session/load` or `session/resume` said about the session, for a host that fills its catalog from it. */
|
|
179
|
+
export interface AcpSessionInfo {
|
|
180
|
+
sessionId: string;
|
|
181
|
+
agent: AcpAgentInfo | null;
|
|
182
|
+
models: AcpModelState | null;
|
|
183
|
+
modes: AcpModeState | null;
|
|
184
|
+
// The reasoning effort a session offers, as a config option with the `thought_level` category.
|
|
185
|
+
thoughtLevel: { configId: string; current: string; values: string[] } | null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type ConfigOption = z.infer<typeof ConfigOptionSchema>;
|
|
189
|
+
|
|
190
|
+
/* The values of a select option, grouped or not. */
|
|
191
|
+
function selectValues(option: ConfigOption): Array<z.infer<typeof SelectValueSchema>> {
|
|
192
|
+
const flat = (option.options ?? []).flatMap((entry) => (isRecord(entry) && Array.isArray(entry.options) ? entry.options : [entry]));
|
|
193
|
+
return validItems(SelectValueSchema, flat);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function selectOption(options: ConfigOption[], category: string): ConfigOption | null {
|
|
197
|
+
return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo {
|
|
201
|
+
const record = isRecord(result) ? result : {};
|
|
202
|
+
const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
|
|
203
|
+
return { sessionId, agent, ...sessionState(options, record.modes, record.models) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sessionState(options: ConfigOption[], modes: unknown, models: unknown): Omit<AcpSessionInfo, 'sessionId' | 'agent'> {
|
|
207
|
+
const modelOption = selectOption(options, 'model');
|
|
208
|
+
const modeOption = selectOption(options, 'mode');
|
|
209
|
+
const thoughtOption = selectOption(options, 'thought_level');
|
|
210
|
+
const modeState = ModeStateSchema.safeParse(modes);
|
|
211
|
+
const modelState = ModelStateSchema.safeParse(models);
|
|
212
|
+
return {
|
|
213
|
+
models: modelOption
|
|
214
|
+
? {
|
|
215
|
+
current: modelOption.currentValue as string,
|
|
216
|
+
models: selectValues(modelOption).map((value) => ({ id: value.value, name: value.name, description: null })),
|
|
217
|
+
configId: modelOption.id
|
|
218
|
+
}
|
|
219
|
+
: modelState.success
|
|
220
|
+
? {
|
|
221
|
+
current: modelState.data.currentModelId,
|
|
222
|
+
models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
|
|
223
|
+
id: model.modelId,
|
|
224
|
+
name: model.name,
|
|
225
|
+
description: model.description ?? null
|
|
226
|
+
})),
|
|
227
|
+
configId: null
|
|
228
|
+
}
|
|
229
|
+
: null,
|
|
230
|
+
modes: modeOption
|
|
231
|
+
? {
|
|
232
|
+
current: modeOption.currentValue as string,
|
|
233
|
+
modes: selectValues(modeOption).map((value) => ({ id: value.value, name: value.name })),
|
|
234
|
+
configId: modeOption.id
|
|
235
|
+
}
|
|
236
|
+
: modeState.success
|
|
237
|
+
? {
|
|
238
|
+
current: modeState.data.currentModeId,
|
|
239
|
+
modes: validItems(ModeSchema, modeState.data.availableModes).map((mode) => ({ id: mode.id, name: mode.name })),
|
|
240
|
+
configId: null
|
|
241
|
+
}
|
|
242
|
+
: null,
|
|
243
|
+
thoughtLevel: thoughtOption
|
|
244
|
+
? { configId: thoughtOption.id, current: thoughtOption.currentValue as string, values: selectValues(thoughtOption).map((value) => value.value) }
|
|
245
|
+
: null
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function sessionIdOf(result: unknown): string | null {
|
|
250
|
+
const parsed = SessionResponseSchema.safeParse(result);
|
|
251
|
+
return parsed.success ? (parsed.data.sessionId ?? null) : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* The agent's own mode ids per runtime mode, best first. */
|
|
255
|
+
export type AcpModeMap = Partial<Record<RuntimeMode, readonly string[]>>;
|
|
256
|
+
|
|
257
|
+
/*
|
|
258
|
+
* The agent mode a runtime mode runs in: the first id the agent offers for that mode, else for the
|
|
259
|
+
* next narrower one, and so on down to `supervised`. Never a wider one; null when nothing fits.
|
|
260
|
+
*/
|
|
261
|
+
export function resolveAcpMode(requested: RuntimeMode, map: AcpModeMap, offered: readonly string[]): { modeId: string; runtimeMode: RuntimeMode } | null {
|
|
262
|
+
for (let index = MODE_ORDER.indexOf(requested); index >= 0; index--) {
|
|
263
|
+
const runtimeMode = MODE_ORDER[index]!;
|
|
264
|
+
const modeId = (map[runtimeMode] ?? []).find((candidate) => offered.includes(candidate));
|
|
265
|
+
if (modeId !== undefined) {
|
|
266
|
+
return { modeId, runtimeMode };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* The widest runtime mode a map puts an agent mode under, which is what that mode grants; null for one it does not name. */
|
|
273
|
+
export function runtimeModeOf(modeId: string, map: AcpModeMap): RuntimeMode | null {
|
|
274
|
+
for (let index = MODE_ORDER.length - 1; index >= 0; index--) {
|
|
275
|
+
const runtimeMode = MODE_ORDER[index]!;
|
|
276
|
+
if ((map[runtimeMode] ?? []).includes(modeId)) {
|
|
277
|
+
return runtimeMode;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The tool kinds whose requests a runtime mode answers on its own when the agent has no mode for it.
|
|
284
|
+
const AUTO_ALLOWED: Record<RuntimeMode, ReadonlySet<string> | 'all'> = {
|
|
285
|
+
supervised: new Set(),
|
|
286
|
+
'auto-accept-edits': new Set(['read', 'search', 'think', 'edit', 'move']),
|
|
287
|
+
auto: new Set(['read', 'search', 'think', 'edit', 'move', 'fetch']),
|
|
288
|
+
'full-access': 'all'
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export function autoAllows(mode: RuntimeMode, kind: string | null): boolean {
|
|
292
|
+
const allowed = AUTO_ALLOWED[mode];
|
|
293
|
+
return allowed === 'all' || (kind !== null && allowed.has(kind));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* The trimmed hunk between two texts, as one unified diff; null when they are the same. */
|
|
297
|
+
export function unifiedDiff(oldText: string, newText: string): string | null {
|
|
298
|
+
const lines = (text: string): string[] => {
|
|
299
|
+
const split = text === '' ? [] : text.split('\n');
|
|
300
|
+
if (split.at(-1) === '') {
|
|
301
|
+
split.pop();
|
|
302
|
+
}
|
|
303
|
+
return split;
|
|
304
|
+
};
|
|
305
|
+
const before = lines(oldText);
|
|
306
|
+
const after = lines(newText);
|
|
307
|
+
let prefix = 0;
|
|
308
|
+
while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) {
|
|
309
|
+
prefix++;
|
|
310
|
+
}
|
|
311
|
+
let suffix = 0;
|
|
312
|
+
while (suffix < before.length - prefix && suffix < after.length - prefix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) {
|
|
313
|
+
suffix++;
|
|
314
|
+
}
|
|
315
|
+
const removed = before.slice(prefix, before.length - suffix);
|
|
316
|
+
const added = after.slice(prefix, after.length - suffix);
|
|
317
|
+
if (removed.length === 0 && added.length === 0) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
const range = (count: number): string => `${count === 0 ? prefix : prefix + 1},${count}`;
|
|
321
|
+
return (
|
|
322
|
+
[`@@ -${range(removed.length)} +${range(added.length)} @@`, ...removed.map((line) => `-${line}`), ...added.map((line) => `+${line}`)].join('\n') + '\n'
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
type Diff = z.infer<typeof DiffSchema>;
|
|
327
|
+
|
|
328
|
+
interface ToolState {
|
|
329
|
+
title: string;
|
|
330
|
+
name: string | null;
|
|
331
|
+
kind: string | null;
|
|
332
|
+
status: string | null;
|
|
333
|
+
rawInput: unknown;
|
|
334
|
+
rawOutput: unknown;
|
|
335
|
+
locations: string[];
|
|
336
|
+
diffs: Diff[];
|
|
337
|
+
text: string[];
|
|
338
|
+
// What the thread last heard, so an update that changes nothing about the row sends nothing.
|
|
339
|
+
shown: string | null;
|
|
340
|
+
settled: boolean;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// `Edit` would have the thread draw a diff of nothing until the agent sends one.
|
|
344
|
+
const KIND_NAMES: Record<string, string> = {
|
|
345
|
+
edit: 'Change',
|
|
346
|
+
delete: 'Delete',
|
|
347
|
+
move: 'Move',
|
|
348
|
+
think: 'Think',
|
|
349
|
+
switch_mode: 'Switch mode',
|
|
350
|
+
other: 'Tool'
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
function str(value: unknown): string | null {
|
|
354
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* A field of the agent's own input, which no schema promises; the first one that is a non-empty string. */
|
|
358
|
+
function rawField(raw: unknown, ...keys: string[]): string | null {
|
|
359
|
+
if (!isRecord(raw)) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
for (const key of keys) {
|
|
363
|
+
const value = raw[key];
|
|
364
|
+
if (Array.isArray(value) && value.every((part) => typeof part === 'string') && value.length > 0) {
|
|
365
|
+
return value.join(' ');
|
|
366
|
+
}
|
|
367
|
+
if (str(value) !== null) {
|
|
368
|
+
return value as string;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* The row a tool call becomes: a name the thread has words for where the kind says which, and an input it reads a subject from. */
|
|
375
|
+
export function toolRow(tool: Pick<ToolState, 'title' | 'name' | 'kind' | 'rawInput' | 'locations' | 'diffs'>): {
|
|
376
|
+
name: string;
|
|
377
|
+
input: Record<string, unknown>;
|
|
378
|
+
changes?: ChatFileChange[];
|
|
379
|
+
} {
|
|
380
|
+
const description = tool.title;
|
|
381
|
+
const path = tool.locations[0] ?? rawField(tool.rawInput, 'file_path', 'path', 'filePath') ?? null;
|
|
382
|
+
if (tool.diffs.length > 0) {
|
|
383
|
+
const paths = new Set(tool.diffs.map((diff) => diff.path));
|
|
384
|
+
if (paths.size === 1) {
|
|
385
|
+
const [first] = tool.diffs;
|
|
386
|
+
if (tool.diffs.length === 1 && (first!.oldText ?? null) === null) {
|
|
387
|
+
return { name: 'Write', input: { file_path: first!.path, content: first!.newText, description } };
|
|
388
|
+
}
|
|
389
|
+
if (tool.diffs.length === 1) {
|
|
390
|
+
return { name: 'Edit', input: { file_path: first!.path, old_string: first!.oldText, new_string: first!.newText, description } };
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
name: 'MultiEdit',
|
|
394
|
+
input: { file_path: first!.path, edits: tool.diffs.map((diff) => ({ old_string: diff.oldText ?? '', new_string: diff.newText })), description }
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const changes = tool.diffs.flatMap((diff): ChatFileChange[] => {
|
|
398
|
+
const text = unifiedDiff(diff.oldText ?? '', diff.newText);
|
|
399
|
+
return text === null ? [] : [{ path: diff.path, kind: (diff.oldText ?? null) === null ? 'add' : 'update', diff: text }];
|
|
400
|
+
});
|
|
401
|
+
return { name: 'ApplyPatch', input: { summary: [...paths].join(', '), description }, changes };
|
|
402
|
+
}
|
|
403
|
+
const raw = isRecord(tool.rawInput) ? tool.rawInput : {};
|
|
404
|
+
switch (tool.kind) {
|
|
405
|
+
case 'execute':
|
|
406
|
+
return { name: 'Bash', input: { ...raw, description, command: rawField(tool.rawInput, 'command', 'cmd') ?? description } };
|
|
407
|
+
case 'read':
|
|
408
|
+
return { name: 'Read', input: { ...raw, description, ...(path === null ? {} : { file_path: path }) } };
|
|
409
|
+
case 'search':
|
|
410
|
+
return { name: 'Grep', input: { ...raw, description, pattern: rawField(tool.rawInput, 'pattern', 'query', 'regex') ?? description } };
|
|
411
|
+
case 'fetch': {
|
|
412
|
+
const url = rawField(tool.rawInput, 'url', 'uri');
|
|
413
|
+
return url === null
|
|
414
|
+
? { name: 'WebSearch', input: { ...raw, description, query: rawField(tool.rawInput, 'query') ?? description } }
|
|
415
|
+
: { name: 'WebFetch', input: { ...raw, description, url } };
|
|
416
|
+
}
|
|
417
|
+
default:
|
|
418
|
+
return {
|
|
419
|
+
name: tool.name ?? KIND_NAMES[tool.kind ?? 'other'] ?? 'Tool',
|
|
420
|
+
input: { description, ...raw, ...(path === null ? {} : { path }) }
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function toolOutput(tool: ToolState): string | null {
|
|
426
|
+
if (tool.text.length > 0) {
|
|
427
|
+
return tool.text.join('\n');
|
|
428
|
+
}
|
|
429
|
+
if (typeof tool.rawOutput === 'string') {
|
|
430
|
+
return tool.rawOutput;
|
|
431
|
+
}
|
|
432
|
+
if (isRecord(tool.rawOutput)) {
|
|
433
|
+
const text = rawField(tool.rawOutput, 'output', 'stdout', 'content', 'result', 'error');
|
|
434
|
+
return text ?? JSON.stringify(tool.rawOutput);
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/* A permission request as the backend acts on it: the event it raises, and the tool kind a runtime mode may answer it by. */
|
|
440
|
+
export interface AcpPermission {
|
|
441
|
+
requestId: string;
|
|
442
|
+
rpcId: number | string;
|
|
443
|
+
kind: string | null;
|
|
444
|
+
events: BackendEvent[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/* The words a turn that ended for another reason than its end leaves in the thread. */
|
|
448
|
+
export interface AcpStopWords {
|
|
449
|
+
maxTokens: string;
|
|
450
|
+
maxTurnRequests: string;
|
|
451
|
+
refusal: string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function acpStopWords(agentName: string): AcpStopWords {
|
|
455
|
+
return {
|
|
456
|
+
maxTokens: `${agentName} stopped: the response reached its token limit.`,
|
|
457
|
+
maxTurnRequests: `${agentName} stopped: the turn reached its limit of model requests.`,
|
|
458
|
+
refusal: `${agentName} refused to continue this turn.`
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/*
|
|
463
|
+
* One ACP session's `session/update` stream, mapped onto backend events. A message's chunks become
|
|
464
|
+
* one text item, cut where the agent switches to a thought or a tool; a tool call becomes a row that
|
|
465
|
+
* is drawn again whenever an update changes what it shows, and settles when it completes or fails.
|
|
466
|
+
*/
|
|
467
|
+
export class AcpProtocol {
|
|
468
|
+
private readonly generation: number;
|
|
469
|
+
private readonly modes: AcpModeMap;
|
|
470
|
+
private readonly words: AcpStopWords;
|
|
471
|
+
private readonly tools = new Map<string, ToolState>();
|
|
472
|
+
private readonly permissions = new Map<string, { rpcId: number | string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
|
|
473
|
+
private text: { ref: string; messageId: string | null; text: string } | null = null;
|
|
474
|
+
private thought: { ref: string; text: string } | null = null;
|
|
475
|
+
private counter = 0;
|
|
476
|
+
private costUsd = 0;
|
|
477
|
+
|
|
478
|
+
constructor(generation: number, modes: AcpModeMap, words: AcpStopWords) {
|
|
479
|
+
this.generation = generation;
|
|
480
|
+
this.modes = modes;
|
|
481
|
+
this.words = words;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
|
|
485
|
+
update(sessionId: string, params: unknown): BackendEvent[] {
|
|
486
|
+
const notification = SessionNotificationSchema.safeParse(params);
|
|
487
|
+
if (!notification.success || notification.data.sessionId !== sessionId) {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
const update = notification.data.update;
|
|
491
|
+
switch (update.sessionUpdate) {
|
|
492
|
+
case 'agent_message_chunk':
|
|
493
|
+
return this.messageChunk(update);
|
|
494
|
+
case 'agent_thought_chunk':
|
|
495
|
+
return this.thoughtChunk(update);
|
|
496
|
+
case 'tool_call':
|
|
497
|
+
case 'tool_call_update': {
|
|
498
|
+
const call = ToolCallSchema.safeParse(update);
|
|
499
|
+
return call.success ? [...this.closeText(), ...this.closeThought(), ...this.toolCall(call.data)] : [];
|
|
500
|
+
}
|
|
501
|
+
case 'plan':
|
|
502
|
+
return this.plan(update);
|
|
503
|
+
case 'available_commands_update': {
|
|
504
|
+
const commands = validItems(CommandSchema, Array.isArray(update.availableCommands) ? update.availableCommands : []);
|
|
505
|
+
return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commands.map((command) => command.name) }];
|
|
506
|
+
}
|
|
507
|
+
case 'current_mode_update':
|
|
508
|
+
return typeof update.currentModeId === 'string' ? [this.modeEvent(update.currentModeId)] : [];
|
|
509
|
+
case 'config_option_update': {
|
|
510
|
+
const options = validItems(ConfigOptionSchema, Array.isArray(update.configOptions) ? update.configOptions : []);
|
|
511
|
+
const state = sessionState(options, null, null);
|
|
512
|
+
return [
|
|
513
|
+
...(state.models === null ? [] : [{ type: 'model', model: state.models.current } as const]),
|
|
514
|
+
...(state.modes === null ? [] : [this.modeEvent(state.modes.current)])
|
|
515
|
+
];
|
|
516
|
+
}
|
|
517
|
+
case 'usage_update': {
|
|
518
|
+
const usage = UsageSchema.safeParse(update);
|
|
519
|
+
if (!usage.success) {
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
const cost = usage.data.cost?.currency === 'USD' ? usage.data.cost.amount : undefined;
|
|
523
|
+
if (cost !== undefined) {
|
|
524
|
+
this.costUsd = cost;
|
|
525
|
+
}
|
|
526
|
+
return [{ type: 'usage', contextTokens: usage.data.used, contextWindow: usage.data.size, ...(cost === undefined ? {} : { costUsd: cost }) }];
|
|
527
|
+
}
|
|
528
|
+
case 'session_info_update':
|
|
529
|
+
return str(update.title) === null ? [] : [{ type: 'title', title: update.title as string }];
|
|
530
|
+
default:
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
modeEvent(modeId: string): BackendEvent {
|
|
536
|
+
const runtimeMode = runtimeModeOf(modeId, this.modes);
|
|
537
|
+
return { type: 'permissions', permissionMode: modeId, ...(runtimeMode === null ? {} : { effectiveRuntimeMode: runtimeMode }) };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/* A `session/request_permission` request; null when it does not parse, which the caller answers with an error. */
|
|
541
|
+
permission(rpcId: number | string, sessionId: string, params: unknown): AcpPermission | null {
|
|
542
|
+
const request = PermissionRequestSchema.safeParse(params);
|
|
543
|
+
if (!request.success || request.data.sessionId !== sessionId) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
const requestId = `acp-${this.generation}-${String(rpcId)}`;
|
|
547
|
+
const events = [...this.closeText(), ...this.closeThought(), ...this.toolCall(request.data.toolCall, false)];
|
|
548
|
+
const tool = this.tools.get(request.data.toolCall.toolCallId)!;
|
|
549
|
+
const row = toolRow(tool);
|
|
550
|
+
const options = request.data.options;
|
|
551
|
+
this.permissions.set(requestId, { rpcId, options });
|
|
552
|
+
events.push({
|
|
553
|
+
type: 'approval.requested',
|
|
554
|
+
requestId,
|
|
555
|
+
ref: request.data.toolCall.toolCallId,
|
|
556
|
+
toolName: row.name,
|
|
557
|
+
input: row.changes ? { ...row.input, changes: row.changes } : row.input,
|
|
558
|
+
description: tool.title === '' ? null : tool.title,
|
|
559
|
+
canAllowAlways: options.some((option) => option.kind === 'allow_always')
|
|
560
|
+
});
|
|
561
|
+
return { requestId, rpcId, kind: tool.kind, events };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/*
|
|
565
|
+
* The answer to a permission request: allow once, reject, or allow always only when the agent offered it.
|
|
566
|
+
* A decision the agent offered no option for falls to the narrower one, and a refusal with no reject option
|
|
567
|
+
* at all is a cancelled outcome.
|
|
568
|
+
*/
|
|
569
|
+
answer(requestId: string, decision: ApprovalDecision): { rpcId: number | string; result: unknown } | null {
|
|
570
|
+
const pending = this.permissions.get(requestId);
|
|
571
|
+
if (!pending) {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
this.permissions.delete(requestId);
|
|
575
|
+
const byKind = (...kinds: string[]): string | null => {
|
|
576
|
+
for (const kind of kinds) {
|
|
577
|
+
const option = pending.options.find((candidate) => candidate.kind === kind);
|
|
578
|
+
if (option) {
|
|
579
|
+
return option.optionId;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
};
|
|
584
|
+
const optionId =
|
|
585
|
+
decision === 'allow-always'
|
|
586
|
+
? byKind('allow_always', 'allow_once')
|
|
587
|
+
: decision === 'allow'
|
|
588
|
+
? byKind('allow_once', 'allow_always')
|
|
589
|
+
: byKind('reject_once', 'reject_always');
|
|
590
|
+
return { rpcId: pending.rpcId, result: { outcome: optionId === null ? { outcome: 'cancelled' } : { outcome: 'selected', optionId } } };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
|
|
594
|
+
cancelPermissions(): Array<{ requestId: string; rpcId: number | string; result: unknown }> {
|
|
595
|
+
const cancelled = [...this.permissions].map(([requestId, pending]) => ({
|
|
596
|
+
requestId,
|
|
597
|
+
rpcId: pending.rpcId,
|
|
598
|
+
result: { outcome: { outcome: 'cancelled' } }
|
|
599
|
+
}));
|
|
600
|
+
this.permissions.clear();
|
|
601
|
+
return cancelled;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
hasPermission(requestId: string): boolean {
|
|
605
|
+
return this.permissions.has(requestId);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/* The end of a turn, from the `stopReason` of its `session/prompt` response. */
|
|
609
|
+
finish(result: unknown, promptId: string | undefined): BackendEvent[] {
|
|
610
|
+
const parsed = PromptResponseSchema.safeParse(result);
|
|
611
|
+
const reason = parsed.success ? parsed.data.stopReason : 'end_turn';
|
|
612
|
+
const events = [...this.closeText(), ...this.closeThought()];
|
|
613
|
+
const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
|
|
614
|
+
switch (reason) {
|
|
615
|
+
case 'cancelled':
|
|
616
|
+
events.push({ type: 'turn.done', ...base, state: 'aborted' });
|
|
617
|
+
break;
|
|
618
|
+
case 'refusal':
|
|
619
|
+
events.push({ type: 'turn.done', ...base, state: 'error', error: this.words.refusal });
|
|
620
|
+
break;
|
|
621
|
+
case 'max_tokens':
|
|
622
|
+
events.push({ type: 'note', level: 'warning', text: this.words.maxTokens }, { type: 'turn.done', ...base, state: 'done' });
|
|
623
|
+
break;
|
|
624
|
+
case 'max_turn_requests':
|
|
625
|
+
events.push({ type: 'note', level: 'warning', text: this.words.maxTurnRequests }, { type: 'turn.done', ...base, state: 'done' });
|
|
626
|
+
break;
|
|
627
|
+
default:
|
|
628
|
+
events.push({ type: 'turn.done', ...base, state: 'done' });
|
|
629
|
+
}
|
|
630
|
+
return events;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
|
|
634
|
+
abort(promptId: string | undefined, error: string | null): BackendEvent[] {
|
|
635
|
+
const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
|
|
636
|
+
return [
|
|
637
|
+
...this.closeText(),
|
|
638
|
+
...this.closeThought(),
|
|
639
|
+
error === null ? { type: 'turn.done', ...base, state: 'aborted' } : { type: 'turn.done', ...base, state: 'error', error }
|
|
640
|
+
];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private messageChunk(update: Record<string, unknown>): BackendEvent[] {
|
|
644
|
+
const chunk = ContentChunkSchema.safeParse(update);
|
|
645
|
+
if (!chunk.success || chunk.data.content.type !== 'text' || chunk.data.content.text === undefined) {
|
|
646
|
+
return [];
|
|
647
|
+
}
|
|
648
|
+
const messageId = chunk.data.messageId ?? null;
|
|
649
|
+
const events = this.closeThought();
|
|
650
|
+
if (this.text !== null && messageId !== null && this.text.messageId !== messageId) {
|
|
651
|
+
events.push(...this.closeText());
|
|
652
|
+
}
|
|
653
|
+
if (this.text === null) {
|
|
654
|
+
this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '' };
|
|
655
|
+
}
|
|
656
|
+
this.text.text += chunk.data.content.text;
|
|
657
|
+
events.push({ type: 'text.delta', ref: this.text.ref, text: chunk.data.content.text });
|
|
658
|
+
return events;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private thoughtChunk(update: Record<string, unknown>): BackendEvent[] {
|
|
662
|
+
const chunk = ContentChunkSchema.safeParse(update);
|
|
663
|
+
if (!chunk.success || chunk.data.content.type !== 'text' || chunk.data.content.text === undefined) {
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
const events = this.closeText();
|
|
667
|
+
if (this.thought === null) {
|
|
668
|
+
this.thought = { ref: `thought-${++this.counter}`, text: '' };
|
|
669
|
+
}
|
|
670
|
+
this.thought.text += chunk.data.content.text;
|
|
671
|
+
events.push({ type: 'thinking.delta', ref: this.thought.ref, text: chunk.data.content.text });
|
|
672
|
+
return events;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private closeText(): BackendEvent[] {
|
|
676
|
+
const text = this.text;
|
|
677
|
+
this.text = null;
|
|
678
|
+
return text === null ? [] : [{ type: 'text.done', ref: text.ref, text: text.text }];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private closeThought(): BackendEvent[] {
|
|
682
|
+
const thought = this.thought;
|
|
683
|
+
this.thought = null;
|
|
684
|
+
return thought === null ? [] : [{ type: 'thinking.done', ref: thought.ref, text: thought.text }];
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/* A tool call or an update to one; fields an update leaves out keep what the call said before, and a list it sends replaces the old one. */
|
|
688
|
+
private toolCall(call: z.infer<typeof ToolCallSchema>, settle = true): BackendEvent[] {
|
|
689
|
+
const known = this.tools.get(call.toolCallId);
|
|
690
|
+
const tool: ToolState = known ?? {
|
|
691
|
+
title: '',
|
|
692
|
+
name: null,
|
|
693
|
+
kind: null,
|
|
694
|
+
status: null,
|
|
695
|
+
rawInput: undefined,
|
|
696
|
+
rawOutput: undefined,
|
|
697
|
+
locations: [],
|
|
698
|
+
diffs: [],
|
|
699
|
+
text: [],
|
|
700
|
+
shown: null,
|
|
701
|
+
settled: false
|
|
702
|
+
};
|
|
703
|
+
tool.title = call.title ?? tool.title;
|
|
704
|
+
tool.name = call.name ?? tool.name;
|
|
705
|
+
tool.kind = call.kind ?? tool.kind;
|
|
706
|
+
tool.status = call.status ?? tool.status;
|
|
707
|
+
tool.rawInput = call.rawInput ?? tool.rawInput;
|
|
708
|
+
tool.rawOutput = call.rawOutput ?? tool.rawOutput;
|
|
709
|
+
if (call.locations) {
|
|
710
|
+
tool.locations = validItems(LocationSchema, call.locations).map((location) => location.path);
|
|
711
|
+
}
|
|
712
|
+
if (call.content) {
|
|
713
|
+
tool.diffs = validItems(DiffSchema, call.content);
|
|
714
|
+
tool.text = validItems(ToolContentSchema, call.content).flatMap((entry) =>
|
|
715
|
+
entry.content.type === 'text' && entry.content.text ? [entry.content.text] : []
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
this.tools.set(call.toolCallId, tool);
|
|
719
|
+
const events: BackendEvent[] = [];
|
|
720
|
+
const row = toolRow(tool);
|
|
721
|
+
const shown = JSON.stringify(row);
|
|
722
|
+
if (shown !== tool.shown) {
|
|
723
|
+
tool.shown = shown;
|
|
724
|
+
events.push({
|
|
725
|
+
type: 'tool.started',
|
|
726
|
+
ref: call.toolCallId,
|
|
727
|
+
name: row.name,
|
|
728
|
+
input: row.input,
|
|
729
|
+
parentRef: null,
|
|
730
|
+
...(row.changes ? { changes: row.changes } : {})
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
if (settle && !tool.settled && (tool.status === 'completed' || tool.status === 'failed')) {
|
|
734
|
+
tool.settled = true;
|
|
735
|
+
events.push({
|
|
736
|
+
type: 'tool.done',
|
|
737
|
+
ref: call.toolCallId,
|
|
738
|
+
output: toolOutput(tool),
|
|
739
|
+
state: tool.status === 'completed' ? 'done' : 'error',
|
|
740
|
+
...(row.changes ? { changes: row.changes } : {})
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
return events;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/* A plan is the whole list each time; it becomes a to-do row of its own, as a plan update in the thread. */
|
|
747
|
+
private plan(update: Record<string, unknown>): BackendEvent[] {
|
|
748
|
+
const entries = validItems(PlanEntrySchema, Array.isArray(update.entries) ? update.entries : []);
|
|
749
|
+
const ref = `plan-${++this.counter}`;
|
|
750
|
+
const todos = entries.map((entry) => ({
|
|
751
|
+
content: entry.content,
|
|
752
|
+
status: entry.status,
|
|
753
|
+
activeForm: entry.content,
|
|
754
|
+
...(entry.priority ? { priority: entry.priority } : {})
|
|
755
|
+
}));
|
|
756
|
+
return [
|
|
757
|
+
...this.closeText(),
|
|
758
|
+
...this.closeThought(),
|
|
759
|
+
{ type: 'tool.started', ref, name: 'TodoWrite', input: { todos }, parentRef: null },
|
|
760
|
+
{ type: 'tool.done', ref, output: null, state: 'done' }
|
|
761
|
+
];
|
|
762
|
+
}
|
|
763
|
+
}
|