@adecore/agents 0.31.3 → 0.31.5
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 +112 -21
- package/dist/chat/acp-protocol.d.ts +18 -5
- package/dist/chat/acp-protocol.js +53 -12
- package/dist/chat/index.d.ts +1 -1
- 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 -13
- package/dist/providers/catalog.d.ts +4 -2
- package/dist/providers/catalog.js +18 -5
- 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 +4 -0
- package/dist/testing/fake-acp.js +33 -5
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +120 -20
- package/src/chat/acp-protocol.ts +79 -14
- package/src/chat/index.ts +2 -0
- package/src/providers/accounts/acp-account.ts +34 -3
- package/src/providers/acp-agent-state.ts +206 -0
- package/src/providers/acp-provider.ts +32 -15
- package/src/providers/catalog.ts +23 -6
- 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 +43 -5
package/src/chat/acp-backend.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
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, type ChatAttachment, type ModelSelection } from '@adecore/agent-contracts';
|
|
3
5
|
import { errorText } from '../error-text.ts';
|
|
4
6
|
import { MODE_ORDER } from '../modes.ts';
|
|
@@ -14,10 +16,13 @@ import {
|
|
|
14
16
|
resolveAcpMode,
|
|
15
17
|
runtimeModeOf,
|
|
16
18
|
sessionIdOf,
|
|
19
|
+
sessionUpdateOf,
|
|
20
|
+
thoughtLevelOf,
|
|
17
21
|
type AcpAgentCapabilities,
|
|
18
22
|
type AcpModeMap,
|
|
19
23
|
type AcpPermissionRequest,
|
|
20
24
|
type AcpSessionInfo,
|
|
25
|
+
type AcpThoughtLevel,
|
|
21
26
|
type AcpTurnUsage
|
|
22
27
|
} from './acp-protocol.ts';
|
|
23
28
|
import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
|
|
@@ -54,6 +59,10 @@ export interface AcpAgentSpec {
|
|
|
54
59
|
onSession?(info: AcpSessionInfo): void;
|
|
55
60
|
// The tokens of every turn whose `session/prompt` response reports them, for a host that keeps a usage log.
|
|
56
61
|
onUsage?(usage: AcpUsageReport): void;
|
|
62
|
+
// Called once per process, at the first `usage_update` that names the context window.
|
|
63
|
+
onContextReport?(): void;
|
|
64
|
+
// The slash commands a session offers (without the slash), each time the agent lists them.
|
|
65
|
+
onCommands?(commands: string[]): void;
|
|
57
66
|
// A permission request the host answers itself, in any runtime mode: true allows it once without asking the
|
|
58
67
|
// person, for a call the host already guards. A throw counts as false.
|
|
59
68
|
autoAllow?(request: AcpPermissionRequest): boolean;
|
|
@@ -69,6 +78,15 @@ export interface AcpUsageReport extends AcpTurnUsage {
|
|
|
69
78
|
// What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
|
|
70
79
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
71
80
|
|
|
81
|
+
// What a prompt carries of mentioned files; a larger file stays a path the agent opens with its own tools.
|
|
82
|
+
const MAX_EMBEDDED_FILE_BYTES = 256 * 1024;
|
|
83
|
+
const MAX_EMBEDDED_BYTES = 1024 * 1024;
|
|
84
|
+
|
|
85
|
+
function inside(folder: string, path: string): boolean {
|
|
86
|
+
const rest = relative(folder, path);
|
|
87
|
+
return rest === '' || (!rest.startsWith('..') && !isAbsolute(rest));
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
function nameOf(client: AcpClientInfo): string {
|
|
73
91
|
return client.title ?? client.name;
|
|
74
92
|
}
|
|
@@ -93,6 +111,10 @@ export class AcpBackend implements ChatBackend {
|
|
|
93
111
|
private contextPending: string | null = null;
|
|
94
112
|
private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
|
|
95
113
|
private model: string | null = null;
|
|
114
|
+
private contextReported = false;
|
|
115
|
+
private commands: string[] = [];
|
|
116
|
+
// An agent may send updates for a new session before it answers with the session's id.
|
|
117
|
+
private early: unknown[] = [];
|
|
96
118
|
|
|
97
119
|
constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
|
|
98
120
|
this.launch = launch;
|
|
@@ -154,9 +176,9 @@ export class AcpBackend implements ChatBackend {
|
|
|
154
176
|
this.capabilities = capabilities;
|
|
155
177
|
const info = await this.openSession(transport, capabilities);
|
|
156
178
|
const modeId = await this.applyMode(transport, info);
|
|
157
|
-
const model = await this.applyModel(transport, info);
|
|
179
|
+
const { model, thoughtLevel } = await this.applyModel(transport, info);
|
|
158
180
|
this.model = model;
|
|
159
|
-
await this.applyThoughtLevel(transport,
|
|
181
|
+
await this.applyThoughtLevel(transport, thoughtLevel);
|
|
160
182
|
this.spec.onSession?.(info);
|
|
161
183
|
this.emit({
|
|
162
184
|
type: 'session',
|
|
@@ -165,6 +187,9 @@ export class AcpBackend implements ChatBackend {
|
|
|
165
187
|
effectiveRuntimeMode: this.launch.runtimeMode,
|
|
166
188
|
...(modeId === null ? {} : { permissionMode: modeId })
|
|
167
189
|
});
|
|
190
|
+
for (const params of this.early.splice(0)) {
|
|
191
|
+
this.emitAll(this.protocol.update(this.sessionId, params));
|
|
192
|
+
}
|
|
168
193
|
}
|
|
169
194
|
|
|
170
195
|
private sessionParams(): Record<string, unknown> {
|
|
@@ -263,24 +288,29 @@ export class AcpBackend implements ChatBackend {
|
|
|
263
288
|
return resolved.modeId;
|
|
264
289
|
}
|
|
265
290
|
|
|
266
|
-
/*
|
|
267
|
-
|
|
291
|
+
/*
|
|
292
|
+
* Switches to the selected model where the agent offers it; answers the model the session runs and the
|
|
293
|
+
* reasoning effort it offers there, which may change with the model.
|
|
294
|
+
*/
|
|
295
|
+
private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<{ model: string | null; thoughtLevel: AcpThoughtLevel | null }> {
|
|
268
296
|
const models = info.models;
|
|
297
|
+
const thoughtLevel = info.thoughtLevel;
|
|
269
298
|
const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
|
|
270
299
|
if (models === null || wanted === null || wanted === models.current) {
|
|
271
|
-
return models?.current ?? wanted;
|
|
300
|
+
return { model: models?.current ?? wanted, thoughtLevel };
|
|
272
301
|
}
|
|
273
302
|
if (!models.models.some((model) => model.id === wanted)) {
|
|
274
303
|
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
|
|
275
|
-
return models.current;
|
|
304
|
+
return { model: models.current, thoughtLevel };
|
|
276
305
|
}
|
|
277
306
|
try {
|
|
278
307
|
if (models.configId === null) {
|
|
279
308
|
await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
|
|
280
|
-
|
|
281
|
-
await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
|
|
309
|
+
return { model: wanted, thoughtLevel };
|
|
282
310
|
}
|
|
283
|
-
|
|
311
|
+
const result = await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
|
|
312
|
+
const after = thoughtLevelOf(result);
|
|
313
|
+
return { model: wanted, thoughtLevel: after === undefined ? thoughtLevel : after };
|
|
284
314
|
} catch (error) {
|
|
285
315
|
if (!(error instanceof JsonRpcError)) {
|
|
286
316
|
throw error;
|
|
@@ -290,21 +320,26 @@ export class AcpBackend implements ChatBackend {
|
|
|
290
320
|
level: 'warning',
|
|
291
321
|
text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
|
|
292
322
|
});
|
|
293
|
-
return models.current;
|
|
323
|
+
return { model: models.current, thoughtLevel };
|
|
294
324
|
}
|
|
295
325
|
}
|
|
296
326
|
|
|
297
|
-
|
|
298
|
-
|
|
327
|
+
/* Sets the selected effort where the session offers it; a model without one runs as it is. */
|
|
328
|
+
private async applyThoughtLevel(transport: JsonRpcTransport, level: AcpThoughtLevel | null): Promise<void> {
|
|
299
329
|
const effort = this.launch.selection.options.effort;
|
|
300
|
-
|
|
330
|
+
const choice = level?.choices.find((entry) => entry.id === effort);
|
|
331
|
+
if (level === null || choice === undefined || effort === level.current) {
|
|
301
332
|
return;
|
|
302
333
|
}
|
|
303
|
-
|
|
334
|
+
try {
|
|
335
|
+
await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: choice.id });
|
|
336
|
+
} catch (error) {
|
|
304
337
|
if (!(error instanceof JsonRpcError)) {
|
|
305
338
|
throw error;
|
|
306
339
|
}
|
|
307
|
-
|
|
340
|
+
const current = level.choices.find((entry) => entry.id === level.current)?.name ?? level.current;
|
|
341
|
+
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} kept its reasoning at ${current} instead of ${choice.name}.` });
|
|
342
|
+
}
|
|
308
343
|
}
|
|
309
344
|
|
|
310
345
|
private promptBlocks(input: TurnInput): unknown[] {
|
|
@@ -322,7 +357,50 @@ export class AcpBackend implements ChatBackend {
|
|
|
322
357
|
const named: ChatAttachment[] = input.attachments.filter((attachment) => !images.includes(attachment));
|
|
323
358
|
const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part): part is string => part !== null && part !== '');
|
|
324
359
|
this.contextPending = null;
|
|
325
|
-
return [{ type: 'text', text: parts.join('\n\n') }, ...blocks];
|
|
360
|
+
return [{ type: 'text', text: parts.join('\n\n') }, ...blocks, ...this.mentionBlocks(input.mentions)];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/*
|
|
364
|
+
* The text of each mentioned file as a `resource` block, for an agent that takes embedded context. Only a text file
|
|
365
|
+
* inside the chat's folders goes along, since a mention is a path anyone who may send could name; the rest stays
|
|
366
|
+
* in the prompt as the path it is.
|
|
367
|
+
*/
|
|
368
|
+
private mentionBlocks(mentions: readonly string[]): unknown[] {
|
|
369
|
+
if (this.capabilities?.embeddedContext !== true || mentions.length === 0) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
const real = (path: string): string | null => {
|
|
373
|
+
try {
|
|
374
|
+
return realpathSync(path);
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const folders = [this.launch.cwd, ...(this.launch.folders ?? [])].flatMap((folder) => real(folder) ?? []);
|
|
380
|
+
const blocks: unknown[] = [];
|
|
381
|
+
let budget = MAX_EMBEDDED_BYTES;
|
|
382
|
+
for (const mention of new Set(mentions)) {
|
|
383
|
+
const path = real(resolve(this.launch.cwd, mention));
|
|
384
|
+
if (path === null || !folders.some((folder) => inside(folder, path))) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
let text: string;
|
|
388
|
+
try {
|
|
389
|
+
const stat = statSync(path);
|
|
390
|
+
if (!stat.isFile() || stat.size > MAX_EMBEDDED_FILE_BYTES || stat.size > budget) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
text = readFileSync(path, 'utf8');
|
|
394
|
+
} catch {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (text.includes('\u0000')) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
budget -= Buffer.byteLength(text);
|
|
401
|
+
blocks.push({ type: 'resource', resource: { uri: pathToFileURL(path).href, text } });
|
|
402
|
+
}
|
|
403
|
+
return blocks;
|
|
326
404
|
}
|
|
327
405
|
|
|
328
406
|
sendTurn(input: TurnInput): void {
|
|
@@ -352,8 +430,12 @@ export class AcpBackend implements ChatBackend {
|
|
|
352
430
|
});
|
|
353
431
|
}
|
|
354
432
|
|
|
355
|
-
/* ACP has no call to fold the context
|
|
433
|
+
/* ACP has no call to fold the context, so an agent that lists a `compact` command gets it as a prompt. */
|
|
356
434
|
compact(): void {
|
|
435
|
+
if (this.commands.includes('compact')) {
|
|
436
|
+
this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
357
439
|
this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
|
|
358
440
|
this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
|
|
359
441
|
}
|
|
@@ -431,9 +513,11 @@ export class AcpBackend implements ChatBackend {
|
|
|
431
513
|
}
|
|
432
514
|
const id = typeof frame.id === 'number' || typeof frame.id === 'string' ? frame.id : null;
|
|
433
515
|
if (frame.method === 'session/update' && id === null) {
|
|
434
|
-
if (
|
|
435
|
-
this.
|
|
516
|
+
if (this.sessionId === '' || this.replaying) {
|
|
517
|
+
this.holdEarly(frame.params);
|
|
518
|
+
return;
|
|
436
519
|
}
|
|
520
|
+
this.emitAll(this.protocol.update(this.sessionId, frame.params));
|
|
437
521
|
return;
|
|
438
522
|
}
|
|
439
523
|
if (id === null) {
|
|
@@ -467,6 +551,14 @@ export class AcpBackend implements ChatBackend {
|
|
|
467
551
|
this.emitAll(permission.events);
|
|
468
552
|
}
|
|
469
553
|
|
|
554
|
+
/* Holds an update until the session is known; of a history a load replays, only the commands, since the thread has the rest. */
|
|
555
|
+
private holdEarly(params: unknown): void {
|
|
556
|
+
const update = sessionUpdateOf(params);
|
|
557
|
+
if (update !== null && (!this.replaying || update.commands !== null)) {
|
|
558
|
+
this.early.push(params);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
470
562
|
private hostAllows(request: AcpPermissionRequest): boolean {
|
|
471
563
|
try {
|
|
472
564
|
return this.spec.autoAllow?.(request) === true;
|
|
@@ -504,6 +596,14 @@ export class AcpBackend implements ChatBackend {
|
|
|
504
596
|
if (event.type === 'model') {
|
|
505
597
|
this.model = event.model;
|
|
506
598
|
}
|
|
599
|
+
if (event.type === 'session' && event.slashCommands !== undefined) {
|
|
600
|
+
this.commands = event.slashCommands;
|
|
601
|
+
this.spec.onCommands?.(event.slashCommands);
|
|
602
|
+
}
|
|
603
|
+
if (event.type === 'usage' && event.contextWindow !== undefined && !this.contextReported) {
|
|
604
|
+
this.contextReported = true;
|
|
605
|
+
this.spec.onContextReport?.();
|
|
606
|
+
}
|
|
507
607
|
this.host.onEvent(event);
|
|
508
608
|
}
|
|
509
609
|
}
|
package/src/chat/acp-protocol.ts
CHANGED
|
@@ -107,7 +107,7 @@ const ModeStateSchema = z.looseObject({
|
|
|
107
107
|
|
|
108
108
|
const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
109
109
|
|
|
110
|
-
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string() });
|
|
110
|
+
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish(), _meta: z.unknown().optional() });
|
|
111
111
|
|
|
112
112
|
const ConfigOptionSchema = z.looseObject({
|
|
113
113
|
id: z.string(),
|
|
@@ -124,7 +124,13 @@ const ModelStateSchema = z.looseObject({
|
|
|
124
124
|
availableModels: z.array(z.unknown())
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish() });
|
|
127
|
+
const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish(), _meta: z.unknown().optional() });
|
|
128
|
+
|
|
129
|
+
/* 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. */
|
|
130
|
+
function contextWindowOf(meta: unknown): number | null {
|
|
131
|
+
const tokens = isRecord(meta) ? meta.totalContextTokens : undefined;
|
|
132
|
+
return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
|
|
133
|
+
}
|
|
128
134
|
|
|
129
135
|
const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
130
136
|
|
|
@@ -133,7 +139,7 @@ const InitializeResponseSchema = z.looseObject({
|
|
|
133
139
|
agentCapabilities: z
|
|
134
140
|
.looseObject({
|
|
135
141
|
loadSession: z.boolean().optional(),
|
|
136
|
-
promptCapabilities: z.looseObject({ image: z.boolean().optional() }).optional(),
|
|
142
|
+
promptCapabilities: z.looseObject({ image: z.boolean().optional(), embeddedContext: z.boolean().optional() }).optional(),
|
|
137
143
|
sessionCapabilities: z
|
|
138
144
|
.looseObject({
|
|
139
145
|
resume: z.unknown().optional(),
|
|
@@ -172,6 +178,8 @@ export interface AcpAgentCapabilities {
|
|
|
172
178
|
// Whether the agent takes `session/close`, which ends a session it would otherwise keep.
|
|
173
179
|
closeSession: boolean;
|
|
174
180
|
images: boolean;
|
|
181
|
+
// Whether a prompt may carry a file's text as a `resource` block.
|
|
182
|
+
embeddedContext: boolean;
|
|
175
183
|
authMethods: string[];
|
|
176
184
|
agent: AcpAgentInfo | null;
|
|
177
185
|
}
|
|
@@ -191,6 +199,7 @@ export function parseInitialize(result: unknown): AcpAgentCapabilities {
|
|
|
191
199
|
additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
|
|
192
200
|
closeSession: sessions?.close !== undefined && sessions.close !== null,
|
|
193
201
|
images: capabilities?.promptCapabilities?.image === true,
|
|
202
|
+
embeddedContext: capabilities?.promptCapabilities?.embeddedContext === true,
|
|
194
203
|
authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
|
|
195
204
|
agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
|
|
196
205
|
};
|
|
@@ -200,6 +209,8 @@ export interface AcpModel {
|
|
|
200
209
|
id: string;
|
|
201
210
|
name: string;
|
|
202
211
|
description: string | null;
|
|
212
|
+
// In tokens, where the agent names it.
|
|
213
|
+
contextWindow: number | null;
|
|
203
214
|
}
|
|
204
215
|
|
|
205
216
|
/* The models a session offers. `configId` is the config option that picks one, or null where the agent takes `session/set_model`. */
|
|
@@ -221,14 +232,27 @@ export interface AcpModeState {
|
|
|
221
232
|
configId: string | null;
|
|
222
233
|
}
|
|
223
234
|
|
|
235
|
+
export interface AcpChoice {
|
|
236
|
+
id: string;
|
|
237
|
+
name: string;
|
|
238
|
+
description: string | null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* The reasoning effort a session offers: the config option that sets it, the value it runs at and the values it takes. */
|
|
242
|
+
export interface AcpThoughtLevel {
|
|
243
|
+
configId: string;
|
|
244
|
+
current: string;
|
|
245
|
+
choices: AcpChoice[];
|
|
246
|
+
}
|
|
247
|
+
|
|
224
248
|
/* What `session/new`, `session/load` or `session/resume` said about the session, for a host that fills its catalog from it. */
|
|
225
249
|
export interface AcpSessionInfo {
|
|
226
250
|
sessionId: string;
|
|
227
251
|
agent: AcpAgentInfo | null;
|
|
228
252
|
models: AcpModelState | null;
|
|
229
253
|
modes: AcpModeState | null;
|
|
230
|
-
//
|
|
231
|
-
thoughtLevel:
|
|
254
|
+
// A config option of the `thought_level` category, or one with the id `reasoning_effort` where an agent sets no category.
|
|
255
|
+
thoughtLevel: AcpThoughtLevel | null;
|
|
232
256
|
}
|
|
233
257
|
|
|
234
258
|
type ConfigOption = z.infer<typeof ConfigOptionSchema>;
|
|
@@ -243,6 +267,22 @@ function selectOption(options: ConfigOption[], category: string): ConfigOption |
|
|
|
243
267
|
return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
|
|
244
268
|
}
|
|
245
269
|
|
|
270
|
+
function thoughtOption(options: ConfigOption[]): ConfigOption | null {
|
|
271
|
+
return (
|
|
272
|
+
selectOption(options, 'thought_level') ??
|
|
273
|
+
options.find((option) => option.type === 'select' && option.id === 'reasoning_effort' && typeof option.currentValue === 'string') ??
|
|
274
|
+
null
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* The reasoning effort in a response that carries the session's config options; undefined when it carries none. */
|
|
279
|
+
export function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined {
|
|
280
|
+
if (!isRecord(result) || !Array.isArray(result.configOptions)) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
return sessionState(validItems(ConfigOptionSchema, result.configOptions), null, null).thoughtLevel;
|
|
284
|
+
}
|
|
285
|
+
|
|
246
286
|
export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo {
|
|
247
287
|
const record = isRecord(result) ? result : {};
|
|
248
288
|
const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
|
|
@@ -252,14 +292,19 @@ export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpA
|
|
|
252
292
|
function sessionState(options: ConfigOption[], modes: unknown, models: unknown): Omit<AcpSessionInfo, 'sessionId' | 'agent'> {
|
|
253
293
|
const modelOption = selectOption(options, 'model');
|
|
254
294
|
const modeOption = selectOption(options, 'mode');
|
|
255
|
-
const
|
|
295
|
+
const thought = thoughtOption(options);
|
|
256
296
|
const modeState = ModeStateSchema.safeParse(modes);
|
|
257
297
|
const modelState = ModelStateSchema.safeParse(models);
|
|
258
298
|
return {
|
|
259
299
|
models: modelOption
|
|
260
300
|
? {
|
|
261
301
|
current: modelOption.currentValue as string,
|
|
262
|
-
models: selectValues(modelOption).map((value) => ({
|
|
302
|
+
models: selectValues(modelOption).map((value) => ({
|
|
303
|
+
id: value.value,
|
|
304
|
+
name: value.name,
|
|
305
|
+
description: null,
|
|
306
|
+
contextWindow: contextWindowOf(value._meta)
|
|
307
|
+
})),
|
|
263
308
|
configId: modelOption.id
|
|
264
309
|
}
|
|
265
310
|
: modelState.success
|
|
@@ -268,7 +313,8 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
|
|
|
268
313
|
models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
|
|
269
314
|
id: model.modelId,
|
|
270
315
|
name: model.name,
|
|
271
|
-
description: model.description ?? null
|
|
316
|
+
description: model.description ?? null,
|
|
317
|
+
contextWindow: contextWindowOf(model._meta)
|
|
272
318
|
})),
|
|
273
319
|
configId: null
|
|
274
320
|
}
|
|
@@ -286,12 +332,33 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
|
|
|
286
332
|
configId: null
|
|
287
333
|
}
|
|
288
334
|
: null,
|
|
289
|
-
thoughtLevel:
|
|
290
|
-
? {
|
|
335
|
+
thoughtLevel: thought
|
|
336
|
+
? {
|
|
337
|
+
configId: thought.id,
|
|
338
|
+
current: thought.currentValue as string,
|
|
339
|
+
choices: selectValues(thought).map((value) => ({ id: value.value, name: value.name, description: value.description ?? null }))
|
|
340
|
+
}
|
|
291
341
|
: null
|
|
292
342
|
};
|
|
293
343
|
}
|
|
294
344
|
|
|
345
|
+
function commandNames(commands: unknown): string[] {
|
|
346
|
+
return validItems(CommandSchema, Array.isArray(commands) ? commands : []).map((command) => command.name);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* 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. */
|
|
350
|
+
export function sessionUpdateOf(params: unknown): { sessionId: string; commands: string[] | null } | null {
|
|
351
|
+
const notification = SessionNotificationSchema.safeParse(params);
|
|
352
|
+
if (!notification.success) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
const update = notification.data.update;
|
|
356
|
+
return {
|
|
357
|
+
sessionId: notification.data.sessionId,
|
|
358
|
+
commands: update.sessionUpdate === 'available_commands_update' ? commandNames(update.availableCommands) : null
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
295
362
|
export function sessionIdOf(result: unknown): string | null {
|
|
296
363
|
const parsed = SessionResponseSchema.safeParse(result);
|
|
297
364
|
return parsed.success ? (parsed.data.sessionId ?? null) : null;
|
|
@@ -557,10 +624,8 @@ export class AcpProtocol {
|
|
|
557
624
|
}
|
|
558
625
|
case 'plan':
|
|
559
626
|
return this.plan(update);
|
|
560
|
-
case 'available_commands_update':
|
|
561
|
-
|
|
562
|
-
return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commands.map((command) => command.name) }];
|
|
563
|
-
}
|
|
627
|
+
case 'available_commands_update':
|
|
628
|
+
return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commandNames(update.availableCommands) }];
|
|
564
629
|
case 'current_mode_update':
|
|
565
630
|
return typeof update.currentModeId === 'string' ? [this.modeEvent(update.currentModeId)] : [];
|
|
566
631
|
case 'config_option_update': {
|
package/src/chat/index.ts
CHANGED
|
@@ -2,7 +2,15 @@ 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, type AcpClientInfo } from '../../chat/acp-backend.ts';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ACP_AUTH_REQUIRED,
|
|
7
|
+
ACP_PROTOCOL_VERSION,
|
|
8
|
+
parseInitialize,
|
|
9
|
+
parseSessionInfo,
|
|
10
|
+
sessionIdOf,
|
|
11
|
+
sessionUpdateOf,
|
|
12
|
+
type AcpSessionInfo
|
|
13
|
+
} from '../../chat/acp-protocol.ts';
|
|
6
14
|
import type { SpawnChatProcess } from '../../chat/chat-process.ts';
|
|
7
15
|
import { JsonRpcError, JsonRpcTransport, type JsonRpcFrame } from '../../chat/json-rpc-transport.ts';
|
|
8
16
|
import { ASK_TIMEOUT_MS, type AccountReading } from './status.ts';
|
|
@@ -24,6 +32,8 @@ export interface AcpAccountProbe {
|
|
|
24
32
|
timer?: ProbeTimer;
|
|
25
33
|
// What the probe's session offered, for a host that fills its model catalog from the agent.
|
|
26
34
|
onSession?(info: AcpSessionInfo): void;
|
|
35
|
+
// The slash commands the probe's session offered, when the agent listed them before the session closed.
|
|
36
|
+
onCommands?(commands: string[]): void;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
const SIGNED_OUT: AccountReading = { signedIn: false, email: null, plan: null, organization: null };
|
|
@@ -63,6 +73,20 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
|
|
|
63
73
|
const folder = await mkdtemp(join(tmpdir(), 'acp-account-'));
|
|
64
74
|
const client = probe.client ?? DEFAULT_ACP_CLIENT;
|
|
65
75
|
let transport: JsonRpcTransport | null = null;
|
|
76
|
+
// An agent may list its commands before it answers with the session's id.
|
|
77
|
+
const listed = new Map<string, string[]>();
|
|
78
|
+
let sessionId: string | null = null;
|
|
79
|
+
const noteCommands = (frame: JsonRpcFrame): void => {
|
|
80
|
+
const update = frame.method === 'session/update' && (frame.id === undefined || frame.id === null) ? sessionUpdateOf(frame.params) : null;
|
|
81
|
+
if (update === null || update.commands === null) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (sessionId === null) {
|
|
85
|
+
listed.set(update.sessionId, update.commands);
|
|
86
|
+
} else if (update.sessionId === sessionId) {
|
|
87
|
+
probe.onCommands?.(update.commands);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
66
90
|
try {
|
|
67
91
|
const started = new JsonRpcTransport({
|
|
68
92
|
command: [...probe.command],
|
|
@@ -74,7 +98,10 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
|
|
|
74
98
|
// The probe's own deadline covers every request.
|
|
75
99
|
requestTimeoutMs: null,
|
|
76
100
|
versionField: true,
|
|
77
|
-
onFrame: (frame) =>
|
|
101
|
+
onFrame: (frame) => {
|
|
102
|
+
noteCommands(frame);
|
|
103
|
+
refuseRequest(started, frame);
|
|
104
|
+
},
|
|
78
105
|
onExit: () => undefined
|
|
79
106
|
});
|
|
80
107
|
transport = started;
|
|
@@ -98,11 +125,15 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
|
|
|
98
125
|
}
|
|
99
126
|
throw error;
|
|
100
127
|
}
|
|
101
|
-
|
|
128
|
+
sessionId = sessionIdOf(result);
|
|
102
129
|
if (sessionId === null) {
|
|
103
130
|
throw new Error(`${probe.name} opened a session without an id`);
|
|
104
131
|
}
|
|
105
132
|
probe.onSession?.(parseSessionInfo(result, sessionId, capabilities.agent));
|
|
133
|
+
const commands = listed.get(sessionId);
|
|
134
|
+
if (commands !== undefined) {
|
|
135
|
+
probe.onCommands?.(commands);
|
|
136
|
+
}
|
|
106
137
|
if (capabilities.closeSession) {
|
|
107
138
|
// The answer is known by now; an agent that cannot close it keeps an empty session, nothing worse.
|
|
108
139
|
await started.request('session/close', { sessionId }).catch(() => undefined);
|