@adecore/agents 0.31.12 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/chat/acp-backend.d.ts +4 -2
  2. package/dist/chat/acp-backend.js +50 -11
  3. package/dist/chat/acp-protocol.d.ts +29 -4
  4. package/dist/chat/acp-protocol.js +258 -21
  5. package/dist/chat/backend.d.ts +3 -0
  6. package/dist/chat/chat-core.js +6 -3
  7. package/dist/chat/chat-session.js +5 -2
  8. package/dist/chat/claude-backend.js +4 -4
  9. package/dist/chat/claude-protocol.d.ts +4 -1
  10. package/dist/chat/claude-protocol.js +46 -2
  11. package/dist/chat/codex-backend.js +4 -4
  12. package/dist/chat/fixtures/antigravity-1.4.1-question.json +69 -0
  13. package/dist/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
  14. package/dist/chat/fixtures/grok-1.0.62-limits.json +66 -0
  15. package/dist/chat/fixtures/grok-1.0.62-question.json +99 -0
  16. package/dist/chat/json-rpc-transport.js +2 -2
  17. package/dist/chat/projector.js +22 -10
  18. package/dist/providers/accounts/service.js +4 -0
  19. package/dist/providers/claude-provider.js +2 -1
  20. package/dist/providers/detect.d.ts +1 -1
  21. package/dist/providers/detect.js +6 -3
  22. package/dist/providers/registry.d.ts +7 -0
  23. package/dist/providers/registry.js +64 -5
  24. package/dist/skills.d.ts +9 -1
  25. package/dist/skills.js +178 -20
  26. package/dist/testing/fake-acp-capture.d.ts +1 -0
  27. package/dist/testing/fake-acp-capture.js +4 -3
  28. package/dist/usage/accounts.js +3 -2
  29. package/package.json +3 -3
  30. package/src/chat/acp-backend.ts +56 -11
  31. package/src/chat/acp-protocol.ts +303 -24
  32. package/src/chat/backend.ts +5 -1
  33. package/src/chat/chat-core.ts +6 -3
  34. package/src/chat/chat-session.ts +5 -2
  35. package/src/chat/claude-backend.ts +4 -4
  36. package/src/chat/claude-protocol.ts +48 -2
  37. package/src/chat/codex-backend.ts +4 -4
  38. package/src/chat/fixtures/antigravity-1.4.1-question.json +69 -0
  39. package/src/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
  40. package/src/chat/fixtures/grok-1.0.62-limits.json +66 -0
  41. package/src/chat/fixtures/grok-1.0.62-question.json +99 -0
  42. package/src/chat/json-rpc-transport.ts +2 -2
  43. package/src/chat/projector.ts +24 -12
  44. package/src/providers/accounts/service.ts +4 -0
  45. package/src/providers/claude-provider.ts +2 -1
  46. package/src/providers/detect.ts +7 -3
  47. package/src/providers/registry.ts +71 -5
  48. package/src/skills.ts +198 -19
  49. package/src/testing/fake-acp-capture.ts +8 -4
  50. package/src/usage/accounts.ts +3 -2
@@ -1,4 +1,4 @@
1
- import { type ModelSelection } from '@adecore/agent-contracts';
1
+ import { type AgentKind, type ModelSelection } from '@adecore/agent-contracts';
2
2
  import { type AcpModeMap, type AcpPermissionRequest, type AcpSessionInfo, type AcpTurnUsage } from './acp-protocol.ts';
3
3
  import type { ApprovalDecision, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
4
4
  export interface AcpClientInfo {
@@ -9,6 +9,7 @@ export interface AcpClientInfo {
9
9
  export declare const DEFAULT_ACP_CLIENT: AcpClientInfo;
10
10
  export interface AcpAgentSpec {
11
11
  name: string;
12
+ kind?: AgentKind;
12
13
  env?: Record<string, string>;
13
14
  modes?: AcpModeMap;
14
15
  authMethod?: string | null;
@@ -60,13 +61,14 @@ export declare class AcpBackend implements ChatBackend {
60
61
  compact(): void;
61
62
  interrupt(): void;
62
63
  respondApproval(requestId: string, decision: ApprovalDecision): boolean;
63
- respondQuestion(): boolean;
64
+ respondQuestion(requestId: string, answers: Record<string, string>): boolean;
64
65
  declineRequest(requestId: string): boolean;
65
66
  stop(): void;
66
67
  dispose(): Promise<void>;
67
68
  private respond;
68
69
  private fail;
69
70
  private handleFrame;
71
+ private ask;
70
72
  private holdEarly;
71
73
  private hostAllows;
72
74
  private handleExit;
@@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
  import { attachmentImageMime } from '@adecore/agent-contracts';
5
5
  import { errorText } from '../error-text.js';
6
6
  import { MODE_ORDER } from '../modes.js';
7
- import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf, sessionUpdateOf, thoughtLevelOf } from './acp-protocol.js';
7
+ import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpErrorLimit, acpStopWords, autoAllows, GROK_QUESTION_METHODS, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf, sessionUpdateOf, thoughtLevelOf } from './acp-protocol.js';
8
8
  import { attachmentNote } from './input.js';
9
9
  import { JsonRpcError, JsonRpcTransport } from './json-rpc-transport.js';
10
10
  export const DEFAULT_ACP_CLIENT = { name: 'agents', title: 'Agents', version: '0.1.0' };
@@ -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), spec.adoptTitles === true);
52
+ this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name), spec.adoptTitles === true, launch.now);
53
53
  }
