@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.
@@ -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;
@@ -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 } 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;
@@ -86,9 +99,9 @@ export class AcpBackend {
86
99
  this.capabilities = capabilities;
87
100
  const info = await this.openSession(transport, capabilities);
88
101
  const modeId = await this.applyMode(transport, info);
89
- const model = await this.applyModel(transport, info);
102
+ const { model, thoughtLevel } = await this.applyModel(transport, info);
90
103
  this.model = model;
91
- await this.applyThoughtLevel(transport, info);
104
+ await this.applyThoughtLevel(transport, thoughtLevel);
92
105
  this.spec.onSession?.(info);
93
106
  this.emit({
94
107
  type: 'session',
@@ -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 ?? [];
@@ -191,25 +207,29 @@ export class AcpBackend {
191
207
  }
192
208
  return resolved.modeId;
193
209
  }
194
- /* Switches to the selected model where the agent offers it; answers the model the session runs. */
210
+ /*
211
+ * Switches to the selected model where the agent offers it; answers the model the session runs and the
212
+ * reasoning effort it offers there, which may change with the model.
213
+ */
195
214
  async applyModel(transport, info) {
196
215
  const models = info.models;
216
+ const thoughtLevel = info.thoughtLevel;
197
217
  const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
198
218
  if (models === null || wanted === null || wanted === models.current) {
199
- return models?.current ?? wanted;
219
+ return { model: models?.current ?? wanted, thoughtLevel };
200
220
  }
201
221
  if (!models.models.some((model) => model.id === wanted)) {
202
222
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
203
- return models.current;
223
+ return { model: models.current, thoughtLevel };
204
224
  }
205
225
  try {
206
226
  if (models.configId === null) {
207
227
  await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
228
+ return { model: wanted, thoughtLevel };
208
229
  }
209
- else {
210
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
211
- }
212
- return wanted;
230
+ const result = await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
231
+ const after = thoughtLevelOf(result);
232
+ return { model: wanted, thoughtLevel: after === undefined ? thoughtLevel : after };
213
233
  }
214
234
  catch (error) {
215
235
  if (!(error instanceof JsonRpcError)) {
@@ -220,20 +240,26 @@ export class AcpBackend {
220
240
  level: 'warning',
221
241
  text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
222
242
  });
223
- return models.current;
243
+ return { model: models.current, thoughtLevel };
224
244
  }
225
245
  }
226
- async applyThoughtLevel(transport, info) {
227
- const level = info.thoughtLevel;
246
+ /* Sets the selected effort where the session offers it; a model without one runs as it is. */
247
+ async applyThoughtLevel(transport, level) {
228
248
  const effort = this.launch.selection.options.effort;
229
- if (level === null || typeof effort !== 'string' || effort === level.current || !level.values.includes(effort)) {
249
+ const choice = level?.choices.find((entry) => entry.id === effort);
250
+ if (level === null || choice === undefined || effort === level.current) {
230
251
  return;
231
252
  }
232
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: effort }).catch((error) => {
253
+ try {
254
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: choice.id });
255
+ }
256
+ catch (error) {
233
257
  if (!(error instanceof JsonRpcError)) {
234
258
  throw error;
235
259
  }
236
- });
260
+ const current = level.choices.find((entry) => entry.id === level.current)?.name ?? level.current;
261
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} kept its reasoning at ${current} instead of ${choice.name}.` });
262
+ }
237
263
  }
238
264
  promptBlocks(input) {
239
265
  const sendsImages = this.capabilities?.images === true;
@@ -251,7 +277,51 @@ export class AcpBackend {
251
277
  const named = input.attachments.filter((attachment) => !images.includes(attachment));
252
278
  const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part) => part !== null && part !== '');
253
279
  this.contextPending = null;
254
- 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;
255
325
  }
256
326
  sendTurn(input) {
257
327
  const transport = this.transport;
@@ -279,8 +349,12 @@ export class AcpBackend {
279
349
  }
280
350
  });
281
351
  }
282
- /* ACP has no call to fold the context; a host that offers it sends the agent's own command as a prompt. */
352
+ /* ACP has no call to fold the context, so an agent that lists a `compact` command gets it as a prompt. */
283
353
  compact() {
354
+ if (this.commands.includes('compact')) {
355
+ this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
356
+ return;
357
+ }
284
358
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
285
359
  this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
286
360
  }
@@ -351,9 +425,11 @@ export class AcpBackend {
351
425
  }
352
426
  const id = typeof frame.id === 'number' || typeof frame.id === 'string' ? frame.id : null;
353
427
  if (frame.method === 'session/update' && id === null) {
354
- if (!this.replaying) {
355
- this.emitAll(this.protocol.update(this.sessionId, frame.params));
428
+ if (this.sessionId === '' || this.replaying) {
429
+ this.holdEarly(frame.params);
430
+ return;
356
431
  }
432
+ this.emitAll(this.protocol.update(this.sessionId, frame.params));
357
433
  return;
358
434
  }
359
435
  if (id === null) {
@@ -386,6 +462,13 @@ export class AcpBackend {
386
462
  }
387
463
  this.emitAll(permission.events);
388
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
+ }
389
472
  hostAllows(request) {
390
473
  try {
391
474
  return this.spec.autoAllow?.(request) === true;
@@ -420,6 +503,14 @@ export class AcpBackend {
420
503
  if (event.type === 'model') {
421
504
  this.model = event.model;
422
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
+ }
423
514
  this.host.onEvent(event);
424
515
  }
425
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;
@@ -52,18 +54,29 @@ export interface AcpModeState {
52
54
  modes: AcpMode[];
53
55
  configId: string | null;
54
56
  }
57
+ export interface AcpChoice {
58
+ id: string;
59
+ name: string;
60
+ description: string | null;
61
+ }
62
+ export interface AcpThoughtLevel {
63
+ configId: string;
64
+ current: string;
65
+ choices: AcpChoice[];
66
+ }
55
67
  export interface AcpSessionInfo {
56
68
  sessionId: string;
57
69
  agent: AcpAgentInfo | null;
58
70
  models: AcpModelState | null;
59
71
  modes: AcpModeState | null;
60
- thoughtLevel: {
61
- configId: string;
62
- current: string;
63
- values: string[];
64
- } | null;
72
+ thoughtLevel: AcpThoughtLevel | null;
65
73
  }
74
+ export declare function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined;
66
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;
67
80
  export declare function sessionIdOf(result: unknown): string | null;
68
81
  export type AcpModeMap = Partial<Record<RuntimeMode, readonly string[]>>;
69
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() });
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
  };
@@ -144,6 +150,18 @@ function selectValues(option) {
144
150
  function selectOption(options, category) {
145
151
  return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
146
152
  }
153
+ function thoughtOption(options) {
154
+ return (selectOption(options, 'thought_level') ??
155
+ options.find((option) => option.type === 'select' && option.id === 'reasoning_effort' && typeof option.currentValue === 'string') ??
156
+ null);
157
+ }
158
+ /* The reasoning effort in a response that carries the session's config options; undefined when it carries none. */
159
+ export function thoughtLevelOf(result) {
160
+ if (!isRecord(result) || !Array.isArray(result.configOptions)) {
161
+ return undefined;
162
+ }
163
+ return sessionState(validItems(ConfigOptionSchema, result.configOptions), null, null).thoughtLevel;
164
+ }
147
165
  export function parseSessionInfo(result, sessionId, agent) {
148
166
  const record = isRecord(result) ? result : {};
149
167
  const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
@@ -152,14 +170,19 @@ export function parseSessionInfo(result, sessionId, agent) {
152
170
  function sessionState(options, modes, models) {
153
171
  const modelOption = selectOption(options, 'model');
154
172
  const modeOption = selectOption(options, 'mode');
155
- const thoughtOption = selectOption(options, 'thought_level');
173
+ const thought = thoughtOption(options);
156
174
  const modeState = ModeStateSchema.safeParse(modes);
157
175
  const modelState = ModelStateSchema.safeParse(models);
158
176
  return {
159
177
  models: modelOption
160
178
  ? {
161
179
  current: modelOption.currentValue,
162
- models: selectValues(modelOption).map((value) => ({ id: value.value, name: value.name, description: null })),
180
+ models: selectValues(modelOption).map((value) => ({
181
+ id: value.value,
182
+ name: value.name,
183
+ description: null,
184
+ contextWindow: contextWindowOf(value._meta)
185
+ })),
163
186
  configId: modelOption.id
164
187
  }
165
188
  : modelState.success
@@ -168,7 +191,8 @@ function sessionState(options, modes, models) {
168
191
  models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
169
192
  id: model.modelId,
170
193
  name: model.name,
171
- description: model.description ?? null
194
+ description: model.description ?? null,
195
+ contextWindow: contextWindowOf(model._meta)
172
196
  })),
173
197
  configId: null
174
198
  }
@@ -186,11 +210,30 @@ function sessionState(options, modes, models) {
186
210
  configId: null
187
211
  }
188
212
  : null,
189
- thoughtLevel: thoughtOption
190
- ? { configId: thoughtOption.id, current: thoughtOption.currentValue, values: selectValues(thoughtOption).map((value) => value.value) }
213
+ thoughtLevel: thought
214
+ ? {
215
+ configId: thought.id,
216
+ current: thought.currentValue,
217
+ choices: selectValues(thought).map((value) => ({ id: value.value, name: value.name, description: value.description ?? null }))
218
+ }
191
219
  : null
192
220
  };
193
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
+ }
194
237
  export function sessionIdOf(result) {
195
238
  const parsed = SessionResponseSchema.safeParse(result);
196
239
  return parsed.success ? (parsed.data.sessionId ?? null) : null;
@@ -390,10 +433,8 @@ export class AcpProtocol {
390
433
  }
391
434
  case 'plan':
392
435
  return this.plan(update);
393
- case 'available_commands_update': {
394
- const commands = validItems(CommandSchema, Array.isArray(update.availableCommands) ? update.availableCommands : []);
395
- return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commands.map((command) => command.name) }];
396
- }
436
+ case 'available_commands_update':
437
+ return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commandNames(update.availableCommands) }];
397
438
  case 'current_mode_update':
398
439
  return typeof update.currentModeId === 'string' ? [this.modeEvent(update.currentModeId)] : [];
399
440
  case 'config_option_update': {
@@ -1,5 +1,5 @@
1
1
  export { type AcpAgentSpec, type AcpClientInfo, type AcpUsageReport, createAcpBackend } from './acp-backend.ts';
2
- export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpPermissionRequest, AcpSessionInfo, AcpModeMap, AcpTurnUsage } from './acp-protocol.ts';
2
+ export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpPermissionRequest, AcpSessionInfo, AcpModeMap, AcpChoice, AcpThoughtLevel, AcpTurnUsage } from './acp-protocol.ts';
3
3
  export { AttachmentStore } from './attachment-store.ts';
4
4
  export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
5
5
  export { runningInBackground, type BackgroundWork } from './background-work.ts';
@@ -15,5 +15,6 @@ export interface AcpAccountProbe {
15
15
  timeoutMs?: number;
16
16
  timer?: ProbeTimer;
17
17
  onSession?(info: AcpSessionInfo): void;
18
+ onCommands?(commands: string[]): void;
18
19
  }
19
20
  export declare function askAcpAccount(probe: AcpAccountProbe): Promise<AccountReading>;
@@ -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) => refuseRequest(started, 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
- const sessionId = sessionIdOf(result);
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
+ }