295 lines
9.7 KiB
TypeScript
295 lines
9.7 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
|
import {
|
|
asBoolean,
|
|
asNumber,
|
|
asString,
|
|
ensureAbsoluteDirectory,
|
|
ensureCommandResolvable,
|
|
ensurePathInEnv,
|
|
filterConflictingArgs,
|
|
redactEnvForLogs,
|
|
renderTemplate,
|
|
runChildProcess,
|
|
} from "@agentcompany/adapter-utils/server-utils";
|
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
|
import {
|
|
describeGeminiFailure,
|
|
detectGeminiAuthRequired,
|
|
isGeminiTurnLimitResult,
|
|
isGeminiUnknownSessionError,
|
|
parseGeminiJsonl,
|
|
} from "./parse.js";
|
|
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
|
|
import {
|
|
resolveGeminiBillingType,
|
|
renderEnvNote,
|
|
renderApiAccessNote,
|
|
ensureGeminiSkillsInjected,
|
|
parseManagedSkills,
|
|
parseExtraArgs,
|
|
buildCommonAgentCompanyEnv,
|
|
resolveSessionResumeState,
|
|
} from "./execute-helpers.js";
|
|
|
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
|
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
|
|
|
const promptTemplate = asString(
|
|
config.promptTemplate,
|
|
"{{context.taskTitle}}\n\n{{context.taskDescription}}",
|
|
);
|
|
const command = asString(config.command, "gemini");
|
|
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
|
const sandbox = asBoolean(config.sandbox, false);
|
|
|
|
const { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } =
|
|
await buildCommonAgentCompanyEnv(
|
|
{ runId, agent, config, context, authToken },
|
|
{ ensureAbsoluteDirectory },
|
|
);
|
|
|
|
const managedSkills = parseManagedSkills(config);
|
|
await ensureGeminiSkillsInjected(managedSkills, onLog);
|
|
|
|
const billingType = resolveGeminiBillingType(env);
|
|
const runtimeEnv = ensurePathInEnv(env);
|
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
|
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
|
const graceSec = asNumber(config.graceSec, 20);
|
|
const extraArgs = parseExtraArgs(config);
|
|
|
|
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } =
|
|
resolveSessionResumeState(runtime, cwd);
|
|
if (runtimeSessionId && !canResumeSession) {
|
|
await onLog(
|
|
"stderr",
|
|
`[agent-company] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
|
);
|
|
}
|
|
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
let instructionsPrefix = "";
|
|
if (instructionsFilePath) {
|
|
try {
|
|
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
|
instructionsPrefix =
|
|
`${instructionsContents}\n\n` +
|
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
await onLog(
|
|
"stderr",
|
|
`[agent-company] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
|
);
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
await onLog(
|
|
"stderr",
|
|
`[agent-company] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
const renderedPrompt = renderTemplate(promptTemplate, {
|
|
agentId: agent.id,
|
|
companyId: agent.companyId,
|
|
runId,
|
|
company: { id: agent.companyId },
|
|
agent,
|
|
run: { id: runId, source: "on_demand" },
|
|
context,
|
|
});
|
|
const envNote = renderEnvNote(env);
|
|
const apiAccessNote = renderApiAccessNote(env);
|
|
const prompt = `${instructionsPrefix}${envNote}${apiAccessNote}${renderedPrompt}`;
|
|
|
|
const SAFE_ARG_LIMIT = 30_000;
|
|
const useStdin = prompt.length > SAFE_ARG_LIMIT;
|
|
const commandNotes = (() => {
|
|
const notes: string[] = [useStdin
|
|
? "Prompt passed via stdin (exceeds command-line length limit)."
|
|
: "Prompt is passed to Gemini as the final positional argument."];
|
|
notes.push("Added --approval-mode yolo for unattended execution.");
|
|
if (!instructionsFilePath) return notes;
|
|
if (instructionsPrefix.length > 0) {
|
|
notes.push(
|
|
`Loaded agent instructions from ${instructionsFilePath}`,
|
|
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
|
);
|
|
return notes;
|
|
}
|
|
notes.push(
|
|
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
);
|
|
return notes;
|
|
})();
|
|
let promptFilePath: string | null = null;
|
|
if (useStdin) {
|
|
promptFilePath = path.join(os.tmpdir(), `agentcompany-gemini-prompt-${runId}.txt`);
|
|
await fs.writeFile(promptFilePath, prompt, "utf8");
|
|
}
|
|
|
|
const buildArgs = (resumeSessionId: string | null) => {
|
|
const args = ["--output-format", "stream-json"];
|
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
|
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
|
args.push("--approval-mode", "yolo");
|
|
if (sandbox) {
|
|
args.push("--sandbox");
|
|
} else {
|
|
args.push("--sandbox=none");
|
|
}
|
|
if (extraArgs.length > 0) {
|
|
const managedFlags = [
|
|
"--approval-mode",
|
|
"--output-format",
|
|
"--model",
|
|
"--sandbox",
|
|
];
|
|
const cleaned = filterConflictingArgs(extraArgs, managedFlags);
|
|
if (cleaned.length > 0) args.push(...cleaned);
|
|
}
|
|
if (!useStdin) {
|
|
args.push(prompt);
|
|
}
|
|
return args;
|
|
};
|
|
|
|
const runAttempt = async (resumeSessionId: string | null) => {
|
|
const args = buildArgs(resumeSessionId);
|
|
if (onMeta) {
|
|
await onMeta({
|
|
adapterType: "gemini_local",
|
|
command,
|
|
cwd,
|
|
commandNotes,
|
|
commandArgs: args.map((value, index) => (
|
|
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
|
)),
|
|
env: redactEnvForLogs(env),
|
|
prompt,
|
|
context,
|
|
});
|
|
}
|
|
|
|
const proc = await runChildProcess(runId, command, args, {
|
|
cwd,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
...(useStdin ? { stdin: prompt } : {}),
|
|
});
|
|
return {
|
|
proc,
|
|
parsed: parseGeminiJsonl(proc.stdout),
|
|
};
|
|
};
|
|
|
|
const toResult = (
|
|
attempt: {
|
|
proc: {
|
|
exitCode: number | null;
|
|
signal: string | null;
|
|
timedOut: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
parsed: ReturnType<typeof parseGeminiJsonl>;
|
|
},
|
|
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 clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
|
|
|
|
const canFallbackToRuntimeSession = !isRetry;
|
|
const resolvedSessionId = attempt.parsed.sessionId
|
|
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
|
const resolvedSessionParams = resolvedSessionId
|
|
? ({
|
|
sessionId: resolvedSessionId,
|
|
cwd,
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
} as Record<string, unknown>)
|
|
: null;
|
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
const structuredFailure = attempt.parsed.resultEvent
|
|
? describeGeminiFailure(attempt.parsed.resultEvent)
|
|
: null;
|
|
const fallbackErrorMessage =
|
|
parsedError ||
|
|
structuredFailure ||
|
|
stderrLine ||
|
|
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
|
|
|
|
return {
|
|
exitCode: attempt.proc.exitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: false,
|
|
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
|
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
|
|
usage: attempt.parsed.usage,
|
|
sessionId: resolvedSessionId,
|
|
sessionParams: resolvedSessionParams,
|
|
sessionDisplayId: resolvedSessionId,
|
|
provider: "google",
|
|
model,
|
|
billingType,
|
|
costUsd: attempt.parsed.costUsd,
|
|
resultJson: attempt.parsed.resultEvent ?? {
|
|
stdout: attempt.proc.stdout,
|
|
stderr: attempt.proc.stderr,
|
|
},
|
|
summary: attempt.parsed.summary,
|
|
question: attempt.parsed.question,
|
|
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
|
};
|
|
};
|
|
|
|
try {
|
|
const initial = await runAttempt(sessionId);
|
|
if (
|
|
sessionId &&
|
|
!initial.proc.timedOut &&
|
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
|
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
|
) {
|
|
await onLog(
|
|
"stderr",
|
|
`[agent-company] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
);
|
|
const retry = await runAttempt(null);
|
|
return toResult(retry, true, true);
|
|
}
|
|
|
|
return toResult(initial);
|
|
} finally {
|
|
if (promptFilePath) {
|
|
await fs.unlink(promptFilePath).catch(() => {});
|
|
}
|
|
}
|
|
}
|