jeo-code 0.12.1 → 0.12.3
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/CHANGELOG.md +27 -15
- package/README.ja.md +26 -5
- package/README.ko.md +25 -5
- package/README.md +25 -5
- package/README.zh.md +26 -5
- package/package.json +1 -1
- package/src/agent/antigravity-cli.ts +499 -0
- package/src/agent/session.ts +25 -0
- package/src/agent/task-tool.ts +9 -3
- package/src/ai/model-cache.ts +7 -2
- package/src/ai/model-catalog-compat.ts +5 -1
- package/src/ai/model-catalog.ts +17 -16
- package/src/ai/model-discovery.ts +57 -20
- package/src/ai/model-manager.ts +7 -0
- package/src/ai/model-registry.ts +4 -4
- package/src/ai/provider-ranking.ts +2 -2
- package/src/ai/provider-status.ts +21 -2
- package/src/ai/providers/custom-providers.ts +1 -1
- package/src/ai/types.ts +1 -1
- package/src/commands/doctor.ts +18 -1
- package/src/commands/launch/agy-turn.ts +60 -0
- package/src/commands/launch/route-slash.ts +8 -3
- package/src/commands/launch.ts +139 -15
- package/src/util/provider-error.ts +3 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import type { ReadableStreamDefaultReader as WebStreamReader } from "node:stream/web";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TURN_TIMEOUT_MS = 5 * 60_000;
|
|
4
|
+
const DEFAULT_MODEL_TIMEOUT_MS = 15_000;
|
|
5
|
+
const MAX_TURN_TIMEOUT_MS = DEFAULT_TURN_TIMEOUT_MS;
|
|
6
|
+
const MAX_MODEL_TIMEOUT_MS = DEFAULT_MODEL_TIMEOUT_MS;
|
|
7
|
+
const MAX_STREAM_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const MAX_MODEL_OUTPUT_BYTES = 256 * 1024;
|
|
9
|
+
const KILL_ESCALATION_MS = 250;
|
|
10
|
+
|
|
11
|
+
export interface AgyModelOptions {
|
|
12
|
+
cwd?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
executable?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AgyUsage {
|
|
19
|
+
inputTokens: number;
|
|
20
|
+
outputTokens: number;
|
|
21
|
+
thinkingTokens?: number;
|
|
22
|
+
cacheReadTokens?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AgyTurnOptions {
|
|
26
|
+
model: string;
|
|
27
|
+
prompt: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
conversationId?: string;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
mode?: "plan" | "auto" | "accept-edits";
|
|
33
|
+
executable?: string;
|
|
34
|
+
onText?: (text: string) => void;
|
|
35
|
+
onNotice?: (text: string) => void;
|
|
36
|
+
onConversation?: (id: string) => void | Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AgyTurnResult {
|
|
40
|
+
model: string;
|
|
41
|
+
conversationId: string;
|
|
42
|
+
response: string;
|
|
43
|
+
steps: number;
|
|
44
|
+
usage?: AgyUsage;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type AgyCliErrorKind = "spawn" | "timeout" | "cancelled" | "protocol" | "upstream" | "exit";
|
|
48
|
+
|
|
49
|
+
export class AgyCliError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
message: string,
|
|
52
|
+
readonly kind: AgyCliErrorKind,
|
|
53
|
+
readonly exitCode?: number | null,
|
|
54
|
+
readonly stderr?: string,
|
|
55
|
+
) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "AgyCliError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface InitPayload {
|
|
62
|
+
model: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface StepPayload {
|
|
66
|
+
step_type?: unknown;
|
|
67
|
+
step_index?: unknown;
|
|
68
|
+
state?: unknown;
|
|
69
|
+
text_delta?: unknown;
|
|
70
|
+
tool_name?: unknown;
|
|
71
|
+
tool_info?: unknown;
|
|
72
|
+
permission_mode?: unknown;
|
|
73
|
+
permission?: unknown;
|
|
74
|
+
[key: string]: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ResultPayload {
|
|
78
|
+
conversation_id?: unknown;
|
|
79
|
+
status?: unknown;
|
|
80
|
+
response?: unknown;
|
|
81
|
+
error?: unknown;
|
|
82
|
+
model?: unknown;
|
|
83
|
+
usage?: unknown;
|
|
84
|
+
[key: string]: unknown;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface AgyEvent {
|
|
88
|
+
event: unknown;
|
|
89
|
+
conversation_id?: unknown;
|
|
90
|
+
init?: unknown;
|
|
91
|
+
step_update?: unknown;
|
|
92
|
+
result?: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined => {
|
|
96
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
97
|
+
return value as Record<string, unknown>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const nonEmptyString = (value: unknown): string | undefined =>
|
|
101
|
+
typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
102
|
+
|
|
103
|
+
const finiteNonNegative = (value: unknown): number | undefined =>
|
|
104
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
105
|
+
|
|
106
|
+
const normalizeTimeout = (value: number | undefined, defaultMs: number, maxMs: number): number => {
|
|
107
|
+
if (value === undefined) return defaultMs;
|
|
108
|
+
if (!Number.isFinite(value) || value <= 0) throw new AgyCliError("Agy timeout must be a positive finite number of milliseconds.", "protocol");
|
|
109
|
+
return Math.min(Math.max(1, Math.ceil(value)), maxMs);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
const canonicalModelParts = (model: string): { canonical: string; slug: string } => {
|
|
114
|
+
if (typeof model !== "string" || !model.startsWith("agy/")) {
|
|
115
|
+
throw new AgyCliError(`Agy model must use the explicit agy/<slug> namespace; received ${String(model)}.`, "protocol");
|
|
116
|
+
}
|
|
117
|
+
const slug = model.slice("agy/".length);
|
|
118
|
+
if (!slug || /[\s/]/u.test(slug)) {
|
|
119
|
+
throw new AgyCliError(`Agy model must contain one non-empty CLI slug; received ${model}.`, "protocol");
|
|
120
|
+
}
|
|
121
|
+
return { canonical: `agy/${slug}`, slug };
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const decodeError = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
|
125
|
+
|
|
126
|
+
const appendBounded = (current: string, chunk: string, limit: number): string => {
|
|
127
|
+
if (!chunk) return current;
|
|
128
|
+
const next = current + chunk;
|
|
129
|
+
return next.length <= limit ? next : next.slice(0, limit);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const killProcess = (proc: Pick<Bun.Subprocess, "kill">, signal: "SIGTERM" | "SIGKILL"): void => {
|
|
133
|
+
try {
|
|
134
|
+
proc.kill(signal);
|
|
135
|
+
} catch {
|
|
136
|
+
// The process may have exited between the liveness check and the signal.
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function parseModelList(stdout: string): string[] {
|
|
141
|
+
const models: string[] = [];
|
|
142
|
+
const seen = new Set<string>();
|
|
143
|
+
for (const [index, rawLine] of stdout.split(/\r?\n/u).entries()) {
|
|
144
|
+
const line = rawLine.trimEnd();
|
|
145
|
+
if (!line.trim()) continue;
|
|
146
|
+
const tab = line.indexOf("\t");
|
|
147
|
+
if (tab < 0) {
|
|
148
|
+
throw new AgyCliError(`Malformed agy model list record at line ${index + 1}; expected <slug>\\t<label>.`, "protocol");
|
|
149
|
+
}
|
|
150
|
+
const slug = line.slice(0, tab);
|
|
151
|
+
const label = line.slice(tab + 1);
|
|
152
|
+
if (!slug.trim() || !label.trim() || /[\s/]/u.test(slug)) {
|
|
153
|
+
throw new AgyCliError(`Malformed agy model list record at line ${index + 1}; slug and label are required.`, "protocol");
|
|
154
|
+
}
|
|
155
|
+
if (seen.has(slug)) continue;
|
|
156
|
+
seen.add(slug);
|
|
157
|
+
models.push(`agy/${slug}`);
|
|
158
|
+
}
|
|
159
|
+
return models;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function listAgyModels(options: AgyModelOptions = {}): Promise<string[]> {
|
|
163
|
+
const timeoutMs = normalizeTimeout(options.timeoutMs, DEFAULT_MODEL_TIMEOUT_MS, MAX_MODEL_TIMEOUT_MS);
|
|
164
|
+
if (options.signal?.aborted) throw new AgyCliError("Agy model discovery was aborted before spawn.", "cancelled");
|
|
165
|
+
|
|
166
|
+
const executable = options.executable ?? "agy";
|
|
167
|
+
const cwd = options.cwd ?? process.cwd();
|
|
168
|
+
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
169
|
+
try {
|
|
170
|
+
proc = Bun.spawn([executable, "models"], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new AgyCliError(`Failed to start agy model discovery: ${decodeError(error)}. Install agy and authenticate it with its own CLI.`, "spawn");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let stdout = "";
|
|
176
|
+
let stderr = "";
|
|
177
|
+
let stdoutBytes = 0;
|
|
178
|
+
let stderrBytes = 0;
|
|
179
|
+
let timedOut = false;
|
|
180
|
+
let cancelled = false;
|
|
181
|
+
let killed = false;
|
|
182
|
+
let escalation: NodeJS.Timeout | undefined;
|
|
183
|
+
let stdoutReader: WebStreamReader<Uint8Array> | undefined;
|
|
184
|
+
let stderrReader: WebStreamReader<Uint8Array> | undefined;
|
|
185
|
+
let streamFailed: unknown;
|
|
186
|
+
|
|
187
|
+
const stopProcess = (): void => {
|
|
188
|
+
if (killed) return;
|
|
189
|
+
killed = true;
|
|
190
|
+
void stdoutReader?.cancel().catch(() => {});
|
|
191
|
+
void stderrReader?.cancel().catch(() => {});
|
|
192
|
+
killProcess(proc, "SIGTERM");
|
|
193
|
+
escalation = setTimeout(() => killProcess(proc, "SIGKILL"), KILL_ESCALATION_MS);
|
|
194
|
+
};
|
|
195
|
+
const terminate = (reason: "timeout" | "cancelled"): void => {
|
|
196
|
+
if (reason === "timeout") timedOut = true;
|
|
197
|
+
else cancelled = true;
|
|
198
|
+
stopProcess();
|
|
199
|
+
};
|
|
200
|
+
const onAbort = (): void => terminate("cancelled");
|
|
201
|
+
if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
|
|
202
|
+
|
|
203
|
+
const drain = async (stream: ReadableStream<Uint8Array>, which: "stdout" | "stderr"): Promise<void> => {
|
|
204
|
+
const reader = stream.getReader();
|
|
205
|
+
if (which === "stdout") stdoutReader = reader;
|
|
206
|
+
else stderrReader = reader;
|
|
207
|
+
const decoder = new TextDecoder();
|
|
208
|
+
try {
|
|
209
|
+
for (;;) {
|
|
210
|
+
const part = await reader.read();
|
|
211
|
+
if (part.done) break;
|
|
212
|
+
const text = decoder.decode(part.value, { stream: true });
|
|
213
|
+
if (which === "stdout") {
|
|
214
|
+
stdoutBytes += part.value.byteLength;
|
|
215
|
+
if (stdoutBytes > MAX_MODEL_OUTPUT_BYTES) throw new AgyCliError("Agy model list exceeded the bounded output limit.", "protocol");
|
|
216
|
+
stdout = appendBounded(stdout, text, MAX_MODEL_OUTPUT_BYTES);
|
|
217
|
+
} else {
|
|
218
|
+
stderrBytes += part.value.byteLength;
|
|
219
|
+
stderr = appendBounded(stderr, text, MAX_MODEL_OUTPUT_BYTES);
|
|
220
|
+
if (stderrBytes > MAX_MODEL_OUTPUT_BYTES) stderr = `${stderr}\n[agy stderr truncated]`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const tail = decoder.decode();
|
|
224
|
+
if (which === "stdout") stdout = appendBounded(stdout, tail, MAX_MODEL_OUTPUT_BYTES);
|
|
225
|
+
else stderr = appendBounded(stderr, tail, MAX_MODEL_OUTPUT_BYTES);
|
|
226
|
+
} finally {
|
|
227
|
+
reader.releaseLock();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const stdoutPromise = drain(proc.stdout, "stdout").catch(error => { streamFailed = error; stopProcess(); });
|
|
232
|
+
const stderrPromise = drain(proc.stderr, "stderr").catch(error => { if (!streamFailed) streamFailed = error; stopProcess(); });
|
|
233
|
+
try {
|
|
234
|
+
const timer = setTimeout(() => terminate("timeout"), timeoutMs);
|
|
235
|
+
let exitCode: number | null;
|
|
236
|
+
try {
|
|
237
|
+
exitCode = await proc.exited;
|
|
238
|
+
await Promise.all([stdoutPromise, stderrPromise]);
|
|
239
|
+
} finally {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
}
|
|
242
|
+
if (cancelled) throw new AgyCliError("Agy model discovery was cancelled.", "cancelled", exitCode, stderr);
|
|
243
|
+
if (timedOut) throw new AgyCliError(`Agy model discovery timed out after ${timeoutMs}ms.`, "timeout", exitCode, stderr);
|
|
244
|
+
if (streamFailed) throw streamFailed;
|
|
245
|
+
if (exitCode !== 0) {
|
|
246
|
+
const detail = stderr.trim() || `agy models exited with code ${exitCode}`;
|
|
247
|
+
throw new AgyCliError(`${detail} (exit ${exitCode}).`, "exit", exitCode, stderr);
|
|
248
|
+
}
|
|
249
|
+
return parseModelList(stdout);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!killed && proc.exitCode === null) stopProcess();
|
|
252
|
+
if (error instanceof AgyCliError) throw error;
|
|
253
|
+
throw new AgyCliError(`Agy model discovery failed: ${decodeError(error)}${stderr.trim() ? ` — ${stderr.trim()}` : ""}`, "protocol", proc.exitCode, stderr);
|
|
254
|
+
} finally {
|
|
255
|
+
if (options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
256
|
+
try { await proc.exited; } catch { /* ignore exit read failures during cleanup */ }
|
|
257
|
+
clearTimeout(escalation);
|
|
258
|
+
try { await stdoutReader?.cancel(); } catch { /* ignore closed reader */ }
|
|
259
|
+
try { await stderrReader?.cancel(); } catch { /* ignore closed reader */ }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseUsage(value: unknown): AgyUsage | undefined {
|
|
264
|
+
const usage = asRecord(value);
|
|
265
|
+
if (!usage) return undefined;
|
|
266
|
+
const inputTokens = finiteNonNegative(usage.input_tokens ?? usage.inputTokens);
|
|
267
|
+
const outputTokens = finiteNonNegative(usage.output_tokens ?? usage.outputTokens);
|
|
268
|
+
if (inputTokens === undefined || outputTokens === undefined) return undefined;
|
|
269
|
+
const out: AgyUsage = { inputTokens, outputTokens };
|
|
270
|
+
const thinkingTokens = finiteNonNegative(usage.thinking_tokens ?? usage.thinkingTokens);
|
|
271
|
+
const cacheReadTokens = finiteNonNegative(usage.cache_read_tokens ?? usage.cacheReadTokens);
|
|
272
|
+
if (thinkingTokens !== undefined) out.thinkingTokens = thinkingTokens;
|
|
273
|
+
if (cacheReadTokens !== undefined) out.cacheReadTokens = cacheReadTokens;
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function runAgyTurn(options: AgyTurnOptions): Promise<AgyTurnResult> {
|
|
278
|
+
const { canonical, slug } = canonicalModelParts(options.model);
|
|
279
|
+
if (typeof options.prompt !== "string") throw new AgyCliError("Agy prompt must be a string.", "protocol");
|
|
280
|
+
if (!options.cwd) throw new AgyCliError("Agy cwd is required.", "protocol");
|
|
281
|
+
const timeoutMs = normalizeTimeout(options.timeoutMs, DEFAULT_TURN_TIMEOUT_MS, MAX_TURN_TIMEOUT_MS);
|
|
282
|
+
if (options.signal?.aborted) throw new AgyCliError("Agy turn was aborted before spawn.", "cancelled");
|
|
283
|
+
|
|
284
|
+
const args = [
|
|
285
|
+
"--input-format", "stream-json",
|
|
286
|
+
"--output-format", "stream-json",
|
|
287
|
+
"--model", slug,
|
|
288
|
+
"--sandbox",
|
|
289
|
+
"--print-timeout", `${timeoutMs}ms`,
|
|
290
|
+
];
|
|
291
|
+
if (options.conversationId !== undefined) {
|
|
292
|
+
if (!options.conversationId.trim()) throw new AgyCliError("Agy conversationId must be non-empty when provided.", "protocol");
|
|
293
|
+
args.push("--conversation", options.conversationId);
|
|
294
|
+
}
|
|
295
|
+
if (options.mode !== undefined) args.push("--mode", options.mode);
|
|
296
|
+
|
|
297
|
+
const executable = options.executable ?? "agy";
|
|
298
|
+
let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
299
|
+
try {
|
|
300
|
+
proc = Bun.spawn([executable, ...args], { cwd: options.cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
throw new AgyCliError(`Failed to start agy: ${decodeError(error)}. Install agy and authenticate it with its own CLI.`, "spawn");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let stdoutBytes = 0;
|
|
306
|
+
let stderr = "";
|
|
307
|
+
let stderrBytes = 0;
|
|
308
|
+
let stdoutLine = "";
|
|
309
|
+
let initModel: string | undefined;
|
|
310
|
+
let conversationId: string | undefined;
|
|
311
|
+
let terminal: ResultPayload | undefined;
|
|
312
|
+
let stepCount = 0;
|
|
313
|
+
let streamedText = "";
|
|
314
|
+
let streamFailed: unknown;
|
|
315
|
+
let timedOut = false;
|
|
316
|
+
let cancelled = false;
|
|
317
|
+
let killed = false;
|
|
318
|
+
let escalation: NodeJS.Timeout | undefined;
|
|
319
|
+
let stdoutReader: WebStreamReader<Uint8Array> | undefined;
|
|
320
|
+
let stderrReader: WebStreamReader<Uint8Array> | undefined;
|
|
321
|
+
const stopProcess = (): void => {
|
|
322
|
+
if (killed) return;
|
|
323
|
+
killed = true;
|
|
324
|
+
void stdoutReader?.cancel().catch(() => {});
|
|
325
|
+
void stderrReader?.cancel().catch(() => {});
|
|
326
|
+
killProcess(proc, "SIGTERM");
|
|
327
|
+
escalation = setTimeout(() => killProcess(proc, "SIGKILL"), KILL_ESCALATION_MS);
|
|
328
|
+
};
|
|
329
|
+
const terminate = (reason: "timeout" | "cancelled"): void => {
|
|
330
|
+
if (reason === "timeout") timedOut = true;
|
|
331
|
+
else cancelled = true;
|
|
332
|
+
stopProcess();
|
|
333
|
+
};
|
|
334
|
+
const onAbort = (): void => terminate("cancelled");
|
|
335
|
+
if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
|
|
336
|
+
|
|
337
|
+
const handleEvent = async (line: string): Promise<void> => {
|
|
338
|
+
let parsed: unknown;
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(line);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
throw new AgyCliError(`Malformed agy NDJSON event: ${decodeError(error)}`, "protocol", proc.exitCode, stderr);
|
|
343
|
+
}
|
|
344
|
+
const event = asRecord(parsed) as AgyEvent | undefined;
|
|
345
|
+
if (!event || typeof event.event !== "string") throw new AgyCliError("Malformed agy NDJSON envelope: missing event type.", "protocol", proc.exitCode, stderr);
|
|
346
|
+
|
|
347
|
+
if (event.event === "init") {
|
|
348
|
+
if (initModel !== undefined) throw new AgyCliError("Agy emitted more than one init event.", "protocol", proc.exitCode, stderr);
|
|
349
|
+
const init = asRecord(event.init) as InitPayload | undefined;
|
|
350
|
+
const model = nonEmptyString(init?.model);
|
|
351
|
+
const id = nonEmptyString(event.conversation_id);
|
|
352
|
+
if (!init || !model || !id) throw new AgyCliError("Agy init event is missing model or conversation_id.", "protocol", proc.exitCode, stderr);
|
|
353
|
+
if (model !== slug) throw new AgyCliError(`Agy init model mismatch: expected ${slug}, received ${model}.`, "protocol", proc.exitCode, stderr);
|
|
354
|
+
if (options.conversationId !== undefined && id !== options.conversationId) {
|
|
355
|
+
throw new AgyCliError(`Agy init conversation_id mismatch: expected ${options.conversationId}, received ${id}.`, "protocol", proc.exitCode, stderr);
|
|
356
|
+
}
|
|
357
|
+
initModel = model;
|
|
358
|
+
conversationId = id;
|
|
359
|
+
if (options.onConversation) await options.onConversation(id);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (event.event === "step_update") {
|
|
364
|
+
if (terminal) throw new AgyCliError("Agy emitted step_update after terminal result.", "protocol", proc.exitCode, stderr);
|
|
365
|
+
if (!initModel || !conversationId) throw new AgyCliError("Agy emitted step_update before init.", "protocol", proc.exitCode, stderr);
|
|
366
|
+
const step = asRecord(event.step_update) as StepPayload | undefined;
|
|
367
|
+
if (!step || typeof step.step_index !== "number" || !Number.isInteger(step.step_index) || step.step_index < 0
|
|
368
|
+
|| !nonEmptyString(step.state) || !nonEmptyString(step.step_type)
|
|
369
|
+
|| (step.text_delta !== undefined && typeof step.text_delta !== "string")
|
|
370
|
+
|| (step.tool_name !== undefined && typeof step.tool_name !== "string")
|
|
371
|
+
|| (step.permission_mode !== undefined && typeof step.permission_mode !== "string")
|
|
372
|
+
|| (step.tool_info !== undefined && !asRecord(step.tool_info))
|
|
373
|
+
|| (step.subagent_info !== undefined && !asRecord(step.subagent_info))) {
|
|
374
|
+
throw new AgyCliError("Malformed agy step_update event.", "protocol", proc.exitCode, stderr);
|
|
375
|
+
}
|
|
376
|
+
stepCount++;
|
|
377
|
+
if (step.conversation_id !== conversationId) {
|
|
378
|
+
throw new AgyCliError("Agy step_update conversation_id does not match init.", "protocol", proc.exitCode, stderr);
|
|
379
|
+
}
|
|
380
|
+
const textDelta = typeof step.text_delta === "string" ? step.text_delta : undefined;
|
|
381
|
+
if (step.step_type === "agent_response" && textDelta) {
|
|
382
|
+
streamedText += textDelta;
|
|
383
|
+
options.onText?.(textDelta);
|
|
384
|
+
}
|
|
385
|
+
if (step.step_type === "tool" || step.tool_info !== undefined || step.permission !== undefined || step.permission_mode !== undefined) {
|
|
386
|
+
const notice = typeof step.tool_name === "string" ? `agy tool: ${step.tool_name}` : `agy ${String(step.step_type ?? "permission")} notice`;
|
|
387
|
+
options.onNotice?.(notice);
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (event.event === "result") {
|
|
393
|
+
if (terminal) throw new AgyCliError("Agy emitted more than one terminal result event.", "protocol", proc.exitCode, stderr);
|
|
394
|
+
const result = asRecord(event.result) as ResultPayload | undefined;
|
|
395
|
+
if (!result) throw new AgyCliError("Malformed agy result event.", "protocol", proc.exitCode, stderr);
|
|
396
|
+
terminal = result;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Forward-compatible unknown event types are intentionally ignored.
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const drainStdout = async (): Promise<void> => {
|
|
403
|
+
const reader = proc.stdout.getReader();
|
|
404
|
+
stdoutReader = reader;
|
|
405
|
+
const decoder = new TextDecoder();
|
|
406
|
+
const consume = async (text: string): Promise<void> => {
|
|
407
|
+
stdoutLine += text;
|
|
408
|
+
if (stdoutLine.length > MAX_STREAM_BYTES) throw new AgyCliError("Agy output exceeded the bounded stream limit.", "protocol", proc.exitCode, stderr);
|
|
409
|
+
let newline = stdoutLine.indexOf("\n");
|
|
410
|
+
while (newline >= 0) {
|
|
411
|
+
const line = stdoutLine.slice(0, newline).replace(/\r$/u, "");
|
|
412
|
+
stdoutLine = stdoutLine.slice(newline + 1);
|
|
413
|
+
if (line.trim()) await handleEvent(line);
|
|
414
|
+
newline = stdoutLine.indexOf("\n");
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
try {
|
|
418
|
+
for (;;) {
|
|
419
|
+
const part = await reader.read();
|
|
420
|
+
if (part.done) break;
|
|
421
|
+
stdoutBytes += part.value.byteLength;
|
|
422
|
+
if (stdoutBytes > MAX_STREAM_BYTES) throw new AgyCliError("Agy output exceeded the bounded stream limit.", "protocol", proc.exitCode, stderr);
|
|
423
|
+
await consume(decoder.decode(part.value, { stream: true }));
|
|
424
|
+
}
|
|
425
|
+
await consume(decoder.decode());
|
|
426
|
+
if (stdoutLine.trim()) await handleEvent(stdoutLine.trim());
|
|
427
|
+
stdoutLine = "";
|
|
428
|
+
} finally {
|
|
429
|
+
reader.releaseLock();
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const drainStderr = async (): Promise<void> => {
|
|
434
|
+
const reader = proc.stderr.getReader();
|
|
435
|
+
stderrReader = reader;
|
|
436
|
+
const decoder = new TextDecoder();
|
|
437
|
+
try {
|
|
438
|
+
for (;;) {
|
|
439
|
+
const part = await reader.read();
|
|
440
|
+
if (part.done) break;
|
|
441
|
+
stderrBytes += part.value.byteLength;
|
|
442
|
+
stderr = appendBounded(stderr, decoder.decode(part.value, { stream: true }), MAX_STREAM_BYTES);
|
|
443
|
+
if (stderrBytes > MAX_STREAM_BYTES) stderr = `${stderr}\n[agy stderr truncated]`;
|
|
444
|
+
}
|
|
445
|
+
stderr = appendBounded(stderr, decoder.decode(), MAX_STREAM_BYTES);
|
|
446
|
+
} finally {
|
|
447
|
+
reader.releaseLock();
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const stdoutPromise = drainStdout().catch(error => { streamFailed = error; stopProcess(); });
|
|
452
|
+
const stderrPromise = drainStderr().catch(error => { if (!streamFailed) streamFailed = error; stopProcess(); });
|
|
453
|
+
const input = `${JSON.stringify({ event: "user", message: { content: options.prompt } })}\n`;
|
|
454
|
+
const timer = setTimeout(() => terminate("timeout"), timeoutMs);
|
|
455
|
+
try {
|
|
456
|
+
await proc.stdin.write(input);
|
|
457
|
+
await proc.stdin.end();
|
|
458
|
+
const exitCode = await proc.exited;
|
|
459
|
+
await Promise.all([stdoutPromise, stderrPromise]);
|
|
460
|
+
if (cancelled) throw new AgyCliError("Agy turn was cancelled.", "cancelled", exitCode, stderr);
|
|
461
|
+
if (timedOut) throw new AgyCliError(`Agy turn timed out after ${timeoutMs}ms.`, "timeout", exitCode, stderr);
|
|
462
|
+
if (streamFailed) throw streamFailed;
|
|
463
|
+
const terminalStatus = nonEmptyString(terminal?.status)?.toUpperCase();
|
|
464
|
+
if (exitCode !== 0) {
|
|
465
|
+
const upstream = terminal?.error;
|
|
466
|
+
const detail = nonEmptyString(upstream) ?? (stderr.trim() || `agy exited with code ${exitCode}`);
|
|
467
|
+
throw new AgyCliError(`${detail} (exit ${exitCode}).`, terminalStatus === "ERROR" ? "upstream" : "exit", exitCode, stderr);
|
|
468
|
+
}
|
|
469
|
+
if (!initModel || !conversationId) throw new AgyCliError("Agy exited successfully without a valid init event.", "protocol", exitCode, stderr);
|
|
470
|
+
if (!terminal) throw new AgyCliError("Agy exited successfully without a terminal result event.", "protocol", exitCode, stderr);
|
|
471
|
+
const status = terminalStatus;
|
|
472
|
+
const resultConversation = nonEmptyString(terminal.conversation_id);
|
|
473
|
+
if (!resultConversation || resultConversation !== conversationId) throw new AgyCliError("Agy result conversation_id does not match init.", "protocol", exitCode, stderr);
|
|
474
|
+
if (status !== "SUCCESS") {
|
|
475
|
+
const detail = nonEmptyString(terminal.error) ?? `Agy returned terminal status ${status ?? "unknown"}.`;
|
|
476
|
+
throw new AgyCliError(`${detail}${exitCode === 0 ? " (exit 0)." : ` (exit ${exitCode}).`}`, "upstream", exitCode, stderr);
|
|
477
|
+
}
|
|
478
|
+
if (terminal.model !== undefined && terminal.model !== slug) throw new AgyCliError(`Agy result model mismatch: expected ${slug}, received ${String(terminal.model)}.`, "protocol", exitCode, stderr);
|
|
479
|
+
const response = typeof terminal.response === "string" ? terminal.response : undefined;
|
|
480
|
+
if (!response || !response.trim()) throw new AgyCliError("Agy returned SUCCESS with an empty response.", "protocol", exitCode, stderr);
|
|
481
|
+
if (!streamedText && response) options.onText?.(response);
|
|
482
|
+
else if (streamedText && response.startsWith(streamedText) && response.length > streamedText.length) options.onText?.(response.slice(streamedText.length));
|
|
483
|
+
return { model: canonical, conversationId, response, steps: stepCount, usage: parseUsage(terminal.usage) };
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (!killed && proc.exitCode === null) stopProcess();
|
|
486
|
+
if (cancelled) throw new AgyCliError("Agy turn was cancelled.", "cancelled", proc.exitCode, stderr);
|
|
487
|
+
if (timedOut) throw new AgyCliError(`Agy turn timed out after ${timeoutMs}ms.`, "timeout", proc.exitCode, stderr);
|
|
488
|
+
if (error instanceof AgyCliError) throw error;
|
|
489
|
+
if (error instanceof Error && /abort|cancel/i.test(error.message)) throw new AgyCliError(error.message, "cancelled", proc.exitCode, stderr);
|
|
490
|
+
throw new AgyCliError(`Agy turn failed: ${decodeError(error)}${stderr.trim() ? ` — ${stderr.trim()}` : ""}`, "protocol", proc.exitCode, stderr);
|
|
491
|
+
} finally {
|
|
492
|
+
if (options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
try { await proc.exited; } catch { /* ignore exit read failures during cleanup */ }
|
|
495
|
+
clearTimeout(escalation);
|
|
496
|
+
try { await stdoutReader?.cancel(); } catch { /* ignore closed reader */ }
|
|
497
|
+
try { await stderrReader?.cancel(); } catch { /* ignore closed reader */ }
|
|
498
|
+
}
|
|
499
|
+
}
|
package/src/agent/session.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface SessionHeader {
|
|
|
13
13
|
/** Model id pinned to this session; restored on resume so each session can carry
|
|
14
14
|
* its own model independent of the global default (per-session model selection). */
|
|
15
15
|
model?: string;
|
|
16
|
+
/** Latest CLI-owned conversation, folded from append-only metadata records. */
|
|
17
|
+
agy?: { conversationId: string; messageCount: number };
|
|
16
18
|
/** Unsent input-box draft at last save, restored into the prompt on `/resume`
|
|
17
19
|
* (gajae-code parity: an in-progress cursor survives a quit + resume instead
|
|
18
20
|
* of being silently lost). Absent/empty when there was nothing to restore. */
|
|
@@ -135,6 +137,26 @@ export async function appendMessages(
|
|
|
135
137
|
await fs.appendFile(file, chunk, "utf8");
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
function isAgySessionState(value: unknown): value is NonNullable<SessionHeader["agy"]> {
|
|
141
|
+
if (!value || typeof value !== "object") return false;
|
|
142
|
+
const state = value as Record<string, unknown>;
|
|
143
|
+
return typeof state.conversationId === "string" && state.conversationId.length > 0
|
|
144
|
+
&& state.conversationId.length <= 1024 && !/[\s\u0000-\u001f\u007f]/.test(state.conversationId)
|
|
145
|
+
&& typeof state.messageCount === "number" && Number.isSafeInteger(state.messageCount) && state.messageCount >= 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Append metadata instead of rewriting a header while transcript messages arrive. */
|
|
149
|
+
export async function appendAgySession(
|
|
150
|
+
id: string,
|
|
151
|
+
state: NonNullable<SessionHeader["agy"]>,
|
|
152
|
+
cwd = process.cwd(),
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
if (!isAgySessionState(state)) throw new Error("Invalid official CLI session metadata");
|
|
155
|
+
await fs.appendFile(sessionPath(id, cwd), JSON.stringify({
|
|
156
|
+
type: "agy_session", timestamp: new Date().toISOString(), state,
|
|
157
|
+
}) + "\n", "utf8");
|
|
158
|
+
}
|
|
159
|
+
|
|
138
160
|
export async function appendCompaction(
|
|
139
161
|
id: string,
|
|
140
162
|
seq: number,
|
|
@@ -185,6 +207,9 @@ export async function loadSession(
|
|
|
185
207
|
rawMessages.push(entry.message);
|
|
186
208
|
} else if (entry.type === "compaction") {
|
|
187
209
|
compactions.push(entry as CompactionEntry);
|
|
210
|
+
} else if (entry.type === "agy_session" && header) {
|
|
211
|
+
if (isAgySessionState(entry.state)) header.agy = entry.state;
|
|
212
|
+
else delete header.agy;
|
|
188
213
|
}
|
|
189
214
|
}
|
|
190
215
|
} catch (err) {
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -433,6 +433,10 @@ export async function runSubagentOnce(
|
|
|
433
433
|
// "false positive -> try a different model" design this implements.
|
|
434
434
|
const safetyFallbackAvailable = (): boolean => safetyFallbackCandidates().length > 0;
|
|
435
435
|
|
|
436
|
+
// Failover (reroute to an equivalent model) is part of routing: it only runs when
|
|
437
|
+
// `routing.enabled` is on. `launch.ts` passes the parent session's EFFECTIVE state
|
|
438
|
+
// (`/route on|off` override ?? config) in `config.routing.enabled`.
|
|
439
|
+
const failoverEnabled = !!opts.config.routing?.enabled;
|
|
436
440
|
let activeModel = initialModel;
|
|
437
441
|
emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
|
|
438
442
|
const runOnce = () => {
|
|
@@ -448,8 +452,7 @@ export async function runSubagentOnce(
|
|
|
448
452
|
signal,
|
|
449
453
|
steer,
|
|
450
454
|
tools: subagentToolset(role),
|
|
451
|
-
rateLimitFallbackAvailable,
|
|
452
|
-
safetyFallbackAvailable,
|
|
455
|
+
...(failoverEnabled ? { rateLimitFallbackAvailable, safetyFallbackAvailable } : {}),
|
|
453
456
|
events: {
|
|
454
457
|
onStep: n => { currentStep = n; },
|
|
455
458
|
// Live reasoning preview (native extended-thinking models only — the JSON-protocol
|
|
@@ -494,7 +497,10 @@ export async function runSubagentOnce(
|
|
|
494
497
|
};
|
|
495
498
|
|
|
496
499
|
let result = await runOnce();
|
|
497
|
-
|
|
500
|
+
// `routing.enabled` off = the model is FIXED: no failover reroute at all (the
|
|
501
|
+
// engine bail predicates above are withheld too, so a 429 rides its normal
|
|
502
|
+
// backoff ladder instead of bailing early with nowhere to go).
|
|
503
|
+
for (let attempt = 0; failoverEnabled && attempt < MAX_SUBAGENT_REROUTES; attempt++) {
|
|
498
504
|
const rateLimited = !result.done && isRateLimitError(new Error(result.doneReason ?? ""));
|
|
499
505
|
const safetyBail = !result.done && !rateLimited && isSafetyFallbackBail(result.doneReason);
|
|
500
506
|
if (!rateLimited && !safetyBail) break;
|
package/src/ai/model-cache.ts
CHANGED
|
@@ -100,6 +100,7 @@ export function normalizeCacheEntries(raw: unknown): CachedProviderModels[] {
|
|
|
100
100
|
const row = entry as Partial<CachedProviderModels>;
|
|
101
101
|
if (!row || typeof row.provider !== "string" || !Array.isArray(row.models)) continue;
|
|
102
102
|
const provider = row.provider as ProviderName;
|
|
103
|
+
if (provider === "agy") continue;
|
|
103
104
|
const source: ModelCacheSource =
|
|
104
105
|
row.source === "oauth" || row.source === "api_key" || row.source === "keyless" || row.source === "none" ? row.source : "none";
|
|
105
106
|
const baseUrl = typeof row.baseUrl === "string" ? row.baseUrl.replace(/\/+$/, "") : undefined;
|
|
@@ -150,7 +151,7 @@ export function isModelCacheStale(cache: ModelCacheFile | null, now = Date.now()
|
|
|
150
151
|
*/
|
|
151
152
|
export function mergeCacheEntries(
|
|
152
153
|
previous: readonly CachedProviderModels[],
|
|
153
|
-
results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string; fallback?: boolean }[],
|
|
154
|
+
results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource | "cli_session"; baseUrl?: string; accountId?: string; fallback?: boolean }[],
|
|
154
155
|
): CachedProviderModels[] {
|
|
155
156
|
const byKey = new Map<string, CachedProviderModels>();
|
|
156
157
|
const isScopedOAuth = (provider: ProviderName, source: ModelCacheSource): boolean =>
|
|
@@ -159,10 +160,12 @@ export function mergeCacheEntries(
|
|
|
159
160
|
`${provider}\u0000${baseUrl?.replace(/\/+$/, "") ?? ""}\u0000${isScopedOAuth(provider, source ?? "none") ? accountId ?? "" : ""}\u0000${provider === "openai" || provider === "antigravity" ? source ?? "none" : ""}`;
|
|
160
161
|
for (const entry of previous) {
|
|
161
162
|
const scoped = isScopedOAuth(entry.provider, entry.source);
|
|
163
|
+
if (entry.provider === "agy") continue;
|
|
162
164
|
if ((scoped && !entry.accountId) || (entry.provider === "antigravity" && entry.source !== "oauth")) continue;
|
|
163
165
|
byKey.set(keyOf(entry.provider, entry.baseUrl, entry.accountId, entry.source), entry);
|
|
164
166
|
}
|
|
165
167
|
for (const result of results) {
|
|
168
|
+
if (result.provider === "agy" || result.source === "cli_session") continue;
|
|
166
169
|
const source: ModelCacheSource = result.source ?? "none";
|
|
167
170
|
const scoped = isScopedOAuth(result.provider, source);
|
|
168
171
|
const isAntigravity = result.provider === "antigravity";
|
|
@@ -183,13 +186,14 @@ export function mergeCacheEntries(
|
|
|
183
186
|
|
|
184
187
|
/** Persist discovery results (best-effort; never throws, never blocks a turn). */
|
|
185
188
|
export async function writeModelCache(
|
|
186
|
-
results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string; fallback?: boolean }[],
|
|
189
|
+
results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource | "cli_session"; baseUrl?: string; accountId?: string; fallback?: boolean }[],
|
|
187
190
|
): Promise<void> {
|
|
188
191
|
// Same hermeticity rule as saveGlobalConfig: a test run may only write into an
|
|
189
192
|
// explicitly sandboxed JEO_CONFIG_DIR, never the developer's real ~/.jeo.
|
|
190
193
|
if (process.env.NODE_ENV === "test" && !jeoEnv("CONFIG_DIR")) return;
|
|
191
194
|
// A failed/fallback-only refresh must not make an old snapshot appear fresh.
|
|
192
195
|
const hasFreshResult = results.some(result => {
|
|
196
|
+
if (result.provider === "agy" || result.source === "cli_session") return false;
|
|
193
197
|
if (!result.ok || result.fallback) return false;
|
|
194
198
|
if (result.provider === "antigravity") return result.source === "oauth" && !!result.accountId?.trim();
|
|
195
199
|
if (result.provider === "openai") return result.models.length > 0 || !(result.source === "oauth" && !result.accountId?.trim());
|
|
@@ -220,6 +224,7 @@ export function applyCachedModels(cache: ModelCacheFile | null, openAIOauthAccou
|
|
|
220
224
|
if (!cache) return 0;
|
|
221
225
|
let applied = 0;
|
|
222
226
|
for (const entry of cache.providers) {
|
|
227
|
+
if (entry.provider === "agy") continue;
|
|
223
228
|
const isOpenAIOAuth = entry.provider === "openai" && entry.source === "oauth";
|
|
224
229
|
const isAntigravityOAuth = entry.provider === "antigravity" && entry.source === "oauth";
|
|
225
230
|
if (isOpenAIOAuth && (!openAIScope || entry.accountId !== openAIScope)) continue;
|
|
@@ -55,7 +55,11 @@ export function findCatalogEntry(id: string): ModelCatalogEntry | undefined {
|
|
|
55
55
|
const direct = findCatalogModel(id);
|
|
56
56
|
if (direct) return adapt(direct);
|
|
57
57
|
const n = normalizeModelId(id);
|
|
58
|
-
const hit = MODEL_CATALOG.find(m =>
|
|
58
|
+
const hit = MODEL_CATALOG.find(m =>
|
|
59
|
+
normalizeModelId(m.canonical) === n
|
|
60
|
+
|| normalizeModelId(`${m.provider}/${m.providerModel}`) === n
|
|
61
|
+
|| (!m.canonical.startsWith(`${m.provider}/`) && normalizeModelId(m.providerModel) === n),
|
|
62
|
+
);
|
|
59
63
|
return hit ? adapt(hit) : undefined;
|
|
60
64
|
}
|
|
61
65
|
|