@adecore/agents 0.31.4 → 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.
@@ -1,10 +1,11 @@
1
1
  import { createAcpBackend } from '../chat/acp-backend.js';
2
2
  import { askAcpAccount } from './accounts/acp-account.js';
3
+ import { AcpAgentState } from './acp-agent-state.js';
3
4
  import { writeAcpUsage } from './acp-usage-log.js';
4
5
  import { detectCli } from './detect.js';
5
6
  /*
6
- * What every ACP agent can do through this backend. A file change comes as the text before and after;
7
- * a message names its mentions and attachments by path, as text the agent opens with its own tools.
7
+ * What every ACP agent can do through this backend, before what it shows of itself. A file change comes as the text
8
+ * before and after; a message names its mentions and attachments by path, and carries the files themselves where the agent takes them.
8
9
  */
9
10
  export const ACP_CAPABILITIES = {
10
11
  chat: true,
@@ -25,38 +26,22 @@ export const ACP_CAPABILITIES = {
25
26
  };
26
27
  /* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
27
28
  const OWN_USAGE_FILES = new Set(['grok']);
28
- /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
29
- function adoptableModels(info) {
30
- return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
31
- }
32
- /* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
33
- function effortOption(info) {
34
- const level = info.thoughtLevel;
35
- if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
36
- return [];
37
- }
38
- return [
39
- {
40
- id: 'effort',
41
- label: 'Reasoning',
42
- type: 'select',
43
- defaultChoice: level.current,
44
- choices: level.choices.map((choice) => ({
45
- id: choice.id,
46
- label: choice.name,
47
- ...(choice.description === null ? {} : { description: choice.description })
48
- }))
49
- }
50
- ];
51
- }
52
29
  export function createAcpProvider(spec) {
53
- const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
30
+ const state = new AcpAgentState({
31
+ catalog: spec.catalog,
32
+ adoptModels: spec.adoptModels !== false,
33
+ store: spec.catalogStore ?? null,
34
+ base: ACP_CAPABILITIES,
35
+ overrides: spec.capabilities ?? {}
36
+ });
54
37
  const onSession = (info) => {
55
- if (spec.adoptModels !== false && info.models !== null) {
56
- spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
57
- }
38
+ state.session(info);
58
39
  spec.onSession?.(info);
59
40
  };
41
+ const onCommands = (commands) => {
42
+ state.commandsListed(commands);
43
+ spec.onCommands?.(commands);
44
+ };
60
45
  const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
61
46
  const onUsage = usageLog === null
62
47
  ? spec.onUsage
@@ -68,7 +53,8 @@ export function createAcpProvider(spec) {
68
53
  kind: spec.kind,
69
54
  name: spec.name,
70
55
  catalog: spec.catalog,
71
- capabilities,
56
+ capabilities: state.capabilities,
57
+ onChange: (listener) => state.onChange(listener),
72
58
  command: [spec.command, ...(spec.args ?? [])],
73
59
  resumeCommand: spec.resumeCommand ?? '',
74
60
  ...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
@@ -90,9 +76,19 @@ export function createAcpProvider(spec) {
90
76
  command,
91
77
  env: { ...env, ...spec.env },
92
78
  ...(spec.client ? { client: spec.client } : {}),
93
- onSession
79
+ onSession,
80
+ onCommands
94
81
  })
95
82
  }),
96
- createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
83
+ createBackend: createAcpBackend({
84
+ ...spec,
85
+ onSession,
86
+ onCommands,
87
+ onContextReport: () => {
88
+ state.contextReported();
89
+ spec.onContextReport?.();
90
+ },
91
+ ...(onUsage === undefined ? {} : { onUsage })
92
+ })
97
93
  };
98
94
  }
@@ -5,12 +5,14 @@ export type ModelCatalogInput = Omit<ModelCatalogData, 'updatedAt'> & {
5
5
  export declare class ModelCatalog {
6
6
  private readonly shippedAt;
7
7
  private data;
8
+ private hostWindows;
8
9
  constructor(data?: ModelCatalogInput);
9
10
  get defaultModel(): string;
10
11
  replace(data: ModelCatalogData): boolean;
11
12
  adopt(models: ReadonlyArray<{
12
13
  slug: string;
13
14
  name: string;
15
+ contextWindow?: number;
14
16
  }>, defaultModel: string, options?: readonly ModelOptionDescriptor[]): boolean;
15
17
  list(): ModelInfo[];
16
18
  nameOf(slug: string): string;
@@ -11,6 +11,8 @@ function timeOf(data) {
11
11
  export class ModelCatalog {
12
12
  shippedAt;
13
13
  data;
14
+ // The sizes the host's own default had before an agent's models replaced it, for an adopted model that names none.
15
+ hostWindows;
14
16
  constructor(data = manifest) {
15
17
  this.shippedAt = timeOf(data);
16
18
  this.data = data;
@@ -25,13 +27,14 @@ export class ModelCatalog {
25
27
  }
26
28
  const changed = JSON.stringify(data) !== JSON.stringify(this.data);
27
29
  this.data = data;
30
+ this.hostWindows = undefined;
28
31
  return changed;
29
32
  }
30
33
  /*
31
34
  * Takes the models an agent reported for itself in place of the list, the agent's current model as the
32
35
  * default and its order kept; answers whether the models changed. Every model gets the profile the
33
36
  * default has now, as an agent names its models but not what each can be asked for, with `options`
34
- * in place of the profile's options of the same id.
37
+ * in place of the profile's options of the same id, and the context window in tokens a model names as its only size.
35
38
  */
36
39
  adopt(models, defaultModel, options = []) {
37
40
  if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
@@ -39,12 +42,19 @@ export class ModelCatalog {
39
42
  }
40
43
  const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
41
44
  const replaced = new Set(options.map((option) => option.id));
42
- const profile = { ...base, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
45
+ this.hostWindows ??= base.contextWindowTokens;
46
+ const profile = { ...base, contextWindowTokens: this.hostWindows, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
47
+ const profileName = (window) => (window === undefined ? ADOPTED_PROFILE : `${ADOPTED_PROFILE}-${window}`);
48
+ const profiles = {};
49
+ for (const model of models) {
50
+ profiles[profileName(model.contextWindow)] =
51
+ model.contextWindow === undefined ? profile : { ...profile, contextWindowTokens: { '*': model.contextWindow } };
52
+ }
43
53
  const data = {
44
54
  ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
45
55
  defaultModel,
46
- profiles: { [ADOPTED_PROFILE]: profile },
47
- models: models.map((model) => ({ slug: model.slug, name: model.name, profile: ADOPTED_PROFILE }))
56
+ profiles,
57
+ models: models.map((model) => ({ slug: model.slug, name: model.name, profile: profileName(model.contextWindow) }))
48
58
  };
49
59
  const changed = JSON.stringify(data) !== JSON.stringify(this.data);
50
60
  this.data = data;
@@ -1,3 +1,4 @@
1
+ export { type AcpCatalogStore } from './acp-agent-state.ts';
1
2
  export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
2
3
  export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
3
4
  export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
@@ -1,4 +1,5 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
+ export {} from './acp-agent-state.js';
2
3
  export { ACP_CAPABILITIES, createAcpProvider } from './acp-provider.js';
3
4
  export { askAcpAccount } from './accounts/acp-account.js';
4
5
  export { AccountError, storedAccount, launchEnv } from './accounts/launch.js';
@@ -25,5 +25,6 @@ export interface ChatProvider {
25
25
  detect(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
26
26
  enabled?(): boolean;
27
27
  askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
28
+ onChange?(listener: () => void): () => void;
28
29
  createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
29
30
  }
@@ -23,6 +23,7 @@ export declare class ProviderRegistry {
23
23
  oneShotProvider(preferred?: AgentKind): Promise<ChatProvider | null>;
24
24
  catalogFor(kind: AgentKind): ModelCatalog;
25
25
  enabled(kind: AgentKind): boolean;
26
+ onChange(listener: (kind: AgentKind) => void): () => void;
26
27
  invalidate(kind: AgentKind): void;
27
28
  protected fallback(kind: AgentKind): ChatProvider;
28
29
  private detection;
@@ -97,6 +97,15 @@ export class ProviderRegistry {
97
97
  enabled(kind) {
98
98
  return this.get(kind).enabled?.() !== false;
99
99
  }
100
+ /* Calls the listener with the kind of every provider whose models or capabilities changed, so a host can list them again; answers how to stop. */
101
+ onChange(listener) {
102
+ const stops = this.providers.flatMap((provider) => (provider.onChange ? [provider.onChange(() => listener(provider.kind))] : []));
103
+ return () => {
104
+ for (const stop of stops) {
105
+ stop();
106
+ }
107
+ };
108
+ }
100
109
  invalidate(kind) {
101
110
  this.cache.delete(kind);
102
111
  }
@@ -8,6 +8,9 @@ export interface FakeAcpOptions {
8
8
  closeSession?: boolean;
9
9
  turnUsage?: Record<string, number>;
10
10
  reasoningEffort?: 'accept' | 'reject';
11
+ contextWindows?: boolean;
12
+ commands?: readonly string[];
13
+ embeddedContext?: boolean;
11
14
  }
12
15
  export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
13
16
  export declare const fakeAcp: FakeCli;
@@ -38,9 +38,10 @@ export function fakeAcpWith(options) {
38
38
  prompt = null;
39
39
  };
40
40
  const sessionState = () => {
41
+ const meta = (totalContextTokens) => (options.contextWindows ? { _meta: { totalContextTokens } } : {});
41
42
  const modelChoices = [
42
- { value: 'fake-small', name: 'Fake small' },
43
- { value: 'fake-large', name: 'Fake large' }
43
+ { value: 'fake-small', name: 'Fake small', ...meta(128000) },
44
+ { value: 'fake-large', name: 'Fake large', ...meta(1000000) }
44
45
  ];
45
46
  const effortChoices = [
46
47
  { value: 'low', name: 'Low', description: 'Answers quickly' },
@@ -53,7 +54,7 @@ export function fakeAcpWith(options) {
53
54
  return {
54
55
  ...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
55
56
  ...(options.legacyModels
56
- ? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
57
+ ? { models: { currentModelId: model, availableModels: modelChoices.map(({ value, ...choice }) => ({ modelId: value, ...choice })) } }
57
58
  : {
58
59
  configOptions: [
59
60
  { id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
@@ -193,7 +194,7 @@ export function fakeAcpWith(options) {
193
194
  protocolVersion: 1,
194
195
  agentCapabilities: {
195
196
  loadSession: options.loadSession ?? true,
196
- promptCapabilities: { image: true, audio: false, embeddedContext: false },
197
+ promptCapabilities: { image: true, audio: false, embeddedContext: options.embeddedContext === true },
197
198
  sessionCapabilities: { ...(options.resumeSession ? { resume: {} } : {}), ...(options.closeSession ? { close: {} } : {}) }
198
199
  },
199
200
  authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
@@ -211,6 +212,12 @@ export function fakeAcpWith(options) {
211
212
  }
212
213
  sessionId = `fake-session-${Math.random().toString(36).slice(2, 8)}`;
213
214
  sessionHistory.set(sessionId, []);
215
+ if (options.commands !== undefined) {
216
+ update({
217
+ sessionUpdate: 'available_commands_update',
218
+ availableCommands: options.commands.map((name) => ({ name, description: name }))
219
+ });
220
+ }
214
221
  respond(id, { sessionId, ...sessionState() });
215
222
  return;
216
223
  case 'session/load':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.31.4",
3
+ "version": "0.31.5",
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.5",
85
+ "@adecore/intelligent-ui": "0.31.5",
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);