/**
* GeminiExecutor — Google Gemini API via @google/generative-ai.
*/
import type { IExecutor, ExecutorConfig, ExecutorStreamEvent } from "./types.ts";
export class GeminiExecutor implements IExecutor {
readonly executorId: string;
readonly executorType = "gemini" as const;
private history: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> = [];
private pendingContent: string | null = null;
private model: string;
private apiKey: string;
private systemPrompt: string;
private _turnCount = 0;
private static readonly MAX_TURNS = 50;
private static readonly WARN_TURNS = 40;
private pendingSummary: string | null = null;
private cancelled = false;
constructor(config: ExecutorConfig) {
this.executorId = config.sessionId;
this.model = config.model || "gemini-2.0-flash";
this.apiKey = config.apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
this.systemPrompt = config.systemPrompt || "";
}
sendMessage(content: string): void {
if (!content?.trim()) {
console.warn(`[Executor] sendMessage called with empty content, ignoring`);
return;
}
this.cancelled = false; // reset cancel flag at start of each new message
let msg = content;
if (this.pendingSummary) {
msg = `\n${this.pendingSummary}\n\n\n${content}`;
this.pendingSummary = null;
}
this.pendingContent = msg;
}
async *getOutputStream(): AsyncGenerator {
if (!this.pendingContent) {
yield { type: "error", error: "No message queued" };
return;
}
const userMsg = this.pendingContent;
this.pendingContent = null;
let GoogleGenerativeAI: any;
try {
const sdk = await import("@google/generative-ai");
GoogleGenerativeAI = sdk.GoogleGenerativeAI;
} catch {
yield { type: "error", error: "@google/generative-ai not installed. Run: npm install @google/generative-ai" };
return;
}
const genAI = new GoogleGenerativeAI(this.apiKey);
const modelInstance = genAI.getGenerativeModel({
model: this.model,
...(this.systemPrompt ? { systemInstruction: this.systemPrompt } : {}),
});
const chat = modelInstance.startChat({ history: this.history });
let assistantText = "";
let userPushed = false;
try {
this.history.push({ role: "user", parts: [{ text: userMsg }] });
userPushed = true;
const result = await chat.sendMessageStream(userMsg);
for await (const chunk of result.stream) {
if (this.cancelled) break;
const text = chunk.text?.();
if (text) {
assistantText += text;
yield { type: "text_delta", text };
}
}
if (this.cancelled) {
if (assistantText) {
this.history.push({ role: "model", parts: [{ text: assistantText }] });
} else if (userPushed) {
this.history.pop(); // remove user turn on clean abort
}
yield { type: "result", cost: 0 };
return; // don't yield error on abort
}
this.history.push({ role: "model", parts: [{ text: assistantText }] });
yield { type: "result", cost: null, duration: null };
} catch (err) {
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
if (assistantText) {
this.history.push({ role: "model", parts: [{ text: assistantText }] });
} else if (userPushed) {
this.history.pop(); // remove user turn on clean abort
}
return; // don't yield error on abort
} else {
if (userPushed) this.history.pop(); // rollback user turn on error
yield { type: "error", error: err instanceof Error ? err.message : String(err) };
}
}
}
interrupt(): void {
this.pendingContent = null;
this.cancelled = true;
}
get turnCount(): number { return this._turnCount; }
get shouldRotate(): boolean { return this._turnCount >= GeminiExecutor.MAX_TURNS; }
get shouldWarnRotation(): boolean { return this._turnCount >= GeminiExecutor.WARN_TURNS && !this.shouldRotate; }
incrementTurn(): void { this._turnCount++; }
async rotate(): Promise {
const histText = this.history.map(h => `${h.role}: ${h.parts[0]?.text?.slice(0, 300)}`).join("\n");
this.pendingSummary = histText.slice(0, 2000);
this.history = [];
this._turnCount = 0;
}
}