@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.
- package/dist/chat/acp-backend.d.ts +4 -2
- package/dist/chat/acp-backend.js +50 -11
- package/dist/chat/acp-protocol.d.ts +29 -4
- package/dist/chat/acp-protocol.js +262 -23
- package/dist/chat/backend.d.ts +3 -0
- package/dist/chat/chat-core.js +6 -3
- package/dist/chat/chat-session.js +5 -2
- package/dist/chat/claude-backend.js +4 -4
- package/dist/chat/claude-protocol.d.ts +4 -1
- package/dist/chat/claude-protocol.js +46 -2
- package/dist/chat/codex-backend.js +4 -4
- package/dist/chat/fixtures/antigravity-1.4.1-question.json +69 -0
- package/dist/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
- package/dist/chat/fixtures/grok-1.0.62-limits.json +66 -0
- package/dist/chat/fixtures/grok-1.0.62-question.json +99 -0
- package/dist/chat/json-rpc-transport.js +2 -2
- package/dist/chat/projector.js +22 -10
- package/dist/providers/accounts/service.js +4 -0
- package/dist/providers/claude-provider.js +2 -1
- package/dist/providers/detect.d.ts +1 -1
- package/dist/providers/detect.js +6 -3
- package/dist/providers/registry.d.ts +7 -0
- package/dist/providers/registry.js +64 -5
- package/dist/skills.d.ts +9 -1
- package/dist/skills.js +178 -20
- package/dist/testing/fake-acp-capture.d.ts +1 -0
- package/dist/testing/fake-acp-capture.js +4 -3
- package/dist/usage/accounts.js +3 -2
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +56 -11
- package/src/chat/acp-protocol.ts +308 -26
- package/src/chat/backend.ts +5 -1
- package/src/chat/chat-core.ts +6 -3
- package/src/chat/chat-session.ts +5 -2
- package/src/chat/claude-backend.ts +4 -4
- package/src/chat/claude-protocol.ts +48 -2
- package/src/chat/codex-backend.ts +4 -4
- package/src/chat/fixtures/antigravity-1.4.1-question.json +69 -0
- package/src/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
- package/src/chat/fixtures/grok-1.0.62-limits.json +66 -0
- package/src/chat/fixtures/grok-1.0.62-question.json +99 -0
- package/src/chat/json-rpc-transport.ts +2 -2
- package/src/chat/projector.ts +24 -12
- package/src/providers/accounts/service.ts +4 -0
- package/src/providers/claude-provider.ts +2 -1
- package/src/providers/detect.ts +7 -3
- package/src/providers/registry.ts +71 -5
- package/src/skills.ts +198 -19
- package/src/testing/fake-acp-capture.ts +8 -4
- package/src/usage/accounts.ts +3 -2
package/src/chat/acp-protocol.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isRecord, type ChatFileChange, type RuntimeMode } from '@adecore/agent-contracts';
|
|
1
|
+
import { isRecord, type AgentKind, type ChatFileChange, type ChatQuestion, type ChatTurnLimit, type RuntimeMode } from '@adecore/agent-contracts';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { MODE_ORDER } from '../modes.ts';
|
|
4
4
|
import { cleanTitle } from '../title-file.ts';
|
|
@@ -55,6 +55,32 @@ const PermissionRequestSchema = z.looseObject({
|
|
|
55
55
|
options: z.array(PermissionOptionSchema)
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
/*
|
|
59
|
+
* Grok's questions to the person (`_x.ai/ask_user_question` in xai-org/grok-build, `ask_user_question/types.rs`). The
|
|
60
|
+
* answer goes back keyed by the question's text, each a list of the labels picked.
|
|
61
|
+
*/
|
|
62
|
+
export const GROK_QUESTION_METHODS: readonly string[] = ['_x.ai/ask_user_question', 'x.ai/ask_user_question'];
|
|
63
|
+
|
|
64
|
+
const GrokQuestionSchema = z.looseObject({
|
|
65
|
+
question: z.string(),
|
|
66
|
+
header: z.string().nullish(),
|
|
67
|
+
options: z.array(z.unknown()).nullish(),
|
|
68
|
+
multiSelect: z.boolean().nullish()
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const GrokChoiceSchema = z.looseObject({ label: z.string(), description: z.string().nullish() });
|
|
72
|
+
|
|
73
|
+
const GrokQuestionRequestSchema = z.looseObject({ sessionId: z.string(), questions: z.array(z.unknown()) });
|
|
74
|
+
|
|
75
|
+
// The label Grok takes for an answer in a person's own words, which ride along as the question's `notes`.
|
|
76
|
+
const GROK_OWN_ANSWER = 'Other';
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* The prefix Antigravity's ACP server 1.4.1 gives the tool call of a question it asks over `session/request_permission`,
|
|
80
|
+
* one request per question with an option per choice (`ExternalOnInteractionHook`).
|
|
81
|
+
*/
|
|
82
|
+
const INTERACTION_PREFIX = 'interaction_';
|
|
83
|
+
|
|
58
84
|
const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }) });
|
|
59
85
|
|
|
60
86
|
const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
|
|
@@ -484,6 +510,17 @@ const KEPT_PROMPTS = 3;
|
|
|
484
510
|
// 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.
|
|
485
511
|
const REFUSAL_LINE = 'Info: Operation cancelled by user';
|
|
486
512
|
|
|
513
|
+
/*
|
|
514
|
+
* What Antigravity's ACP server 1.4.1 writes in place of an answer once the account's quota ran out (`quota_errors.py`).
|
|
515
|
+
* Its turn still ends with `end_turn`, so this text is all that tells the limit apart.
|
|
516
|
+
*/
|
|
517
|
+
const QUOTA_HEAD = 'Usage Limit Reached\n\nYou have reached your current quota for this period.';
|
|
518
|
+
|
|
519
|
+
/* 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. */
|
|
520
|
+
function withheld(text: string, afterRefusal: boolean): boolean {
|
|
521
|
+
return (afterRefusal && REFUSAL_LINE.startsWith(text)) || QUOTA_HEAD.startsWith(text) || text.startsWith(QUOTA_HEAD);
|
|
522
|
+
}
|
|
523
|
+
|
|
487
524
|
function foldSpace(text: string): string {
|
|
488
525
|
return text.replace(/\s+/g, ' ').trim();
|
|
489
526
|
}
|
|
@@ -493,6 +530,9 @@ function str(value: unknown): string | null {
|
|
|
493
530
|
}
|
|
494
531
|
|
|
495
532
|
/* A field of the agent's own input, which no schema promises; the first one that is a non-empty string. */
|
|
533
|
+
// Where agents put the command a call runs; Antigravity names it `CommandLine`.
|
|
534
|
+
const COMMAND_KEYS = ['command', 'cmd', 'CommandLine'];
|
|
535
|
+
|
|
496
536
|
function rawField(raw: unknown, ...keys: string[]): string | null {
|
|
497
537
|
if (!isRecord(raw)) {
|
|
498
538
|
return null;
|
|
@@ -541,7 +581,7 @@ export function toolRow(tool: Pick<ToolState, 'title' | 'name' | 'kind' | 'rawIn
|
|
|
541
581
|
const raw = isRecord(tool.rawInput) ? tool.rawInput : {};
|
|
542
582
|
switch (tool.kind) {
|
|
543
583
|
case 'execute':
|
|
544
|
-
return { name: 'Bash', input: { ...raw, description, command: rawField(tool.rawInput,
|
|
584
|
+
return { name: 'Bash', input: { ...raw, description, command: rawField(tool.rawInput, ...COMMAND_KEYS) ?? description } };
|
|
545
585
|
case 'read':
|
|
546
586
|
return { name: 'Read', input: { ...raw, description, ...(path === null ? {} : { file_path: path }) } };
|
|
547
587
|
case 'search':
|
|
@@ -593,21 +633,123 @@ export interface AcpPermission {
|
|
|
593
633
|
events: BackendEvent[];
|
|
594
634
|
}
|
|
595
635
|
|
|
596
|
-
/*
|
|
636
|
+
/* A question an agent asks the person, as the backend acts on it. */
|
|
637
|
+
export interface AcpQuestion {
|
|
638
|
+
requestId: string;
|
|
639
|
+
rpcId: number | string;
|
|
640
|
+
events: BackendEvent[];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/* The words a turn that ended for another reason than its end leaves in the thread, and a question it could not answer. */
|
|
597
644
|
export interface AcpStopWords {
|
|
598
645
|
maxTokens: string;
|
|
599
646
|
maxTurnRequests: string;
|
|
600
647
|
refusal: string;
|
|
648
|
+
ownAnswer: string;
|
|
601
649
|
}
|
|
602
650
|
|
|
603
651
|
export function acpStopWords(agentName: string): AcpStopWords {
|
|
604
652
|
return {
|
|
605
|
-
maxTokens: `${agentName} stopped
|
|
606
|
-
maxTurnRequests: `${agentName} stopped
|
|
607
|
-
refusal: `${agentName} refused to continue this turn
|
|
653
|
+
maxTokens: `${agentName} stopped because the reply hit its length limit.`,
|
|
654
|
+
maxTurnRequests: `${agentName} stopped because this turn hit its step limit.`,
|
|
655
|
+
refusal: `${agentName} refused to continue this turn.`,
|
|
656
|
+
ownAnswer: `${agentName} accepts only the answers it offered, so it did not get yours.`
|
|
608
657
|
};
|
|
609
658
|
}
|
|
610
659
|
|
|
660
|
+
// 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).
|
|
661
|
+
const GROK_RATE_LIMITED = -32003;
|
|
662
|
+
|
|
663
|
+
// What that 429 says when an account on the free tier spent its usage; any other 429 is a rate limit.
|
|
664
|
+
const GROK_FREE_USAGE = 'subscription:free-usage-exhausted';
|
|
665
|
+
|
|
666
|
+
// The stream error types of an overloaded model, as Grok passes them on in its error text.
|
|
667
|
+
const OVERLOADED_TYPES = /\b(overloaded_error|service_unavailable_error)\b/;
|
|
668
|
+
|
|
669
|
+
/*
|
|
670
|
+
* The limit a failed `session/prompt` names in its JSON-RPC error; null for any other failure. ACP 1 has no code
|
|
671
|
+
* 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
|
|
672
|
+
* HTTP status or the stream error of an overloaded model once its own retries ran out. Neither says when a limit lifts.
|
|
673
|
+
*/
|
|
674
|
+
export function acpErrorLimit(code: number | null, data: unknown, kind: AgentKind | null): ChatTurnLimit | null {
|
|
675
|
+
const text = typeof data === 'string' ? data : isRecord(data) && typeof data.message === 'string' ? data.message : '';
|
|
676
|
+
if (code === GROK_RATE_LIMITED && kind === 'grok') {
|
|
677
|
+
return text.includes(GROK_FREE_USAGE) ? { kind: 'usage' } : { kind: 'overload' };
|
|
678
|
+
}
|
|
679
|
+
if (code !== -32603) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
const status = isRecord(data) && typeof data.http_status === 'number' ? data.http_status : null;
|
|
683
|
+
const overloaded = status === 529 || OVERLOADED_TYPES.test(text) || (status !== null && status >= 500 && /overloaded/i.test(text));
|
|
684
|
+
return overloaded ? { kind: 'overload' } : null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function quotaLimit(quota: { resetsAt: number | null }): ChatTurnLimit {
|
|
688
|
+
return { kind: 'usage', ...(quota.resetsAt === null ? {} : { resetsAt: quota.resetsAt }) };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const UNIT_MS: Record<string, number> = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 };
|
|
692
|
+
|
|
693
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
694
|
+
|
|
695
|
+
/*
|
|
696
|
+
* When the quota in an Antigravity limit message lifts, rounded up: the server writes the two largest units of the
|
|
697
|
+
* delay and drops the rest (`will reset in 4 days, 23 hours.`), or the time to the minute (`will reset on Oct 21,
|
|
698
|
+
* 2026 14:05 UTC.`). Null when it names neither.
|
|
699
|
+
*/
|
|
700
|
+
export function quotaResetOf(text: string, now: number): number | null {
|
|
701
|
+
const relative = /will reset in ([^.]+)\./.exec(text);
|
|
702
|
+
if (relative) {
|
|
703
|
+
let total = 0;
|
|
704
|
+
let smallest = Infinity;
|
|
705
|
+
for (const part of relative[1]!.split(',')) {
|
|
706
|
+
const amount = /^(\d+) (second|minute|hour|day)s?$/.exec(part.trim());
|
|
707
|
+
if (!amount) {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
const unit = UNIT_MS[amount[2]!]!;
|
|
711
|
+
total += Number(amount[1]) * unit;
|
|
712
|
+
smallest = Math.min(smallest, unit);
|
|
713
|
+
}
|
|
714
|
+
return now + total + smallest;
|
|
715
|
+
}
|
|
716
|
+
const absolute = /will reset on ([A-Z][a-z]{2}) (\d{1,2}), (\d{4}) (\d{2}):(\d{2}) UTC\./.exec(text);
|
|
717
|
+
const month = absolute ? MONTHS.indexOf(absolute[1]!) : -1;
|
|
718
|
+
if (!absolute || month === -1) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
return Date.UTC(Number(absolute[3]), month, Number(absolute[2]), Number(absolute[4]), Number(absolute[5])) + UNIT_MS.minute!;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* The labels a person picked, out of the one answer a question card gives (choices joined with `, `); null for words of their own. */
|
|
725
|
+
function pickedLabels(answer: string, labels: readonly string[], multiSelect: boolean): string[] | null {
|
|
726
|
+
if (labels.includes(answer)) {
|
|
727
|
+
return [answer];
|
|
728
|
+
}
|
|
729
|
+
if (!multiSelect) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
// A label may hold `, ` itself, so the pieces join up again until they make one.
|
|
733
|
+
const picked: string[] = [];
|
|
734
|
+
let part = '';
|
|
735
|
+
for (const piece of answer.split(', ')) {
|
|
736
|
+
part = part === '' ? piece : `${part}, ${piece}`;
|
|
737
|
+
if (labels.includes(part)) {
|
|
738
|
+
picked.push(part);
|
|
739
|
+
part = '';
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return part === '' && picked.length > 0 ? picked : null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
type PendingQuestion =
|
|
746
|
+
| { shape: 'grok'; rpcId: number | string; questions: ChatQuestion[] }
|
|
747
|
+
| { shape: 'interaction'; rpcId: number | string; question: ChatQuestion; options: Array<z.infer<typeof PermissionOptionSchema>> };
|
|
748
|
+
|
|
749
|
+
function cancelledAnswer(pending: PendingQuestion): unknown {
|
|
750
|
+
return pending.shape === 'grok' ? { outcome: 'cancelled' } : { outcome: { outcome: 'cancelled' } };
|
|
751
|
+
}
|
|
752
|
+
|
|
611
753
|
/*
|
|
612
754
|
* One ACP session's `session/update` stream, mapped onto backend events. A message's chunks become
|
|
613
755
|
* one text item, cut where the agent switches to a thought or a tool; a tool call becomes a row that
|
|
@@ -619,10 +761,14 @@ export class AcpProtocol {
|
|
|
619
761
|
private readonly words: AcpStopWords;
|
|
620
762
|
private readonly tools = new Map<string, ToolState>();
|
|
621
763
|
private readonly permissions = new Map<string, { rpcId: number | string; toolCallId: string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
|
|
622
|
-
|
|
623
|
-
|
|
764
|
+
private readonly questions = new Map<string, PendingQuestion>();
|
|
765
|
+
// `held` while the message may still be the refusal line or a limit message, whose chunks the thread has not heard yet.
|
|
766
|
+
private text: { ref: string; messageId: string | null; text: string; held: boolean; afterRefusal: boolean } | null = null;
|
|
624
767
|
// Set by a refusal until the next message shows whether it is the refusal line.
|
|
625
768
|
private refusalLine = false;
|
|
769
|
+
// 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.
|
|
770
|
+
private quota: { ref: string; text: string; resetsAt: number | null } | null = null;
|
|
771
|
+
private readonly now: () => number;
|
|
626
772
|
private thought: { ref: string; text: string } | null = null;
|
|
627
773
|
private counter = 0;
|
|
628
774
|
private costUsd = 0;
|
|
@@ -635,11 +781,12 @@ export class AcpProtocol {
|
|
|
635
781
|
private readonly titles: boolean;
|
|
636
782
|
|
|
637
783
|
// `titles` takes the session titles the agent sends as the chat's suggested title.
|
|
638
|
-
constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false) {
|
|
784
|
+
constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false, now: () => number = Date.now) {
|
|
639
785
|
this.generation = generation;
|
|
640
786
|
this.modes = modes;
|
|
641
787
|
this.words = words;
|
|
642
788
|
this.titles = titles;
|
|
789
|
+
this.now = now;
|
|
643
790
|
}
|
|
644
791
|
|
|
645
792
|
/* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
|
|
@@ -752,7 +899,7 @@ export class AcpProtocol {
|
|
|
752
899
|
description: tool.title === '' ? null : tool.title,
|
|
753
900
|
canAllowAlways: options.some((option) => option.kind === 'allow_always')
|
|
754
901
|
});
|
|
755
|
-
const command = rawField(tool.rawInput,
|
|
902
|
+
const command = rawField(tool.rawInput, ...COMMAND_KEYS);
|
|
756
903
|
const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
|
|
757
904
|
return { requestId, rpcId, kind: tool.kind, request: asked, events };
|
|
758
905
|
}
|
|
@@ -793,14 +940,122 @@ export class AcpProtocol {
|
|
|
793
940
|
return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
|
|
794
941
|
}
|
|
795
942
|
|
|
796
|
-
/*
|
|
797
|
-
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
943
|
+
/* A Grok `ask_user_question` request; null when it does not parse or asks nothing, which the caller answers with an error. */
|
|
944
|
+
question(rpcId: number | string, sessionId: string, params: unknown): AcpQuestion | null {
|
|
945
|
+
const request = GrokQuestionRequestSchema.safeParse(params);
|
|
946
|
+
if (!request.success || request.data.sessionId !== sessionId) {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
const questions = validItems(GrokQuestionSchema, request.data.questions).map((question, index): ChatQuestion => ({
|
|
950
|
+
id: String(index),
|
|
951
|
+
header: question.header ?? '',
|
|
952
|
+
question: question.question,
|
|
953
|
+
choices: validItems(GrokChoiceSchema, question.options).map((choice) => ({ label: choice.label, description: choice.description ?? '' })),
|
|
954
|
+
multiSelect: question.multiSelect === true
|
|
802
955
|
}));
|
|
956
|
+
if (questions.length === 0) {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
return this.ask(rpcId, { shape: 'grok', rpcId, questions });
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/*
|
|
963
|
+
* A `session/request_permission` that is a question rather than a permission, as Antigravity asks one: one choice of
|
|
964
|
+
* several, never a call to run. Null for every other permission request.
|
|
965
|
+
*/
|
|
966
|
+
interaction(rpcId: number | string, sessionId: string, params: unknown): AcpQuestion | null {
|
|
967
|
+
const request = PermissionRequestSchema.safeParse(params);
|
|
968
|
+
if (!request.success || request.data.sessionId !== sessionId) {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
const call = request.data.toolCall;
|
|
972
|
+
const raw = call.rawInput;
|
|
973
|
+
const bare = raw === undefined || raw === null || (isRecord(raw) && Object.keys(raw).length === 0);
|
|
974
|
+
if (!call.toolCallId.startsWith(INTERACTION_PREFIX) || !bare || call.kind || request.data.options.length === 0) {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
const question: ChatQuestion = {
|
|
978
|
+
id: '0',
|
|
979
|
+
header: '',
|
|
980
|
+
question: call.title ?? '',
|
|
981
|
+
choices: request.data.options.map((option) => ({ label: option.name, description: '' })),
|
|
982
|
+
multiSelect: false
|
|
983
|
+
};
|
|
984
|
+
return this.ask(rpcId, { shape: 'interaction', rpcId, question, options: request.data.options });
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private ask(rpcId: number | string, pending: PendingQuestion): AcpQuestion {
|
|
988
|
+
const requestId = `acp-${this.generation}-${String(rpcId)}`;
|
|
989
|
+
this.questions.set(requestId, pending);
|
|
990
|
+
const questions = pending.shape === 'grok' ? pending.questions : [pending.question];
|
|
991
|
+
return { requestId, rpcId, events: [...this.closeText(), ...this.closeThought(), { type: 'question.requested', requestId, questions }] };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/* The answer to a question in the agent's own shape, from the card's answers keyed by question id. */
|
|
995
|
+
answerQuestion(requestId: string, answers: Record<string, string>): { rpcId: number | string; result: unknown; events: BackendEvent[] } | null {
|
|
996
|
+
const pending = this.questions.get(requestId);
|
|
997
|
+
if (!pending) {
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
this.questions.delete(requestId);
|
|
1001
|
+
if (pending.shape === 'interaction') {
|
|
1002
|
+
const given = (answers[pending.question.id] ?? '').trim();
|
|
1003
|
+
const option = pending.options.find((candidate) => candidate.name === given) ?? pending.options.find((candidate) => candidate.optionId === given);
|
|
1004
|
+
if (option === undefined) {
|
|
1005
|
+
return {
|
|
1006
|
+
rpcId: pending.rpcId,
|
|
1007
|
+
result: { outcome: { outcome: 'cancelled' } },
|
|
1008
|
+
events: [{ type: 'note', level: 'warning', text: this.words.ownAnswer }]
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
return { rpcId: pending.rpcId, result: { outcome: { outcome: 'selected', optionId: option.optionId } }, events: [] };
|
|
1012
|
+
}
|
|
1013
|
+
const picked: Record<string, string[]> = {};
|
|
1014
|
+
const notes: Record<string, { notes: string }> = {};
|
|
1015
|
+
for (const question of pending.questions) {
|
|
1016
|
+
const given = answers[question.id];
|
|
1017
|
+
if (given === undefined) {
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const labels = pickedLabels(
|
|
1021
|
+
given,
|
|
1022
|
+
question.choices.map((choice) => choice.label),
|
|
1023
|
+
question.multiSelect
|
|
1024
|
+
);
|
|
1025
|
+
picked[question.question] = labels ?? [GROK_OWN_ANSWER];
|
|
1026
|
+
if (labels === null) {
|
|
1027
|
+
notes[question.question] = { notes: given };
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
rpcId: pending.rpcId,
|
|
1032
|
+
result: { outcome: 'accepted', answers: picked, ...(Object.keys(notes).length === 0 ? {} : { annotations: notes }) },
|
|
1033
|
+
events: []
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* Turns one question down, in the shape its agent takes a refusal in. */
|
|
1038
|
+
cancelQuestion(requestId: string): { rpcId: number | string; result: unknown } | null {
|
|
1039
|
+
const pending = this.questions.get(requestId);
|
|
1040
|
+
if (!pending) {
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
this.questions.delete(requestId);
|
|
1044
|
+
return { rpcId: pending.rpcId, result: cancelledAnswer(pending) };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/* Every permission request and question still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
|
|
1048
|
+
cancelRequests(): Array<{ requestId: string; rpcId: number | string; result: unknown }> {
|
|
1049
|
+
const cancelled = [
|
|
1050
|
+
...[...this.permissions].map(([requestId, pending]) => ({
|
|
1051
|
+
requestId,
|
|
1052
|
+
rpcId: pending.rpcId,
|
|
1053
|
+
result: { outcome: { outcome: 'cancelled' } } as unknown
|
|
1054
|
+
})),
|
|
1055
|
+
...[...this.questions].map(([requestId, pending]) => ({ requestId, rpcId: pending.rpcId, result: cancelledAnswer(pending) }))
|
|
1056
|
+
];
|
|
803
1057
|
this.permissions.clear();
|
|
1058
|
+
this.questions.clear();
|
|
804
1059
|
return cancelled;
|
|
805
1060
|
}
|
|
806
1061
|
|
|
@@ -808,6 +1063,10 @@ export class AcpProtocol {
|
|
|
808
1063
|
return this.permissions.has(requestId);
|
|
809
1064
|
}
|
|
810
1065
|
|
|
1066
|
+
hasQuestion(requestId: string): boolean {
|
|
1067
|
+
return this.questions.has(requestId);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
811
1070
|
/*
|
|
812
1071
|
* The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
|
|
813
1072
|
* (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
|
|
@@ -820,6 +1079,15 @@ export class AcpProtocol {
|
|
|
820
1079
|
this.refusalLine = false;
|
|
821
1080
|
const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
|
|
822
1081
|
const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
|
|
1082
|
+
const quota = this.quota;
|
|
1083
|
+
this.quota = null;
|
|
1084
|
+
if (quota !== null && reason !== 'cancelled') {
|
|
1085
|
+
events.push({ type: 'turn.done', ...base, state: 'error', error: quota.text, limit: quotaLimit(quota) });
|
|
1086
|
+
return events;
|
|
1087
|
+
}
|
|
1088
|
+
if (quota !== null) {
|
|
1089
|
+
events.push({ type: 'text.done', ref: quota.ref, text: quota.text });
|
|
1090
|
+
}
|
|
823
1091
|
switch (reason) {
|
|
824
1092
|
case 'cancelled':
|
|
825
1093
|
events.push({ type: 'turn.done', ...base, state: 'aborted' });
|
|
@@ -839,15 +1107,24 @@ export class AcpProtocol {
|
|
|
839
1107
|
return events;
|
|
840
1108
|
}
|
|
841
1109
|
|
|
842
|
-
/* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
|
|
843
|
-
abort(promptId: string | undefined, error: string | null): BackendEvent[] {
|
|
1110
|
+
/* A turn that ended without a `stopReason`: the request failed, on a limit its error names, or was cancelled before the agent said so. */
|
|
1111
|
+
abort(promptId: string | undefined, error: string | null, limit: ChatTurnLimit | null = null): BackendEvent[] {
|
|
844
1112
|
this.refusalLine = false;
|
|
845
1113
|
const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1114
|
+
const events = [...this.closeText(), ...this.closeThought()];
|
|
1115
|
+
const quota = this.quota;
|
|
1116
|
+
this.quota = null;
|
|
1117
|
+
if (quota !== null) {
|
|
1118
|
+
events.push({ type: 'text.done', ref: quota.ref, text: quota.text });
|
|
1119
|
+
}
|
|
1120
|
+
// A failed request after the limit message still ran into that limit, so it pauses as one when the error names none.
|
|
1121
|
+
const named = limit ?? (quota === null ? null : quotaLimit(quota));
|
|
1122
|
+
events.push(
|
|
1123
|
+
error === null
|
|
1124
|
+
? { type: 'turn.done', ...base, state: 'aborted' }
|
|
1125
|
+
: { type: 'turn.done', ...base, state: 'error', error, ...(named === null ? {} : { limit: named }) }
|
|
1126
|
+
);
|
|
1127
|
+
return events;
|
|
851
1128
|
}
|
|
852
1129
|
|
|
853
1130
|
private messageChunk(update: Record<string, unknown>): BackendEvent[] {
|
|
@@ -861,12 +1138,13 @@ export class AcpProtocol {
|
|
|
861
1138
|
events.push(...this.closeText());
|
|
862
1139
|
}
|
|
863
1140
|
if (this.text === null) {
|
|
864
|
-
|
|
1141
|
+
const ref = messageId === null ? `message-${++this.counter}` : `message-${messageId}`;
|
|
1142
|
+
this.text = { ref, messageId, text: '', held: true, afterRefusal: this.refusalLine };
|
|
865
1143
|
this.refusalLine = false;
|
|
866
1144
|
}
|
|
867
1145
|
const text = this.text;
|
|
868
1146
|
text.text += chunk.data.content.text;
|
|
869
|
-
if (text.held && !
|
|
1147
|
+
if (text.held && !withheld(text.text, text.afterRefusal)) {
|
|
870
1148
|
text.held = false;
|
|
871
1149
|
events.push({ type: 'text.delta', ref: text.ref, text: text.text });
|
|
872
1150
|
} else if (!text.held) {
|
|
@@ -892,7 +1170,11 @@ export class AcpProtocol {
|
|
|
892
1170
|
private closeText(): BackendEvent[] {
|
|
893
1171
|
const text = this.text;
|
|
894
1172
|
this.text = null;
|
|
895
|
-
if (text === null || (text.held && text.text === REFUSAL_LINE)) {
|
|
1173
|
+
if (text === null || (text.held && text.afterRefusal && text.text === REFUSAL_LINE)) {
|
|
1174
|
+
return [];
|
|
1175
|
+
}
|
|
1176
|
+
if (text.held && text.text.startsWith(QUOTA_HEAD)) {
|
|
1177
|
+
this.quota = { ref: text.ref, text: text.text.trim(), resetsAt: quotaResetOf(text.text, this.now()) };
|
|
896
1178
|
return [];
|
|
897
1179
|
}
|
|
898
1180
|
return [{ type: 'text.done', ref: text.ref, text: text.text }];
|
package/src/chat/backend.ts
CHANGED
|
@@ -47,6 +47,9 @@ export interface BackendLaunch {
|
|
|
47
47
|
resumeInstructions?: string | null;
|
|
48
48
|
// The goal the chat last knew of, which a CLI that takes it up again from its own transcript on a resume still works toward.
|
|
49
49
|
goal?: ChatGoal;
|
|
50
|
+
// How long the prompt cache the chat last wrote lives, in milliseconds, for a CLI that reads that cache again
|
|
51
|
+
// without saying its lifetime.
|
|
52
|
+
cacheTtlMs?: number;
|
|
50
53
|
// Folders outside `cwd` the agent may reach without asking. Claude Code takes them as `--add-dir`; Codex's
|
|
51
54
|
// sandbox reads the whole disk already and keeps writes to the workspace, so it needs nothing.
|
|
52
55
|
folders?: readonly string[];
|
|
@@ -165,7 +168,8 @@ export type BackendEvent =
|
|
|
165
168
|
// call did; `monitor` is set when the CLI's own frame already says it is one.
|
|
166
169
|
| { type: 'background.started'; taskId: string; ref: string | null; monitor: boolean; description: string | null }
|
|
167
170
|
| { type: 'background.ended'; taskId: string }
|
|
168
|
-
|
|
171
|
+
// `cacheTtlMs` and `cacheExpiresAt` come together, for a CLI that says how long its prompt cache lives.
|
|
172
|
+
| { type: 'usage'; contextTokens?: number; contextWindow?: number; costUsd?: number; cacheTtlMs?: number; cacheExpiresAt?: number }
|
|
169
173
|
// What the CLI said in passing about the plan it runs on; it belongs to the machine, not the chat.
|
|
170
174
|
| { type: 'limits'; update: LimitsUpdate }
|
|
171
175
|
| { type: 'compaction'; preTokens: number | null }
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -446,10 +446,12 @@ export class ChatCore {
|
|
|
446
446
|
await this.hooks.admit?.(payload.chatId, cwd);
|
|
447
447
|
// A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
|
|
448
448
|
// A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
|
|
449
|
-
|
|
449
|
+
// The pick is checked as named, before the default account loses its id, so one that is turned off is refused too.
|
|
450
|
+
const requested = payload.account ?? this.composerPreferences.for(kind).account;
|
|
450
451
|
if (!stored) {
|
|
451
|
-
this.requireAccount(kind,
|
|
452
|
+
this.requireAccount(kind, requested);
|
|
452
453
|
}
|
|
454
|
+
const account = stored ? stored.info.account : storedAccount(kind, requested);
|
|
453
455
|
const narrowed = (mode: RuntimeMode): RuntimeMode => (this.hooks.runtimeModeFor ? this.hooks.runtimeModeFor(payload.chatId, mode) : mode);
|
|
454
456
|
// Asked again for a chat on disk, since the host may have listed it since.
|
|
455
457
|
const hidden = this.hooks.hiddenFor?.(payload.chatId) ? { hidden: true } : {};
|
|
@@ -568,7 +570,8 @@ export class ChatCore {
|
|
|
568
570
|
if (to === from) {
|
|
569
571
|
return;
|
|
570
572
|
}
|
|
571
|
-
|
|
573
|
+
// As named, so a switch to the default account is refused while it is turned off.
|
|
574
|
+
this.requireAccount(kind, requested);
|
|
572
575
|
const spoke = session.info.agentSessionId !== null || session.info.usage.turns > 0;
|
|
573
576
|
if (spoke && !this.canContinue(kind, from, to)) {
|
|
574
577
|
const label = this.accountLabel(kind, to);
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -737,7 +737,7 @@ export class ChatSession {
|
|
|
737
737
|
compact(): void {
|
|
738
738
|
const compaction = this.options.provider.capabilities.compaction;
|
|
739
739
|
if (compaction === 'none') {
|
|
740
|
-
throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot
|
|
740
|
+
throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot compact its context`);
|
|
741
741
|
}
|
|
742
742
|
if (compaction === 'prompt') {
|
|
743
743
|
this.send('/compact');
|
|
@@ -899,7 +899,9 @@ export class ChatSession {
|
|
|
899
899
|
...usage,
|
|
900
900
|
contextTokens: 0,
|
|
901
901
|
contextWindow: this.options.provider.catalog.contextWindowFor(this.thread.info.selection),
|
|
902
|
-
breakdown: undefined
|
|
902
|
+
breakdown: undefined,
|
|
903
|
+
cacheTtlMs: undefined,
|
|
904
|
+
cacheExpiresAt: undefined
|
|
903
905
|
}
|
|
904
906
|
})
|
|
905
907
|
]);
|
|
@@ -1892,6 +1894,7 @@ export class ChatSession {
|
|
|
1892
1894
|
resumeNote: this.options.resumeNote?.() ?? null,
|
|
1893
1895
|
resumeInstructions: this.options.resumeInstructions?.() ?? null,
|
|
1894
1896
|
...(info.goal === undefined ? {} : { goal: info.goal }),
|
|
1897
|
+
...(info.usage.cacheTtlMs === undefined ? {} : { cacheTtlMs: info.usage.cacheTtlMs }),
|
|
1895
1898
|
...(folders.length > 0 ? { folders } : {}),
|
|
1896
1899
|
...(projectConfig ? {} : { projectConfig }),
|
|
1897
1900
|
...(this.options.spawn ? { spawn: this.options.spawn } : {})
|
|
@@ -41,7 +41,7 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
41
41
|
this.launch = launch;
|
|
42
42
|
this.host = host;
|
|
43
43
|
this.allowedTools = options.allowedTools ?? [];
|
|
44
|
-
this.protocol = new ClaudeProtocol(launch.goal ?? null, launch.now);
|
|
44
|
+
this.protocol = new ClaudeProtocol(launch.goal ?? null, launch.now, launch.cacheTtlMs ?? null);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
get running(): boolean {
|
|
@@ -292,10 +292,10 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
private failed(error: unknown): void {
|
|
295
|
-
this.rejectSteers(`
|
|
295
|
+
this.rejectSteers(`Lost the connection to Claude: ${errorText(error)}`);
|
|
296
296
|
this.protocol.forgetPending();
|
|
297
297
|
try {
|
|
298
|
-
this.host.onEvent({ type: 'failed', message: `
|
|
298
|
+
this.host.onEvent({ type: 'failed', message: `Lost the connection to Claude: ${errorText(error)}`, processAlive: this.running });
|
|
299
299
|
} catch (reportError) {
|
|
300
300
|
console.error('Reporting a Claude transport failure failed:', errorText(reportError));
|
|
301
301
|
}
|
|
@@ -343,7 +343,7 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
343
343
|
return;
|
|
344
344
|
}
|
|
345
345
|
this.child = null;
|
|
346
|
-
this.rejectSteers('Claude
|
|
346
|
+
this.rejectSteers('Claude stopped before it got your message');
|
|
347
347
|
this.protocol.forgetPending();
|
|
348
348
|
this.host.onEvent({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
|
|
349
349
|
}
|