@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
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { zstdDecompressSync } from 'node:zlib';
4
5
  import { errorText } from '../error-text.js';
5
6
  import { isNotFound, writeAtomic } from '../fs.js';
6
7
  import { decodeIndex, encodeIndex } from './index-file.js';
@@ -11,6 +12,7 @@ import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.j
11
12
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
12
13
  import { usageRoots } from './roots.js';
13
14
  const HASH_CHUNK_BYTES = 65536;
15
+ const CODEX_PARSER_VERSION = 2;
14
16
  /*
15
17
  * Hashes the bytes before `from` and returns the rest, in one pass over one handle, so verifying
16
18
  * what a transcript committed and parsing what it added never reads a byte twice.
@@ -55,7 +57,7 @@ function readsWhole(provider) {
55
57
  return provider === 'grok';
56
58
  }
57
59
  function isUsageFile(provider, name) {
58
- return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
60
+ return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl') || (provider === 'codex' && name.endsWith('.jsonl.zst'));
59
61
  }
60
62
  async function listUsageFiles(dir, provider, out = []) {
61
63
  let entries;
@@ -215,7 +217,12 @@ export class UsageScanner {
215
217
  }
216
218
  let failure = null;
217
219
  const accountOf = accountResolver(root, sessionAccounts);
218
- for (const path of await listUsageFiles(root.path, root.provider)) {
220
+ const paths = await listUsageFiles(root.path, root.provider);
221
+ const plain = new Set(paths);
222
+ for (const path of paths) {
223
+ if (root.provider === 'codex' && path.endsWith('.jsonl.zst') && plain.has(path.slice(0, -4))) {
224
+ continue;
225
+ }
219
226
  files += 1;
220
227
  try {
221
228
  if (await this.readFileInto(path, root.provider, accountOf)) {
@@ -246,15 +253,31 @@ export class UsageScanner {
246
253
  }
247
254
  async readFileInto(path, provider, accountOf) {
248
255
  const info = await stat(path);
249
- const known = this.index.get(path);
250
- if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
256
+ const compressed = provider === 'codex' && path.endsWith('.jsonl.zst');
257
+ const key = compressed ? path.slice(0, -4) : path;
258
+ const known = this.index.get(key);
259
+ const currentParser = provider !== 'codex' || known?.parserVersion === CODEX_PARSER_VERSION;
260
+ if (currentParser &&
261
+ known !== undefined &&
262
+ known.provider === provider &&
263
+ (known.compressed ?? false) === compressed &&
264
+ known.size === info.size &&
265
+ known.mtimeMs === info.mtimeMs) {
251
266
  return false;
252
267
  }
253
268
  if (readsWhole(provider)) {
254
269
  return this.readWholeInto(path, provider, accountOf, info);
255
270
  }
256
- const appended = known !== undefined && known.provider === provider && info.size > known.size && known.head?.startsWith('sha256:') === true;
257
- let read = await readHashed(path, appended ? known.offset : 0);
271
+ const appended = !compressed &&
272
+ currentParser &&
273
+ known?.compressed !== true &&
274
+ known !== undefined &&
275
+ known.provider === provider &&
276
+ info.size > known.size &&
277
+ known.head?.startsWith('sha256:') === true;
278
+ let read = compressed
279
+ ? { bytes: zstdDecompressSync(await readFile(path)), hash: createHash('sha256') }
280
+ : await readHashed(path, appended ? known.offset : 0);
258
281
  // A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
259
282
  const grown = appended && headOf(read.hash) === known.head;
260
283
  if (appended && !grown) {
@@ -273,9 +296,10 @@ export class UsageScanner {
273
296
  records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
274
297
  tail: withAccount(parsed.tail, accountOf),
275
298
  codex: parsed.codex,
276
- head: headOf(read.hash)
299
+ head: headOf(read.hash),
300
+ ...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
277
301
  };
278
- this.index.set(path, entry);
302
+ this.index.set(key, entry);
279
303
  this.dirty = true;
280
304
  return true;
281
305
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.31.4",
3
+ "version": "0.31.6",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.31.4",
85
- "@adecore/intelligent-ui": "0.31.4",
84
+ "@adecore/agent-contracts": "0.31.6",
85
+ "@adecore/intelligent-ui": "0.31.6",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -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,6 +16,7 @@ import {
14
16
  resolveAcpMode,
15
17
  runtimeModeOf,
16
18
  sessionIdOf,
19
+ sessionUpdateOf,
17
20
  thoughtLevelOf,
18
21
  type AcpAgentCapabilities,
19
22
  type AcpModeMap,
@@ -56,6 +59,10 @@ export interface AcpAgentSpec {
56
59
  onSession?(info: AcpSessionInfo): void;
57
60
  // The tokens of every turn whose `session/prompt` response reports them, for a host that keeps a usage log.
58
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;
59
66
  // A permission request the host answers itself, in any runtime mode: true allows it once without asking the
60
67
  // person, for a call the host already guards. A throw counts as false.
61
68
  autoAllow?(request: AcpPermissionRequest): boolean;
@@ -71,6 +78,15 @@ export interface AcpUsageReport extends AcpTurnUsage {
71
78
  // What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
72
79
  const REQUEST_TIMEOUT_MS = 60_000;
73
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
+
74
90
  function nameOf(client: AcpClientInfo): string {
75
91
  return client.title ?? client.name;
76
92
  }
@@ -95,6 +111,10 @@ export class AcpBackend implements ChatBackend {
95
111
  private contextPending: string | null = null;
96
112
  private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
97
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[] = [];
98
118
 
99
119
  constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
100
120
  this.launch = launch;
@@ -167,6 +187,9 @@ export class AcpBackend implements ChatBackend {
167
187
  effectiveRuntimeMode: this.launch.runtimeMode,
168
188
  ...(modeId === null ? {} : { permissionMode: modeId })
169
189
  });
190
+ for (const params of this.early.splice(0)) {
191
+ this.emitAll(this.protocol.update(this.sessionId, params));
192
+ }
170
193
  }
171
194
 
172
195
  private sessionParams(): Record<string, unknown> {
@@ -334,7 +357,50 @@ export class AcpBackend implements ChatBackend {
334
357
  const named: ChatAttachment[] = input.attachments.filter((attachment) => !images.includes(attachment));
335
358
  const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part): part is string => part !== null && part !== '');
336
359
  this.contextPending = null;
337
- 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;
338
404
  }
339
405
 
340
406
  sendTurn(input: TurnInput): void {
@@ -364,8 +430,12 @@ export class AcpBackend implements ChatBackend {
364
430
  });
365
431
  }
366
432
 
367
- /* ACP has no call to fold the context; a host that offers it sends the agent's own command as a prompt. */
433
+ /* ACP has no call to fold the context, so an agent that lists a `compact` command gets it as a prompt. */
368
434
  compact(): void {
435
+ if (this.commands.includes('compact')) {
436
+ this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
437
+ return;
438
+ }
369
439
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
370
440
  this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
371
441
  }
