@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.
- 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 +258 -21
- 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 +303 -24
- 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
|
}
|
|
@@ -596,21 +633,123 @@ export interface AcpPermission {
|
|
|
596
633
|
events: BackendEvent[];
|
|
597
634
|
}
|
|
598
635
|
|
|
599
|
-
/*
|
|
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. */
|
|
600
644
|
export interface AcpStopWords {
|
|
601
645
|
maxTokens: string;
|
|
602
646
|
maxTurnRequests: string;
|
|
603
647
|
refusal: string;
|
|
648
|
+
ownAnswer: string;
|
|
604
649
|
}
|
|
605
650
|
|
|
606
651
|
export function acpStopWords(agentName: string): AcpStopWords {
|
|
607
652
|
return {
|
|
608
|
-
maxTokens: `${agentName} stopped
|
|
609
|
-
maxTurnRequests: `${agentName} stopped
|
|
610
|
-
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.`
|
|
611
657
|
};
|
|
612
658
|
}
|
|
613
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
|
+
|
|
614
753
|
/*
|
|
615
754
|
* One ACP session's `session/update` stream, mapped onto backend events. A message's chunks become
|
|
616
755
|
* one text item, cut where the agent switches to a thought or a tool; a tool call becomes a row that
|
|
@@ -622,10 +761,14 @@ export class AcpProtocol {
|
|
|
622
761
|
private readonly words: AcpStopWords;
|
|
623
762
|
private readonly tools = new Map<string, ToolState>();
|
|
624
763
|
private readonly permissions = new Map<string, { rpcId: number | string; toolCallId: string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
|
|
625
|
-
|
|
626
|
-
|
|
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;
|
|
627
767
|
// Set by a refusal until the next message shows whether it is the refusal line.
|
|
628
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;
|
|
629
772
|
private thought: { ref: string; text: string } | null = null;
|
|
630
773
|
private counter = 0;
|
|
631
774
|
private costUsd = 0;
|
|
@@ -638,11 +781,12 @@ export class AcpProtocol {
|
|
|
638
781
|
private readonly titles: boolean;
|
|
639
782
|
|
|
640
783
|
// `titles` takes the session titles the agent sends as the chat's suggested title.
|
|
641
|
-
constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false) {
|
|
784
|
+
constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false, now: () => number = Date.now) {
|
|
642
785
|
this.generation = generation;
|
|
643
786
|
this.modes = modes;
|
|
644
787
|
this.words = words;
|
|
645
788
|
this.titles = titles;
|
|
789
|
+
this.now = now;
|
|
646
790
|
}
|
|
647
791
|
|
|
648
792
|
/* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
|
|
@@ -796,14 +940,122 @@ export class AcpProtocol {
|
|
|
796
940
|
return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
|
|
797
941
|
}
|
|
798
942
|
|
|
799
|
-
/*
|
|
800
|
-
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
|
805
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
|
+
];
|
|
806
1057
|
this.permissions.clear();
|
|
1058
|
+
this.questions.clear();
|
|
807
1059
|
return cancelled;
|
|
808
1060
|
}
|
|
809
1061
|
|
|
@@ -811,6 +1063,10 @@ export class AcpProtocol {
|
|
|
811
1063
|
return this.permissions.has(requestId);
|
|
812
1064
|
}
|
|
813
1065
|
|
|
1066
|
+
hasQuestion(requestId: string): boolean {
|
|
1067
|
+
return this.questions.has(requestId);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
814
1070
|
/*
|
|
815
1071
|
* The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
|
|
816
1072
|
* (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
|
|
@@ -823,6 +1079,15 @@ export class AcpProtocol {
|
|
|
823
1079
|
this.refusalLine = false;
|
|
824
1080
|
const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
|
|
825
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
|
+
}
|
|
826
1091
|
switch (reason) {
|
|
827
1092
|
case 'cancelled':
|
|
828
1093
|
events.push({ type: 'turn.done', ...base, state: 'aborted' });
|
|
@@ -842,15 +1107,24 @@ export class AcpProtocol {
|
|
|
842
1107
|
return events;
|
|
843
1108
|
}
|
|
844
1109
|
|
|
845
|
-
/* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
|
|
846
|
-
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[] {
|
|
847
1112
|
this.refusalLine = false;
|
|
848
1113
|
const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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;
|
|
854
1128
|
}
|
|
855
1129
|
|
|
856
1130
|
private messageChunk(update: Record<string, unknown>): BackendEvent[] {
|
|
@@ -864,12 +1138,13 @@ export class AcpProtocol {
|
|
|
864
1138
|
events.push(...this.closeText());
|
|
865
1139
|
}
|
|
866
1140
|
if (this.text === null) {
|
|
867
|
-
|
|
1141
|
+
const ref = messageId === null ? `message-${++this.counter}` : `message-${messageId}`;
|
|
1142
|
+
this.text = { ref, messageId, text: '', held: true, afterRefusal: this.refusalLine };
|
|
868
1143
|
this.refusalLine = false;
|
|
869
1144
|
}
|
|
870
1145
|
const text = this.text;
|
|
871
1146
|
text.text += chunk.data.content.text;
|
|
872
|
-
if (text.held && !
|
|
1147
|
+
if (text.held && !withheld(text.text, text.afterRefusal)) {
|
|
873
1148
|
text.held = false;
|
|
874
1149
|
events.push({ type: 'text.delta', ref: text.ref, text: text.text });
|
|
875
1150
|
} else if (!text.held) {
|
|
@@ -895,7 +1170,11 @@ export class AcpProtocol {
|
|
|
895
1170
|
private closeText(): BackendEvent[] {
|
|
896
1171
|
const text = this.text;
|
|
897
1172
|
this.text = null;
|
|
898
|
-
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()) };
|
|
899
1178
|
return [];
|
|
900
1179
|
}
|
|
901
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
|
}
|
|
@@ -46,6 +46,22 @@ function contextTokens(usage: unknown): number {
|
|
|
46
46
|
return isRecord(usage) ? num(usage.input_tokens) + num(usage.cache_creation_input_tokens) + num(usage.cache_read_input_tokens) : 0;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// How long a prompt cache entry lives, by the field of `usage.cache_creation` that counts what a request wrote with it.
|
|
50
|
+
const CACHE_TTL_MS: Record<string, number> = { ephemeral_5m_input_tokens: 5 * 60_000, ephemeral_1h_input_tokens: 60 * 60_000 };
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* The lifetime of the cache a response wrote, or null when it wrote none or names no lifetime. A response that wrote
|
|
54
|
+
* with both answers the shorter, which is when the part of its context that goes cold first does.
|
|
55
|
+
*/
|
|
56
|
+
function cacheTtlWritten(usage: unknown): number | null {
|
|
57
|
+
const creation = isRecord(usage) && isRecord(usage.cache_creation) ? usage.cache_creation : null;
|
|
58
|
+
if (creation === null) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const written = Object.entries(CACHE_TTL_MS).filter(([field]) => num(creation[field]) > 0);
|
|
62
|
+
return written.length === 0 ? null : Math.min(...written.map(([, ttl]) => ttl));
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
// The kinds of task Claude Code keeps beside its turns that are a command or a monitor; a subagent has a row of its own.
|
|
50
66
|
const BACKGROUND_TASK_TYPES = new Set(['local_bash', 'monitor_mcp', 'monitor_ws']);
|
|
51
67
|
const ENDED_TASK_STATUSES = new Set(['completed', 'failed', 'killed', 'stopped']);
|
|
@@ -202,6 +218,9 @@ export class ClaudeProtocol {
|
|
|
202
218
|
private readonly backgroundTasks = new Set<string>();
|
|
203
219
|
// Workflows still running, by task id, to the Workflow call that launched each.
|
|
204
220
|
private readonly workflows = new Map<string, string>();
|
|
221
|
+
// The main-chain response whose cache lifetime the thread has, and the lifetime of the cache last written.
|
|
222
|
+
private cacheMessageId: string | null = null;
|
|
223
|
+
private cacheTtlMs: number | null;
|
|
205
224
|
// The uuid of the last main-chain assistant frame of the turn, which is the line of the transcript the turn ends on.
|
|
206
225
|
private lastUuid: string | null = null;
|
|
207
226
|
// The model each subagent last answered with, so a frame only reports one it had not named yet.
|
|
@@ -224,9 +243,11 @@ export class ClaudeProtocol {
|
|
|
224
243
|
// Background subagents still at work; Claude Code holds a goal's check until they report.
|
|
225
244
|
private readonly backgroundAgents = new Set<string>();
|
|
226
245
|
|
|
227
|
-
|
|
246
|
+
// `cacheTtlMs` is the lifetime the chat knew before this process, so a resumed CLI that only reads the cache still moves its expiry.
|
|
247
|
+
constructor(goal: ChatGoal | null = null, now: () => number = () => Date.now(), cacheTtlMs: number | null = null) {
|
|
228
248
|
this.goal = goal;
|
|
229
249
|
this.now = now;
|
|
250
|
+
this.cacheTtlMs = cacheTtlMs;
|
|
230
251
|
}
|
|
231
252
|
|
|
232
253
|
/* A stop is on its way; the turn it ends leaves the goal set but nobody working on it. */
|
|
@@ -572,6 +593,7 @@ export class ClaudeProtocol {
|
|
|
572
593
|
}
|
|
573
594
|
if (event.type === 'message_start') {
|
|
574
595
|
const message = isRecord(event.message) ? event.message : {};
|
|
596
|
+
this.noteCache(str(message.id), message.usage, events);
|
|
575
597
|
this.streamMessageId = str(message.id);
|
|
576
598
|
this.streamTextCount = 0;
|
|
577
599
|
this.streamThinkingCount = 0;
|
|
@@ -607,6 +629,27 @@ export class ClaudeProtocol {
|
|
|
607
629
|
}
|
|
608
630
|
}
|
|
609
631
|
|
|
632
|
+
/*
|
|
633
|
+
* When the prompt cache goes cold, from the first frame of each main-chain response: its `message_start`, or its
|
|
634
|
+
* first `assistant` frame when the CLI streams no partial messages. The lifetime runs from the start of the request,
|
|
635
|
+
* and a response that only read the cache starts the lifetime it was written with again, as a cache hit does.
|
|
636
|
+
*/
|
|
637
|
+
private noteCache(messageId: string | null, usage: unknown, events: BackendEvent[]): void {
|
|
638
|
+
if (messageId === null || messageId === this.cacheMessageId || !isRecord(usage)) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
this.cacheMessageId = messageId;
|
|
642
|
+
const written = cacheTtlWritten(usage);
|
|
643
|
+
if (written !== null) {
|
|
644
|
+
this.cacheTtlMs = written;
|
|
645
|
+
} else if (num(usage.cache_read_input_tokens) === 0) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (this.cacheTtlMs !== null) {
|
|
649
|
+
events.push({ type: 'usage', cacheTtlMs: this.cacheTtlMs, cacheExpiresAt: this.now() + this.cacheTtlMs });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
610
653
|
private handleAssistant(frame: Frame, events: BackendEvent[]): void {
|
|
611
654
|
const message = isRecord(frame.message) ? frame.message : {};
|
|
612
655
|
const messageId = str(message.id) ?? 'message';
|
|
@@ -653,6 +696,9 @@ export class ClaudeProtocol {
|
|
|
653
696
|
if (tokens > 0) {
|
|
654
697
|
events.push({ type: 'usage', contextTokens: tokens });
|
|
655
698
|
}
|
|
699
|
+
if (!parentRef) {
|
|
700
|
+
this.noteCache(str(message.id), message.usage, events);
|
|
701
|
+
}
|
|
656
702
|
const error = str(frame.error);
|
|
657
703
|
if (error) {
|
|
658
704
|
events.push({ type: 'note', level: 'error', text: `The request failed: ${error.replaceAll('_', ' ')}` });
|
|
@@ -756,7 +802,7 @@ export class ClaudeProtocol {
|
|
|
756
802
|
events.push({ type: 'usage', costUsd: num(frame.total_cost_usd) });
|
|
757
803
|
}
|
|
758
804
|
if (frame.is_error === true) {
|
|
759
|
-
events.push({ type: 'note', level: 'warning', text: '
|
|
805
|
+
events.push({ type: 'note', level: 'warning', text: 'Claude could not finish a turn in the background. Your message is still waiting.' });
|
|
760
806
|
}
|
|
761
807
|
return;
|
|
762
808
|
}
|