@adecore/agents 0.31.3 → 0.31.4

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.
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { attachmentImageMime } from '@adecore/agent-contracts';
3
3
  import { errorText } from '../error-text.js';
4
4
  import { MODE_ORDER } from '../modes.js';
5
- import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf } from './acp-protocol.js';
5
+ import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf, thoughtLevelOf } from './acp-protocol.js';
6
6
  import { attachmentNote } from './input.js';
7
7
  import { JsonRpcError, JsonRpcTransport } from './json-rpc-transport.js';
8
8
  export const DEFAULT_ACP_CLIENT = { name: 'agents', title: 'Agents', version: '0.1.0' };
@@ -86,9 +86,9 @@ export class AcpBackend {
86
86
  this.capabilities = capabilities;
87
87
  const info = await this.openSession(transport, capabilities);
88
88
  const modeId = await this.applyMode(transport, info);
89
- const model = await this.applyModel(transport, info);
89
+ const { model, thoughtLevel } = await this.applyModel(transport, info);
90
90
  this.model = model;
91
- await this.applyThoughtLevel(transport, info);
91
+ await this.applyThoughtLevel(transport, thoughtLevel);
92
92
  this.spec.onSession?.(info);
93
93
  this.emit({
94
94
  type: 'session',
@@ -191,25 +191,29 @@ export class AcpBackend {
191
191
  }
192
192
  return resolved.modeId;
193
193
  }
194
- /* Switches to the selected model where the agent offers it; answers the model the session runs. */
194
+ /*
195
+ * Switches to the selected model where the agent offers it; answers the model the session runs and the
196
+ * reasoning effort it offers there, which may change with the model.
197
+ */
195
198
  async applyModel(transport, info) {
196
199
  const models = info.models;
200
+ const thoughtLevel = info.thoughtLevel;
197
201
  const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
198
202
  if (models === null || wanted === null || wanted === models.current) {
199
- return models?.current ?? wanted;
203
+ return { model: models?.current ?? wanted, thoughtLevel };
200
204
  }
201
205
  if (!models.models.some((model) => model.id === wanted)) {
202
206
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
203
- return models.current;
207
+ return { model: models.current, thoughtLevel };
204
208
  }
205
209
  try {
206
210
  if (models.configId === null) {
207
211
  await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
212
+ return { model: wanted, thoughtLevel };
208
213
  }
209
- else {
210
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
211
- }
212
- return wanted;
214
+ const result = await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
215
+ const after = thoughtLevelOf(result);
216
+ return { model: wanted, thoughtLevel: after === undefined ? thoughtLevel : after };
213
217
  }
214
218
  catch (error) {
215
219
  if (!(error instanceof JsonRpcError)) {
@@ -220,20 +224,26 @@ export class AcpBackend {
220
224
  level: 'warning',
221
225
  text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
222
226
  });
223
- return models.current;
227
+ return { model: models.current, thoughtLevel };
224
228
  }
225
229
  }
226
- async applyThoughtLevel(transport, info) {
227
- const level = info.thoughtLevel;
230
+ /* Sets the selected effort where the session offers it; a model without one runs as it is. */
231
+ async applyThoughtLevel(transport, level) {
228
232
  const effort = this.launch.selection.options.effort;
229
- if (level === null || typeof effort !== 'string' || effort === level.current || !level.values.includes(effort)) {
233
+ const choice = level?.choices.find((entry) => entry.id === effort);
234
+ if (level === null || choice === undefined || effort === level.current) {
230
235
  return;
231
236
  }
232
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: effort }).catch((error) => {
237
+ try {
238
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: choice.id });
239
+ }
240
+ catch (error) {
233
241
  if (!(error instanceof JsonRpcError)) {
234
242
  throw error;
235
243
  }
236
- });
244
+ const current = level.choices.find((entry) => entry.id === level.current)?.name ?? level.current;
245
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} kept its reasoning at ${current} instead of ${choice.name}.` });
246
+ }
237
247
  }
238
248
  promptBlocks(input) {
239
249
  const sendsImages = this.capabilities?.images === true;
@@ -52,17 +52,24 @@ export interface AcpModeState {
52
52
  modes: AcpMode[];
53
53
  configId: string | null;
54
54
  }
55
+ export interface AcpChoice {
56
+ id: string;
57
+ name: string;
58
+ description: string | null;
59
+ }
60
+ export interface AcpThoughtLevel {
61
+ configId: string;
62
+ current: string;
63
+ choices: AcpChoice[];
64
+ }
55
65
  export interface AcpSessionInfo {
56
66
  sessionId: string;
57
67
  agent: AcpAgentInfo | null;
58
68
  models: AcpModelState | null;
59
69
  modes: AcpModeState | null;
60
- thoughtLevel: {
61
- configId: string;
62
- current: string;
63
- values: string[];
64
- } | null;
70
+ thoughtLevel: AcpThoughtLevel | null;
65
71
  }
72
+ export declare function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined;
66
73
  export declare function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo;
67
74
  export declare function sessionIdOf(result: unknown): string | null;
68
75
  export type AcpModeMap = Partial<Record<RuntimeMode, readonly string[]>>;
@@ -75,7 +75,7 @@ const ModeStateSchema = z.looseObject({
75
75
  availableModes: z.array(z.unknown())
76
76
  });
77
77
  const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
78
- const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string() });
78
+ const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish() });
79
79
  const ConfigOptionSchema = z.looseObject({
80
80
  id: z.string(),
81
81
  name: z.string(),
@@ -144,6 +144,18 @@ function selectValues(option) {
144
144
  function selectOption(options, category) {
145
145
  return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
146
146
  }
147
+ function thoughtOption(options) {
148
+ return (selectOption(options, 'thought_level') ??
149
+ options.find((option) => option.type === 'select' && option.id === 'reasoning_effort' && typeof option.currentValue === 'string') ??
150
+ null);
151
+ }
152
+ /* The reasoning effort in a response that carries the session's config options; undefined when it carries none. */
153
+ export function thoughtLevelOf(result) {
154
+ if (!isRecord(result) || !Array.isArray(result.configOptions)) {
155
+ return undefined;
156
+ }
157
+ return sessionState(validItems(ConfigOptionSchema, result.configOptions), null, null).thoughtLevel;
158
+ }
147
159
  export function parseSessionInfo(result, sessionId, agent) {
148
160
  const record = isRecord(result) ? result : {};
149
161
  const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
@@ -152,7 +164,7 @@ export function parseSessionInfo(result, sessionId, agent) {
152
164
  function sessionState(options, modes, models) {
153
165
  const modelOption = selectOption(options, 'model');
154
166
  const modeOption = selectOption(options, 'mode');
155
- const thoughtOption = selectOption(options, 'thought_level');
167
+ const thought = thoughtOption(options);
156
168
  const modeState = ModeStateSchema.safeParse(modes);
157
169
  const modelState = ModelStateSchema.safeParse(models);
158
170
  return {
@@ -186,8 +198,12 @@ function sessionState(options, modes, models) {
186
198
  configId: null
187
199
  }
188
200
  : null,
189
- thoughtLevel: thoughtOption
190
- ? { configId: thoughtOption.id, current: thoughtOption.currentValue, values: selectValues(thoughtOption).map((value) => value.value) }
201
+ thoughtLevel: thought
202
+ ? {
203
+ configId: thought.id,
204
+ current: thought.currentValue,
205
+ choices: selectValues(thought).map((value) => ({ id: value.value, name: value.name, description: value.description ?? null }))
206
+ }
191
207
  : null
192
208
  };
193
209
  }
@@ -1,5 +1,5 @@
1
1
  export { type AcpAgentSpec, type AcpClientInfo, type AcpUsageReport, createAcpBackend } from './acp-backend.ts';
2
- export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpPermissionRequest, AcpSessionInfo, AcpModeMap, AcpTurnUsage } from './acp-protocol.ts';
2
+ export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpPermissionRequest, AcpSessionInfo, AcpModeMap, AcpChoice, AcpThoughtLevel, AcpTurnUsage } from './acp-protocol.ts';
3
3
  export { AttachmentStore } from './attachment-store.ts';
4
4
  export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
5
5
  export { runningInBackground, type BackgroundWork } from './background-work.ts';
@@ -29,11 +29,31 @@ const OWN_USAGE_FILES = new Set(['grok']);
29
29
  function adoptableModels(info) {
30
30
  return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
31
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
+ }
32
52
  export function createAcpProvider(spec) {
33
53
  const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
34
54
  const onSession = (info) => {
35
55
  if (spec.adoptModels !== false && info.models !== null) {
36
- spec.catalog.adopt(adoptableModels(info), info.models.current);
56
+ spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
37
57
  }
38
58
  spec.onSession?.(info);
39
59
  };
@@ -1,4 +1,4 @@
1
- import type { ModelCatalogData, ModelInfo, ModelSelection } from '@adecore/agent-contracts';
1
+ import type { ModelCatalogData, ModelInfo, ModelOptionDescriptor, ModelSelection } from '@adecore/agent-contracts';
2
2
  export type ModelCatalogInput = Omit<ModelCatalogData, 'updatedAt'> & {
3
3
  updatedAt?: string;
4
4
  };
@@ -11,7 +11,7 @@ export declare class ModelCatalog {
11
11
  adopt(models: ReadonlyArray<{
12
12
  slug: string;
13
13
  name: string;
14
- }>, defaultModel: string): boolean;
14
+ }>, defaultModel: string, options?: readonly ModelOptionDescriptor[]): boolean;
15
15
  list(): ModelInfo[];
16
16
  nameOf(slug: string): string;
17
17
  resolveModel(name: string): string | null;
@@ -30,13 +30,16 @@ export class ModelCatalog {
30
30
  /*
31
31
  * Takes the models an agent reported for itself in place of the list, the agent's current model as the
32
32
  * default and its order kept; answers whether the models changed. Every model gets the profile the
33
- * default has now, as an agent names its models but not what each can be asked for.
33
+ * 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.
34
35
  */
35
- adopt(models, defaultModel) {
36
+ adopt(models, defaultModel, options = []) {
36
37
  if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
37
38
  return false;
38
39
  }
39
- const profile = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
40
+ const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
41
+ const replaced = new Set(options.map((option) => option.id));
42
+ const profile = { ...base, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
40
43
  const data = {
41
44
  ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
42
45
  defaultModel,
@@ -7,6 +7,7 @@ export interface FakeAcpOptions {
7
7
  legacyModels?: boolean;
8
8
  closeSession?: boolean;
9
9
  turnUsage?: Record<string, number>;
10
+ reasoningEffort?: 'accept' | 'reject';
10
11
  }
11
12
  export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
12
13
  export declare const fakeAcp: FakeCli;
@@ -7,6 +7,7 @@ export function fakeAcpWith(options) {
7
7
  let sessionId = '';
8
8
  let mode = modes[0] ?? '';
9
9
  let model = 'fake-small';
10
+ let effort = 'low';
10
11
  let serverRequestId = 0;
11
12
  let callCounter = 0;
12
13
  let prompt = null;
@@ -41,11 +42,24 @@ export function fakeAcpWith(options) {
41
42
  { value: 'fake-small', name: 'Fake small' },
42
43
  { value: 'fake-large', name: 'Fake large' }
43
44
  ];
45
+ const effortChoices = [
46
+ { value: 'low', name: 'Low', description: 'Answers quickly' },
47
+ { value: 'high', name: 'High' },
48
+ ...(model === 'fake-large' ? [{ value: 'xhigh', name: 'Extra high' }] : [])
49
+ ];
50
+ const effortOption = options.reasoningEffort === undefined
51
+ ? []
52
+ : [{ id: 'reasoning_effort', name: 'Reasoning effort', type: 'select', currentValue: effort, options: effortChoices }];
44
53
  return {
45
54
  ...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
46
55
  ...(options.legacyModels
47
56
  ? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
48
- : { configOptions: [{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices }] })
57
+ : {
58
+ configOptions: [
59
+ { id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
60
+ ...effortOption
61
+ ]
62
+ })
49
63
  };
50
64
  };
51
65
  const askPermission = (toolCall, onAnswer) => {
@@ -241,6 +255,13 @@ export function fakeAcpWith(options) {
241
255
  if (params.configId === 'model') {
242
256
  model = String(params.value);
243
257
  }
258
+ if (params.configId === 'reasoning_effort') {
259
+ if (options.reasoningEffort === 'reject') {
260
+ refuse(id, -32602, 'Reasoning effort is locked');
261
+ return;
262
+ }
263
+ effort = String(params.value);
264
+ }
244
265
  respond(id, { configOptions: sessionState().configOptions ?? [] });
245
266
  return;
246
267
  case 'session/prompt':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.31.3",
3
+ "version": "0.31.4",
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.3",
85
- "@adecore/intelligent-ui": "0.31.3",
84
+ "@adecore/agent-contracts": "0.31.4",
85
+ "@adecore/intelligent-ui": "0.31.4",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -14,10 +14,12 @@ import {
14
14
  resolveAcpMode,
15
15
  runtimeModeOf,
16
16
  sessionIdOf,
17
+ thoughtLevelOf,
17
18
  type AcpAgentCapabilities,
18
19
  type AcpModeMap,
19
20
  type AcpPermissionRequest,
20
21
  type AcpSessionInfo,
22
+ type AcpThoughtLevel,
21
23
  type AcpTurnUsage
22
24
  } from './acp-protocol.ts';
23
25
  import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
@@ -154,9 +156,9 @@ export class AcpBackend implements ChatBackend {
154
156
  this.capabilities = capabilities;
155
157
  const info = await this.openSession(transport, capabilities);
156
158
  const modeId = await this.applyMode(transport, info);
157
- const model = await this.applyModel(transport, info);
159
+ const { model, thoughtLevel } = await this.applyModel(transport, info);
158
160
  this.model = model;
159
- await this.applyThoughtLevel(transport, info);
161
+ await this.applyThoughtLevel(transport, thoughtLevel);
160
162
  this.spec.onSession?.(info);
161
163
  this.emit({
162
164
  type: 'session',
@@ -263,24 +265,29 @@ export class AcpBackend implements ChatBackend {
263
265
  return resolved.modeId;
264
266
  }
265
267
 
266
- /* Switches to the selected model where the agent offers it; answers the model the session runs. */
267
- private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<string | null> {
268
+ /*
269
+ * Switches to the selected model where the agent offers it; answers the model the session runs and the
270
+ * reasoning effort it offers there, which may change with the model.
271
+ */
272
+ private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<{ model: string | null; thoughtLevel: AcpThoughtLevel | null }> {
268
273
  const models = info.models;
274
+ const thoughtLevel = info.thoughtLevel;
269
275
  const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
270
276
  if (models === null || wanted === null || wanted === models.current) {
271
- return models?.current ?? wanted;
277
+ return { model: models?.current ?? wanted, thoughtLevel };
272
278
  }
273
279
  if (!models.models.some((model) => model.id === wanted)) {
274
280
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
275
- return models.current;
281
+ return { model: models.current, thoughtLevel };
276
282
  }
277
283
  try {
278
284
  if (models.configId === null) {
279
285
  await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
280
- } else {
281
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
286
+ return { model: wanted, thoughtLevel };
282
287
  }
283
- return wanted;
288
+ const result = await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
289
+ const after = thoughtLevelOf(result);
290
+ return { model: wanted, thoughtLevel: after === undefined ? thoughtLevel : after };
284
291
  } catch (error) {
285
292
  if (!(error instanceof JsonRpcError)) {
286
293
  throw error;
@@ -290,21 +297,26 @@ export class AcpBackend implements ChatBackend {
290
297
  level: 'warning',
291
298
  text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
292
299
  });
293
- return models.current;
300
+ return { model: models.current, thoughtLevel };
294
301
  }
295
302
  }
296
303
 
297
- private async applyThoughtLevel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<void> {
298
- const level = info.thoughtLevel;
304
+ /* Sets the selected effort where the session offers it; a model without one runs as it is. */
305
+ private async applyThoughtLevel(transport: JsonRpcTransport, level: AcpThoughtLevel | null): Promise<void> {
299
306
  const effort = this.launch.selection.options.effort;
300
- if (level === null || typeof effort !== 'string' || effort === level.current || !level.values.includes(effort)) {
307
+ const choice = level?.choices.find((entry) => entry.id === effort);
308
+ if (level === null || choice === undefined || effort === level.current) {
301
309
  return;
302
310
  }
303
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: effort }).catch((error: unknown) => {
311
+ try {
312
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: choice.id });
313
+ } catch (error) {
304
314
  if (!(error instanceof JsonRpcError)) {
305
315
  throw error;
306
316
  }
307
- });
317
+ const current = level.choices.find((entry) => entry.id === level.current)?.name ?? level.current;
318
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} kept its reasoning at ${current} instead of ${choice.name}.` });
319
+ }
308
320
  }
