@adecore/agents 0.31.4 → 0.31.6
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 +7 -0
- package/dist/chat/acp-backend.js +87 -6
- package/dist/chat/acp-protocol.d.ts +6 -0
- package/dist/chat/acp-protocol.js +34 -9
- package/dist/providers/accounts/acp-account.d.ts +1 -0
- package/dist/providers/accounts/acp-account.js +25 -3
- package/dist/providers/acp-agent-state.d.ts +35 -0
- package/dist/providers/acp-agent-state.js +170 -0
- package/dist/providers/acp-provider.d.ts +2 -0
- package/dist/providers/acp-provider.js +29 -33
- package/dist/providers/catalog.d.ts +2 -0
- package/dist/providers/catalog.js +14 -4
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/provider.d.ts +1 -0
- package/dist/providers/registry.d.ts +1 -0
- package/dist/providers/registry.js +9 -0
- package/dist/testing/fake-acp.d.ts +3 -0
- package/dist/testing/fake-acp.js +11 -4
- package/dist/usage/aggregate.js +1 -1
- package/dist/usage/index-file.d.ts +2 -0
- package/dist/usage/index-file.js +19 -10
- package/dist/usage/pricing.d.ts +3 -2
- package/dist/usage/pricing.js +42 -8
- package/dist/usage/readers/codex.d.ts +9 -0
- package/dist/usage/readers/codex.js +75 -14
- package/dist/usage/record.d.ts +1 -0
- package/dist/usage/scanner.js +32 -8
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +93 -5
- package/src/chat/acp-protocol.ts +41 -9
- package/src/providers/accounts/acp-account.ts +34 -3
- package/src/providers/acp-agent-state.ts +206 -0
- package/src/providers/acp-provider.ts +33 -37
- package/src/providers/catalog.ts +19 -5
- package/src/providers/index.ts +1 -0
- package/src/providers/provider.ts +2 -0
- package/src/providers/registry.ts +10 -0
- package/src/testing/fake-acp.ts +17 -4
- package/src/usage/aggregate.ts +1 -1
- package/src/usage/index-file.ts +25 -18
- package/src/usage/pricing.ts +46 -8
- package/src/usage/readers/codex.ts +86 -14
- package/src/usage/record.ts +1 -0
- package/src/usage/scanner.ts +35 -8
|
@@ -17,6 +17,8 @@ export interface AcpAgentSpec {
|
|
|
17
17
|
model?(selection: ModelSelection): string | null;
|
|
18
18
|
onSession?(info: AcpSessionInfo): void;
|
|
19
19
|
onUsage?(usage: AcpUsageReport): void;
|
|
20
|
+
onContextReport?(): void;
|
|
21
|
+
onCommands?(commands: string[]): void;
|
|
20
22
|
autoAllow?(request: AcpPermissionRequest): boolean;
|
|
21
23
|
}
|
|
22
24
|
export interface AcpUsageReport extends AcpTurnUsage {
|
|
@@ -37,6 +39,9 @@ export declare class AcpBackend implements ChatBackend {
|
|
|
37
39
|
private contextPending;
|
|
38
40
|
private prompt;
|
|
39
41
|
private model;
|
|
42
|
+
private contextReported;
|
|
43
|
+
private commands;
|
|
44
|
+
private early;
|
|
40
45
|
constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec);
|
|
41
46
|
get running(): boolean;
|
|
42
47
|
get pid(): number | null;
|
|
@@ -49,6 +54,7 @@ export declare class AcpBackend implements ChatBackend {
|
|
|
49
54
|
private applyModel;
|
|
50
55
|
private applyThoughtLevel;
|
|
51
56
|
private promptBlocks;
|
|
57
|
+
private mentionBlocks;
|
|
52
58
|
sendTurn(input: TurnInput): void;
|
|
53
59
|
compact(): void;
|
|
54
60
|
interrupt(): void;
|
|
@@ -60,6 +66,7 @@ export declare class AcpBackend implements ChatBackend {
|
|
|
60
66
|
private respond;
|
|
61
67
|
private fail;
|
|
62
68
|
private handleFrame;
|
|
69
|
+
private holdEarly;
|
|
63
70
|
private hostAllows;
|
|
64
71
|
private handleExit;
|
|
65
72
|
private report;
|
package/dist/chat/acp-backend.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
2
4
|
import { attachmentImageMime } from '@adecore/agent-contracts';
|
|
3
5
|
import { errorText } from '../error-text.js';
|
|
4
6
|
import { MODE_ORDER } from '../modes.js';
|
|
5
|
-
import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf, thoughtLevelOf } from './acp-protocol.js';
|
|
7
|
+
import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf, sessionUpdateOf, thoughtLevelOf } from './acp-protocol.js';
|
|
6
8
|
import { attachmentNote } from './input.js';
|
|
7
9
|
import { JsonRpcError, JsonRpcTransport } from './json-rpc-transport.js';
|
|
8
10
|
export const DEFAULT_ACP_CLIENT = { name: 'agents', title: 'Agents', version: '0.1.0' };
|
|
9
11
|
// What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
|
|
10
12
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
13
|
+
// What a prompt carries of mentioned files; a larger file stays a path the agent opens with its own tools.
|
|
14
|
+
const MAX_EMBEDDED_FILE_BYTES = 256 * 1024;
|
|
15
|
+
const MAX_EMBEDDED_BYTES = 1024 * 1024;
|
|
16
|
+
function inside(folder, path) {
|
|
17
|
+
const rest = relative(folder, path);
|
|
18
|
+
return rest === '' || (!rest.startsWith('..') && !isAbsolute(rest));
|
|
19
|
+
}
|
|
11
20
|
function nameOf(client) {
|
|
12
21
|
return client.title ?? client.name;
|
|
13
22
|
}
|
|
@@ -31,6 +40,10 @@ export class AcpBackend {
|
|
|
31
40
|
contextPending = null;
|
|
32
41
|
prompt = null;
|
|
33
42
|
model = null;
|
|
43
|
+
contextReported = false;
|
|
44
|
+
commands = [];
|
|
45
|
+
// An agent may send updates for a new session before it answers with the session's id.
|
|
46
|
+
early = [];
|
|
34
47
|
constructor(launch, host, spec) {
|
|
35
48
|
this.launch = launch;
|
|
36
49
|
this.host = host;
|
|
@@ -97,6 +110,9 @@ export class AcpBackend {
|
|
|
97
110
|
effectiveRuntimeMode: this.launch.runtimeMode,
|
|
98
111
|
...(modeId === null ? {} : { permissionMode: modeId })
|
|
99
112
|
});
|
|
113
|
+
for (const params of this.early.splice(0)) {
|
|
114
|
+
this.emitAll(this.protocol.update(this.sessionId, params));
|
|
115
|
+
}
|
|
100
116
|
}
|
|
101
117
|
sessionParams() {
|
|
102
118
|
const folders = this.launch.folders ?? [];
|
|
@@ -261,7 +277,51 @@ export class AcpBackend {
|
|
|
261
277
|
const named = input.attachments.filter((attachment) => !images.includes(attachment));
|
|
262
278
|
const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part) => part !== null && part !== '');
|
|
263
279
|
this.contextPending = null;
|
|
264
|
-
return [{ type: 'text', text: parts.join('\n\n') }, ...blocks];
|
|
280
|
+
return [{ type: 'text', text: parts.join('\n\n') }, ...blocks, ...this.mentionBlocks(input.mentions)];
|
|
281
|
+
}
|
|
282
|
+
/*
|
|
283
|
+
* The text of each mentioned file as a `resource` block, for an agent that takes embedded context. Only a text file
|
|
284
|
+
* inside the chat's folders goes along, since a mention is a path anyone who may send could name; the rest stays
|
|
285
|
+
* in the prompt as the path it is.
|
|
286
|
+
*/
|
|
287
|
+
mentionBlocks(mentions) {
|
|
288
|
+
if (this.capabilities?.embeddedContext !== true || mentions.length === 0) {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
const real = (path) => {
|
|
292
|
+
try {
|
|
293
|
+
return realpathSync(path);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
const folders = [this.launch.cwd, ...(this.launch.folders ?? [])].flatMap((folder) => real(folder) ?? []);
|
|
300
|
+
const blocks = [];
|
|
301
|
+
let budget = MAX_EMBEDDED_BYTES;
|
|
302
|
+
for (const mention of new Set(mentions)) {
|
|
303
|
+
const path = real(resolve(this.launch.cwd, mention));
|
|
304
|
+
if (path === null || !folders.some((folder) => inside(folder, path))) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
let text;
|
|
308
|
+
try {
|
|
309
|
+
const stat = statSync(path);
|
|
310
|
+
if (!stat.isFile() || stat.size > MAX_EMBEDDED_FILE_BYTES || stat.size > budget) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
text = readFileSync(path, 'utf8');
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (text.includes('\u0000')) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
budget -= Buffer.byteLength(text);
|
|
322
|
+
blocks.push({ type: 'resource', resource: { uri: pathToFileURL(path).href, text } });
|
|
323
|
+
}
|
|
324
|
+
return blocks;
|
|
265
325
|
}
|
|
266
326
|
sendTurn(input) {
|
|
267
327
|
const transport = this.transport;
|
|
@@ -289,8 +349,12 @@ export class AcpBackend {
|
|
|
289
349
|
}
|
|
290
350
|
});
|
|
291
351
|
}
|
|
292
|
-
/* ACP has no call to fold the context
|
|
352
|
+
/* ACP has no call to fold the context, so an agent that lists a `compact` command gets it as a prompt. */
|
|
293
353
|
compact() {
|
|
354
|
+
if (this.commands.includes('compact')) {
|
|
355
|
+
this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
294
358
|
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
|
|
295
359
|
this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
|
|
296
360
|
}
|
|
@@ -361,9 +425,11 @@ export class AcpBackend {
|
|
|
361
425
|
}
|
|
362
426
|
const id = typeof frame.id === 'number' || typeof frame.id === 'string' ? frame.id : null;
|
|
363
427
|
if (frame.method === 'session/update' && id === null) {
|
|
364
|
-
if (
|
|
365
|
-
this.
|
|
428
|
+
if (this.sessionId === '' || this.replaying) {
|
|
429
|
+
this.holdEarly(frame.params);
|
|
430
|
+
return;
|
|
366
431
|
}
|
|
432
|
+
this.emitAll(this.protocol.update(this.sessionId, frame.params));
|
|
367
433
|
return;
|
|
368
434
|
}
|
|
369
435
|
if (id === null) {
|
|
@@ -396,6 +462,13 @@ export class AcpBackend {
|
|
|
396
462
|
}
|
|
397
463
|
this.emitAll(permission.events);
|
|
398
464
|
}
|
|
465
|
+
/* Holds an update until the session is known; of a history a load replays, only the commands, since the thread has the rest. */
|
|
466
|
+
holdEarly(params) {
|
|
467
|
+
const update = sessionUpdateOf(params);
|
|
468
|
+
if (update !== null && (!this.replaying || update.commands !== null)) {
|
|
469
|
+
this.early.push(params);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
399
472
|
hostAllows(request) {
|
|
400
473
|
try {
|
|
401
474
|
return this.spec.autoAllow?.(request) === true;
|
|
@@ -430,6 +503,14 @@ export class AcpBackend {
|
|
|
430
503
|
if (event.type === 'model') {
|
|
431
504
|
this.model = event.model;
|
|
432
505
|
}
|
|
506
|
+
if (event.type === 'session' && event.slashCommands !== undefined) {
|
|
507
|
+
this.commands = event.slashCommands;
|
|
508
|
+
this.spec.onCommands?.(event.slashCommands);
|
|
509
|
+
}
|
|
510
|
+
if (event.type === 'usage' && event.contextWindow !== undefined && !this.contextReported) {
|
|
511
|
+
this.contextReported = true;
|
|
512
|
+
this.spec.onContextReport?.();
|
|
513
|
+
}
|
|
433
514
|
this.host.onEvent(event);
|
|
434
515
|
}
|
|
435
516
|
}
|
|
@@ -29,6 +29,7 @@ export interface AcpAgentCapabilities {
|
|
|
29
29
|
additionalDirectories: boolean;
|
|
30
30
|
closeSession: boolean;
|
|
31
31
|
images: boolean;
|
|
32
|
+
embeddedContext: boolean;
|
|
32
33
|
authMethods: string[];
|
|
33
34
|
agent: AcpAgentInfo | null;
|
|
34
35
|
}
|
|
@@ -37,6 +38,7 @@ export interface AcpModel {
|
|
|
37
38
|
id: string;
|
|
38
39
|
name: string;
|
|
39
40
|
description: string | null;
|
|
41
|
+
contextWindow: number | null;
|
|
40
42
|
}
|
|
41
43
|
export interface AcpModelState {
|
|
42
44
|
current: string;
|
|
@@ -71,6 +73,10 @@ export interface AcpSessionInfo {
|
|
|
71
73
|
}
|
|
72
74
|
export declare function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined;
|
|
73
75
|
export declare function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo;
|
|
76
|
+
export declare function sessionUpdateOf(params: unknown): {
|
|
77
|
+
sessionId: string;
|
|
78
|
+
commands: string[] | null;
|
|
79
|
+
} | null;
|
|
74
80
|
export declare function sessionIdOf(result: unknown): string | null;
|
|
75
81
|
export type AcpModeMap = Partial<Record<RuntimeMode, readonly string[]>>;
|
|
76
82
|
export declare function resolveAcpMode(requested: RuntimeMode, map: AcpModeMap, offered: readonly string[]): {
|
|
@@ -75,7 +75,7 @@ const ModeStateSchema = z.looseObject({
|
|
|
75
75
|
availableModes: z.array(z.unknown())
|
|
76
76
|
});
|
|
77
77
|
const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
78
|
-
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish() });
|
|
78
|
+
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish(), _meta: z.unknown().optional() });
|
|
79
79
|
const ConfigOptionSchema = z.looseObject({
|
|
80
80
|
id: z.string(),
|
|
81
81
|
name: z.string(),
|
|
@@ -89,14 +89,19 @@ const ModelStateSchema = z.looseObject({
|
|
|
89
89
|
currentModelId: z.string(),
|
|
90
90
|
availableModels: z.array(z.unknown())
|
|
91
91
|
});
|
|
92
|
-
const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish() });
|
|
92
|
+
const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish(), _meta: z.unknown().optional() });
|
|
93
|
+
/* The context window an agent names for a model in the model's `_meta` (`totalContextTokens`, not part of the protocol); null where it names none. */
|
|
94
|
+
function contextWindowOf(meta) {
|
|
95
|
+
const tokens = isRecord(meta) ? meta.totalContextTokens : undefined;
|
|
96
|
+
return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
|
|
97
|
+
}
|
|
93
98
|
const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
94
99
|
const InitializeResponseSchema = z.looseObject({
|
|
95
100
|
protocolVersion: z.number(),
|
|
96
101
|
agentCapabilities: z
|
|
97
102
|
.looseObject({
|
|
98
103
|
loadSession: z.boolean().optional(),
|
|
99
|
-
promptCapabilities: z.looseObject({ image: z.boolean().optional() }).optional(),
|
|
104
|
+
promptCapabilities: z.looseObject({ image: z.boolean().optional(), embeddedContext: z.boolean().optional() }).optional(),
|
|
100
105
|
sessionCapabilities: z
|
|
101
106
|
.looseObject({
|
|
102
107
|
resume: z.unknown().optional(),
|
|
@@ -132,6 +137,7 @@ export function parseInitialize(result) {
|
|
|
132
137
|
additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
|
|
133
138
|
closeSession: sessions?.close !== undefined && sessions.close !== null,
|
|
134
139
|
images: capabilities?.promptCapabilities?.image === true,
|
|
140
|
+
embeddedContext: capabilities?.promptCapabilities?.embeddedContext === true,
|
|
135
141
|
authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
|
|
136
142
|
agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
|
|
137
143
|
};
|
|
@@ -171,7 +177,12 @@ function sessionState(options, modes, models) {
|
|
|
171
177
|
models: modelOption
|
|
172
178
|
? {
|
|
173
179
|
current: modelOption.currentValue,
|
|
174
|
-
models: selectValues(modelOption).map((value) => ({
|
|
180
|
+
models: selectValues(modelOption).map((value) => ({
|
|
181
|
+
id: value.value,
|
|
182
|
+
name: value.name,
|
|
183
|
+
description: null,
|
|
184
|
+
contextWindow: contextWindowOf(value._meta)
|
|
185
|
+
})),
|
|
175
186
|
configId: modelOption.id
|
|
176
187
|
}
|
|
177
188
|
: modelState.success
|
|
@@ -180,7 +191,8 @@ function sessionState(options, modes, models) {
|
|
|
180
191
|
models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
|
|
181
192
|
id: model.modelId,
|
|
182
193
|
name: model.name,
|
|
183
|
-
description: model.description ?? null
|
|
194
|
+
description: model.description ?? null,
|
|
195
|
+
contextWindow: contextWindowOf(model._meta)
|
|
184
196
|
})),
|
|
185
197
|
configId: null
|
|
186
198
|
}
|
|
@@ -207,6 +219,21 @@ function sessionState(options, modes, models) {
|
|
|
207
219
|
: null
|
|
208
220
|
};
|
|
209
221
|
}
|
|
222
|
+
function commandNames(commands) {
|
|
223
|
+
return validItems(CommandSchema, Array.isArray(commands) ? commands : []).map((command) => command.name);
|
|
224
|
+
}
|
|
225
|
+
/* The session a `session/update` notification is for, and the commands it lists when it is an `available_commands_update`; null when it does not parse. */
|
|
226
|
+
export function sessionUpdateOf(params) {
|
|
227
|
+
const notification = SessionNotificationSchema.safeParse(params);
|
|
228
|
+
if (!notification.success) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const update = notification.data.update;
|
|
232
|
+
return {
|
|
233
|
+
sessionId: notification.data.sessionId,
|
|
234
|
+
commands: update.sessionUpdate === 'available_commands_update' ? commandNames(update.availableCommands) : null
|
|
235
|
+
};
|
|
236
|
+
}
|
|
210
237
|
export function sessionIdOf(result) {
|
|
211
238
|
const parsed = SessionResponseSchema.safeParse(result);
|
|
212
239
|
return parsed.success ? (parsed.data.sessionId ?? null) : null;
|
|
@@ -406,10 +433,8 @@ export class AcpProtocol {
|
|
|
406
433
|
}
|
|
407
434
|
case 'plan':
|
|
408
435
|
return this.plan(update);
|
|
409
|
-
case 'available_commands_update':
|
|
410
|
-
|
|
411
|
-
return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commands.map((command) => command.name) }];
|
|
412
|
-
}
|
|
436
|
+
case 'available_commands_update':
|
|
437
|
+
return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commandNames(update.availableCommands) }];
|
|
413
438
|
case 'current_mode_update':
|
|
414
439
|
return typeof update.currentModeId === 'string' ? [this.modeEvent(update.currentModeId)] : [];
|
|
415
440
|
case 'config_option_update': {
|
|
@@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { DEFAULT_ACP_CLIENT } from '../../chat/acp-backend.js';
|
|
5
|
-
import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, parseInitialize, parseSessionInfo, sessionIdOf } from '../../chat/acp-protocol.js';
|
|
5
|
+
import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, parseInitialize, parseSessionInfo, sessionIdOf, sessionUpdateOf } from '../../chat/acp-protocol.js';
|
|
6
6
|
import { JsonRpcError, JsonRpcTransport } from '../../chat/json-rpc-transport.js';
|
|
7
7
|
import { ASK_TIMEOUT_MS } from './status.js';
|
|
8
8
|
const SIGNED_OUT = { signedIn: false, email: null, plan: null, organization: null };
|
|
@@ -35,6 +35,21 @@ export async function askAcpAccount(probe) {
|
|
|
35
35
|
const folder = await mkdtemp(join(tmpdir(), 'acp-account-'));
|
|
36
36
|
const client = probe.client ?? DEFAULT_ACP_CLIENT;
|
|
37
37
|
let transport = null;
|
|
38
|
+
// An agent may list its commands before it answers with the session's id.
|
|
39
|
+
const listed = new Map();
|
|
40
|
+
let sessionId = null;
|
|
41
|
+
const noteCommands = (frame) => {
|
|
42
|
+
const update = frame.method === 'session/update' && (frame.id === undefined || frame.id === null) ? sessionUpdateOf(frame.params) : null;
|
|
43
|
+
if (update === null || update.commands === null) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (sessionId === null) {
|
|
47
|
+
listed.set(update.sessionId, update.commands);
|
|
48
|
+
}
|
|
49
|
+
else if (update.sessionId === sessionId) {
|
|
50
|
+
probe.onCommands?.(update.commands);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
38
53
|
try {
|
|
39
54
|
const started = new JsonRpcTransport({
|
|
40
55
|
command: [...probe.command],
|
|
@@ -46,7 +61,10 @@ export async function askAcpAccount(probe) {
|
|
|
46
61
|
// The probe's own deadline covers every request.
|
|
47
62
|
requestTimeoutMs: null,
|
|
48
63
|
versionField: true,
|
|
49
|
-
onFrame: (frame) =>
|
|
64
|
+
onFrame: (frame) => {
|
|
65
|
+
noteCommands(frame);
|
|
66
|
+
refuseRequest(started, frame);
|
|
67
|
+
},
|
|
50
68
|
onExit: () => undefined
|
|
51
69
|
});
|
|
52
70
|
transport = started;
|
|
@@ -69,11 +87,15 @@ export async function askAcpAccount(probe) {
|
|
|
69
87
|
}
|
|
70
88
|
throw error;
|
|
71
89
|
}
|
|
72
|
-
|
|
90
|
+
sessionId = sessionIdOf(result);
|
|
73
91
|
if (sessionId === null) {
|
|
74
92
|
throw new Error(`${probe.name} opened a session without an id`);
|
|
75
93
|
}
|
|
76
94
|
probe.onSession?.(parseSessionInfo(result, sessionId, capabilities.agent));
|
|
95
|
+
const commands = listed.get(sessionId);
|
|
96
|
+
if (commands !== undefined) {
|
|
97
|
+
probe.onCommands?.(commands);
|
|
98
|
+
}
|
|
77
99
|
if (capabilities.closeSession) {
|
|
78
100
|
// The answer is known by now; an agent that cannot close it keeps an empty session, nothing worse.
|
|
79
101
|
await started.request('session/close', { sessionId }).catch(() => undefined);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ProviderCapabilities } from '@adecore/agent-contracts';
|
|
2
|
+
import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
|
|
3
|
+
import type { ModelCatalog } from './catalog.ts';
|
|
4
|
+
export interface AcpCatalogStore {
|
|
5
|
+
load(): unknown;
|
|
6
|
+
save(value: unknown): void;
|
|
7
|
+
}
|
|
8
|
+
export interface AcpAgentStateOptions {
|
|
9
|
+
catalog: ModelCatalog;
|
|
10
|
+
adoptModels: boolean;
|
|
11
|
+
store: AcpCatalogStore | null;
|
|
12
|
+
base: ProviderCapabilities;
|
|
13
|
+
overrides: Partial<ProviderCapabilities>;
|
|
14
|
+
}
|
|
15
|
+
export declare class AcpAgentState {
|
|
16
|
+
readonly capabilities: ProviderCapabilities;
|
|
17
|
+
private readonly options;
|
|
18
|
+
private readonly listeners;
|
|
19
|
+
private adopted;
|
|
20
|
+
private reportsContext;
|
|
21
|
+
private commands;
|
|
22
|
+
constructor(options: AcpAgentStateOptions);
|
|
23
|
+
onChange(listener: () => void): () => void;
|
|
24
|
+
session(info: AcpSessionInfo): void;
|
|
25
|
+
commandsListed(commands: readonly string[]): void;
|
|
26
|
+
contextReported(): void;
|
|
27
|
+
private restore;
|
|
28
|
+
private learned;
|
|
29
|
+
private refresh;
|
|
30
|
+
private adopt;
|
|
31
|
+
private settle;
|
|
32
|
+
private save;
|
|
33
|
+
private notify;
|
|
34
|
+
private stored;
|
|
35
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ModelOptionDescriptorSchema } from '@adecore/agent-contracts';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const AdoptedSchema = z.object({
|
|
4
|
+
defaultModel: z.string().min(1),
|
|
5
|
+
models: z.array(z.object({ slug: z.string().min(1), name: z.string(), contextWindow: z.number().int().positive().optional() })).min(1),
|
|
6
|
+
options: z.array(ModelOptionDescriptorSchema)
|
|
7
|
+
});
|
|
8
|
+
/* An agent's model id may reach a command line, so one that reads as a flag is left out. */
|
|
9
|
+
function adoptableModels(info) {
|
|
10
|
+
return (info.models?.models ?? [])
|
|
11
|
+
.filter((model) => model.id !== '' && !model.id.startsWith('-'))
|
|
12
|
+
.map((model) => ({ slug: model.id, name: model.name, ...(model.contextWindow === null ? {} : { contextWindow: model.contextWindow }) }));
|
|
13
|
+
}
|
|
14
|
+
/* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
|
|
15
|
+
function effortOption(info) {
|
|
16
|
+
const level = info.thoughtLevel;
|
|
17
|
+
if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
id: 'effort',
|
|
23
|
+
label: 'Reasoning',
|
|
24
|
+
type: 'select',
|
|
25
|
+
defaultChoice: level.current,
|
|
26
|
+
choices: level.choices.map((choice) => ({
|
|
27
|
+
id: choice.id,
|
|
28
|
+
label: choice.name,
|
|
29
|
+
...(choice.description === null ? {} : { description: choice.description })
|
|
30
|
+
}))
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
/* Parses one stored part on its own, so a part a later version wrote differently costs only that part. */
|
|
35
|
+
function storedPart(schema, value) {
|
|
36
|
+
const parsed = schema.safeParse(value);
|
|
37
|
+
return parsed.success ? parsed.data : null;
|
|
38
|
+
}
|
|
39
|
+
/*
|
|
40
|
+
* What an agent reported about itself, outside any one session: the models it offers and what follows from it for the
|
|
41
|
+
* provider's capabilities. Kept in the host's store when it has one, and every change is told to the listeners, so a host
|
|
42
|
+
* can hand its clients the new list.
|
|
43
|
+
*/
|
|
44
|
+
export class AcpAgentState {
|
|
45
|
+
// Changed in place, so a host that spread the provider into a value of its own reads the same object.
|
|
46
|
+
capabilities;
|
|
47
|
+
options;
|
|
48
|
+
listeners = new Set();
|
|
49
|
+
adopted = null;
|
|
50
|
+
// Whether the agent reported its context window in a `usage_update`, once in any session.
|
|
51
|
+
reportsContext = false;
|
|
52
|
+
// The slash commands the last session listed; null until one did.
|
|
53
|
+
commands = null;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.capabilities = { ...options.base, ...options.overrides };
|
|
57
|
+
this.restore();
|
|
58
|
+
}
|
|
59
|
+
onChange(listener) {
|
|
60
|
+
this.listeners.add(listener);
|
|
61
|
+
return () => {
|
|
62
|
+
this.listeners.delete(listener);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
session(info) {
|
|
66
|
+
if (!this.options.adoptModels || info.models === null) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const adopted = { defaultModel: info.models.current, models: adoptableModels(info), options: effortOption(info) };
|
|
70
|
+
const changed = this.adopt(adopted);
|
|
71
|
+
this.settle(changed);
|
|
72
|
+
}
|
|
73
|
+
commandsListed(commands) {
|
|
74
|
+
const names = [...new Set(commands)].sort();
|
|
75
|
+
if (this.commands !== null && JSON.stringify(names) === JSON.stringify(this.commands)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.commands = names;
|
|
79
|
+
const changed = this.refresh();
|
|
80
|
+
// The commands are saved even when the capabilities stay, so a restart knows them too.
|
|
81
|
+
this.save();
|
|
82
|
+
if (changed) {
|
|
83
|
+
this.notify();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
contextReported() {
|
|
87
|
+
if (this.reportsContext) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.reportsContext = true;
|
|
91
|
+
this.settle(this.refresh());
|
|
92
|
+
}
|
|
93
|
+
restore() {
|
|
94
|
+
let stored;
|
|
95
|
+
try {
|
|
96
|
+
stored = this.options.store?.load();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (typeof stored !== 'object' || stored === null) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const record = stored;
|
|
105
|
+
const adopted = storedPart(AdoptedSchema, record.models);
|
|
106
|
+
if (adopted !== null && this.options.adoptModels) {
|
|
107
|
+
this.adopt(adopted);
|
|
108
|
+
}
|
|
109
|
+
this.reportsContext = record.reportsContext === true;
|
|
110
|
+
this.commands = storedPart(z.array(z.string()), record.commands);
|
|
111
|
+
this.refresh();
|
|
112
|
+
}
|
|
113
|
+
/* What the agent showed it can do, under what the host set itself. */
|
|
114
|
+
learned() {
|
|
115
|
+
const commands = this.commands ?? [];
|
|
116
|
+
return {
|
|
117
|
+
...(this.reportsContext ? { reportsContextWindow: true } : {}),
|
|
118
|
+
...(commands.includes('compact') ? { compaction: 'prompt' } : {}),
|
|
119
|
+
...(commands.includes('goal') ? { goals: 'command' } : {})
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/* Lays what the agent showed over the capabilities, in place; answers whether they changed. */
|
|
123
|
+
refresh() {
|
|
124
|
+
const next = { ...this.options.base, ...this.learned(), ...this.options.overrides };
|
|
125
|
+
if (JSON.stringify(next) === JSON.stringify(this.capabilities)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const capabilities = this.capabilities;
|
|
129
|
+
for (const key of Object.keys(capabilities)) {
|
|
130
|
+
delete capabilities[key];
|
|
131
|
+
}
|
|
132
|
+
Object.assign(capabilities, next);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
/* Answers whether the catalog changed; a list without its default is refused there and leaves the one adopted before. */
|
|
136
|
+
adopt(adopted) {
|
|
137
|
+
if (!adopted.models.some((model) => model.slug === adopted.defaultModel)) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
this.adopted = adopted;
|
|
141
|
+
return this.options.catalog.adopt(adopted.models, adopted.defaultModel, adopted.options);
|
|
142
|
+
}
|
|
143
|
+
/* Saves and tells the listeners once something changed. */
|
|
144
|
+
settle(changed) {
|
|
145
|
+
if (changed) {
|
|
146
|
+
this.save();
|
|
147
|
+
this.notify();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
save() {
|
|
151
|
+
try {
|
|
152
|
+
this.options.store?.save(this.stored());
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// A store that cannot write costs the next start what it would have kept, never this session.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
notify() {
|
|
159
|
+
for (const listener of [...this.listeners]) {
|
|
160
|
+
listener();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
stored() {
|
|
164
|
+
return {
|
|
165
|
+
...(this.adopted === null ? {} : { models: this.adopted }),
|
|
166
|
+
...(this.reportsContext ? { reportsContext: true } : {}),
|
|
167
|
+
...(this.commands === null ? {} : { commands: this.commands })
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
|
|
2
2
|
import { type AcpAgentSpec } from '../chat/acp-backend.ts';
|
|
3
|
+
import { type AcpCatalogStore } from './acp-agent-state.ts';
|
|
3
4
|
import type { ModelCatalog } from './catalog.ts';
|
|
4
5
|
import { type CliDetection } from './detect.ts';
|
|
5
6
|
import type { ChatProvider, ProviderHome } from './provider.ts';
|
|
@@ -22,5 +23,6 @@ export interface AcpProviderSpec extends AcpAgentSpec {
|
|
|
22
23
|
accountProbe?: boolean;
|
|
23
24
|
adoptModels?: boolean;
|
|
24
25
|
usageLog?: string;
|
|
26
|
+
catalogStore?: AcpCatalogStore;
|
|
25
27
|
}
|
|
26
28
|
export declare function createAcpProvider(spec: AcpProviderSpec): ChatProvider;
|