@koreanhole/opencodex 2.96.0 → 2.98.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 +18 -4
- package/README.md +86 -7
- 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 +168 -9
- package/src/adapters/cli-agent/gemini/adapter.ts +211 -17
- package/src/adapters/cli-agent/gemini/live-models.ts +55 -6
- package/src/adapters/cli-agent/gemini/model-metadata.ts +387 -0
- 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 +43 -1
- package/src/lib/codex-catalog.ts +351 -0
- package/src/server/index.ts +49 -8
- package/src/server/responses.ts +120 -14
- package/src/server/websocket.ts +261 -0
- package/src/types.ts +25 -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;
|
|
@@ -203,24 +205,63 @@ export interface GeminiAcpWorkspace {
|
|
|
203
205
|
cleanup: () => Promise<void>;
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
export interface GeminiReasoningSelection {
|
|
209
|
+
modelId: string;
|
|
210
|
+
thinkingConfig: {
|
|
211
|
+
thinkingLevel?: string;
|
|
212
|
+
thinkingBudget?: number;
|
|
213
|
+
includeThoughts?: boolean;
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function geminiSystemSettings(reasoning?: GeminiReasoningSelection): string {
|
|
218
|
+
const settings = JSON.parse(GEMINI_SYSTEM_SETTINGS) as Record<string, unknown>;
|
|
219
|
+
if (reasoning) {
|
|
220
|
+
settings.modelConfigs = {
|
|
221
|
+
customOverrides: [{
|
|
222
|
+
match: { model: reasoning.modelId },
|
|
223
|
+
modelConfig: {
|
|
224
|
+
generateContentConfig: {
|
|
225
|
+
thinkingConfig: reasoning.thinkingConfig,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
}],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
export async function stageGeminiAcpWorkspace(
|
|
207
235
|
catalog: readonly Record<string, unknown>[] = [],
|
|
236
|
+
reasoning?: GeminiReasoningSelection,
|
|
237
|
+
options: {
|
|
238
|
+
root?: string;
|
|
239
|
+
sourceEnv?: NodeJS.ProcessEnv;
|
|
240
|
+
} = {},
|
|
208
241
|
): Promise<GeminiAcpWorkspace> {
|
|
209
|
-
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");
|
|
210
245
|
const policyPath = join(cwd, "policy.toml");
|
|
211
246
|
const settingsPath = join(cwd, "system-settings.json");
|
|
212
247
|
const catalogPath = join(cwd, "tool-catalog.json");
|
|
213
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 } : {}) };
|
|
214
252
|
await Promise.all([
|
|
215
|
-
|
|
216
|
-
writeFile(
|
|
217
|
-
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),
|
|
218
257
|
...(catalog.length > 0
|
|
219
|
-
? [writeFile(capturePath, "",
|
|
258
|
+
? [writeFile(capturePath, "", writeOptions)]
|
|
220
259
|
: []),
|
|
221
260
|
]);
|
|
261
|
+
await linkGeminiAuth(geminiConfigDir, options.sourceEnv ?? process.env);
|
|
222
262
|
return {
|
|
223
263
|
cwd,
|
|
264
|
+
geminiHome,
|
|
224
265
|
policyPath,
|
|
225
266
|
settingsPath,
|
|
226
267
|
catalogPath,
|
|
@@ -229,6 +270,30 @@ export async function stageGeminiAcpWorkspace(
|
|
|
229
270
|
};
|
|
230
271
|
}
|
|
231
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
|
+
|
|
232
297
|
export function geminiAcpArgs(policyPath: string): string[] {
|
|
233
298
|
return [
|
|
234
299
|
"--acp",
|
|
@@ -247,16 +312,25 @@ export function geminiAcpArgs(policyPath: string): string[] {
|
|
|
247
312
|
export interface GeminiAcpTurnInput {
|
|
248
313
|
binary: string;
|
|
249
314
|
env: Record<string, string>;
|
|
315
|
+
runtimeEnv?: NodeJS.ProcessEnv;
|
|
250
316
|
cwd: string;
|
|
251
317
|
args: readonly string[];
|
|
252
318
|
modelId: string;
|
|
319
|
+
fingerprint?: string;
|
|
253
320
|
prompt: string;
|
|
321
|
+
deltaPrompt?: string;
|
|
322
|
+
resumeSessionId?: string;
|
|
323
|
+
sessionKeySource?: string;
|
|
324
|
+
hadPreviousResponseId?: boolean;
|
|
325
|
+
images?: readonly { mimeType: string; data: string }[];
|
|
254
326
|
toolNames: ReadonlyMap<string, string>;
|
|
255
327
|
mcpServers: readonly Record<string, unknown>[];
|
|
256
328
|
capturePath?: string;
|
|
257
329
|
maxToolCalls: number;
|
|
258
330
|
requireToolCall: boolean;
|
|
259
331
|
incoming: IncomingMeta;
|
|
332
|
+
onVendorSession?: (sessionId: string) => void;
|
|
333
|
+
onResumeFailed?: () => void;
|
|
260
334
|
deps?: GeminiAcpDeps;
|
|
261
335
|
}
|
|
262
336
|
|
|
@@ -353,6 +427,7 @@ export async function runGeminiAcpTurn(
|
|
|
353
427
|
emit: (event: AdapterEvent) => void,
|
|
354
428
|
): Promise<void> {
|
|
355
429
|
const deps = input.deps ?? {};
|
|
430
|
+
const fingerprint = input.fingerprint ?? "unknown";
|
|
356
431
|
const state = createGeminiStreamState();
|
|
357
432
|
let client: AcpClient | undefined;
|
|
358
433
|
let session: GeminiAcpSessionInfo | undefined;
|
|
@@ -364,6 +439,10 @@ export async function runGeminiAcpTurn(
|
|
|
364
439
|
let captureResolve: ((records: unknown[]) => void) | undefined;
|
|
365
440
|
let captureReject: ((error: unknown) => void) | undefined;
|
|
366
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>();
|
|
367
446
|
|
|
368
447
|
const deliver = (event: AdapterEvent): void => {
|
|
369
448
|
if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
|
|
@@ -380,8 +459,39 @@ export async function runGeminiAcpTurn(
|
|
|
380
459
|
})
|
|
381
460
|
: new Promise<never>(() => undefined);
|
|
382
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
|
+
|
|
383
492
|
const hooks: GeminiAcpHooks = {
|
|
384
493
|
onSessionUpdate: notification => {
|
|
494
|
+
if (loadingSession) return;
|
|
385
495
|
const update = notification.update;
|
|
386
496
|
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text" && update.content.text) {
|
|
387
497
|
state.sawText = true;
|
|
@@ -389,9 +499,24 @@ export async function runGeminiAcpTurn(
|
|
|
389
499
|
} else if (update.sessionUpdate === "agent_thought_chunk" && update.content.type === "text" && update.content.text) {
|
|
390
500
|
state.sawThinking = true;
|
|
391
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
|
+
}
|
|
392
515
|
}
|
|
393
516
|
},
|
|
394
|
-
onPermission:
|
|
517
|
+
onPermission: params => {
|
|
518
|
+
const toolCall = asRecord(params.toolCall);
|
|
519
|
+
if (toolCall) captureTool(toolCall);
|
|
395
520
|
if (session) void client?.cancel(session.sessionId).catch(() => undefined);
|
|
396
521
|
},
|
|
397
522
|
};
|
|
@@ -412,8 +537,34 @@ export async function runGeminiAcpTurn(
|
|
|
412
537
|
label: "Gemini ACP",
|
|
413
538
|
fallbackAllowed: false,
|
|
414
539
|
}, deps);
|
|
415
|
-
|
|
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 });
|
|
416
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;
|
|
417
568
|
|
|
418
569
|
if (input.incoming.abortSignal?.aborted) {
|
|
419
570
|
deliver({
|
|
@@ -456,7 +607,15 @@ export async function runGeminiAcpTurn(
|
|
|
456
607
|
}, CAPTURE_POLL_MS);
|
|
457
608
|
}
|
|
458
609
|
|
|
459
|
-
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)
|
|
460
619
|
.then(response => ({ kind: "prompt" as const, response }))
|
|
461
620
|
.catch(error => ({ kind: "error" as const, error }));
|
|
462
621
|
const captureOutcome = capturePromise
|
|
@@ -1,12 +1,17 @@
|
|
|
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
|
+
import {
|
|
11
|
+
discoverGeminiModelMetadata,
|
|
12
|
+
reasoningLevelForEffort,
|
|
13
|
+
type GeminiMetadataResult,
|
|
14
|
+
} from "./model-metadata";
|
|
10
15
|
import {
|
|
11
16
|
GeminiAcpError,
|
|
12
17
|
buildGeminiAcpEnv,
|
|
@@ -17,6 +22,18 @@ import {
|
|
|
17
22
|
type GeminiAcpDeps,
|
|
18
23
|
type GeminiAcpTurnInput,
|
|
19
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";
|
|
20
37
|
|
|
21
38
|
export const GEMINI_MCP_SERVER_NAME = "opencodex";
|
|
22
39
|
const GEMINI_MCP_SERVER_PATH = fileURLToPath(new URL("../mcp-server.ts", import.meta.url));
|
|
@@ -35,6 +52,7 @@ export interface GeminiTurnDeps extends CliAgentTurnDeps {
|
|
|
35
52
|
env?: NodeJS.ProcessEnv;
|
|
36
53
|
probeAcp?: typeof probeGeminiAcpSupport;
|
|
37
54
|
stageAcp?: typeof stageGeminiAcpWorkspace;
|
|
55
|
+
metadata?: typeof discoverGeminiModelMetadata;
|
|
38
56
|
runAcp?: (input: GeminiAcpTurnInput, emit: (event: AdapterEvent) => void, deps?: GeminiAcpDeps) => Promise<void>;
|
|
39
57
|
acpDeps?: GeminiAcpDeps;
|
|
40
58
|
}
|
|
@@ -42,12 +60,43 @@ export interface GeminiTurnDeps extends CliAgentTurnDeps {
|
|
|
42
60
|
function hasUnsupportedMedia(parsed: OcxParsedRequest): boolean {
|
|
43
61
|
return parsed.context.messages.some(message =>
|
|
44
62
|
Array.isArray(message.content)
|
|
45
|
-
&& message.content.some(part => part.type === "
|
|
63
|
+
&& message.content.some(part => part.type === "video" || part.type === "audio"),
|
|
46
64
|
);
|
|
47
65
|
}
|
|
48
66
|
|
|
49
|
-
|
|
50
|
-
|
|
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];
|
|
51
100
|
if (!first) return "";
|
|
52
101
|
try {
|
|
53
102
|
const frame = JSON.parse(first) as { message?: { content?: Array<{ type?: string; text?: string }> } };
|
|
@@ -64,16 +113,26 @@ export function buildGeminiPrompt(
|
|
|
64
113
|
parsed: OcxParsedRequest,
|
|
65
114
|
vendorNameByWire: ReadonlyMap<string, string>,
|
|
66
115
|
fixedSystem = GEMINI_SYSTEM_PROMPT,
|
|
116
|
+
maxChars?: number,
|
|
67
117
|
): string {
|
|
68
118
|
return [
|
|
69
119
|
"SYSTEM AND DEVELOPER INSTRUCTIONS:",
|
|
70
120
|
fixedSystem,
|
|
71
121
|
buildSystemPrompt(parsed),
|
|
72
122
|
"CONVERSATION REPLAY:",
|
|
73
|
-
conversationText(parsed, vendorNameByWire),
|
|
123
|
+
conversationText(parsed, vendorNameByWire, maxChars ?? Number.POSITIVE_INFINITY),
|
|
74
124
|
].filter(Boolean).join("\n\n");
|
|
75
125
|
}
|
|
76
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
|
+
|
|
77
136
|
function toolNameMap(catalog: ReturnType<typeof buildCodingAgentToolCatalog>): Map<string, string> {
|
|
78
137
|
const names = new Map<string, string>();
|
|
79
138
|
for (const [vendorName, wireName] of catalog.emittedNameMap) {
|
|
@@ -127,6 +186,31 @@ function failure(error: unknown): Extract<AdapterEvent, { type: "error" }> {
|
|
|
127
186
|
};
|
|
128
187
|
}
|
|
129
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
|
+
|
|
130
214
|
export async function runGeminiTurn(
|
|
131
215
|
parsed: OcxParsedRequest,
|
|
132
216
|
incoming: IncomingMeta,
|
|
@@ -138,11 +222,6 @@ export async function runGeminiTurn(
|
|
|
138
222
|
emit({ type: "error", message: "Gemini CLI requires a model selector.", status: 400, errorType: "invalid_request_error", code: "model_not_found", retryable: false });
|
|
139
223
|
return;
|
|
140
224
|
}
|
|
141
|
-
if (hasUnsupportedMedia(parsed)) {
|
|
142
|
-
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 });
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
225
|
let catalog: ReturnType<typeof buildCodingAgentToolCatalog>;
|
|
147
226
|
try {
|
|
148
227
|
catalog = buildCodingAgentToolCatalog(parsed, GEMINI_MCP_SERVER_NAME);
|
|
@@ -153,12 +232,51 @@ export async function runGeminiTurn(
|
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
const sourceEnv = deps.env ?? process.env;
|
|
235
|
+
await reclaimGeminiSessions(sourceEnv).catch(() => undefined);
|
|
156
236
|
const binary = resolveGeminiBinary(deps.which ?? whichFromPath, sourceEnv);
|
|
157
237
|
if (!binary) {
|
|
158
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 });
|
|
159
239
|
return;
|
|
160
240
|
}
|
|
161
241
|
|
|
242
|
+
const fingerprint = geminiBinaryFingerprintSync(binary);
|
|
243
|
+
let metadata: GeminiMetadataResult | undefined;
|
|
244
|
+
let reasoning;
|
|
245
|
+
try {
|
|
246
|
+
metadata = await (deps.metadata ?? discoverGeminiModelMetadata)({ binary, env: sourceEnv });
|
|
247
|
+
const capability = metadata.capabilities[requestedModel];
|
|
248
|
+
reasoning = reasoningLevelForEffort(capability, parsed.options.reasoning);
|
|
249
|
+
if (capability?.thinkingSupported && parsed.options.reasoning && !reasoning) {
|
|
250
|
+
emit({
|
|
251
|
+
type: "error",
|
|
252
|
+
message: `Gemini model "${requestedModel}" does not support reasoning effort "${parsed.options.reasoning}". Advertised levels: ${capability.reasoningLevels.map(level => level.effort).join(", ") || "none"}.`,
|
|
253
|
+
status: 400,
|
|
254
|
+
errorType: "invalid_request_error",
|
|
255
|
+
code: "reasoning_effort_unsupported",
|
|
256
|
+
retryable: false,
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
metadata = undefined;
|
|
262
|
+
reasoning = undefined;
|
|
263
|
+
}
|
|
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
|
+
|
|
162
280
|
const acpDeps: GeminiAcpDeps = {
|
|
163
281
|
...deps.acpDeps,
|
|
164
282
|
env: sourceEnv,
|
|
@@ -183,9 +301,58 @@ export async function runGeminiTurn(
|
|
|
183
301
|
|
|
184
302
|
const vendorNames = toolNameMap(catalog);
|
|
185
303
|
const vendorNameByWire = new Map([...catalog.emittedNameMap.values()].map(wireName => [wireName, wireName]));
|
|
186
|
-
const
|
|
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
|
+
}
|
|
340
|
+
const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(
|
|
341
|
+
catalog.tools,
|
|
342
|
+
reasoning ? { modelId: requestedModel, thinkingConfig: reasoning.thinkingConfig } : undefined,
|
|
343
|
+
{
|
|
344
|
+
...(reuse && prior ? { root: prior.workspace } : sessionKey ? { root: geminiSessionWorkspace(sessionKey, sourceEnv) } : {}),
|
|
345
|
+
sourceEnv,
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
let vendorSessionId: string | undefined;
|
|
349
|
+
let terminal: AdapterEvent | undefined;
|
|
187
350
|
try {
|
|
188
|
-
const turnEnv = {
|
|
351
|
+
const turnEnv = {
|
|
352
|
+
...acpEnv,
|
|
353
|
+
GEMINI_CLI_HOME: workspace.geminiHome,
|
|
354
|
+
GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath,
|
|
355
|
+
};
|
|
189
356
|
const mcpServers = workspace.capturePath && catalog.emittedNameMap.size > 0
|
|
190
357
|
? [{
|
|
191
358
|
type: "stdio",
|
|
@@ -200,21 +367,48 @@ export async function runGeminiTurn(
|
|
|
200
367
|
await (deps.runAcp ?? runGeminiAcpTurn)({
|
|
201
368
|
binary,
|
|
202
369
|
env: turnEnv,
|
|
370
|
+
runtimeEnv: sourceEnv,
|
|
203
371
|
cwd: workspace.cwd,
|
|
204
372
|
args: geminiAcpArgs(workspace.policyPath),
|
|
205
373
|
modelId: requestedModel,
|
|
206
|
-
|
|
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 } : {}),
|
|
207
380
|
toolNames: vendorNames,
|
|
208
381
|
mcpServers,
|
|
209
382
|
...(workspace.capturePath ? { capturePath: workspace.capturePath } : {}),
|
|
210
383
|
maxToolCalls: 16,
|
|
211
384
|
requireToolCall: catalog.requireToolCall,
|
|
212
385
|
incoming,
|
|
386
|
+
onVendorSession: sessionId => { vendorSessionId = sessionId; },
|
|
213
387
|
deps: acpDeps,
|
|
214
|
-
},
|
|
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
|
+
}
|
|
215
409
|
} catch (error) {
|
|
216
|
-
emit(failure(error));
|
|
217
|
-
|
|
410
|
+
if (!terminal) emit(failure(error));
|
|
411
|
+
if (sessionKey) await deleteGeminiSession(sessionKey, sourceEnv).catch(() => undefined);
|
|
218
412
|
await workspace.cleanup();
|
|
219
413
|
}
|
|
220
414
|
}
|