@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.
@@ -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
+ }
@@ -1,15 +1,16 @@
1
- import type { AgentKind, ModelOptionDescriptor, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
2
2
  import { createAcpBackend, type AcpAgentSpec, type AcpUsageReport } from '../chat/acp-backend.ts';
3
3
  import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
4
4
  import { askAcpAccount } from './accounts/acp-account.ts';
5
+ import { AcpAgentState, type AcpCatalogStore } from './acp-agent-state.ts';
5
6
  import { writeAcpUsage } from './acp-usage-log.ts';
6
7
  import type { ModelCatalog } from './catalog.ts';
7
8
  import { detectCli, type CliDetection } from './detect.ts';
8
9
  import type { ChatProvider, ProviderHome } from './provider.ts';
9
10
 
10
11
  /*
11
- * What every ACP agent can do through this backend. A file change comes as the text before and after;
12
- * a message names its mentions and attachments by path, as text the agent opens with its own tools.
12
+ * What every ACP agent can do through this backend, before what it shows of itself. A file change comes as the text
13
+ * before and after; a message names its mentions and attachments by path, and carries the files themselves where the agent takes them.
13
14
  */
14
15
  export const ACP_CAPABILITIES: ProviderCapabilities = {
15
16
  chat: true,
@@ -36,7 +37,7 @@ export interface AcpProviderSpec extends AcpAgentSpec {
36
37
  command: string;
37
38
  args?: readonly string[];
38
39
  catalog: ModelCatalog;
39
- // Over `ACP_CAPABILITIES`, for an agent that reports its cost or context window, or whose commands fold its context.
40
+ // Over `ACP_CAPABILITIES` and what the agent showed it can do, for what the host knows better.
40
41
  capabilities?: Partial<ProviderCapabilities>;
41
42
  // What a terminal runs to continue one of its sessions; empty where it has none.
42
43
  resumeCommand?: string;
@@ -56,45 +57,29 @@ export interface AcpProviderSpec extends AcpAgentSpec {
56
57
  adoptModels?: boolean;
57
58
  // The folder the usage of every turn the agent reports is logged to, for a usage root of the agent's kind; absent logs nothing.
58
59
  usageLog?: string;
60
+ // Keeps the models the agent reported across a restart of the host; absent starts from `catalog` every time.
61
+ catalogStore?: AcpCatalogStore;
59
62
  }
60
63
 
61
64
  /* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
62
65
  const OWN_USAGE_FILES: ReadonlySet<AgentKind> = new Set(['grok']);
63
66
 
64
- /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
65
- function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: string }> {
66
- return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
67
- }
68
-
69
- /* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
70
- function effortOption(info: AcpSessionInfo): ModelOptionDescriptor[] {
71
- const level = info.thoughtLevel;
72
- if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
73
- return [];
74
- }
75
- return [
76
- {
77
- id: 'effort',
78
- label: 'Reasoning',
79
- type: 'select',
80
- defaultChoice: level.current,
81
- choices: level.choices.map((choice) => ({
82
- id: choice.id,
83
- label: choice.name,
84
- ...(choice.description === null ? {} : { description: choice.description })
85
- }))
86
- }
87
- ];
88
- }
89
-
90
67
  export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
91
- const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
68
+ const state = new AcpAgentState({
69
+ catalog: spec.catalog,
70
+ adoptModels: spec.adoptModels !== false,
71
+ store: spec.catalogStore ?? null,
72
+ base: ACP_CAPABILITIES,
73
+ overrides: spec.capabilities ?? {}
74
+ });
92
75
  const onSession = (info: AcpSessionInfo): void => {
93
- if (spec.adoptModels !== false && info.models !== null) {
94
- spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
95
- }
76
+ state.session(info);
96
77
  spec.onSession?.(info);
97
78
  };
79
+ const onCommands = (commands: string[]): void => {
80
+ state.commandsListed(commands);
81
+ spec.onCommands?.(commands);
82
+ };
98
83
  const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
99
84
  const onUsage =
100
85
  usageLog === null
@@ -107,7 +92,8 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
107
92
  kind: spec.kind,
108
93
  name: spec.name,
109
94
  catalog: spec.catalog,
110
- capabilities,
95
+ capabilities: state.capabilities,
96
+ onChange: (listener) => state.onChange(listener),
111
97
  command: [spec.command, ...(spec.args ?? [])],
112
98
  resumeCommand: spec.resumeCommand ?? '',
113
99
  ...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
@@ -131,9 +117,19 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
131
117
  command,
132
118
  env: { ...env, ...spec.env },
133
119
  ...(spec.client ? { client: spec.client } : {}),
134
- onSession
120
+ onSession,
121
+ onCommands
135
122
  })
136
123
  }),
137
- createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
124
+ createBackend: createAcpBackend({
125
+ ...spec,
126
+ onSession,
127
+ onCommands,
128
+ onContextReport: () => {
129
+ state.contextReported();
130
+ spec.onContextReport?.();
131
+ },
132
+ ...(onUsage === undefined ? {} : { onUsage })
133
+ })
138
134
  };
139
135
  }
@@ -18,6 +18,8 @@ function timeOf(data: ModelCatalogInput): number {
18
18
  export class ModelCatalog {
19
19
  private readonly shippedAt: number;
20
20
  private data: ModelCatalogInput;
21
+ // The sizes the host's own default had before an agent's models replaced it, for an adopted model that names none.
22
+ private hostWindows: ModelCatalogProfile['contextWindowTokens'] | undefined;
21
23
 
22
24
  constructor(data: ModelCatalogInput = manifest as never) {
23
25
  this.shippedAt = timeOf(data);
@@ -35,6 +37,7 @@ export class ModelCatalog {
35
37
  }
36
38
  const changed = JSON.stringify(data) !== JSON.stringify(this.data);
37
39
  this.data = data;
40
+ this.hostWindows = undefined;
38
41
  return changed;
39
42
  }
40
43
 
@@ -42,20 +45,31 @@ export class ModelCatalog {
42
45
  * Takes the models an agent reported for itself in place of the list, the agent's current model as the
43
46
  * default and its order kept; answers whether the models changed. Every model gets the profile the
44
47
  * default has now, as an agent names its models but not what each can be asked for, with `options`
45
- * in place of the profile's options of the same id.
48
+ * in place of the profile's options of the same id, and the context window in tokens a model names as its only size.
46
49
  */
47
- adopt(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string, options: readonly ModelOptionDescriptor[] = []): boolean {
50
+ adopt(
51
+ models: ReadonlyArray<{ slug: string; name: string; contextWindow?: number }>,
52
+ defaultModel: string,
53
+ options: readonly ModelOptionDescriptor[] = []
54
+ ): boolean {
48
55
  if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
49
56
  return false;
50
57
  }
51
58
  const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
52
59
  const replaced = new Set(options.map((option) => option.id));
53
- const profile = { ...base, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
60
+ this.hostWindows ??= base.contextWindowTokens;
61
+ const profile = { ...base, contextWindowTokens: this.hostWindows, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
62
+ const profileName = (window: number | undefined): string => (window === undefined ? ADOPTED_PROFILE : `${ADOPTED_PROFILE}-${window}`);
63
+ const profiles: Record<string, ModelCatalogProfile> = {};
64
+ for (const model of models) {
65
+ profiles[profileName(model.contextWindow)] =
66
+ model.contextWindow === undefined ? profile : { ...profile, contextWindowTokens: { '*': model.contextWindow } };
67
+ }
54
68
  const data: ModelCatalogInput = {
55
69
  ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
56
70
  defaultModel,
57
- profiles: { [ADOPTED_PROFILE]: profile },
58
- models: models.map((model) => ({ slug: model.slug, name: model.name, profile: ADOPTED_PROFILE }))
71
+ profiles,
72
+ models: models.map((model) => ({ slug: model.slug, name: model.name, profile: profileName(model.contextWindow) }))
59
73
  };
60
74
  const changed = JSON.stringify(data) !== JSON.stringify(this.data);
61
75
  this.data = data;
@@ -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 { type AcpCatalogStore } from './acp-agent-state.ts';
2
3
  export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
3
4
  export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
4
5
  export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
@@ -50,5 +50,7 @@ export interface ChatProvider {
50
50
  enabled?(): boolean;
51
51
  /* Asks the CLI, started in an account's environment, who is signed in there. Absent leaves the host's own reader. */
52
52
  askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
53
+ /* Calls the listener whenever the models or the capabilities changed, for a provider that learns them from its CLI; answers how to stop. */
54
+ onChange?(listener: () => void): () => void;
53
55
  createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
54
56
  }
@@ -128,6 +128,16 @@ export class ProviderRegistry {
128
128
  return this.get(kind).enabled?.() !== false;
129
129
  }
130
130
 
131
+ /* 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. */
132
+ onChange(listener: (kind: AgentKind) => void): () => void {
133
+ const stops = this.providers.flatMap((provider) => (provider.onChange ? [provider.onChange(() => listener(provider.kind))] : []));
134
+ return () => {
135
+ for (const stop of stops) {
136
+ stop();
137
+ }
138
+ };
139
+ }
140
+
131
141
  invalidate(kind: AgentKind): void {
132
142
  this.cache.delete(kind);
133
143
  }
@@ -27,6 +27,12 @@ export interface FakeAcpOptions {
27
27
  // Offers a reasoning effort as the config option `reasoning_effort`, without a category: Low and High, and
28
28
  // Extra high on `fake-large`. `reject` refuses every change to it.
29
29
  reasoningEffort?: 'accept' | 'reject';
30
+ // Names each model's context window in its `_meta` (`totalContextTokens`): 128000 for `fake-small`, 1000000 for `fake-large`.
31
+ contextWindows?: boolean;
32
+ // The slash commands a new session lists, sent before the answer that names the session.
33
+ commands?: readonly string[];
34
+ // Advertises `promptCapabilities.embeddedContext`, so a prompt may carry files as `resource` blocks.
35
+ embeddedContext?: boolean;
30
36
  }
31
37
 
32
38
  export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
@@ -74,9 +80,10 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
74
80
  };
75
81
 
76
82
  const sessionState = (): Frame => {
83
+ const meta = (totalContextTokens: number): Frame => (options.contextWindows ? { _meta: { totalContextTokens } } : {});
77
84
  const modelChoices = [
78
- { value: 'fake-small', name: 'Fake small' },
79
- { value: 'fake-large', name: 'Fake large' }
85
+ { value: 'fake-small', name: 'Fake small', ...meta(128000) },
86
+ { value: 'fake-large', name: 'Fake large', ...meta(1000000) }
80
87
  ];
81
88
  const effortChoices = [
82
89
  { value: 'low', name: 'Low', description: 'Answers quickly' },
@@ -90,7 +97,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
90
97
  return {
91
98
  ...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
92
99
  ...(options.legacyModels
93
- ? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
100
+ ? { models: { currentModelId: model, availableModels: modelChoices.map(({ value, ...choice }) => ({ modelId: value, ...choice })) } }
94
101
  : {
95
102
  configOptions: [
96
103
  { id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
@@ -233,7 +240,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
233
240
  protocolVersion: 1,
234
241
  agentCapabilities: {
235
242
  loadSession: options.loadSession ?? true,
236
- promptCapabilities: { image: true, audio: false, embeddedContext: false },
243
+ promptCapabilities: { image: true, audio: false, embeddedContext: options.embeddedContext === true },
237
244
  sessionCapabilities: { ...(options.resumeSession ? { resume: {} } : {}), ...(options.closeSession ? { close: {} } : {}) }
238
245
  },
239
246
  authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
@@ -251,6 +258,12 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
251
258
  }
252
259
  sessionId = `fake-session-${Math.random().toString(36).slice(2, 8)}`;
253
260
  sessionHistory.set(sessionId, []);
261
+ if (options.commands !== undefined) {
262
+ update({
263
+ sessionUpdate: 'available_commands_update',
264
+ availableCommands: options.commands.map((name) => ({ name, description: name }))
265
+ });
266
+ }
254
267
  respond(id, { sessionId, ...sessionState() });
255
268
  return;
256
269
  case 'session/load':