Fix Gemini local execution and diagnostics
This commit is contained in:
parent
5814249ea9
commit
c44dbf79cb
5 changed files with 90 additions and 10 deletions
|
|
@ -262,7 +262,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const commandNotes = (() => {
|
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.");
|
notes.push("Added --approval-mode yolo for unattended execution.");
|
||||||
if (!instructionsFilePath) return notes;
|
if (!instructionsFilePath) return notes;
|
||||||
if (instructionsPrefix.length > 0) {
|
if (instructionsPrefix.length > 0) {
|
||||||
|
|
@ -324,7 +324,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
args.push("--sandbox=none");
|
args.push("--sandbox=none");
|
||||||
}
|
}
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
args.push(prompt);
|
args.push("--prompt", prompt);
|
||||||
return args;
|
return args;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_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: {
|
export function detectGeminiAuthRequired(input: {
|
||||||
parsed: Record<string, unknown> | null;
|
parsed: Record<string, unknown> | null;
|
||||||
|
|
@ -248,6 +250,22 @@ export function detectGeminiAuthRequired(input: {
|
||||||
return { requiresAuth };
|
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(
|
export function isGeminiTurnLimitResult(
|
||||||
parsed: Record<string, unknown> | null | undefined,
|
parsed: Record<string, unknown> | null | undefined,
|
||||||
exitCode?: number | null,
|
exitCode?: number | null,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
asBoolean,
|
asBoolean,
|
||||||
|
asNumber,
|
||||||
asString,
|
asString,
|
||||||
asStringArray,
|
asStringArray,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
|
|
@ -15,7 +16,7 @@ import {
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
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";
|
import { firstNonEmptyLine } from "./utils.js";
|
||||||
|
|
||||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
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 model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||||
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
||||||
const sandbox = asBoolean(config.sandbox, false);
|
const sandbox = asBoolean(config.sandbox, false);
|
||||||
|
const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 10));
|
||||||
const extraArgs = (() => {
|
const extraArgs = (() => {
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
return asStringArray(config.args);
|
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 (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||||
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
||||||
if (sandbox) {
|
if (sandbox) {
|
||||||
|
|
@ -149,7 +151,6 @@ export async function testEnvironment(
|
||||||
args.push("--sandbox=none");
|
args.push("--sandbox=none");
|
||||||
}
|
}
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
args.push("Respond with hello.");
|
|
||||||
|
|
||||||
const probe = await runChildProcess(
|
const probe = await runChildProcess(
|
||||||
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
|
@ -158,7 +159,7 @@ export async function testEnvironment(
|
||||||
{
|
{
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
timeoutSec: 45,
|
timeoutSec: helloProbeTimeoutSec,
|
||||||
graceSec: 5,
|
graceSec: 5,
|
||||||
onLog: async () => { },
|
onLog: async () => { },
|
||||||
},
|
},
|
||||||
|
|
@ -170,8 +171,23 @@ export async function testEnvironment(
|
||||||
stdout: probe.stdout,
|
stdout: probe.stdout,
|
||||||
stderr: probe.stderr,
|
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({
|
checks.push({
|
||||||
code: "gemini_hello_probe_timed_out",
|
code: "gemini_hello_probe_timed_out",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,20 @@ console.log(JSON.stringify({
|
||||||
return commandPath;
|
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", () => {
|
describe("gemini_local environment diagnostics", () => {
|
||||||
it("creates a missing working directory when cwd is absolute", async () => {
|
it("creates a missing working directory when cwd is absolute", async () => {
|
||||||
const cwd = path.join(
|
const cwd = path.join(
|
||||||
|
|
@ -86,6 +100,35 @@ describe("gemini_local environment diagnostics", () => {
|
||||||
expect(args).toContain("gemini-2.5-pro");
|
expect(args).toContain("gemini-2.5-pro");
|
||||||
expect(args).toContain("--approval-mode");
|
expect(args).toContain("--approval-mode");
|
||||||
expect(args).toContain("yolo");
|
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 });
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ type CapturePayload = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("gemini execute", () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
|
||||||
const workspace = path.join(root, "workspace");
|
const workspace = path.join(root, "workspace");
|
||||||
const commandPath = path.join(root, "gemini");
|
const commandPath = path.join(root, "gemini");
|
||||||
|
|
@ -96,10 +96,13 @@ describe("gemini execute", () => {
|
||||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
expect(capture.argv).toContain("--output-format");
|
expect(capture.argv).toContain("--output-format");
|
||||||
expect(capture.argv).toContain("stream-json");
|
expect(capture.argv).toContain("stream-json");
|
||||||
|
expect(capture.argv).toContain("--prompt");
|
||||||
expect(capture.argv).toContain("--approval-mode");
|
expect(capture.argv).toContain("--approval-mode");
|
||||||
expect(capture.argv).toContain("yolo");
|
expect(capture.argv).toContain("yolo");
|
||||||
expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat.");
|
const promptFlagIndex = capture.argv.indexOf("--prompt");
|
||||||
expect(capture.argv.at(-1)).toContain("Paperclip runtime note:");
|
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(capture.paperclipEnvKeys).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
"PAPERCLIP_AGENT_ID",
|
"PAPERCLIP_AGENT_ID",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue