@adecore/agents 0.31.9 → 0.31.10

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.
@@ -19,6 +19,7 @@ export interface AcpAgentSpec {
19
19
  onUsage?(usage: AcpUsageReport): void;
20
20
  onContextReport?(): void;
21
21
  onCommands?(commands: string[]): void;
22
+ adoptTitles?: boolean;
22
23
  autoAllow?(request: AcpPermissionRequest): boolean;
23
24
  }
24
25
  export interface AcpUsageReport extends AcpTurnUsage {
@@ -49,7 +49,7 @@ export class AcpBackend {
49
49
  this.host = host;
50
50
  this.spec = spec;
51
51
  this.client = spec.client ?? DEFAULT_ACP_CLIENT;
52
- this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name));
52
+ this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name), spec.adoptTitles === true);
53
53
  }
54
54
  get running() {
55
55
  return this.transport !== null;
@@ -340,7 +340,7 @@ export class AcpBackend {
340
340
  if (this.transport === transport && this.prompt === turn) {
341
341
  this.prompt = null;
342
342
  this.report(result);
343
- this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
343
+ this.emitAll(this.protocol.finish(result, turn.promptId, turn.cancelled));
344
344
  }
345
345
  })
346
346
  .catch((error) => {
@@ -387,7 +387,11 @@ export class AcpBackend {
387
387
  if (!answer) {
388
388
  return false;
389
389
  }
390
- return this.respond(requestId, answer.rpcId, answer.result);
390
+ if (!this.respond(requestId, answer.rpcId, answer.result)) {
391
+ return false;
392
+ }
393
+ this.emitAll(answer.events);
394
+ return true;
391
395
  }
392
396
  /* ACP asks no questions of its own; an agent asks in its message and the next prompt answers. */
393
397
  respondQuestion() {
@@ -99,6 +99,7 @@ interface ToolState {
99
99
  text: string[];
100
100
  shown: string | null;
101
101
  settled: boolean;
102
+ refused: boolean;
102
103
  }
103
104
  export declare function toolRow(tool: Pick<ToolState, 'title' | 'name' | 'kind' | 'rawInput' | 'locations' | 'diffs'>): {
104
105
  name: string;
@@ -130,12 +131,18 @@ export declare class AcpProtocol {
130
131
  private readonly tools;
131
132
  private readonly permissions;
132
133
  private text;
134
+ private refusalLine;
133
135
  private thought;
134
136
  private counter;
135
137
  private costUsd;
138
+ private standardUsage;
139
+ private vendorTokens;
136
140
  private readonly prompts;
137
- constructor(generation: number, modes: AcpModeMap, words: AcpStopWords);
141
+ private readonly titles;
142
+ constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles?: boolean);
138
143
  update(sessionId: string, params: unknown): BackendEvent[];
144
+ private mapUpdate;
145
+ private vendorUsage;
139
146
  prompted(text: string): void;
140
147
  private restatesPrompt;
141
148
  modeEvent(modeId: string): BackendEvent;
@@ -143,6 +150,7 @@ export declare class AcpProtocol {
143
150
  answer(requestId: string, decision: ApprovalDecision): {
144
151
  rpcId: number | string;
145
152
  result: unknown;
153
+ events: BackendEvent[];
146
154
  } | null;
147
155
  cancelPermissions(): Array<{
148
156
  requestId: string;
@@ -150,7 +158,7 @@ export declare class AcpProtocol {
150
158
  result: unknown;
151
159
  }>;
152
160
  hasPermission(requestId: string): boolean;
153
- finish(result: unknown, promptId: string | undefined): BackendEvent[];
161
+ finish(result: unknown, promptId: string | undefined, cancelled?: boolean): BackendEvent[];
154
162
  abort(promptId: string | undefined, error: string | null): BackendEvent[];
155
163
  private messageChunk;
156
164
  private thoughtChunk;
@@ -96,6 +96,14 @@ function contextWindowOf(meta) {
96
96
  const tokens = isRecord(meta) ? meta.totalContextTokens : undefined;
97
97
  return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
98
98
  }
99
+ /*
100
+ * The context tokens an agent counts outside the protocol, in `_meta.totalTokens`: Grok CLI 1.0.62 sends no `usage_update`
101
+ * and puts the session's count there, on every `session/update` notification and on the `session/prompt` response.
102
+ */
103
+ function vendorContextTokens(meta) {
104
+ const tokens = isRecord(meta) ? meta.totalTokens : undefined;
105
+ return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
106
+ }
99
107
  const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
100
108
  const InitializeResponseSchema = z.looseObject({
101
109
  protocolVersion: z.number(),
@@ -174,6 +182,9 @@ function sessionState(options, modes, models) {
174
182
  const thought = thoughtOption(options);
175
183
  const modeState = ModeStateSchema.safeParse(modes);
176
184
  const modelState = ModelStateSchema.safeParse(models);
185
+ const entries = modelState.success ? validItems(ModelEntrySchema, modelState.data.availableModels) : [];
186
+ // Grok CLI 1.0.62 offers its models as a config option too, but names their windows only in the older field.
187
+ const windows = new Map(entries.map((model) => [model.modelId, contextWindowOf(model._meta)]));
177
188
  return {
178
189
  models: modelOption
179
190
  ? {
@@ -182,14 +193,14 @@ function sessionState(options, modes, models) {
182
193
  id: value.value,
183
194
  name: value.name,
184
195
  description: null,
185
- contextWindow: contextWindowOf(value._meta)
196
+ contextWindow: contextWindowOf(value._meta) ?? windows.get(value.value) ?? null
186
197
  })),
187
198
  configId: modelOption.id
188
199
  }
189
200
  : modelState.success
190
201
  ? {
191
202
  current: modelState.data.currentModelId,
192
- models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
203
+ models: entries.map((model) => ({
193
204
  id: model.modelId,
194
205
  name: model.name,
195
206
  description: model.description ?? null,
@@ -312,6 +323,8 @@ const KIND_NAMES = {
312
323
  };
313
324
  // A session's title comes from its opening prompts; later ones are not kept.
314
325
  const KEPT_PROMPTS = 3;
326
+ // What Copilot CLI 1.0.101 writes as a message of its own after a call a person turned down; the thread shows the refusal already.
327
+ const REFUSAL_LINE = 'Info: Operation cancelled by user';
315
328
  function foldSpace(text) {
316
329
  return text.replace(/\s+/g, ' ').trim();
317
330
  }
@@ -388,7 +401,7 @@ function toolOutput(tool) {
388
401
  return tool.rawOutput;
389
402
  }
390
403
  if (isRecord(tool.rawOutput)) {
391
- const text = rawField(tool.rawOutput, 'output', 'stdout', 'content', 'result', 'error');
404
+ const text = rawField(tool.rawOutput, 'output', 'stdout', 'content', 'result', 'error', 'message');
392
405
  return text ?? JSON.stringify(tool.rawOutput);
393
406
  }
394
407
  return null;
@@ -411,16 +424,25 @@ export class AcpProtocol {
411
424
  words;
412
425
  tools = new Map();
413
426
  permissions = new Map();
427
+ // `held` while the message may still be the refusal line, whose chunks the thread has not heard yet.
414
428
  text = null;
429
+ // Set by a refusal until the next message shows whether it is the refusal line.
430
+ refusalLine = false;
415
431
  thought = null;
416
432
  counter = 0;
417
433
  costUsd = 0;
434
+ // Set by the first `usage_update`; from then on a count outside the protocol is never read.
435
+ standardUsage = false;
436
+ vendorTokens = null;
418
437
  // The opening prompts of the process, whitespace folded, which an agent may hand back as the session's title.
419
438
  prompts = [];
420
- constructor(generation, modes, words) {
439
+ titles;
440
+ // `titles` takes the session titles the agent sends as the chat's suggested title.
441
+ constructor(generation, modes, words, titles = false) {
421
442
  this.generation = generation;
422
443
  this.modes = modes;
423
444
  this.words = words;
445
+ this.titles = titles;
424
446
  }
425
447
  /* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
426
448
  update(sessionId, params) {
@@ -428,7 +450,9 @@ export class AcpProtocol {
428
450
  if (!notification.success || notification.data.sessionId !== sessionId) {
429
451
  return [];
430
452
  }
431
- const update = notification.data.update;
453
+ return [...this.mapUpdate(notification.data.update), ...this.vendorUsage(notification.data._meta)];
454
+ }
455
+ mapUpdate(update) {
432
456
  switch (update.sessionUpdate) {
433
457
  case 'agent_message_chunk':
434
458
  return this.messageChunk(update);
@@ -458,6 +482,7 @@ export class AcpProtocol {
458
482
  if (!usage.success) {
459
483
  return [];
460
484
  }
485
+ this.standardUsage = true;
461
486
  const cost = usage.data.cost?.currency === 'USD' ? usage.data.cost.amount : undefined;
462
487
  if (cost !== undefined) {
463
488
  this.costUsd = cost;
@@ -465,6 +490,9 @@ export class AcpProtocol {
465
490
  return [{ type: 'usage', contextTokens: usage.data.used, contextWindow: usage.data.size, ...(cost === undefined ? {} : { costUsd: cost }) }];
466
491
  }
467
492
  case 'session_info_update': {
493
+ if (!this.titles) {
494
+ return [];
495
+ }
468
496
  const title = cleanTitle(update.title);
469
497
  return title === null || this.restatesPrompt(title) ? [] : [{ type: 'title', title }];
470
498
  }
@@ -472,6 +500,15 @@ export class AcpProtocol {
472
500
  return [];
473
501
  }
474
502
  }
503
+ /* The fallback for an agent that sends no `usage_update`: its own count of the context, once each time it changes. */
504
+ vendorUsage(meta) {
505
+ const tokens = this.standardUsage ? null : vendorContextTokens(meta);
506
+ if (tokens === null || tokens === this.vendorTokens) {
507
+ return [];
508
+ }
509
+ this.vendorTokens = tokens;
510
+ return [{ type: 'usage', contextTokens: tokens }];
511
+ }
475
512
  /* Notes a prompt sent to the agent, so a title that only repeats its start is not taken for a name. */
476
513
  prompted(text) {
477
514
  if (this.prompts.length < KEPT_PROMPTS) {
@@ -501,7 +538,7 @@ export class AcpProtocol {
501
538
  const tool = this.tools.get(request.data.toolCall.toolCallId);
502
539
  const row = toolRow(tool);
503
540
  const options = request.data.options;
504
- this.permissions.set(requestId, { rpcId, options });
541
+ this.permissions.set(requestId, { rpcId, toolCallId: request.data.toolCall.toolCallId, options });
505
542
  events.push({
506
543
  type: 'approval.requested',
507
544
  requestId,
@@ -518,7 +555,7 @@ export class AcpProtocol {
518
555
  /*
519
556
  * The answer to a permission request: allow once, reject, or allow always only when the agent offered it.
520
557
  * A decision the agent offered no option for falls to the narrower one, and a refusal with no reject option
521
- * at all is a cancelled outcome.
558
+ * at all is a cancelled outcome. A refusal draws the call's row again without its diff.
522
559
  */
523
560
  answer(requestId, decision) {
524
561
  const pending = this.permissions.get(requestId);
@@ -540,7 +577,14 @@ export class AcpProtocol {
540
577
  : decision === 'allow'
541
578
  ? byKind('allow_once', 'allow_always')
542
579
  : byKind('reject_once', 'reject_always');
543
- return { rpcId: pending.rpcId, result: { outcome: optionId === null ? { outcome: 'cancelled' } : { outcome: 'selected', optionId } } };
580
+ const result = { outcome: optionId === null ? { outcome: 'cancelled' } : { outcome: 'selected', optionId } };
581
+ const tool = this.tools.get(pending.toolCallId);
582
+ if (decision !== 'deny' || tool === undefined) {
583
+ return { rpcId: pending.rpcId, result, events: [] };
584
+ }
585
+ tool.refused = true;
586
+ this.refusalLine = true;
587
+ return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
544
588
  }
545
589
  /* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
546
590
  cancelPermissions() {
@@ -555,11 +599,17 @@ export class AcpProtocol {
555
599
  hasPermission(requestId) {
556
600
  return this.permissions.has(requestId);
557
601
  }
558
- /* The end of a turn, from the `stopReason` of its `session/prompt` response. */
559
- finish(result, promptId) {
602
+ /*
603
+ * The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
604
+ * (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
605
+ * that ends its turn the way any answer does.
606
+ */
607
+ finish(result, promptId, cancelled = false) {
560
608
  const parsed = PromptResponseSchema.safeParse(result);
561
- const reason = parsed.success ? parsed.data.stopReason : 'end_turn';
562
- const events = [...this.closeText(), ...this.closeThought()];
609
+ const stopReason = parsed.success ? parsed.data.stopReason : 'end_turn';
610
+ const reason = cancelled ? 'cancelled' : stopReason === 'cancelled' ? 'end_turn' : stopReason;
611
+ this.refusalLine = false;
612
+ const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
563
613
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
564
614
  switch (reason) {
565
615
  case 'cancelled':
@@ -581,6 +631,7 @@ export class AcpProtocol {
581
631
  }
582
632
  /* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
583
633
  abort(promptId, error) {
634
+ this.refusalLine = false;
584
635
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
585
636
  return [
586
637
  ...this.closeText(),
@@ -599,10 +650,18 @@ export class AcpProtocol {
599
650
  events.push(...this.closeText());
600
651
  }
601
652
  if (this.text === null) {
602
- this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '' };
653
+ this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '', held: this.refusalLine };
654
+ this.refusalLine = false;
655
+ }
656
+ const text = this.text;
657
+ text.text += chunk.data.content.text;
658
+ if (text.held && !REFUSAL_LINE.startsWith(text.text)) {
659
+ text.held = false;
660
+ events.push({ type: 'text.delta', ref: text.ref, text: text.text });
661
+ }
662
+ else if (!text.held) {
663
+ events.push({ type: 'text.delta', ref: text.ref, text: chunk.data.content.text });
603
664
  }
604
- this.text.text += chunk.data.content.text;
605
- events.push({ type: 'text.delta', ref: this.text.ref, text: chunk.data.content.text });
606
665
  return events;
607
666
  }
608
667
  thoughtChunk(update) {
@@ -621,7 +680,10 @@ export class AcpProtocol {
621
680
  closeText() {
622
681
  const text = this.text;
623
682
  this.text = null;
624
- return text === null ? [] : [{ type: 'text.done', ref: text.ref, text: text.text }];
683
+ if (text === null || (text.held && text.text === REFUSAL_LINE)) {
684
+ return [];
685
+ }
686
+ return [{ type: 'text.done', ref: text.ref, text: text.text }];
625
687
  }
626
688
  closeThought() {
627
689
  const thought = this.thought;
@@ -642,7 +704,8 @@ export class AcpProtocol {
642
704
  diffs: [],
643
705
  text: [],
644
706
  shown: null,
645
- settled: false
707
+ settled: false,
708
+ refused: false
646
709
  };
647
710
  tool.title = call.title ?? tool.title;
648
711
  tool.name = call.name ?? tool.name;
@@ -659,7 +722,7 @@ export class AcpProtocol {
659
722
  }
660
723
  this.tools.set(call.toolCallId, tool);
661
724
  const events = [];
662
- const row = toolRow(tool);
725
+ const row = toolRow(tool.refused ? { ...tool, diffs: [] } : tool);
663
726
  const shown = JSON.stringify(row);
664
727
  if (shown !== tool.shown) {
665
728
  tool.shown = shown;
@@ -1708,6 +1708,17 @@ export class ChatSession {
1708
1708
  this.refuseStop(event.taskId, event.message);
1709
1709
  return;
1710
1710
  }
1711
+ /*
1712
+ * A CLI that counts its tokens but names no window, in a chat that has none yet: the catalog may have learned the
1713
+ * model's window since the chat began, as an ACP agent's catalog does from the first session that reports its models.
1714
+ */
1715
+ if (event.type === 'usage' && event.contextWindow === undefined && event.contextTokens !== undefined && this.thread.info.usage.contextWindow === null) {
1716
+ const contextWindow = this.options.provider.catalog.contextWindowFor(this.launchedSelection ?? this.thread.info.selection);
1717
+ if (contextWindow !== null) {
1718
+ this.emit(this.projector.project(generation, { ...event, contextWindow }));
1719
+ return;
1720
+ }
1721
+ }
1711
1722
  /*
1712
1723
  * A CLI reports the window it was started on. A pick made since waits for the restart the next
1713
1724
  * send does, so its report may not put the old size back under a meter that already reads the new one.