@adecore/agents 0.31.11 → 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 +262 -23
  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 +308 -26
  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
  }
@@ -332,6 +361,8 @@ function str(value) {
332
361
  return typeof value === 'string' && value !== '' ? value : null;
333
362
  }
334
363
  /* A field of the agent's own input, which no schema promises; the first one that is a non-empty string. */
364
+ // Where agents put the command a call runs; Antigravity names it `CommandLine`.
365
+ const COMMAND_KEYS = ['command', 'cmd', 'CommandLine'];
335
366
  function rawField(raw, ...keys) {
336
367
  if (!isRecord(raw)) {
337
368
  return null;
@@ -375,7 +406,7 @@ export function toolRow(tool) {
375
406
  const raw = isRecord(tool.rawInput) ? tool.rawInput : {};
376
407
  switch (tool.kind) {
377
408
  case 'execute':
378
- return { name: 'Bash', input: { ...raw, description, command: rawField(tool.rawInput, 'command', 'cmd') ?? description } };
409
+ return { name: 'Bash', input: { ...raw, description, command: rawField(tool.rawInput, ...COMMAND_KEYS) ?? description } };
379
410
  case 'read':
380
411
  return { name: 'Read', input: { ...raw, description, ...(path === null ? {} : { file_path: path }) } };
381
412
  case 'search':
@@ -408,11 +439,91 @@ function toolOutput(tool) {
408
439
  }
409
440
  export function acpStopWords(agentName) {
410
441
  return {
411
- maxTokens: `${agentName} stopped: the response reached its token limit.`,
412
- maxTurnRequests: `${agentName} stopped: the turn reached its limit of model requests.`,
413
- 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.`
414
446
  };
415
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
+ }
416
527
  /*
417
528
  * One ACP session's `session/update` stream, mapped onto backend events. A message's chunks become
418
529
  * one text item, cut where the agent switches to a thought or a tool; a tool call becomes a row that
@@ -424,10 +535,14 @@ export class AcpProtocol {
424
535
  words;
425
536
  tools = new Map();
426
537
  permissions = new Map();
427
- // `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.
428
540
  text = null;
429
541
  // Set by a refusal until the next message shows whether it is the refusal line.
430
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;
431
546
  thought = null;
432
547
  counter = 0;
433
548
  costUsd = 0;
@@ -438,11 +553,12 @@ export class AcpProtocol {
438
553
  prompts = [];
439
554
  titles;
440
555
  // `titles` takes the session titles the agent sends as the chat's suggested title.
441
- constructor(generation, modes, words, titles = false) {
556
+ constructor(generation, modes, words, titles = false, now = Date.now) {
442
557
  this.generation = generation;
443
558
  this.modes = modes;
444
559
  this.words = words;
445
560
  this.titles = titles;
561
+ this.now = now;
446
562
  }
447
563
  /* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
448
564
  update(sessionId, params) {
@@ -548,7 +664,7 @@ export class AcpProtocol {
548
664
  description: tool.title === '' ? null : tool.title,
549
665
  canAllowAlways: options.some((option) => option.kind === 'allow_always')
550
666
  });
551
- const command = rawField(tool.rawInput, 'command', 'cmd');
667
+ const command = rawField(tool.rawInput, ...COMMAND_KEYS);
552
668
  const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
553
669
  return { requestId, rpcId, kind: tool.kind, request: asked, events };
554
670
  }
@@ -586,19 +702,121 @@ export class AcpProtocol {
586
702
  this.refusalLine = true;
587
703
  return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
588
704
  }
589
- /* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
590
- cancelPermissions() {
591
- const cancelled = [...this.permissions].map(([requestId, pending]) => ({
592
- requestId,
593
- rpcId: pending.rpcId,
594
- 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
595
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
+ ];
596
810
  this.permissions.clear();
811
+ this.questions.clear();
597
812
  return cancelled;
598
813
  }
599
814
  hasPermission(requestId) {
600
815
  return this.permissions.has(requestId);
601
816
  }
817
+ hasQuestion(requestId) {
818
+ return this.questions.has(requestId);
819
+ }
602
820
  /*
603
821
  * The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
604
822
  * (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
@@ -611,6 +829,15 @@ export class AcpProtocol {
611
829
  this.refusalLine = false;
612
830
  const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
613
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
+ }
614
841
  switch (reason) {
615
842
  case 'cancelled':
616
843
  events.push({ type: 'turn.done', ...base, state: 'aborted' });
@@ -629,15 +856,22 @@ export class AcpProtocol {
629
856
  }
630
857
  return events;
631
858
  }
632
- /* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
633
- 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) {
634
861
  this.refusalLine = false;
635
862
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
636
- return [
637
- ...this.closeText(),
638
- ...this.closeThought(),
639
- error === null ? { type: 'turn.done', ...base, state: 'aborted' } : { type: 'turn.done', ...base, state: 'error', error }
640
- ];
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;
641
875
  }
642
876
  messageChunk(update) {
643
877
  const chunk = ContentChunkSchema.safeParse(update);
@@ -650,12 +884,13 @@ export class AcpProtocol {
650
884
  events.push(...this.closeText());
651
885
  }
652
886
  if (this.text === null) {
653
- 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 };
654
889
  this.refusalLine = false;
655
890
  }
656
891
  const text = this.text;
657
892
  text.text += chunk.data.content.text;
658
- if (text.held && !REFUSAL_LINE.startsWith(text.text)) {
893
+ if (text.held && !withheld(text.text, text.afterRefusal)) {
659
894
  text.held = false;
660
895
  events.push({ type: 'text.delta', ref: text.ref, text: text.text });
661
896
  }
@@ -680,7 +915,11 @@ export class AcpProtocol {
680
915
  closeText() {
681
916
  const text = this.text;
682
917
  this.text = null;
683
- 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()) };
684
923
  return [];
685
924
  }
686
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;