54
54
  get running() {
55
55
  return this.transport !== null;
@@ -328,7 +328,7 @@ export class AcpBackend {
328
328
  sendTurn(input) {
329
329
  const transport = this.transport;
330
330
  if (!transport) {
331
- this.emit({ type: 'failed', message: `session/prompt failed: ${this.spec.name} is not running` });
331
+ this.emit({ type: 'failed', message: `${this.spec.name} is not running` });
332
332
  return;
333
333
  }
334
334
  const prompt = this.promptBlocks(input);
@@ -347,7 +347,8 @@ export class AcpBackend {
347
347
  // A process that went has its exit tell the thread; anything else ends this turn alone.
348
348
  if (this.transport === transport && transport.alive && this.prompt === turn) {
349
349
  this.prompt = null;
350
- this.emitAll(this.protocol.abort(turn.promptId, turn.cancelled ? null : errorText(error)));
350
+ const limit = error instanceof JsonRpcError ? acpErrorLimit(error.code, error.data, this.spec.kind ?? null) : null;
351
+ this.emitAll(this.protocol.abort(turn.promptId, turn.cancelled ? null : errorText(error), limit));
351
352
  }
352
353
  });
353
354
  }
@@ -357,7 +358,7 @@ export class AcpBackend {
357
358
  this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
358
359
  return;
359
360
  }
360
- this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
361
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot compact its context.` });
361
362
  this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
362
363
  }
363
364
  /* Cancels the turn: every permission request still open is answered as cancelled, and the turn ends on the prompt's response. */
@@ -372,7 +373,7 @@ export class AcpBackend {
372
373
  }
373
374
  this.prompt.cancelled = true;
374
375
  try {
375
- for (const cancelled of this.protocol.cancelPermissions()) {
376
+ for (const cancelled of this.protocol.cancelRequests()) {
376
377
  transport.respond(cancelled.rpcId, cancelled.result);
377
378
  this.emit({ type: 'request.withdrawn', requestId: cancelled.requestId });
378
379
  }
@@ -393,11 +394,22 @@ export class AcpBackend {
393
394
  this.emitAll(answer.events);
394
395
  return true;
395
396
  }
396
- /* ACP asks no questions of its own; an agent asks in its message and the next prompt answers. */
397
- respondQuestion() {
398
- return false;
397
+ respondQuestion(requestId, answers) {
398
+ const answer = this.protocol.answerQuestion(requestId, answers);
399
+ if (!answer) {
400
+ return false;
401
+ }
402
+ if (!this.respond(requestId, answer.rpcId, answer.result)) {
403
+ return false;
404
+ }
405
+ this.emitAll(answer.events);
406
+ return true;
399
407
  }
400
408
  declineRequest(requestId) {
409
+ if (this.protocol.hasQuestion(requestId)) {
410
+ const cancelled = this.protocol.cancelQuestion(requestId);
411
+ return cancelled !== null && this.respond(requestId, cancelled.rpcId, cancelled.result);
412
+ }
401
413
  return this.protocol.hasPermission(requestId) && this.respondApproval(requestId, 'deny');
402
414
  }
403
415
  stop() {
@@ -441,10 +453,25 @@ export class AcpBackend {
441
453
  if (id === null) {
442
454
  return;
443
455
  }
456
+ if (typeof frame.method === 'string' && GROK_QUESTION_METHODS.includes(frame.method)) {
457
+ const question = this.protocol.question(id, this.sessionId, frame.params);
458
+ if (question === null) {
459
+ transport.respondError(id, -32602, 'Not a question of this session');
460
+ return;
461
+ }
462
+ this.ask(transport, question);
463
+ return;
464
+ }
444
465
  if (frame.method !== 'session/request_permission') {
445
466
  transport.respondError(id, -32601, `${nameOf(this.client)} does not handle ${String(frame.method)}`);
446
467
  return;
447
468
  }
469
+ // Before any runtime mode answers it: a question is never the agent's to allow.
470
+ const interaction = this.protocol.interaction(id, this.sessionId, frame.params);
471
+ if (interaction !== null) {
472
+ this.ask(transport, interaction);
473
+ return;
474
+ }
448
475
  const permission = this.protocol.permission(id, this.sessionId, frame.params);
449
476
  if (permission === null) {
450
477
  transport.respondError(id, -32602, 'Not a permission request of this session');
@@ -452,7 +479,7 @@ export class AcpBackend {
452
479
  }
453
480
  if (this.prompt?.cancelled) {
454
481
  this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
455
- const answer = this.protocol.cancelPermissions().find((cancelled) => cancelled.requestId === permission.requestId);
482
+ const answer = this.protocol.cancelRequests().find((cancelled) => cancelled.requestId === permission.requestId);
456
483
  if (answer) {
457
484
  transport.respond(answer.rpcId, answer.result);
458
485
  }
@@ -468,6 +495,18 @@ export class AcpBackend {
468
495
  }
469
496
  this.emitAll(permission.events);
470
497
  }
498
+ /* Puts a question to the person, or turns it down at once when the turn is being cancelled. */
499
+ ask(transport, question) {
500
+ if (this.prompt?.cancelled) {
501
+ this.emitAll(question.events.filter((event) => event.type !== 'question.requested'));
502
+ const cancelled = this.protocol.cancelQuestion(question.requestId);
503
+ if (cancelled) {
504
+ transport.respond(cancelled.rpcId, cancelled.result);
505
+ }
506
+ return;
507
+ }
508
+ this.emitAll(question.events);
509
+ }
471
510
  /* Holds an update until the session is known; of a history a load replays, only the commands, since the thread has the rest. */
472
511
  holdEarly(params) {
473
512
  const update = sessionUpdateOf(params);
@@ -489,7 +528,7 @@ export class AcpBackend {
489
528
  }
490
529
  this.transport = null;
491
530
  this.prompt = null;
492
- this.protocol.cancelPermissions();
531
+ this.protocol.cancelRequests();
493
532
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
494
533
  }
495
534
  /* A cancelled turn still spent what it reports. A host that fails to keep it costs the record, never the end of the turn. */
@@ -1,4 +1,4 @@
1
- import { type ChatFileChange, type RuntimeMode } from '@adecore/agent-contracts';
1
+ import { type AgentKind, type ChatFileChange, type ChatTurnLimit, type RuntimeMode } from '@adecore/agent-contracts';
2
2
  import { z } from 'zod';
3
3
  import type { ApprovalDecision, BackendEvent } from './backend.ts';
4
4
  export declare const ACP_PROTOCOL_VERSION = 1;
@@ -9,6 +9,7 @@ declare const DiffSchema: z.ZodObject<{
9
9
  oldText: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
10
  newText: z.ZodString;
11
11
  }, z.core.$loose>;
12
+ export declare const GROK_QUESTION_METHODS: readonly string[];
12
13
  export interface AcpTurnUsage {
13
14
  input: number;
14
15
  cacheRead: number;
@@ -118,20 +119,31 @@ export interface AcpPermission {
118
119
  request: AcpPermissionRequest;
119
120
  events: BackendEvent[];
120
121
  }
122
+ export interface AcpQuestion {
123
+ requestId: string;
124
+ rpcId: number | string;
125
+ events: BackendEvent[];
126
+ }
121
127
  export interface AcpStopWords {
122
128
  maxTokens: string;
123
129
  maxTurnRequests: string;
124
130
  refusal: string;
131
+ ownAnswer: string;
125
132
  }
126
133
  export declare function acpStopWords(agentName: string): AcpStopWords;
134
+ export declare function acpErrorLimit(code: number | null, data: unknown, kind: AgentKind | null): ChatTurnLimit | null;
135
+ export declare function quotaResetOf(text: string, now: number): number | null;
127
136
  export declare class AcpProtocol {
128
137
  private readonly generation;
129
138
  private readonly modes;
130
139
  private readonly words;
131
140
  private readonly tools;
132
141
  private readonly permissions;
142
+ private readonly questions;
133
143
  private text;
134
144
  private refusalLine;
145
+ private quota;
146
+ private readonly now;
135
147
  private thought;
136
148
  private counter;
137
149
  private costUsd;
@@ -139,7 +151,7 @@ export declare class AcpProtocol {
139
151
  private vendorTokens;
140
152
  private readonly prompts;
141
153
  private readonly titles;
142
- constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles?: boolean);
154
+ constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles?: boolean, now?: () => number);
143
155
  update(sessionId: string, params: unknown): BackendEvent[];
144
156
  private mapUpdate;
145
157
  private vendorUsage;
@@ -152,14 +164,27 @@ export declare class AcpProtocol {
152
164
  result: unknown;
153
165
  events: BackendEvent[];
154
166
  } | null;
155
- cancelPermissions(): Array<{
167
+ question(rpcId: number | string, sessionId: string, params: unknown): AcpQuestion | null;
168
+ interaction(rpcId: number | string, sessionId: string, params: unknown): AcpQuestion | null;
169
+ private ask;
170
+ answerQuestion(requestId: string, answers: Record<string, string>): {
171
+ rpcId: number | string;
172
+ result: unknown;
173
+ events: BackendEvent[];
174
+ } | null;
175
+ cancelQuestion(requestId: string): {
176
+ rpcId: number | string;
177
+ result: unknown;
178
+ } | null;
179
+ cancelRequests(): Array<{
156
180
  requestId: string;
157
181
  rpcId: number | string;
158
182
  result: unknown;
159
183
  }>;
160
184
  hasPermission(requestId: string): boolean;
185
+ hasQuestion(requestId: string): boolean;
161
186
  finish(result: unknown, promptId: string | undefined, cancelled?: boolean): BackendEvent[];
162
- abort(promptId: string | undefined, error: string | null): BackendEvent[];
187
+ abort(promptId: string | undefined, error: string | null, limit?: ChatTurnLimit | null): BackendEvent[];
163
188
  private messageChunk;
164
189
  private thoughtChunk;
165
190
  private closeText;
@@ -39,6 +39,26 @@ const PermissionRequestSchema = z.looseObject({
39
39
  toolCall: ToolCallSchema,
40
40
  options: z.array(PermissionOptionSchema)
41
41
  });
42
+ /*
43
+ * Grok's questions to the person (`_x.ai/ask_user_question` in xai-org/grok-build, `ask_user_question/types.rs`). The
44
+ * answer goes back keyed by the question's text, each a list of the labels picked.
45
+ */
46
+ export const GROK_QUESTION_METHODS = ['_x.ai/ask_user_question', 'x.ai/ask_user_question'];
47
+ const GrokQuestionSchema = z.looseObject({
48
+ question: z.string(),
49
+ header: z.string().nullish(),
50
+ options: z.array(z.unknown()).nullish(),
51
+ multiSelect: z.boolean().nullish()
52
+ });
53
+ const GrokChoiceSchema = z.looseObject({ label: z.string(), description: z.string().nullish() });
54
+ const GrokQuestionRequestSchema = z.looseObject({ sessionId: z.string(), questions: z.array(z.unknown()) });
55
+ // The label Grok takes for an answer in a person's own words, which ride along as the question's `notes`.
56
+ const GROK_OWN_ANSWER = 'Other';
57
+ /*
58
+ * The prefix Antigravity's ACP server 1.4.1 gives the tool call of a question it asks over `session/request_permission`,
59
+ * one request per question with an option per choice (`ExternalOnInteractionHook`).
60
+ */
61
+ const INTERACTION_PREFIX = 'interaction_';
42
62
  const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }) });
43
63
  const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
44
64
  const tokenCount = z.number().int().nonnegative();
@@ -325,6 +345,15 @@ const KIND_NAMES = {
325
345
  const KEPT_PROMPTS = 3;
326
346
  // 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
347
  const REFUSAL_LINE = 'Info: Operation cancelled by user';
348
+ /*
349
+ * What Antigravity's ACP server 1.4.1 writes in place of an answer once the account's quota ran out (`quota_errors.py`).
350
+ * Its turn still ends with `end_turn`, so this text is all that tells the limit apart.
351
+ */
352
+ const QUOTA_HEAD = 'Usage Limit Reached\n\nYou have reached your current quota for this period.';
353
+ /* Whether a message may still turn out to be one the thread does not show: the refusal line after a refusal, or a limit message. */
354
+ function withheld(text, afterRefusal) {
355
+ return (afterRefusal && REFUSAL_LINE.startsWith(text)) || QUOTA_HEAD.startsWith(text) || text.startsWith(QUOTA_HEAD);
356
+ }
328
357
  function foldSpace(text) {
329
358
  return text.replace(/\s+/g, ' ').trim();
330
359
  }
@@ -410,11 +439,91 @@ function toolOutput(tool) {
410
439
  }
411
440
  export function acpStopWords(agentName) {
412
441
  return {
413
- maxTokens: `${agentName} stopped: the response reached its token limit.`,
414
- maxTurnRequests: `${agentName} stopped: the turn reached its limit of model requests.`,
415
- refusal: `${agentName} refused to continue this turn.`
442
+ maxTokens: `${agentName} stopped because the reply hit its length limit.`,
443
+ maxTurnRequests: `${agentName} stopped because this turn hit its step limit.`,
444
+ refusal: `${agentName} refused to continue this turn.`,
445
+ ownAnswer: `${agentName} accepts only the answers it offered, so it did not get yours.`
416
446
  };
417
447
  }
448
+ // Grok's code for a request its API turned down with a 429, after its own retry (`RATE_LIMITED_ERROR_CODE` in xai-org/grok-build).
449
+ const GROK_RATE_LIMITED = -32003;
450
+ // What that 429 says when an account on the free tier spent its usage; any other 429 is a rate limit.
451
+ const GROK_FREE_USAGE = 'subscription:free-usage-exhausted';
452
+ // The stream error types of an overloaded model, as Grok passes them on in its error text.
453
+ const OVERLOADED_TYPES = /\b(overloaded_error|service_unavailable_error)\b/;
454
+ /*
455
+ * The limit a failed `session/prompt` names in its JSON-RPC error; null for any other failure. ACP 1 has no code
456
+ * for a limit, so this reads Grok 1.0.62's: -32003 after a 429, which only Grok means that way, and -32603 with the
457
+ * HTTP status or the stream error of an overloaded model once its own retries ran out. Neither says when a limit lifts.
458
+ */
459
+ export function acpErrorLimit(code, data, kind) {
460
+ const text = typeof data === 'string' ? data : isRecord(data) && typeof data.message === 'string' ? data.message : '';
461
+ if (code === GROK_RATE_LIMITED && kind === 'grok') {
462
+ return text.includes(GROK_FREE_USAGE) ? { kind: 'usage' } : { kind: 'overload' };
463
+ }
464
+ if (code !== -32603) {
465
+ return null;
466
+ }
467
+ const status = isRecord(data) && typeof data.http_status === 'number' ? data.http_status : null;
468
+ const overloaded = status === 529 || OVERLOADED_TYPES.test(text) || (status !== null && status >= 500 && /overloaded/i.test(text));
469
+ return overloaded ? { kind: 'overload' } : null;
470
+ }
471
+ function quotaLimit(quota) {
472
+ return { kind: 'usage', ...(quota.resetsAt === null ? {} : { resetsAt: quota.resetsAt }) };
473
+ }
474
+ const UNIT_MS = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 };
475
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
476
+ /*
477
+ * When the quota in an Antigravity limit message lifts, rounded up: the server writes the two largest units of the
478
+ * delay and drops the rest (`will reset in 4 days, 23 hours.`), or the time to the minute (`will reset on Oct 21,
479
+ * 2026 14:05 UTC.`). Null when it names neither.
480
+ */
481
+ export function quotaResetOf(text, now) {
482
+ const relative = /will reset in ([^.]+)\./.exec(text);
483
+ if (relative) {
484
+ let total = 0;
485
+ let smallest = Infinity;
486
+ for (const part of relative[1].split(',')) {
487
+ const amount = /^(\d+) (second|minute|hour|day)s?$/.exec(part.trim());
488
+ if (!amount) {
489
+ return null;
490
+ }
491
+ const unit = UNIT_MS[amount[2]];
492
+ total += Number(amount[1]) * unit;
493
+ smallest = Math.min(smallest, unit);
494
+ }
495
+ return now + total + smallest;
496
+ }
497
+ const absolute = /will reset on ([A-Z][a-z]{2}) (\d{1,2}), (\d{4}) (\d{2}):(\d{2}) UTC\./.exec(text);
498
+ const month = absolute ? MONTHS.indexOf(absolute[1]) : -1;
499
+ if (!absolute || month === -1) {
500
+ return null;
501
+ }
502
+ return Date.UTC(Number(absolute[3]), month, Number(absolute[2]), Number(absolute[4]), Number(absolute[5])) + UNIT_MS.minute;
503
+ }
504
+ /* The labels a person picked, out of the one answer a question card gives (choices joined with `, `); null for words of their own. */
505
+ function pickedLabels(answer, labels, multiSelect) {
506
+ if (labels.includes(answer)) {
507
+ return [answer];
508
+ }
509
+ if (!multiSelect) {
510
+ return null;
511
+ }
512
+ // A label may hold `, ` itself, so the pieces join up again until they make one.
513
+ const picked = [];
514
+ let part = '';
515
+ for (const piece of answer.split(', ')) {
516
+ part = part === '' ? piece : `${part}, ${piece}`;
517
+ if (labels.includes(part)) {
518
+ picked.push(part);
519
+ part = '';
520
+ }
521
+ }
522
+ return part === '' && picked.length > 0 ? picked : null;
523
+ }
524
+ function cancelledAnswer(pending) {
525
+ return pending.shape === 'grok' ? { outcome: 'cancelled' } : { outcome: { outcome: 'cancelled' } };
526
+ }
418
527
  /*
419
528
  * One ACP session's `session/update` stream, mapped onto backend events. A message's chunks become
420
529
  * one text item, cut where the agent switches to a thought or a tool; a tool call becomes a row that
@@ -426,10 +535,14 @@ export class AcpProtocol {
426
535
  words;
427
536
  tools = new Map();
428
537
  permissions = new Map();
429
- // `held` while the message may still be the refusal line, whose chunks the thread has not heard yet.
538
+ questions = new Map();
539
+ // `held` while the message may still be the refusal line or a limit message, whose chunks the thread has not heard yet.
430
540
  text = null;
431
541
  // Set by a refusal until the next message shows whether it is the refusal line.
432
542
  refusalLine = false;
543
+ // The limit message of this turn, held back from the thread: it ends the turn as a limit, or shows as text when the turn ends otherwise.
544
+ quota = null;
545
+ now;
433
546
  thought = null;
434
547
  counter = 0;
435
548
  costUsd = 0;
@@ -440,11 +553,12 @@ export class AcpProtocol {
440
553
  prompts = [];
441
554
  titles;
442
555
  // `titles` takes the session titles the agent sends as the chat's suggested title.
443
- constructor(generation, modes, words, titles = false) {
556
+ constructor(generation, modes, words, titles = false, now = Date.now) {
444
557
  this.generation = generation;
445
558
  this.modes = modes;
446
559
  this.words = words;
447
560
  this.titles = titles;
561
+ this.now = now;
448
562
  }
449
563
  /* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
450
564
  update(sessionId, params) {
@@ -588,19 +702,121 @@ export class AcpProtocol {
588
702
  this.refusalLine = true;
589
703
  return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
590
704
  }
591
- /* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
592
- cancelPermissions() {
593
- const cancelled = [...this.permissions].map(([requestId, pending]) => ({
594
- requestId,
595
- rpcId: pending.rpcId,
596
- result: { outcome: { outcome: 'cancelled' } }
705
+ /* A Grok `ask_user_question` request; null when it does not parse or asks nothing, which the caller answers with an error. */
706
+ question(rpcId, sessionId, params) {
707
+ const request = GrokQuestionRequestSchema.safeParse(params);
708
+ if (!request.success || request.data.sessionId !== sessionId) {
709
+ return null;
710
+ }
711
+ const questions = validItems(GrokQuestionSchema, request.data.questions).map((question, index) => ({
712
+ id: String(index),
713
+ header: question.header ?? '',
714
+ question: question.question,
715
+ choices: validItems(GrokChoiceSchema, question.options).map((choice) => ({ label: choice.label, description: choice.description ?? '' })),
716
+ multiSelect: question.multiSelect === true
597
717
  }));
718
+ if (questions.length === 0) {
719
+ return null;
720
+ }
721
+ return this.ask(rpcId, { shape: 'grok', rpcId, questions });
722
+ }
723
+ /*
724
+ * A `session/request_permission` that is a question rather than a permission, as Antigravity asks one: one choice of
725
+ * several, never a call to run. Null for every other permission request.
726
+ */
727
+ interaction(rpcId, sessionId, params) {
728
+ const request = PermissionRequestSchema.safeParse(params);
729
+ if (!request.success || request.data.sessionId !== sessionId) {
730
+ return null;
731
+ }
732
+ const call = request.data.toolCall;
733
+ const raw = call.rawInput;
734
+ const bare = raw === undefined || raw === null || (isRecord(raw) && Object.keys(raw).length === 0);
735
+ if (!call.toolCallId.startsWith(INTERACTION_PREFIX) || !bare || call.kind || request.data.options.length === 0) {
736
+ return null;
737
+ }
738
+ const question = {
739
+ id: '0',
740
+ header: '',
741
+ question: call.title ?? '',
742
+ choices: request.data.options.map((option) => ({ label: option.name, description: '' })),
743
+ multiSelect: false
744
+ };
745
+ return this.ask(rpcId, { shape: 'interaction', rpcId, question, options: request.data.options });
746
+ }
747
+ ask(rpcId, pending) {
748
+ const requestId = `acp-${this.generation}-${String(rpcId)}`;
749
+ this.questions.set(requestId, pending);
750
+ const questions = pending.shape === 'grok' ? pending.questions : [pending.question];
751
+ return { requestId, rpcId, events: [...this.closeText(), ...this.closeThought(), { type: 'question.requested', requestId, questions }] };
752
+ }
753
+ /* The answer to a question in the agent's own shape, from the card's answers keyed by question id. */
754
+ answerQuestion(requestId, answers) {
755
+ const pending = this.questions.get(requestId);
756
+ if (!pending) {
757
+ return null;
758
+ }
759
+ this.questions.delete(requestId);
760
+ if (pending.shape === 'interaction') {
761
+ const given = (answers[pending.question.id] ?? '').trim();
762
+ const option = pending.options.find((candidate) => candidate.name === given) ?? pending.options.find((candidate) => candidate.optionId === given);
763
+ if (option === undefined) {
764
+ return {
765
+ rpcId: pending.rpcId,
766
+ result: { outcome: { outcome: 'cancelled' } },
767
+ events: [{ type: 'note', level: 'warning', text: this.words.ownAnswer }]
768
+ };
769
+ }
770
+ return { rpcId: pending.rpcId, result: { outcome: { outcome: 'selected', optionId: option.optionId } }, events: [] };
771
+ }
772
+ const picked = {};
773
+ const notes = {};
774
+ for (const question of pending.questions) {
775
+ const given = answers[question.id];
776
+ if (given === undefined) {
777
+ continue;
778
+ }
779
+ const labels = pickedLabels(given, question.choices.map((choice) => choice.label), question.multiSelect);
780
+ picked[question.question] = labels ?? [GROK_OWN_ANSWER];
781
+ if (labels === null) {
782
+ notes[question.question] = { notes: given };
783
+ }
784
+ }
785
+ return {
786
+ rpcId: pending.rpcId,
787
+ result: { outcome: 'accepted', answers: picked, ...(Object.keys(notes).length === 0 ? {} : { annotations: notes }) },
788
+ events: []
789
+ };
790
+ }
791
+ /* Turns one question down, in the shape its agent takes a refusal in. */
792
+ cancelQuestion(requestId) {
793
+ const pending = this.questions.get(requestId);
794
+ if (!pending) {
795
+ return null;
796
+ }
797
+ this.questions.delete(requestId);
798
+ return { rpcId: pending.rpcId, result: cancelledAnswer(pending) };
799
+ }
800
+ /* Every permission request and question still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
801
+ cancelRequests() {
802
+ const cancelled = [
803
+ ...[...this.permissions].map(([requestId, pending]) => ({
804
+ requestId,
805
+ rpcId: pending.rpcId,
806
+ result: { outcome: { outcome: 'cancelled' } }
807
+ })),
808
+ ...[...this.questions].map(([requestId, pending]) => ({ requestId, rpcId: pending.rpcId, result: cancelledAnswer(pending) }))
809
+ ];
598
810
  this.permissions.clear();
811
+ this.questions.clear();
599
812
  return cancelled;
600
813
  }
601
814
  hasPermission(requestId) {
602
815
  return this.permissions.has(requestId);
603
816
  }
817
+ hasQuestion(requestId) {
818
+ return this.questions.has(requestId);
819
+ }
604
820
  /*
605
821
  * The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
606
822
  * (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
@@ -613,6 +829,15 @@ export class AcpProtocol {
613
829
  this.refusalLine = false;
614
830
  const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
615
831
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
832
+ const quota = this.quota;
833
+ this.quota = null;
834
+ if (quota !== null && reason !== 'cancelled') {
835
+ events.push({ type: 'turn.done', ...base, state: 'error', error: quota.text, limit: quotaLimit(quota) });
836
+ return events;
837
+ }
838
+ if (quota !== null) {
839
+ events.push({ type: 'text.done', ref: quota.ref, text: quota.text });
840
+ }
616
841
  switch (reason) {
617
842
  case 'cancelled':
618
843
  events.push({ type: 'turn.done', ...base, state: 'aborted' });
@@ -631,15 +856,22 @@ export class AcpProtocol {
631
856
  }
632
857
  return events;
633
858
  }
634
- /* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
635
- abort(promptId, error) {
859
+ /* A turn that ended without a `stopReason`: the request failed, on a limit its error names, or was cancelled before the agent said so. */
860
+ abort(promptId, error, limit = null) {
636
861
  this.refusalLine = false;
637
862
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
638
- return [
639
- ...this.closeText(),
640
- ...this.closeThought(),
641
- error === null ? { type: 'turn.done', ...base, state: 'aborted' } : { type: 'turn.done', ...base, state: 'error', error }
642
- ];
863
+ const events = [...this.closeText(), ...this.closeThought()];
864
+ const quota = this.quota;
865
+ this.quota = null;
866
+ if (quota !== null) {
867
+ events.push({ type: 'text.done', ref: quota.ref, text: quota.text });
868
+ }
869
+ // A failed request after the limit message still ran into that limit, so it pauses as one when the error names none.
870
+ const named = limit ?? (quota === null ? null : quotaLimit(quota));
871
+ events.push(error === null
872
+ ? { type: 'turn.done', ...base, state: 'aborted' }
873
+ : { type: 'turn.done', ...base, state: 'error', error, ...(named === null ? {} : { limit: named }) });
874
+ return events;
643
875
  }
644
876
  messageChunk(update) {
645
877
  const chunk = ContentChunkSchema.safeParse(update);
@@ -652,12 +884,13 @@ export class AcpProtocol {
652
884
  events.push(...this.closeText());
653
885
  }
654
886
  if (this.text === null) {
655
- this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '', held: this.refusalLine };
887
+ const ref = messageId === null ? `message-${++this.counter}` : `message-${messageId}`;
888
+ this.text = { ref, messageId, text: '', held: true, afterRefusal: this.refusalLine };
656
889
  this.refusalLine = false;
657
890
  }
658
891
  const text = this.text;
659
892
  text.text += chunk.data.content.text;
660
- if (text.held && !REFUSAL_LINE.startsWith(text.text)) {
893
+ if (text.held && !withheld(text.text, text.afterRefusal)) {
661
894
  text.held = false;
662
895
  events.push({ type: 'text.delta', ref: text.ref, text: text.text });
663
896
  }
@@ -682,7 +915,11 @@ export class AcpProtocol {
682
915
  closeText() {
683
916
  const text = this.text;
684
917
  this.text = null;
685
- if (text === null || (text.held && text.text === REFUSAL_LINE)) {
918
+ if (text === null || (text.held && text.afterRefusal && text.text === REFUSAL_LINE)) {
919
+ return [];
920
+ }
921
+ if (text.held && text.text.startsWith(QUOTA_HEAD)) {
922
+ this.quota = { ref: text.ref, text: text.text.trim(), resetsAt: quotaResetOf(text.text, this.now()) };
686
923
  return [];
687
924
  }
688
925
  return [{ type: 'text.done', ref: text.ref, text: text.text }];
@@ -15,6 +15,7 @@ export interface BackendLaunch {
15
15
  resumeNote: string | null;
16
16
  resumeInstructions?: string | null;
17
17
  goal?: ChatGoal;
18
+ cacheTtlMs?: number;
18
19
  folders?: readonly string[];
19
20
  projectConfig?: boolean;
20
21
  spawn?: SpawnChatProcess;
@@ -167,6 +168,8 @@ export type BackendEvent = {
167
168
  contextTokens?: number;
168
169
  contextWindow?: number;
169
170
  costUsd?: number;
171
+ cacheTtlMs?: number;
172
+ cacheExpiresAt?: number;
170
173
  } | {
171
174
  type: 'limits';
172
175
  update: LimitsUpdate;
@@ -255,10 +255,12 @@ export class ChatCore {
255
255
  await this.hooks.admit?.(payload.chatId, cwd);
256
256
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
257
257
  // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
258
- const account = stored ? stored.info.account : storedAccount(kind, payload.account ?? this.composerPreferences.for(kind).account);
258
+ // The pick is checked as named, before the default account loses its id, so one that is turned off is refused too.
259
+ const requested = payload.account ?? this.composerPreferences.for(kind).account;
259
260
  if (!stored) {
260
- this.requireAccount(kind, account);
261
+ this.requireAccount(kind, requested);
261
262
  }
263
+ const account = stored ? stored.info.account : storedAccount(kind, requested);
262
264
  const narrowed = (mode) => (this.hooks.runtimeModeFor ? this.hooks.runtimeModeFor(payload.chatId, mode) : mode);
263
265
  // Asked again for a chat on disk, since the host may have listed it since.
264
266
  const hidden = this.hooks.hiddenFor?.(payload.chatId) ? { hidden: true } : {};
@@ -369,7 +371,8 @@ export class ChatCore {
369
371
  if (to === from) {
370
372
  return;
371
373
  }
372
- this.requireAccount(kind, to);
374
+ // As named, so a switch to the default account is refused while it is turned off.
375
+ this.requireAccount(kind, requested);
373
376
  const spoke = session.info.agentSessionId !== null || session.info.usage.turns > 0;
374
377
  if (spoke && !this.canContinue(kind, from, to)) {
375
378
  const label = this.accountLabel(kind, to);