Fix Gemini local execution and diagnostics

This commit is contained in:
Dotta
2026-03-14 21:36:05 -05:00
parent 5814249ea9
commit c44dbf79cb
5 changed files with 90 additions and 10 deletions

View File

@@ -262,7 +262,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
const notes: string[] = ["Prompt is passed to Gemini via --prompt for non-interactive execution."];
notes.push("Added --approval-mode yolo for unattended execution.");
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
@@ -324,7 +324,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
args.push("--sandbox=none");
}
if (extraArgs.length > 0) args.push(...extraArgs);
args.push(prompt);
args.push("--prompt", prompt);
return args;
};

View File

@@ -231,6 +231,8 @@ export function describeGeminiFailure(parsed: Record<string, unknown>): string |
}
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
const GEMINI_QUOTA_EXHAUSTED_RE =
/(?:resource_exhausted|quota|rate[-\s]?limit|too many requests|\b429\b|billing details)/i;
export function detectGeminiAuthRequired(input: {
parsed: Record<string, unknown> | null;
@@ -248,6 +250,22 @@ export function detectGeminiAuthRequired(input: {
return { requiresAuth };
}
export function detectGeminiQuotaExhausted(input: {
parsed: Record<string, unknown> | null;
stdout: string;
stderr: string;
}): { exhausted: 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 exhausted = messages.some((line) => GEMINI_QUOTA_EXHAUSTED_RE.test(line));
return { exhausted };
}
export function isGeminiTurnLimitResult(
parsed: Record<string, unknown> | null | undefined,
exitCode?: number | null,

View File

@@ -6,6 +6,7 @@ import type {
} from "@paperclipai/adapter-utils";
import {
asBoolean,
asNumber,
asString,
asStringArray,
ensureAbsoluteDirectory,
@@ -15,7 +16,7 @@ import {
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js";
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
@@ -134,13 +135,14 @@ export async function testEnvironment(
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
const sandbox = asBoolean(config.sandbox, false);
const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 10));
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const args = ["--output-format", "stream-json"];
const args = ["--output-format", "stream-json", "--prompt", "Respond with hello."];
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
if (sandbox) {
@@ -149,7 +151,6 @@ export async function testEnvironment(
args.push("--sandbox=none");
}
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello.");
const probe = await runChildProcess(
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -158,7 +159,7 @@ export async function testEnvironment(
{
cwd,
env,
timeoutSec: 45,
timeoutSec: helloProbeTimeoutSec,
graceSec: 5,
onLog: async () => { },
},
@@ -170,8 +171,23 @@ export async function testEnvironment(
stdout: probe.stdout,
stderr: probe.stderr,
});
const quotaMeta = detectGeminiQuotaExhausted({
parsed: parsed.resultEvent,
stdout: probe.stdout,
stderr: probe.stderr,
});
if (probe.timedOut) {
if (quotaMeta.exhausted) {
checks.push({
code: "gemini_hello_probe_quota_exhausted",
level: "warn",
message: probe.timedOut
? "Gemini CLI is retrying after quota exhaustion."
: "Gemini CLI authentication is configured, but the current account or API key is over quota.",
...(detail ? { detail } : {}),
hint: "The configured Gemini account or API key is over quota. Check ai.google.dev usage/billing, then retry the probe.",
});
} else if (probe.timedOut) {
checks.push({
code: "gemini_hello_probe_timed_out",
level: "warn",

View File

@@ -27,6 +27,20 @@ console.log(JSON.stringify({
return commandPath;
}
async function writeQuotaGeminiCommand(binDir: string): Promise<string> {
const commandPath = path.join(binDir, "gemini");
const script = `#!/usr/bin/env node
if (process.argv.includes("--help")) {
process.exit(0);
}
console.error("429 RESOURCE_EXHAUSTED: You exceeded your current quota and billing details.");
process.exit(1);
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
return commandPath;
}
describe("gemini_local environment diagnostics", () => {
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
@@ -86,6 +100,35 @@ describe("gemini_local environment diagnostics", () => {
expect(args).toContain("gemini-2.5-pro");
expect(args).toContain("--approval-mode");
expect(args).toContain("yolo");
expect(args).toContain("--prompt");
await fs.rm(root, { recursive: true, force: true });
});
it("classifies quota exhaustion as a quota warning instead of a generic failure", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-gemini-local-quota-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const binDir = path.join(root, "bin");
const cwd = path.join(root, "workspace");
await fs.mkdir(binDir, { recursive: true });
await writeQuotaGeminiCommand(binDir);
const result = await testEnvironment({
companyId: "company-1",
adapterType: "gemini_local",
config: {
command: "gemini",
cwd,
env: {
GEMINI_API_KEY: "test-key",
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
});
expect(result.status).toBe("warn");
expect(result.checks.some((check) => check.code === "gemini_hello_probe_quota_exhausted")).toBe(true);
await fs.rm(root, { recursive: true, force: true });
});
});

View File

@@ -45,7 +45,7 @@ type CapturePayload = {
};
describe("gemini execute", () => {
it("passes prompt as final argument and injects paperclip env vars", async () => {
it("passes prompt via --prompt and injects paperclip env vars", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
@@ -96,10 +96,13 @@ describe("gemini execute", () => {
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--output-format");
expect(capture.argv).toContain("stream-json");
expect(capture.argv).toContain("--prompt");
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat.");
expect(capture.argv.at(-1)).toContain("Paperclip runtime note:");
const promptFlagIndex = capture.argv.indexOf("--prompt");
const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : "";
expect(promptArg).toContain("Follow the paperclip heartbeat.");
expect(promptArg).toContain("Paperclip runtime note:");
expect(capture.paperclipEnvKeys).toEqual(
expect.arrayContaining([
"PAPERCLIP_AGENT_ID",