@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.
Files changed (45) hide show
  1. package/dist/chat/acp-backend.d.ts +7 -0
  2. package/dist/chat/acp-backend.js +87 -6
  3. package/dist/chat/acp-protocol.d.ts +6 -0
  4. package/dist/chat/acp-protocol.js +34 -9
  5. package/dist/providers/accounts/acp-account.d.ts +1 -0
  6. package/dist/providers/accounts/acp-account.js +25 -3
  7. package/dist/providers/acp-agent-state.d.ts +35 -0
  8. package/dist/providers/acp-agent-state.js +170 -0
  9. package/dist/providers/acp-provider.d.ts +2 -0
  10. package/dist/providers/acp-provider.js +29 -33
  11. package/dist/providers/catalog.d.ts +2 -0
  12. package/dist/providers/catalog.js +14 -4
  13. package/dist/providers/index.d.ts +1 -0
  14. package/dist/providers/index.js +1 -0
  15. package/dist/providers/provider.d.ts +1 -0
  16. package/dist/providers/registry.d.ts +1 -0
  17. package/dist/providers/registry.js +9 -0
  18. package/dist/testing/fake-acp.d.ts +3 -0
  19. package/dist/testing/fake-acp.js +11 -4
  20. package/dist/usage/aggregate.js +1 -1
  21. package/dist/usage/index-file.d.ts +2 -0
  22. package/dist/usage/index-file.js +19 -10
  23. package/dist/usage/pricing.d.ts +3 -2
  24. package/dist/usage/pricing.js +42 -8
  25. package/dist/usage/readers/codex.d.ts +9 -0
  26. package/dist/usage/readers/codex.js +75 -14
  27. package/dist/usage/record.d.ts +1 -0
  28. package/dist/usage/scanner.js +32 -8
  29. package/package.json +3 -3
  30. package/src/chat/acp-backend.ts +93 -5
  31. package/src/chat/acp-protocol.ts +41 -9
  32. package/src/providers/accounts/acp-account.ts +34 -3
  33. package/src/providers/acp-agent-state.ts +206 -0
  34. package/src/providers/acp-provider.ts +33 -37
  35. package/src/providers/catalog.ts +19 -5
  36. package/src/providers/index.ts +1 -0
  37. package/src/providers/provider.ts +2 -0
  38. package/src/providers/registry.ts +10 -0
  39. package/src/testing/fake-acp.ts +17 -4
  40. package/src/usage/aggregate.ts +1 -1
  41. package/src/usage/index-file.ts +25 -18
  42. package/src/usage/pricing.ts +46 -8
  43. package/src/usage/readers/codex.ts +86 -14
  44. package/src/usage/record.ts +1 -0
  45. 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;
@@ -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; 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. */
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 (!this.replaying) {
365
- this.emitAll(this.protocol.update(this.sessionId, frame.params));
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) => ({ 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
+ })),
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
- const commands = validItems(CommandSchema, Array.isArray(update.availableCommands) ? update.availableCommands : []);
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': {
@@ -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
+ }
@@ -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;