309
321
 
310
322
  private promptBlocks(input: TurnInput): unknown[] {
@@ -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() });
110
+ const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish() });
111
111
 
112
112
  const ConfigOptionSchema = z.looseObject({
113
113
  id: z.string(),
@@ -221,14 +221,27 @@ export interface AcpModeState {
221
221
  configId: string | null;
222
222
  }
223
223
 
224
+ export interface AcpChoice {
225
+ id: string;
226
+ name: string;
227
+ description: string | null;
228
+ }
229
+
230
+ /* The reasoning effort a session offers: the config option that sets it, the value it runs at and the values it takes. */
231
+ export interface AcpThoughtLevel {
232
+ configId: string;
233
+ current: string;
234
+ choices: AcpChoice[];
235
+ }
236
+
224
237
  /* What `session/new`, `session/load` or `session/resume` said about the session, for a host that fills its catalog from it. */
225
238
  export interface AcpSessionInfo {
226
239
  sessionId: string;
227
240
  agent: AcpAgentInfo | null;
228
241
  models: AcpModelState | null;
229
242
  modes: AcpModeState | null;
230
- // The reasoning effort a session offers, as a config option with the `thought_level` category.
231
- thoughtLevel: { configId: string; current: string; values: string[] } | null;
243
+ // A config option of the `thought_level` category, or one with the id `reasoning_effort` where an agent sets no category.
244
+ thoughtLevel: AcpThoughtLevel | null;
232
245
  }
233
246
 
234
247
  type ConfigOption = z.infer<typeof ConfigOptionSchema>;
@@ -243,6 +256,22 @@ function selectOption(options: ConfigOption[], category: string): ConfigOption |
243
256
  return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
244
257
  }
245
258
 
259
+ function thoughtOption(options: ConfigOption[]): ConfigOption | null {
260
+ return (
261
+ selectOption(options, 'thought_level') ??
262
+ options.find((option) => option.type === 'select' && option.id === 'reasoning_effort' && typeof option.currentValue === 'string') ??
263
+ null
264
+ );
265
+ }
266
+
267
+ /* The reasoning effort in a response that carries the session's config options; undefined when it carries none. */
268
+ export function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined {
269
+ if (!isRecord(result) || !Array.isArray(result.configOptions)) {
270
+ return undefined;
271
+ }
272
+ return sessionState(validItems(ConfigOptionSchema, result.configOptions), null, null).thoughtLevel;
273
+ }
274
+
246
275
  export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo {
247
276
  const record = isRecord(result) ? result : {};
248
277
  const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
@@ -252,7 +281,7 @@ export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpA
252
281
  function sessionState(options: ConfigOption[], modes: unknown, models: unknown): Omit<AcpSessionInfo, 'sessionId' | 'agent'> {
253
282
  const modelOption = selectOption(options, 'model');
254
283
  const modeOption = selectOption(options, 'mode');
255
- const thoughtOption = selectOption(options, 'thought_level');
284
+ const thought = thoughtOption(options);
256
285
  const modeState = ModeStateSchema.safeParse(modes);
257
286
  const modelState = ModelStateSchema.safeParse(models);
258
287
  return {
@@ -286,8 +315,12 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
286
315
  configId: null
287
316
  }
288
317
  : null,
289
- thoughtLevel: thoughtOption
290
- ? { configId: thoughtOption.id, current: thoughtOption.currentValue as string, values: selectValues(thoughtOption).map((value) => value.value) }
318
+ thoughtLevel: thought
319
+ ? {
320
+ configId: thought.id,
321
+ current: thought.currentValue as string,
322
+ choices: selectValues(thought).map((value) => ({ id: value.value, name: value.name, description: value.description ?? null }))
323
+ }
291
324
  : null
292
325
  };
293
326
  }
package/src/chat/index.ts CHANGED
@@ -9,6 +9,8 @@ export type {
9
9
  AcpPermissionRequest,
10
10
  AcpSessionInfo,
11
11
  AcpModeMap,
12
+ AcpChoice,
13
+ AcpThoughtLevel,
12
14
  AcpTurnUsage
13
15
  } from './acp-protocol.ts';
14
16
  export { AttachmentStore } from './attachment-store.ts';
@@ -1,4 +1,4 @@
1
- import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ModelOptionDescriptor, 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';
@@ -66,11 +66,32 @@ function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: stri
66
66
  return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
67
67
  }