@@ -443,9 +513,11 @@ export class AcpBackend implements ChatBackend {
443
513
  }
444
514
  const id = typeof frame.id === 'number' || typeof frame.id === 'string' ? frame.id : null;
445
515
  if (frame.method === 'session/update' && id === null) {
446
- if (!this.replaying) {
447
- this.emitAll(this.protocol.update(this.sessionId, frame.params));
516
+ if (this.sessionId === '' || this.replaying) {
517
+ this.holdEarly(frame.params);
518
+ return;
448
519
  }
520
+ this.emitAll(this.protocol.update(this.sessionId, frame.params));
449
521
  return;
450
522
  }
451
523
  if (id === null) {
@@ -479,6 +551,14 @@ export class AcpBackend implements ChatBackend {
479
551
  this.emitAll(permission.events);
480
552
  }
481
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
+
482
562
  private hostAllows(request: AcpPermissionRequest): boolean {
483
563
  try {
484
564
  return this.spec.autoAllow?.(request) === true;
@@ -516,6 +596,14 @@ export class AcpBackend implements ChatBackend {
516
596
  if (event.type === 'model') {
517
597
  this.model = event.model;
518
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
+ }
519
607
  this.host.onEvent(event);
520
608
  }
521
609
  }
@@ -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(), description: z.string().nullish() });
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`. */
@@ -288,7 +299,12 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
288
299
  models: modelOption
289
300
  ? {
290
301
  current: modelOption.currentValue as string,
291
- models: selectValues(modelOption).map((value) => ({ id: value.value, name: value.name, description: null })),
302
+ models: selectValues(modelOption).map((value) => ({
303
+ id: value.value,
304
+ name: value.name,
305
+ description: null,
306
+ contextWindow: contextWindowOf(value._meta)
307
+ })),
292
308
  configId: modelOption.id
293
309
  }
294
310
  : modelState.success
@@ -297,7 +313,8 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
297
313
  models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
298
314
  id: model.modelId,
299
315
  name: model.name,
300
- description: model.description ?? null
316
+ description: model.description ?? null,
317
+ contextWindow: contextWindowOf(model._meta)
301
318
  })),
302
319
  configId: null
303
320
  }
@@ -325,6 +342,23 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
325
342
  };
326
343
  }
327
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
+
328
362
  export function sessionIdOf(result: unknown): string | null {
329
363
  const parsed = SessionResponseSchema.safeParse(result);
330
364
  return parsed.success ? (parsed.data.sessionId ?? null) : null;
@@ -590,10 +624,8 @@ export class AcpProtocol {
590
624
  }
591
625
  case 'plan':
592
626
  return this.plan(update);
593
- case 'available_commands_update': {
594
- const commands = validItems(CommandSchema, Array.isArray(update.availableCommands) ? update.availableCommands : []);
595
- return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commands.map((command) => command.name) }];
596
- }
627
+ case 'available_commands_update':
628
+ return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commandNames(update.availableCommands) }];
597
629
  case 'current_mode_update':
598
630
  return typeof update.currentModeId === 'string' ? [this.modeEvent(update.currentModeId)] : [];
599
631
  case 'config_option_update': {
@@ -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 { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, parseInitialize, parseSessionInfo, sessionIdOf, type AcpSessionInfo } from '../../chat/acp-protocol.ts';
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) => refuseRequest(started, 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
- const sessionId = sessionIdOf(result);
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);
@@ -0,0 +1,206 @@
1
+ import { ModelOptionDescriptorSchema, type ModelOptionDescriptor, type ProviderCapabilities } from '@adecore/agent-contracts';
2
+ import { z } from 'zod';
3
+ import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
4
+ import type { ModelCatalog } from './catalog.ts';
5
+
6
+ /* Where a provider keeps what its agent reported, so a host that starts again offers it before the agent runs. The value is the provider's own. */
7
+ export interface AcpCatalogStore {
8
+ load(): unknown;
9
+ save(value: unknown): void;
10
+ }
11
+
12
+ const AdoptedSchema = z.object({
13
+ defaultModel: z.string().min(1),
14
+ models: z.array(z.object({ slug: z.string().min(1), name: z.string(), contextWindow: z.number().int().positive().optional() })).min(1),
15
+ options: z.array(ModelOptionDescriptorSchema)
16
+ });
17
+
18
+ type Adopted = z.infer<typeof AdoptedSchema>;
19
+
20
+ /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
21
+ function adoptableModels(info: AcpSessionInfo): Adopted['models'] {
22
+ return (info.models?.models ?? [])
23
+ .filter((model) => model.id !== '' && !model.id.startsWith('-'))
24
+ .map((model) => ({ slug: model.id, name: model.name, ...(model.contextWindow === null ? {} : { contextWindow: model.contextWindow }) }));
25
+ }
26
+
27
+ /* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
28
+ function effortOption(info: AcpSessionInfo): ModelOptionDescriptor[] {
29
+ const level = info.thoughtLevel;
30
+ if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
31
+ return [];
32
+ }
33
+ return [
34
+ {
35
+ id: 'effort',
36
+ label: 'Reasoning',
37
+ type: 'select',
38
+ defaultChoice: level.current,
39
+ choices: level.choices.map((choice) => ({
40
+ id: choice.id,
41
+ label: choice.name,
42
+ ...(choice.description === null ? {} : { description: choice.description })
43
+ }))
44
+ }
45
+ ];
46
+ }
47
+
48
+ /* Parses one stored part on its own, so a part a later version wrote differently costs only that part. */
49
+ function storedPart<T>(schema: z.ZodType<T>, value: unknown): T | null {
50
+ const parsed = schema.safeParse(value);
51
+ return parsed.success ? parsed.data : null;
52
+ }
53
+
54
+ export interface AcpAgentStateOptions {
55
+ catalog: ModelCatalog;
56
+ adoptModels: boolean;
57
+ store: AcpCatalogStore | null;
58
+ // The capabilities the provider reports before what the agent said is laid over them.
59
+ base: ProviderCapabilities;
60
+ // What the host set itself, which wins over what the agent said.
61
+ overrides: Partial<ProviderCapabilities>;
62
+ }
63
+
64
+ /*
65
+ * What an agent reported about itself, outside any one session: the models it offers and what follows from it for the
66
+ * provider's capabilities. Kept in the host's store when it has one, and every change is told to the listeners, so a host
67
+ * can hand its clients the new list.
68
+ */
69
+ export class AcpAgentState {
70
+ // Changed in place, so a host that spread the provider into a value of its own reads the same object.
71
+ readonly capabilities: ProviderCapabilities;
72
+ private readonly options: AcpAgentStateOptions;
73
+ private readonly listeners = new Set<() => void>();
74
+ private adopted: Adopted | null = null;
75
+ // Whether the agent reported its context window in a `usage_update`, once in any session.
76
+ private reportsContext = false;
77
+ // The slash commands the last session listed; null until one did.
78
+ private commands: string[] | null = null;
79
+
80
+ constructor(options: AcpAgentStateOptions) {
81
+ this.options = options;
82
+ this.capabilities = { ...options.base, ...options.overrides };
83
+ this.restore();
84
+ }
85
+
86
+ onChange(listener: () => void): () => void {
87
+ this.listeners.add(listener);
88
+ return () => {
89
+ this.listeners.delete(listener);
90
+ };
91
+ }
92
+
93
+ session(info: AcpSessionInfo): void {
94
+ if (!this.options.adoptModels || info.models === null) {
95
+ return;
96
+ }
97
+ const adopted: Adopted = { defaultModel: info.models.current, models: adoptableModels(info), options: effortOption(info) };
98
+ const changed = this.adopt(adopted);
99
+ this.settle(changed);
100
+ }
101
+
102
+ commandsListed(commands: readonly string[]): void {
103
+ const names = [...new Set(commands)].sort();
104
+ if (this.commands !== null && JSON.stringify(names) === JSON.stringify(this.commands)) {
105
+ return;
106
+ }
107
+ this.commands = names;
108
+ const changed = this.refresh();
109
+ // The commands are saved even when the capabilities stay, so a restart knows them too.
110
+ this.save();
111
+ if (changed) {
112
+ this.notify();
113
+ }
114
+ }
115
+
116
+ contextReported(): void {
117
+ if (this.reportsContext) {
118
+ return;
119
+ }
120
+ this.reportsContext = true;
121
+ this.settle(this.refresh());
122
+ }
123
+
124
+ private restore(): void {
125
+ let stored: unknown;
126
+ try {
127
+ stored = this.options.store?.load();
128
+ } catch {
129
+ return;
130
+ }
131
+ if (typeof stored !== 'object' || stored === null) {
132
+ return;
133
+ }
134
+ const record = stored as Record<string, unknown>;
135
+ const adopted = storedPart(AdoptedSchema, record.models);
136
+ if (adopted !== null && this.options.adoptModels) {
137
+ this.adopt(adopted);
138
+ }
139
+ this.reportsContext = record.reportsContext === true;
140
+ this.commands = storedPart(z.array(z.string()), record.commands);
141
+ this.refresh();
142
+ }
143
+
144
+ /* What the agent showed it can do, under what the host set itself. */
145
+ private learned(): Partial<ProviderCapabilities> {
146
+ const commands = this.commands ?? [];
147
+ return {
148
+ ...(this.reportsContext ? { reportsContextWindow: true } : {}),
149
+ ...(commands.includes('compact') ? { compaction: 'prompt' as const } : {}),
150
+ ...(commands.includes('goal') ? { goals: 'command' as const } : {})
151
+ };
152
+ }
153
+
154
+ /* Lays what the agent showed over the capabilities, in place; answers whether they changed. */
155
+ private refresh(): boolean {
156
+ const next: ProviderCapabilities = { ...this.options.base, ...this.learned(), ...this.options.overrides };
157
+ if (JSON.stringify(next) === JSON.stringify(this.capabilities)) {
158
+ return false;
159
+ }
160
+ const capabilities = this.capabilities as Partial<ProviderCapabilities>;
161
+ for (const key of Object.keys(capabilities) as Array<keyof ProviderCapabilities>) {
162
+ delete capabilities[key];
163
+ }
164
+ Object.assign(capabilities, next);
165
+ return true;
166
+ }
167
+
168
+ /* Answers whether the catalog changed; a list without its default is refused there and leaves the one adopted before. */
169
+ private adopt(adopted: Adopted): boolean {
170
+ if (!adopted.models.some((model) => model.slug === adopted.defaultModel)) {
171
+ return false;
172
+ }
173
+ this.adopted = adopted;
174
+ return this.options.catalog.adopt(adopted.models, adopted.defaultModel, adopted.options);
175
+ }
176
+
177
+ /* Saves and tells the listeners once something changed. */
178
+ private settle(changed: boolean): void {
179
+ if (changed) {
180
+ this.save();
181
+ this.notify();
182
+ }
183
+ }
184
+
185
+ private save(): void {
186
+ try {
187
+ this.options.store?.save(this.stored());
188
+ } catch {
189
+ // A store that cannot write costs the next start what it would have kept, never this session.
190
+ }
191
+ }
192
+
193
+ private notify(): void {
194
+ for (const listener of [...this.listeners]) {
195
+ listener();
196
+ }
197
+ }
198
+
199
+ private stored(): Record<string, unknown> {
200
+ return {
201
+ ...(this.adopted === null ? {} : { models: this.adopted }),
202
+ ...(this.reportsContext ? { reportsContext: true } : {}),
203
+ ...(this.commands === null ? {} : { commands: this.commands })
204
+ };
205
+ }
206
+ }