Default Gemini adapter to yolo mode and add API access prompt note
Gemini CLI only registers run_shell_command in --approval-mode yolo. Non-yolo modes don't expose it at all, making Paperclip API calls impossible. Always pass --approval-mode yolo and remove the now-unused policy engine code, approval mode config, and UI toggles. Add a "Paperclip API access note" to the prompt with curl examples via run_shell_command, since the universal SKILL.md is tool-agnostic. Also extract structured question events from Gemini assistant messages to support interactive approval flows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,14 @@ export interface AdapterExecutionResult {
|
|||||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
clearSession?: boolean;
|
clearSession?: boolean;
|
||||||
|
question?: {
|
||||||
|
prompt: string;
|
||||||
|
choices: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdapterSessionCodec {
|
export interface AdapterSessionCodec {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ Core fields:
|
|||||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||||
- promptTemplate (string, optional): run prompt template
|
- promptTemplate (string, optional): run prompt template
|
||||||
- model (string, optional): Gemini model id. Defaults to auto.
|
- model (string, optional): Gemini model id. Defaults to auto.
|
||||||
- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default")
|
|
||||||
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
||||||
- command (string, optional): defaults to "gemini"
|
- command (string, optional): defaults to "gemini"
|
||||||
- extraArgs (string[], optional): additional CLI args
|
- extraArgs (string[], optional): additional CLI args
|
||||||
|
|||||||
@@ -59,6 +59,20 @@ function renderPaperclipEnvNote(env: Record<string, string>): string {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderApiAccessNote(env: Record<string, string>): string {
|
||||||
|
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
|
||||||
|
return [
|
||||||
|
"Paperclip API access note:",
|
||||||
|
"Use run_shell_command with curl to make Paperclip API requests.",
|
||||||
|
"GET example:",
|
||||||
|
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`,
|
||||||
|
"POST/PATCH example:",
|
||||||
|
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||||
@@ -132,7 +146,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
);
|
);
|
||||||
const command = asString(config.command, "gemini");
|
const command = asString(config.command, "gemini");
|
||||||
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 sandbox = asBoolean(config.sandbox, false);
|
const sandbox = asBoolean(config.sandbox, false);
|
||||||
|
|
||||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
@@ -250,7 +263,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 as the final positional argument."];
|
||||||
if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} 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) {
|
||||||
notes.push(
|
notes.push(
|
||||||
@@ -275,13 +288,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
|
const apiAccessNote = renderApiAccessNote(env);
|
||||||
|
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
|
||||||
|
|
||||||
const buildArgs = (resumeSessionId: string | null) => {
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
const args = ["--output-format", "stream-json"];
|
const args = ["--output-format", "stream-json"];
|
||||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
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);
|
args.push("--approval-mode", "yolo");
|
||||||
if (sandbox) {
|
if (sandbox) {
|
||||||
args.push("--sandbox");
|
args.push("--sandbox");
|
||||||
} else {
|
} else {
|
||||||
@@ -398,6 +412,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
stderr: attempt.proc.stderr,
|
stderr: attempt.proc.stderr,
|
||||||
},
|
},
|
||||||
summary: attempt.parsed.summary,
|
summary: attempt.parsed.summary,
|
||||||
|
question: attempt.parsed.question,
|
||||||
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function parseGeminiJsonl(stdout: string) {
|
|||||||
let errorMessage: string | null = null;
|
let errorMessage: string | null = null;
|
||||||
let costUsd: number | null = null;
|
let costUsd: number | null = null;
|
||||||
let resultEvent: Record<string, unknown> | null = null;
|
let resultEvent: Record<string, unknown> | null = null;
|
||||||
|
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
|
||||||
const usage = {
|
const usage = {
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
cachedInputTokens: 0,
|
cachedInputTokens: 0,
|
||||||
@@ -98,6 +99,25 @@ export function parseGeminiJsonl(stdout: string) {
|
|||||||
|
|
||||||
if (type === "assistant") {
|
if (type === "assistant") {
|
||||||
messages.push(...collectMessageText(event.message));
|
messages.push(...collectMessageText(event.message));
|
||||||
|
const messageObj = parseObject(event.message);
|
||||||
|
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = parseObject(partRaw);
|
||||||
|
if (asString(part.type, "").trim() === "question") {
|
||||||
|
question = {
|
||||||
|
prompt: asString(part.prompt, "").trim(),
|
||||||
|
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
|
||||||
|
const choice = parseObject(choiceRaw);
|
||||||
|
return {
|
||||||
|
key: asString(choice.key, "").trim(),
|
||||||
|
label: asString(choice.label, "").trim(),
|
||||||
|
description: asString(choice.description, "").trim() || undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
break; // only one question per message
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +174,7 @@ export function parseGeminiJsonl(stdout: string) {
|
|||||||
costUsd,
|
costUsd,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
resultEvent,
|
resultEvent,
|
||||||
|
question,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Object.keys(env).length > 0) ac.env = env;
|
if (Object.keys(env).length > 0) ac.env = env;
|
||||||
if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo";
|
|
||||||
ac.sandbox = !v.dangerouslyBypassSandbox;
|
ac.sandbox = !v.dangerouslyBypassSandbox;
|
||||||
|
|
||||||
if (v.command) ac.command = v.command;
|
if (v.command) ac.command = v.command;
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
return ac;
|
return ac;
|
||||||
|
|||||||
@@ -39,6 +39,37 @@ describe("gemini_local parser", () => {
|
|||||||
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
|
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
|
||||||
expect(parsed.errorMessage).toBe("model access denied");
|
expect(parsed.errorMessage).toBe("model access denied");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts structured questions", () => {
|
||||||
|
const stdout = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "output_text", text: "I have a question." },
|
||||||
|
{
|
||||||
|
type: "question",
|
||||||
|
prompt: "Which model?",
|
||||||
|
choices: [
|
||||||
|
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||||
|
{ key: "flash", label: "Gemini Flash" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const parsed = parseGeminiJsonl(stdout);
|
||||||
|
expect(parsed.summary).toBe("I have a question.");
|
||||||
|
expect(parsed.question).toEqual({
|
||||||
|
prompt: "Which model?",
|
||||||
|
choices: [
|
||||||
|
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||||
|
{ key: "flash", label: "Gemini Flash", description: undefined },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("gemini_local stale session detection", () => {
|
describe("gemini_local stale session detection", () => {
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ describe("gemini execute", () => {
|
|||||||
command: commandPath,
|
command: commandPath,
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
model: "gemini-2.5-pro",
|
model: "gemini-2.5-pro",
|
||||||
yolo: true,
|
|
||||||
env: {
|
env: {
|
||||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||||
},
|
},
|
||||||
@@ -112,6 +111,51 @@ describe("gemini execute", () => {
|
|||||||
);
|
);
|
||||||
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
||||||
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
||||||
|
expect(invocationPrompt).toContain("Paperclip API access note:");
|
||||||
|
expect(invocationPrompt).toContain("run_shell_command");
|
||||||
|
expect(result.question).toBeNull();
|
||||||
|
} finally {
|
||||||
|
if (previousHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = previousHome;
|
||||||
|
}
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always passes --approval-mode yolo", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
|
||||||
|
const workspace = path.join(root, "workspace");
|
||||||
|
const commandPath = path.join(root, "gemini");
|
||||||
|
const capturePath = path.join(root, "capture.json");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await writeFakeGeminiCommand(commandPath);
|
||||||
|
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
process.env.HOME = root;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execute({
|
||||||
|
runId: "run-yolo",
|
||||||
|
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
|
||||||
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||||
|
config: {
|
||||||
|
command: commandPath,
|
||||||
|
cwd: workspace,
|
||||||
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
authToken: "t",
|
||||||
|
onLog: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
|
expect(capture.argv).toContain("--approval-mode");
|
||||||
|
expect(capture.argv).toContain("yolo");
|
||||||
|
expect(capture.argv).not.toContain("--policy");
|
||||||
|
expect(capture.argv).not.toContain("--allow-all");
|
||||||
|
expect(capture.argv).not.toContain("--allow-read");
|
||||||
} finally {
|
} finally {
|
||||||
if (previousHome === undefined) {
|
if (previousHome === undefined) {
|
||||||
delete process.env.HOME;
|
delete process.env.HOME;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { AdapterConfigFieldsProps } from "../types";
|
|||||||
import {
|
import {
|
||||||
DraftInput,
|
DraftInput,
|
||||||
Field,
|
Field,
|
||||||
ToggleField,
|
|
||||||
} from "../../components/agent-config-primitives";
|
} from "../../components/agent-config-primitives";
|
||||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||||
|
|
||||||
@@ -45,20 +44,6 @@ export function GeminiLocalConfigFields({
|
|||||||
<ChoosePathButton />
|
<ChoosePathButton />
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
<ToggleField
|
|
||||||
label="Yolo mode"
|
|
||||||
hint="Run Gemini with --approval-mode yolo for unattended operation."
|
|
||||||
checked={
|
|
||||||
isCreate
|
|
||||||
? values!.dangerouslyBypassSandbox
|
|
||||||
: eff("adapterConfig", "yolo", config.yolo === true)
|
|
||||||
}
|
|
||||||
onChange={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ dangerouslyBypassSandbox: v })
|
|
||||||
: mark("adapterConfig", "yolo", v)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user