From 4e5f67ef9618a93d8916d9c8491ea81fdf3a147e Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 9 Mar 2026 15:16:15 +0000 Subject: [PATCH] feat(adapters/gemini-local): add auth detection, turn-limit handling, sandbox, and approval modes Incorporate improvements from PR #13 and #105 into the gemini-local adapter: - Add detectGeminiAuthRequired() for runtime auth failure detection with errorCode: "gemini_auth_required" on execution results - Add isGeminiTurnLimitResult() to detect exit code 53 / turn_limit status and clear session to prevent stuck sessions on next heartbeat - Add describeGeminiFailure() for structured error messages from parsed result events including errors array extraction - Return parsed resultEvent in resultJson instead of raw stdout/stderr - Add isRetry guard to prevent stale session ID fallback after retry - Replace boolean yolo with approvalMode string (default/auto_edit/yolo) with backwards-compatible config.yolo fallback - Add sandbox config option (--sandbox / --sandbox=none) - Add GOOGLE_GENAI_USE_GCA auth detection in environment test - Consolidate auth detection regex into shared detectGeminiAuthRequired() - Add gemini-2.0-flash and gemini-2.0-flash-lite model IDs Co-Authored-By: Claude Opus 4.6 --- packages/adapters/gemini-local/src/index.ts | 5 +- .../gemini-local/src/server/execute.ts | 46 ++++++++++-- .../adapters/gemini-local/src/server/index.ts | 8 +- .../adapters/gemini-local/src/server/parse.ts | 75 +++++++++++++++++++ .../adapters/gemini-local/src/server/test.ts | 27 ++++--- .../gemini-local/src/ui/build-config.ts | 3 +- 6 files changed, 142 insertions(+), 22 deletions(-) diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 947d4735..2c85dc51 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -7,6 +7,8 @@ export const models = [ { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, + { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + { id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, ]; export const agentConfigurationDoc = `# gemini_local agent configuration @@ -28,7 +30,8 @@ Core fields: - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt - promptTemplate (string, optional): run prompt template - model (string, optional): Gemini model id. Defaults to auto. -- yolo (boolean, optional): pass --approval-mode yolo for unattended operation +- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default") +- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none) - command (string, optional): defaults to "gemini" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 457284fa..f9495a4b 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -19,7 +19,13 @@ import { runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; -import { isGeminiUnknownSessionError, parseGeminiJsonl } from "./parse.js"; +import { + describeGeminiFailure, + detectGeminiAuthRequired, + isGeminiTurnLimitResult, + isGeminiUnknownSessionError, + parseGeminiJsonl, +} from "./parse.js"; import { firstNonEmptyLine } from "./utils.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -93,7 +99,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."]; - if (yolo) notes.push("Added --approval-mode yolo for unattended execution."); + if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} for unattended execution.`); if (!instructionsFilePath) return notes; if (instructionsPrefix.length > 0) { notes.push( @@ -242,7 +249,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); args.push(prompt); return args; @@ -290,18 +302,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise; }, clearSessionOnMissingSession = false, + isRetry = false, ): AdapterExecutionResult => { + const authMeta = detectGeminiAuthRequired({ + parsed: attempt.parsed.resultEvent, + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }); + if (attempt.proc.timedOut) { return { exitCode: attempt.proc.exitCode, signal: attempt.proc.signal, timedOut: true, errorMessage: `Timed out after ${timeoutSec}s`, + errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null, clearSession: clearSessionOnMissingSession, }; } - const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null; + const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode); + + // On retry, don't fall back to old session ID — the old session was stale + const canFallbackToRuntimeSession = !isRetry; + const resolvedSessionId = attempt.parsed.sessionId + ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); const resolvedSessionParams = resolvedSessionId ? ({ sessionId: resolvedSessionId, @@ -313,8 +338,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise | null = null; const usage = { inputTokens: 0, cachedInputTokens: 0, @@ -101,6 +102,7 @@ export function parseGeminiJsonl(stdout: string) { } if (type === "result") { + resultEvent = event; accumulateUsage(usage, event.usage ?? event.usageMetadata); const resultText = asString(event.result, "").trim() || @@ -151,6 +153,7 @@ export function parseGeminiJsonl(stdout: string) { usage, costUsd, errorMessage, + resultEvent, }; } @@ -165,3 +168,75 @@ export function isGeminiUnknownSessionError(stdout: string, stderr: string): boo haystack, ); } + +function extractGeminiErrorMessages(parsed: Record): string[] { + const messages: string[] = []; + const errorMsg = asString(parsed.error, "").trim(); + if (errorMsg) messages.push(errorMsg); + + const raw = Array.isArray(parsed.errors) ? parsed.errors : []; + for (const entry of raw) { + if (typeof entry === "string") { + const msg = entry.trim(); + if (msg) messages.push(msg); + continue; + } + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue; + const obj = entry as Record; + const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, ""); + if (msg) { + messages.push(msg); + continue; + } + try { + messages.push(JSON.stringify(obj)); + } catch { + // skip non-serializable entry + } + } + + return messages; +} + +export function describeGeminiFailure(parsed: Record): string | null { + const status = asString(parsed.status, ""); + const errors = extractGeminiErrorMessages(parsed); + + const detail = errors[0] ?? ""; + const parts = ["Gemini run failed"]; + if (status) parts.push(`status=${status}`); + if (detail) parts.push(detail); + return parts.length > 1 ? parts.join(": ") : null; +} + +const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|GEMINI_API_KEY|GOOGLE_API_KEY|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i; + +export function detectGeminiAuthRequired(input: { + parsed: Record | null; + stdout: string; + stderr: string; +}): { requiresAuth: boolean } { + const errors = extractGeminiErrorMessages(input.parsed ?? {}); + const messages = [...errors, input.stdout, input.stderr] + .join("\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line)); + return { requiresAuth }; +} + +export function isGeminiTurnLimitResult( + parsed: Record | null | undefined, + exitCode?: number | null, +): boolean { + if (exitCode === 53) return true; + if (!parsed) return false; + + const status = asString(parsed.status, "").trim().toLowerCase(); + if (status === "turn_limit" || status === "max_turns") return true; + + const error = asString(parsed.error, "").trim(); + return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error); +} diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 49bdb28f..b7762af8 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -15,7 +15,7 @@ import { runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; -import { parseGeminiJsonl } from "./parse.js"; +import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js"; import { firstNonEmptyLine } from "./utils.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { @@ -41,9 +41,6 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; } -const GEMINI_AUTH_REQUIRED_RE = - /(?:authentication\s+required|not\s+authenticated|not\s+logged\s+in|login\s+required|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|gemini[_\s-]?api[_\s-]?key|google[_\s-]?api[_\s-]?key|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first|api(?:[_\s-]?key)?(?:\s+is)?\s+required)/i; - export async function testEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { @@ -94,15 +91,19 @@ export async function testEnvironment( const hostGeminiApiKey = process.env.GEMINI_API_KEY; const configGoogleApiKey = env.GOOGLE_API_KEY; const hostGoogleApiKey = process.env.GOOGLE_API_KEY; + const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true"; if ( isNonEmpty(configGeminiApiKey) || isNonEmpty(hostGeminiApiKey) || isNonEmpty(configGoogleApiKey) || - isNonEmpty(hostGoogleApiKey) + isNonEmpty(hostGoogleApiKey) || + hasGca ) { - const source = isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey) - ? "adapter config env" - : "server environment"; + const source = hasGca + ? "Google account login (GCA)" + : isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey) + ? "adapter config env" + : "server environment"; checks.push({ code: "gemini_api_key_present", level: "info", @@ -114,7 +115,7 @@ export async function testEnvironment( code: "gemini_api_key_missing", level: "warn", message: "No Gemini API key was detected. Gemini runs may fail until auth is configured.", - hint: "Set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env/shell, or run `gemini auth` / `gemini auth login`.", + hint: "Set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env/shell, run `gemini auth` / `gemini auth login`, or set GOOGLE_GENAI_USE_GCA=true for Google account auth.", }); } @@ -158,7 +159,11 @@ export async function testEnvironment( ); const parsed = parseGeminiJsonl(probe.stdout); const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); - const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + const authMeta = detectGeminiAuthRequired({ + parsed: parsed.resultEvent, + stdout: probe.stdout, + stderr: probe.stderr, + }); if (probe.timedOut) { checks.push({ @@ -183,7 +188,7 @@ export async function testEnvironment( hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.", }), }); - } else if (GEMINI_AUTH_REQUIRED_RE.test(authEvidence)) { + } else if (authMeta.requiresAuth) { checks.push({ code: "gemini_hello_probe_auth_required", level: "warn", diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts index 12f6257e..1fd7ac65 100644 --- a/packages/adapters/gemini-local/src/ui/build-config.ts +++ b/packages/adapters/gemini-local/src/ui/build-config.ts @@ -67,7 +67,8 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; - if (v.dangerouslyBypassSandbox) ac.yolo = true; + if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo"; + ac.sandbox = !v.dangerouslyBypassSandbox; if (v.command) ac.command = v.command; if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); return ac;