68
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
+
69
90
  export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
70
91
  const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
71
92
  const onSession = (info: AcpSessionInfo): void => {
72
93
  if (spec.adoptModels !== false && info.models !== null) {
73
- spec.catalog.adopt(adoptableModels(info), info.models.current);
94
+ spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
74
95
  }
75
96
  spec.onSession?.(info);
76
97
  };
@@ -1,4 +1,4 @@
1
- import type { ModelCatalogData, ModelCatalogProfile, ModelInfo, ModelSelection } from '@adecore/agent-contracts';
1
+ import type { ModelCatalogData, ModelCatalogProfile, ModelInfo, ModelOptionDescriptor, ModelSelection } from '@adecore/agent-contracts';
2
2
  import manifest from './claude-models.json' with { type: 'json' };
3
3
 
4
4
  // What a host builds a catalog from; one that is never replaced needs no date.
@@ -41,13 +41,16 @@ export class ModelCatalog {
41
41
  /*
42
42
  * Takes the models an agent reported for itself in place of the list, the agent's current model as the
43
43
  * default and its order kept; answers whether the models changed. Every model gets the profile the
44
- * default has now, as an agent names its models but not what each can be asked for.
44
+ * 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.
45
46
  */
46
- adopt(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string): boolean {
47
+ adopt(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string, options: readonly ModelOptionDescriptor[] = []): boolean {
47
48
  if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
48
49
  return false;
49
50
  }
50
- const profile = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
51
+ const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
52
+ const replaced = new Set(options.map((option) => option.id));
53
+ const profile = { ...base, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
51
54
  const data: ModelCatalogInput = {
52
55
  ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
53
56
  defaultModel,
@@ -24,6 +24,9 @@ export interface FakeAcpOptions {
24
24
  closeSession?: boolean;
25
25
  // The `usage` every `session/prompt` response carries; absent reports none.
26
26
  turnUsage?: Record<string, number>;
27
+ // Offers a reasoning effort as the config option `reasoning_effort`, without a category: Low and High, and
28
+ // Extra high on `fake-large`. `reject` refuses every change to it.
29
+ reasoningEffort?: 'accept' | 'reject';
27
30
  }
28
31
 
29
32
  export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
@@ -33,6 +36,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
33
36
  let sessionId = '';
34
37
  let mode = modes[0] ?? '';
35
38
  let model = 'fake-small';
39
+ let effort = 'low';
36
40
  let serverRequestId = 0;
37
41
  let callCounter = 0;
38
42
  let prompt: { rpcId: number | string; cancelled: boolean } | null = null;
@@ -74,11 +78,25 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
74
78
  { value: 'fake-small', name: 'Fake small' },
75
79
  { value: 'fake-large', name: 'Fake large' }
76
80
  ];
81
+ const effortChoices = [
82
+ { value: 'low', name: 'Low', description: 'Answers quickly' },
83
+ { value: 'high', name: 'High' },
84
+ ...(model === 'fake-large' ? [{ value: 'xhigh', name: 'Extra high' }] : [])
85
+ ];
86
+ const effortOption =
87
+ options.reasoningEffort === undefined
88
+ ? []
89
+ : [{ id: 'reasoning_effort', name: 'Reasoning effort', type: 'select', currentValue: effort, options: effortChoices }];
77
90
  return {
78
91
  ...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
79
92
  ...(options.legacyModels
80
93
  ? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
81
- : { configOptions: [{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices }] })
94
+ : {
95
+ configOptions: [
96
+ { id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
97
+ ...effortOption
98
+ ]
99
+ })
82
100
  };
83
101
  };
84
102
 
@@ -277,6 +295,13 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
277
295
  if (params.configId === 'model') {
278
296
  model = String(params.value);
279
297
  }
298
+ if (params.configId === 'reasoning_effort') {
299
+ if (options.reasoningEffort === 'reject') {
300
+ refuse(id, -32602, 'Reasoning effort is locked');
301
+ return;
302
+ }
303
+ effort = String(params.value);
304
+ }
280
305
  respond(id, { configOptions: sessionState().configOptions ?? [] });
281
306
  return;
282
307
  case 'session/prompt':