@koreanhole/opencodex 2.97.0 → 2.99.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/AGENTS_INSTALL.md +12 -2
- package/README.md +83 -5
- package/package.json +6 -1
- package/src/adapters/cli-agent/acp-client.ts +60 -3
- package/src/adapters/cli-agent/cline/acp.ts +1 -7
- package/src/adapters/cli-agent/cline/adapter.ts +54 -151
- package/src/adapters/cli-agent/cline/settings.ts +38 -260
- package/src/adapters/cli-agent/cline/stream.ts +2 -216
- package/src/adapters/cli-agent/gemini/acp.ts +141 -9
- package/src/adapters/cli-agent/gemini/adapter.ts +179 -16
- package/src/adapters/cli-agent/gemini/live-models.ts +32 -3
- package/src/adapters/cli-agent/gemini/model-metadata.ts +100 -4
- package/src/adapters/cli-agent/gemini/runtime.ts +183 -0
- package/src/adapters/cli-agent/gemini/session-store.ts +202 -0
- package/src/adapters/cli-agent/protocol.ts +60 -9
- package/src/adapters/cli-agent/types.ts +1 -22
- package/src/cli/index.ts +83 -15
- package/src/lib/codex-catalog.ts +107 -13
- package/src/lib/codex-inject.ts +467 -0
- package/src/server/index.ts +30 -3
- package/src/server/responses.ts +91 -9
- package/src/server/websocket.ts +261 -0
- package/src/types.ts +12 -1
- package/src/adapters/cli-agent/turn.ts +0 -307
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
-
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { link, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import * as acp from "@agentclientprotocol/sdk";
|
|
@@ -8,6 +8,7 @@ import { commandInvocation } from "../../../lib/win-exec";
|
|
|
8
8
|
import { baseScopedEnv } from "../env";
|
|
9
9
|
import type { CliAgentStreamState, CliAgentTurnDeps } from "../types";
|
|
10
10
|
import { mapToolIntent, ToolIntentError } from "../tool-intent";
|
|
11
|
+
import { recordGeminiInitialize, recordGeminiUsage } from "./runtime";
|
|
11
12
|
import {
|
|
12
13
|
AcpClient,
|
|
13
14
|
AcpError,
|
|
@@ -196,6 +197,7 @@ export function buildGeminiAcpEnv(sourceEnv: NodeJS.ProcessEnv = process.env): R
|
|
|
196
197
|
|
|
197
198
|
export interface GeminiAcpWorkspace {
|
|
198
199
|
cwd: string;
|
|
200
|
+
geminiHome: string;
|
|
199
201
|
policyPath: string;
|
|
200
202
|
settingsPath: string;
|
|
201
203
|
catalogPath: string;
|
|
@@ -232,22 +234,34 @@ function geminiSystemSettings(reasoning?: GeminiReasoningSelection): string {
|
|
|
232
234
|
export async function stageGeminiAcpWorkspace(
|
|
233
235
|
catalog: readonly Record<string, unknown>[] = [],
|
|
234
236
|
reasoning?: GeminiReasoningSelection,
|
|
237
|
+
options: {
|
|
238
|
+
root?: string;
|
|
239
|
+
sourceEnv?: NodeJS.ProcessEnv;
|
|
240
|
+
} = {},
|
|
235
241
|
): Promise<GeminiAcpWorkspace> {
|
|
236
|
-
const cwd = await mkdtemp(join(tmpdir(), "ocx-gemini-acp-"));
|
|
242
|
+
const cwd = options.root ?? await mkdtemp(join(tmpdir(), "ocx-gemini-acp-"));
|
|
243
|
+
await mkdir(cwd, { recursive: true, mode: 0o700 });
|
|
244
|
+
const geminiHome = join(cwd, "gemini-home");
|
|
237
245
|
const policyPath = join(cwd, "policy.toml");
|
|
238
246
|
const settingsPath = join(cwd, "system-settings.json");
|
|
239
247
|
const catalogPath = join(cwd, "tool-catalog.json");
|
|
240
248
|
const capturePath = join(cwd, "tool-intents.ndjson");
|
|
249
|
+
const geminiConfigDir = join(geminiHome, ".gemini");
|
|
250
|
+
const exclusive = options.root === undefined;
|
|
251
|
+
const writeOptions = { encoding: "utf8" as const, mode: 0o600, ...(exclusive ? { flag: "wx" as const } : {}) };
|
|
241
252
|
await Promise.all([
|
|
242
|
-
|
|
243
|
-
writeFile(
|
|
244
|
-
writeFile(
|
|
253
|
+
mkdir(geminiConfigDir, { recursive: true, mode: 0o700 }),
|
|
254
|
+
writeFile(policyPath, GEMINI_POLICY, writeOptions),
|
|
255
|
+
writeFile(settingsPath, geminiSystemSettings(reasoning), writeOptions),
|
|
256
|
+
writeFile(catalogPath, JSON.stringify(catalog), writeOptions),
|
|
245
257
|
...(catalog.length > 0
|
|
246
|
-
? [writeFile(capturePath, "",
|
|
258
|
+
? [writeFile(capturePath, "", writeOptions)]
|
|
247
259
|
: []),
|
|
248
260
|
]);
|
|
261
|
+
await linkGeminiAuth(geminiConfigDir, options.sourceEnv ?? process.env);
|
|
249
262
|
return {
|
|
250
263
|
cwd,
|
|
264
|
+
geminiHome,
|
|
251
265
|
policyPath,
|
|
252
266
|
settingsPath,
|
|
253
267
|
catalogPath,
|
|
@@ -256,6 +270,30 @@ export async function stageGeminiAcpWorkspace(
|
|
|
256
270
|
};
|
|
257
271
|
}
|
|
258
272
|
|
|
273
|
+
async function linkGeminiAuth(targetDir: string, sourceEnv: NodeJS.ProcessEnv): Promise<void> {
|
|
274
|
+
const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
|
|
275
|
+
const realGemini = join(realHome, ".gemini");
|
|
276
|
+
const names = ["oauth_creds.json", "google_accounts.json", ".env", "settings.json", "trustedFolders.json"];
|
|
277
|
+
await Promise.all(names.map(async name => {
|
|
278
|
+
const target = join(realGemini, name);
|
|
279
|
+
try {
|
|
280
|
+
await lstat(target);
|
|
281
|
+
} catch {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const path = join(targetDir, name);
|
|
285
|
+
try {
|
|
286
|
+
await symlink(resolve(target), path);
|
|
287
|
+
} catch {
|
|
288
|
+
try {
|
|
289
|
+
await link(resolve(target), path);
|
|
290
|
+
} catch {
|
|
291
|
+
// Best-effort; environment-provided auth remains the primary contract.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
|
|
259
297
|
export function geminiAcpArgs(policyPath: string): string[] {
|
|
260
298
|
return [
|
|
261
299
|
"--acp",
|
|
@@ -274,16 +312,25 @@ export function geminiAcpArgs(policyPath: string): string[] {
|
|
|
274
312
|
export interface GeminiAcpTurnInput {
|
|
275
313
|
binary: string;
|
|
276
314
|
env: Record<string, string>;
|
|
315
|
+
runtimeEnv?: NodeJS.ProcessEnv;
|
|
277
316
|
cwd: string;
|
|
278
317
|
args: readonly string[];
|
|
279
318
|
modelId: string;
|
|
319
|
+
fingerprint?: string;
|
|
280
320
|
prompt: string;
|
|
321
|
+
deltaPrompt?: string;
|
|
322
|
+
resumeSessionId?: string;
|
|
323
|
+
sessionKeySource?: string;
|
|
324
|
+
hadPreviousResponseId?: boolean;
|
|
325
|
+
images?: readonly { mimeType: string; data: string }[];
|
|
281
326
|
toolNames: ReadonlyMap<string, string>;
|
|
282
327
|
mcpServers: readonly Record<string, unknown>[];
|
|
283
328
|
capturePath?: string;
|
|
284
329
|
maxToolCalls: number;
|
|
285
330
|
requireToolCall: boolean;
|
|
286
331
|
incoming: IncomingMeta;
|
|
332
|
+
onVendorSession?: (sessionId: string) => void;
|
|
333
|
+
onResumeFailed?: () => void;
|
|
287
334
|
deps?: GeminiAcpDeps;
|
|
288
335
|
}
|
|
289
336
|
|
|
@@ -380,6 +427,7 @@ export async function runGeminiAcpTurn(
|
|
|
380
427
|
emit: (event: AdapterEvent) => void,
|
|
381
428
|
): Promise<void> {
|
|
382
429
|
const deps = input.deps ?? {};
|
|
430
|
+
const fingerprint = input.fingerprint ?? "unknown";
|
|
383
431
|
const state = createGeminiStreamState();
|
|
384
432
|
let client: AcpClient | undefined;
|
|
385
433
|
let session: GeminiAcpSessionInfo | undefined;
|
|
@@ -391,6 +439,10 @@ export async function runGeminiAcpTurn(
|
|
|
391
439
|
let captureResolve: ((records: unknown[]) => void) | undefined;
|
|
392
440
|
let captureReject: ((error: unknown) => void) | undefined;
|
|
393
441
|
let resolveAbort: (() => void) | undefined;
|
|
442
|
+
let loadingSession = false;
|
|
443
|
+
let resumed = false;
|
|
444
|
+
let sentPromptChars = input.prompt.length;
|
|
445
|
+
const seenToolCalls = new Set<string>();
|
|
394
446
|
|
|
395
447
|
const deliver = (event: AdapterEvent): void => {
|
|
396
448
|
if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
|
|
@@ -407,8 +459,39 @@ export async function runGeminiAcpTurn(
|
|
|
407
459
|
})
|
|
408
460
|
: new Promise<never>(() => undefined);
|
|
409
461
|
|
|
462
|
+
const captureTool = (toolCall: Record<string, unknown>): void => {
|
|
463
|
+
const id = nonEmpty(toolCall.toolCallId);
|
|
464
|
+
if (id && seenToolCalls.has(id)) return;
|
|
465
|
+
if (id) seenToolCalls.add(id);
|
|
466
|
+
const name = nonEmpty(toolCall.name);
|
|
467
|
+
if (!name) return;
|
|
468
|
+
try {
|
|
469
|
+
const events = mapToolIntent("Gemini", name, id, toolCall.rawInput, state, {
|
|
470
|
+
toolNames: input.toolNames,
|
|
471
|
+
maxToolCalls: input.maxToolCalls,
|
|
472
|
+
});
|
|
473
|
+
for (const event of events) deliver(event);
|
|
474
|
+
if (pollTimer) {
|
|
475
|
+
clearInterval(pollTimer);
|
|
476
|
+
pollTimer = undefined;
|
|
477
|
+
}
|
|
478
|
+
if (session) void client?.cancel(session.sessionId).catch(() => undefined);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
deliver({
|
|
481
|
+
type: "error",
|
|
482
|
+
message: safeAcpMessage(error, "Gemini returned an invalid tool call."),
|
|
483
|
+
status: 502,
|
|
484
|
+
errorType: "upstream_error",
|
|
485
|
+
code: "cli_protocol_error",
|
|
486
|
+
retryable: false,
|
|
487
|
+
});
|
|
488
|
+
if (session) void client?.cancel(session.sessionId).catch(() => undefined);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
410
492
|
const hooks: GeminiAcpHooks = {
|
|
411
493
|
onSessionUpdate: notification => {
|
|
494
|
+
if (loadingSession) return;
|
|
412
495
|
const update = notification.update;
|
|
413
496
|
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text" && update.content.text) {
|
|
414
497
|
state.sawText = true;
|
|
@@ -416,9 +499,24 @@ export async function runGeminiAcpTurn(
|
|
|
416
499
|
} else if (update.sessionUpdate === "agent_thought_chunk" && update.content.type === "text" && update.content.text) {
|
|
417
500
|
state.sawThinking = true;
|
|
418
501
|
deliver({ type: "thinking_delta", thinking: update.content.text });
|
|
502
|
+
} else if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
|
|
503
|
+
captureTool(update as unknown as Record<string, unknown>);
|
|
504
|
+
} else if (update.sessionUpdate === "usage_update") {
|
|
505
|
+
if (typeof update.used === "number" && typeof update.size === "number") {
|
|
506
|
+
void recordGeminiUsage(
|
|
507
|
+
fingerprint,
|
|
508
|
+
input.modelId,
|
|
509
|
+
update.used,
|
|
510
|
+
update.size,
|
|
511
|
+
sentPromptChars,
|
|
512
|
+
input.runtimeEnv ?? process.env,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
419
515
|
}
|
|
420
516
|
},
|
|
421
|
-
onPermission:
|
|
517
|
+
onPermission: params => {
|
|
518
|
+
const toolCall = asRecord(params.toolCall);
|
|
519
|
+
if (toolCall) captureTool(toolCall);
|
|
422
520
|
if (session) void client?.cancel(session.sessionId).catch(() => undefined);
|
|
423
521
|
},
|
|
424
522
|
};
|
|
@@ -439,8 +537,34 @@ export async function runGeminiAcpTurn(
|
|
|
439
537
|
label: "Gemini ACP",
|
|
440
538
|
fallbackAllowed: false,
|
|
441
539
|
}, deps);
|
|
442
|
-
|
|
540
|
+
if (client.initializeResult) {
|
|
541
|
+
await recordGeminiInitialize(
|
|
542
|
+
fingerprint,
|
|
543
|
+
client.initializeResult,
|
|
544
|
+
input.runtimeEnv ?? process.env,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
loadingSession = Boolean(input.resumeSessionId && client.initializeResult?.capabilities.loadSession);
|
|
548
|
+
if (loadingSession) {
|
|
549
|
+
try {
|
|
550
|
+
session = await client.loadSession({
|
|
551
|
+
sessionId: input.resumeSessionId!,
|
|
552
|
+
cwd: input.cwd,
|
|
553
|
+
mcpServers: input.mcpServers,
|
|
554
|
+
});
|
|
555
|
+
resumed = true;
|
|
556
|
+
} catch {
|
|
557
|
+
input.onResumeFailed?.();
|
|
558
|
+
session = undefined;
|
|
559
|
+
} finally {
|
|
560
|
+
loadingSession = false;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (!session) session = await client.newSession({ cwd: input.cwd, mcpServers: input.mcpServers });
|
|
443
564
|
await applyGeminiModel(client, session, input.modelId);
|
|
565
|
+
input.onVendorSession?.(session.sessionId);
|
|
566
|
+
const promptText = resumed && input.deltaPrompt ? input.deltaPrompt : input.prompt;
|
|
567
|
+
sentPromptChars = promptText.length;
|
|
444
568
|
|
|
445
569
|
if (input.incoming.abortSignal?.aborted) {
|
|
446
570
|
deliver({
|
|
@@ -483,7 +607,15 @@ export async function runGeminiAcpTurn(
|
|
|
483
607
|
}, CAPTURE_POLL_MS);
|
|
484
608
|
}
|
|
485
609
|
|
|
486
|
-
const
|
|
610
|
+
const promptContent: acp.ContentBlock[] = [
|
|
611
|
+
{ type: "text", text: promptText },
|
|
612
|
+
...(input.images ?? []).map(image => ({
|
|
613
|
+
type: "image" as const,
|
|
614
|
+
data: image.data,
|
|
615
|
+
mimeType: image.mimeType,
|
|
616
|
+
})),
|
|
617
|
+
];
|
|
618
|
+
const promptOutcome = client.prompt(session.sessionId, promptContent, input.incoming.abortSignal)
|
|
487
619
|
.then(response => ({ kind: "prompt" as const, response }))
|
|
488
620
|
.catch(error => ({ kind: "error" as const, error }));
|
|
489
621
|
const captureOutcome = capturePromise
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import type { AdapterEvent, IncomingMeta, OcxParsedRequest } from "../../../types";
|
|
3
3
|
import { buildCodingAgentToolCatalog, CodingAgentToolCatalogError } from "../../tool-catalog";
|
|
4
|
-
import { buildConversationInput, buildSystemPrompt } from "../protocol";
|
|
4
|
+
import { buildConversationDelta, buildConversationInput, buildSystemPrompt } from "../protocol";
|
|
5
5
|
import { whichFromPath, type WhichFn } from "../../coding-agent/profile";
|
|
6
6
|
import { isStandaloneBinary } from "../../../lib/standalone";
|
|
7
7
|
import type { CliAgentTurnDeps } from "../types";
|
|
8
8
|
import { ToolIntentError } from "../tool-intent";
|
|
9
|
-
import { resolveGeminiBinary } from "./binary";
|
|
9
|
+
import { geminiBinaryFingerprintSync, resolveGeminiBinary } from "./binary";
|
|
10
10
|
import {
|
|
11
11
|
discoverGeminiModelMetadata,
|
|
12
12
|
reasoningLevelForEffort,
|
|
@@ -22,6 +22,18 @@ import {
|
|
|
22
22
|
type GeminiAcpDeps,
|
|
23
23
|
type GeminiAcpTurnInput,
|
|
24
24
|
} from "./acp";
|
|
25
|
+
import { getGeminiRuntime } from "./runtime";
|
|
26
|
+
import {
|
|
27
|
+
deleteGeminiSession,
|
|
28
|
+
findGeminiSessionByKeyHash,
|
|
29
|
+
findGeminiSessionByResponseId,
|
|
30
|
+
geminiSessionWorkspace,
|
|
31
|
+
hashConversationKey,
|
|
32
|
+
hashMessages,
|
|
33
|
+
reclaimGeminiSessions,
|
|
34
|
+
saveGeminiSession,
|
|
35
|
+
type GeminiSessionRecord,
|
|
36
|
+
} from "./session-store";
|
|
25
37
|
|
|
26
38
|
export const GEMINI_MCP_SERVER_NAME = "opencodex";
|
|
27
39
|
const GEMINI_MCP_SERVER_PATH = fileURLToPath(new URL("../mcp-server.ts", import.meta.url));
|
|
@@ -48,12 +60,43 @@ export interface GeminiTurnDeps extends CliAgentTurnDeps {
|
|
|
48
60
|
function hasUnsupportedMedia(parsed: OcxParsedRequest): boolean {
|
|
49
61
|
return parsed.context.messages.some(message =>
|
|
50
62
|
Array.isArray(message.content)
|
|
51
|
-
&& message.content.some(part => part.type === "
|
|
63
|
+
&& message.content.some(part => part.type === "video" || part.type === "audio"),
|
|
52
64
|
);
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
interface CollectedImage {
|
|
68
|
+
mimeType: string;
|
|
69
|
+
data: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function collectImages(parsed: OcxParsedRequest, env: NodeJS.ProcessEnv): { images: CollectedImage[]; error?: string } {
|
|
73
|
+
const images: CollectedImage[] = [];
|
|
74
|
+
const maxBytes = Number(env.OPENCODEX_MAX_IMAGE_BYTES ?? "") || 4 * 1024 * 1024;
|
|
75
|
+
const allowed = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
76
|
+
for (const message of parsed.context.messages) {
|
|
77
|
+
if (!Array.isArray(message.content)) continue;
|
|
78
|
+
for (const part of message.content) {
|
|
79
|
+
if (part.type !== "image") continue;
|
|
80
|
+
const match = part.imageUrl?.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/);
|
|
81
|
+
if (!match || !allowed.has(match[1]!) || !match[1]!.startsWith("image/")) {
|
|
82
|
+
return { images: [], error: "Image input must be a supported base64 data URL." };
|
|
83
|
+
}
|
|
84
|
+
const data = match[2]!.replaceAll(/\s+/g, "");
|
|
85
|
+
if (Math.floor(data.length * 3 / 4) > maxBytes) {
|
|
86
|
+
return { images: [], error: "Image input exceeds the configured byte limit." };
|
|
87
|
+
}
|
|
88
|
+
images.push({ mimeType: match[1]!, data });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { images };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function conversationText(
|
|
95
|
+
parsed: OcxParsedRequest,
|
|
96
|
+
vendorNameByWire: ReadonlyMap<string, string>,
|
|
97
|
+
maxChars: number,
|
|
98
|
+
): string {
|
|
99
|
+
const first = buildConversationInput(parsed, { vendorNameByWire, maxChars })[0];
|
|
57
100
|
if (!first) return "";
|
|
58
101
|
try {
|
|
59
102
|
const frame = JSON.parse(first) as { message?: { content?: Array<{ type?: string; text?: string }> } };
|
|
@@ -70,16 +113,26 @@ export function buildGeminiPrompt(
|
|
|
70
113
|
parsed: OcxParsedRequest,
|
|
71
114
|
vendorNameByWire: ReadonlyMap<string, string>,
|
|
72
115
|
fixedSystem = GEMINI_SYSTEM_PROMPT,
|
|
116
|
+
maxChars?: number,
|
|
73
117
|
): string {
|
|
74
118
|
return [
|
|
75
119
|
"SYSTEM AND DEVELOPER INSTRUCTIONS:",
|
|
76
120
|
fixedSystem,
|
|
77
121
|
buildSystemPrompt(parsed),
|
|
78
122
|
"CONVERSATION REPLAY:",
|
|
79
|
-
conversationText(parsed, vendorNameByWire),
|
|
123
|
+
conversationText(parsed, vendorNameByWire, maxChars ?? Number.POSITIVE_INFINITY),
|
|
80
124
|
].filter(Boolean).join("\n\n");
|
|
81
125
|
}
|
|
82
126
|
|
|
127
|
+
function buildGeminiDeltaPrompt(
|
|
128
|
+
parsed: OcxParsedRequest,
|
|
129
|
+
startIndex: number,
|
|
130
|
+
vendorNameByWire: ReadonlyMap<string, string>,
|
|
131
|
+
): string | undefined {
|
|
132
|
+
const text = buildConversationDelta(parsed, startIndex, { vendorNameByWire });
|
|
133
|
+
return text.trim() ? text : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
83
136
|
function toolNameMap(catalog: ReturnType<typeof buildCodingAgentToolCatalog>): Map<string, string> {
|
|
84
137
|
const names = new Map<string, string>();
|
|
85
138
|
for (const [vendorName, wireName] of catalog.emittedNameMap) {
|
|
@@ -133,6 +186,31 @@ function failure(error: unknown): Extract<AdapterEvent, { type: "error" }> {
|
|
|
133
186
|
};
|
|
134
187
|
}
|
|
135
188
|
|
|
189
|
+
async function resolveSession(
|
|
190
|
+
parsed: OcxParsedRequest,
|
|
191
|
+
incoming: IncomingMeta,
|
|
192
|
+
env: NodeJS.ProcessEnv,
|
|
193
|
+
): Promise<{ keyHash?: string; prior?: GeminiSessionRecord; source?: string }> {
|
|
194
|
+
if (parsed.previousResponseId) {
|
|
195
|
+
const prior = await findGeminiSessionByResponseId(parsed.previousResponseId, env);
|
|
196
|
+
if (prior) return { keyHash: prior.keyHash, prior, source: "previous_response_id" };
|
|
197
|
+
}
|
|
198
|
+
const cacheKey = parsed.promptCacheKey ?? incoming.headers.get("x-opencodex-conversation") ?? undefined;
|
|
199
|
+
if (cacheKey) {
|
|
200
|
+
const keyHash = hashConversationKey(`key:${cacheKey}`);
|
|
201
|
+
return {
|
|
202
|
+
keyHash,
|
|
203
|
+
prior: await findGeminiSessionByKeyHash(keyHash, env),
|
|
204
|
+
source: parsed.promptCacheKey ? "prompt_cache_key" : "header",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// Codex may send only previous_response_id on later turns. Seed the first
|
|
208
|
+
// session under the response id so the next turn has a stable mapping.
|
|
209
|
+
return parsed.responseId
|
|
210
|
+
? { keyHash: hashConversationKey(`response:${parsed.responseId}`), source: "response_id" }
|
|
211
|
+
: {};
|
|
212
|
+
}
|
|
213
|
+
|
|
136
214
|
export async function runGeminiTurn(
|
|
137
215
|
parsed: OcxParsedRequest,
|
|
138
216
|
incoming: IncomingMeta,
|
|
@@ -144,11 +222,6 @@ export async function runGeminiTurn(
|
|
|
144
222
|
emit({ type: "error", message: "Gemini CLI requires a model selector.", status: 400, errorType: "invalid_request_error", code: "model_not_found", retryable: false });
|
|
145
223
|
return;
|
|
146
224
|
}
|
|
147
|
-
if (hasUnsupportedMedia(parsed)) {
|
|
148
|
-
emit({ type: "error", message: "Gemini CLI provider is text-only; image, video, and audio input are not enabled.", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false });
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
225
|
let catalog: ReturnType<typeof buildCodingAgentToolCatalog>;
|
|
153
226
|
try {
|
|
154
227
|
catalog = buildCodingAgentToolCatalog(parsed, GEMINI_MCP_SERVER_NAME);
|
|
@@ -159,12 +232,14 @@ export async function runGeminiTurn(
|
|
|
159
232
|
}
|
|
160
233
|
|
|
161
234
|
const sourceEnv = deps.env ?? process.env;
|
|
235
|
+
await reclaimGeminiSessions(sourceEnv).catch(() => undefined);
|
|
162
236
|
const binary = resolveGeminiBinary(deps.which ?? whichFromPath, sourceEnv);
|
|
163
237
|
if (!binary) {
|
|
164
238
|
emit({ type: "error", message: "Gemini CLI not found on PATH. Install it and verify `gemini --help`, or set GEMINI_BIN_PATH.", status: 500, errorType: "upstream_error", code: "cli_not_found", retryable: false });
|
|
165
239
|
return;
|
|
166
240
|
}
|
|
167
241
|
|
|
242
|
+
const fingerprint = geminiBinaryFingerprintSync(binary);
|
|
168
243
|
let metadata: GeminiMetadataResult | undefined;
|
|
169
244
|
let reasoning;
|
|
170
245
|
try {
|
|
@@ -187,6 +262,21 @@ export async function runGeminiTurn(
|
|
|
187
262
|
reasoning = undefined;
|
|
188
263
|
}
|
|
189
264
|
|
|
265
|
+
const runtime = await getGeminiRuntime(fingerprint, sourceEnv);
|
|
266
|
+
const media = collectImages(parsed, sourceEnv);
|
|
267
|
+
if (media.error) {
|
|
268
|
+
emit({ type: "error", message: media.error, status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (hasUnsupportedMedia(parsed)) {
|
|
272
|
+
emit({ type: "error", message: "Gemini CLI provider does not support video or audio input.", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (media.images.length > 0 && !runtime.prompt.image) {
|
|
276
|
+
emit({ type: "error", message: "The installed Gemini CLI does not advertise image input for this model.", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
190
280
|
const acpDeps: GeminiAcpDeps = {
|
|
191
281
|
...deps.acpDeps,
|
|
192
282
|
env: sourceEnv,
|
|
@@ -211,12 +301,58 @@ export async function runGeminiTurn(
|
|
|
211
301
|
|
|
212
302
|
const vendorNames = toolNameMap(catalog);
|
|
213
303
|
const vendorNameByWire = new Map([...catalog.emittedNameMap.values()].map(wireName => [wireName, wireName]));
|
|
304
|
+
const observed = runtime.models[requestedModel];
|
|
305
|
+
const contextWindow = observed?.contextSize
|
|
306
|
+
?? metadata?.tokenLimits[requestedModel]
|
|
307
|
+
?? metadata?.defaultTokenLimit;
|
|
308
|
+
const threshold = runtime.compressionThreshold ?? metadata?.compressionThreshold;
|
|
309
|
+
const charsPerToken = observed?.charsPerToken;
|
|
310
|
+
const maxChars = contextWindow !== undefined && threshold !== undefined && charsPerToken !== undefined
|
|
311
|
+
? Math.max(1, Math.floor(contextWindow * threshold * charsPerToken))
|
|
312
|
+
: undefined;
|
|
313
|
+
const system = systemPromptWithToolNames(catalog);
|
|
314
|
+
const fullPrompt = buildGeminiPrompt(parsed, vendorNameByWire, system, maxChars);
|
|
315
|
+
const sessionMessages = parsed.context.messages.filter(message => message.role !== "developer");
|
|
316
|
+
const resolved = await resolveSession(parsed, incoming, sourceEnv);
|
|
317
|
+
const sessionKey = resolved.prior?.keyHash ?? resolved.keyHash;
|
|
318
|
+
let prior = resolved.prior;
|
|
319
|
+
let reuse = false;
|
|
320
|
+
let deltaPrompt: string | undefined;
|
|
321
|
+
if (prior && prior.fingerprint === fingerprint && prior.modelId === requestedModel) {
|
|
322
|
+
const prefix = sessionMessages.slice(0, prior.historyCount);
|
|
323
|
+
if (prefix.length === prior.historyCount && hashMessages(prefix) === prior.historyHash) {
|
|
324
|
+
let deltaStart = prior.historyCount;
|
|
325
|
+
while (deltaStart < sessionMessages.length && sessionMessages[deltaStart]!.role === "assistant") deltaStart++;
|
|
326
|
+
if (deltaStart < sessionMessages.length) {
|
|
327
|
+
deltaPrompt = buildGeminiDeltaPrompt(parsed, deltaStart, vendorNameByWire);
|
|
328
|
+
reuse = Boolean(deltaPrompt);
|
|
329
|
+
}
|
|
330
|
+
} else if (parsed.previousResponseId && sessionMessages.length > 0) {
|
|
331
|
+
// A previous_response_id client sends only the new turn, not the replay.
|
|
332
|
+
deltaPrompt = buildGeminiDeltaPrompt(parsed, 0, vendorNameByWire);
|
|
333
|
+
reuse = Boolean(deltaPrompt);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (prior && !reuse) {
|
|
337
|
+
await deleteGeminiSession(prior.keyHash, sourceEnv).catch(() => undefined);
|
|
338
|
+
prior = undefined;
|
|
339
|
+
}
|
|
214
340
|
const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(
|
|
215
341
|
catalog.tools,
|
|
216
342
|
reasoning ? { modelId: requestedModel, thinkingConfig: reasoning.thinkingConfig } : undefined,
|
|
343
|
+
{
|
|
344
|
+
...(reuse && prior ? { root: prior.workspace } : sessionKey ? { root: geminiSessionWorkspace(sessionKey, sourceEnv) } : {}),
|
|
345
|
+
sourceEnv,
|
|
346
|
+
},
|
|
217
347
|
);
|
|
348
|
+
let vendorSessionId: string | undefined;
|
|
349
|
+
let terminal: AdapterEvent | undefined;
|
|
218
350
|
try {
|
|
219
|
-
const turnEnv = {
|
|
351
|
+
const turnEnv = {
|
|
352
|
+
...acpEnv,
|
|
353
|
+
GEMINI_CLI_HOME: workspace.geminiHome,
|
|
354
|
+
GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath,
|
|
355
|
+
};
|
|
220
356
|
const mcpServers = workspace.capturePath && catalog.emittedNameMap.size > 0
|
|
221
357
|
? [{
|
|
222
358
|
type: "stdio",
|
|
@@ -231,21 +367,48 @@ export async function runGeminiTurn(
|
|
|
231
367
|
await (deps.runAcp ?? runGeminiAcpTurn)({
|
|
232
368
|
binary,
|
|
233
369
|
env: turnEnv,
|
|
370
|
+
runtimeEnv: sourceEnv,
|
|
234
371
|
cwd: workspace.cwd,
|
|
235
372
|
args: geminiAcpArgs(workspace.policyPath),
|
|
236
373
|
modelId: requestedModel,
|
|
237
|
-
|
|
374
|
+
fingerprint,
|
|
375
|
+
prompt: fullPrompt,
|
|
376
|
+
...(reuse && prior && deltaPrompt ? { deltaPrompt, resumeSessionId: prior.vendorSessionId } : {}),
|
|
377
|
+
...(resolved.source ? { sessionKeySource: resolved.source } : {}),
|
|
378
|
+
hadPreviousResponseId: Boolean(parsed.previousResponseId),
|
|
379
|
+
...(media.images.length > 0 ? { images: media.images } : {}),
|
|
238
380
|
toolNames: vendorNames,
|
|
239
381
|
mcpServers,
|
|
240
382
|
...(workspace.capturePath ? { capturePath: workspace.capturePath } : {}),
|
|
241
383
|
maxToolCalls: 16,
|
|
242
384
|
requireToolCall: catalog.requireToolCall,
|
|
243
385
|
incoming,
|
|
386
|
+
onVendorSession: sessionId => { vendorSessionId = sessionId; },
|
|
244
387
|
deps: acpDeps,
|
|
245
|
-
},
|
|
388
|
+
}, event => {
|
|
389
|
+
if ((event.type === "done" || event.type === "error" || event.type === "incomplete") && !terminal) terminal = event;
|
|
390
|
+
emit(event);
|
|
391
|
+
}, acpDeps);
|
|
392
|
+
const reusable = terminal?.type === "done" && terminal.stopReason !== "tool_use";
|
|
393
|
+
if (sessionKey && vendorSessionId && reusable) {
|
|
394
|
+
await saveGeminiSession({
|
|
395
|
+
keyHash: sessionKey,
|
|
396
|
+
vendorSessionId,
|
|
397
|
+
fingerprint,
|
|
398
|
+
modelId: requestedModel,
|
|
399
|
+
workspace: workspace.cwd,
|
|
400
|
+
historyCount: sessionMessages.length,
|
|
401
|
+
historyHash: hashMessages(sessionMessages),
|
|
402
|
+
lastUsed: Date.now(),
|
|
403
|
+
turnCount: (resolved.prior?.turnCount ?? 0) + 1,
|
|
404
|
+
}, parsed.responseId, sourceEnv);
|
|
405
|
+
} else {
|
|
406
|
+
if (sessionKey) await deleteGeminiSession(sessionKey, sourceEnv).catch(() => undefined);
|
|
407
|
+
await workspace.cleanup();
|
|
408
|
+
}
|
|
246
409
|
} catch (error) {
|
|
247
|
-
emit(failure(error));
|
|
248
|
-
|
|
410
|
+
if (!terminal) emit(failure(error));
|
|
411
|
+
if (sessionKey) await deleteGeminiSession(sessionKey, sourceEnv).catch(() => undefined);
|
|
249
412
|
await workspace.cleanup();
|
|
250
413
|
}
|
|
251
414
|
}
|
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
stageGeminiAcpWorkspace,
|
|
14
14
|
} from "./acp";
|
|
15
15
|
import { discoverGeminiModelMetadata, mergeGeminiModelDescriptors } from "./model-metadata";
|
|
16
|
+
import {
|
|
17
|
+
getGeminiRuntime,
|
|
18
|
+
recordGeminiCompressionThreshold,
|
|
19
|
+
recordGeminiInitialize,
|
|
20
|
+
} from "./runtime";
|
|
16
21
|
import type { OcxProviderModel } from "../../../types";
|
|
17
22
|
|
|
18
23
|
export type GeminiLiveModelsFailure = "not_found" | "unsupported" | "auth_required" | "backoff" | "transport";
|
|
@@ -108,8 +113,12 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
108
113
|
return staleResult(fingerprint, current)
|
|
109
114
|
?? { ok: false, reason: "unsupported", detail: "Gemini --help does not advertise --acp.", fingerprint };
|
|
110
115
|
}
|
|
111
|
-
const workspace = await stageGeminiAcpWorkspace();
|
|
112
|
-
const sessionEnv = {
|
|
116
|
+
const workspace = await stageGeminiAcpWorkspace([], undefined, { sourceEnv });
|
|
117
|
+
const sessionEnv = {
|
|
118
|
+
...env,
|
|
119
|
+
GEMINI_CLI_HOME: workspace.geminiHome,
|
|
120
|
+
GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath,
|
|
121
|
+
};
|
|
113
122
|
let client: AcpClient | undefined;
|
|
114
123
|
try {
|
|
115
124
|
client = await AcpClient.connect({
|
|
@@ -120,6 +129,7 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
120
129
|
label: "Gemini ACP",
|
|
121
130
|
fallbackAllowed: false,
|
|
122
131
|
}, options.deps);
|
|
132
|
+
if (client.initializeResult) await recordGeminiInitialize(fingerprint, client.initializeResult, sourceEnv);
|
|
123
133
|
const session = await client.newSession({ cwd: workspace.cwd, mcpServers: [] });
|
|
124
134
|
const models = geminiModelIds(session);
|
|
125
135
|
const metadata = await discoverGeminiModelMetadata({
|
|
@@ -129,7 +139,26 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
129
139
|
...(options.now ? { now: options.now } : {}),
|
|
130
140
|
...(options.forceRefresh ? { ttlMs: 0 } : {}),
|
|
131
141
|
});
|
|
132
|
-
|
|
142
|
+
await recordGeminiCompressionThreshold(fingerprint, metadata.compressionThreshold, sourceEnv);
|
|
143
|
+
const runtime = await getGeminiRuntime(fingerprint, sourceEnv);
|
|
144
|
+
const modelInfo = mergeGeminiModelDescriptors(session.availableModels, metadata).map(model => {
|
|
145
|
+
const observed = runtime.models[model.id];
|
|
146
|
+
const contextWindow = observed?.contextSize ?? model.contextWindowTokens;
|
|
147
|
+
return {
|
|
148
|
+
...model,
|
|
149
|
+
...(contextWindow !== undefined
|
|
150
|
+
? { contextWindowTokens: contextWindow, maxContextWindowTokens: contextWindow }
|
|
151
|
+
: {}),
|
|
152
|
+
...(runtime.compressionThreshold !== undefined
|
|
153
|
+
? { compressionThreshold: runtime.compressionThreshold }
|
|
154
|
+
: {}),
|
|
155
|
+
inputModalities: [
|
|
156
|
+
"text" as const,
|
|
157
|
+
...(runtime.prompt.image ? ["image" as const] : []),
|
|
158
|
+
],
|
|
159
|
+
loadSession: runtime.loadSession,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
133
162
|
const source = session.availableModels.length > 0 ? "models" : "default";
|
|
134
163
|
cache.set(fingerprint, { models, modelInfo, source, fetchedAt: timestamp, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) });
|
|
135
164
|
failureCache.delete(fingerprint);
|