From 782902d899a046e5452f14f86a128f9e4a1168b0 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 21 Nov 2025 15:18:14 -0800 Subject: [PATCH] Add logging to Gemini --- agents/gemini.ts | 178 +++++++++++++++++++++++++++++++++++++++++++++-- utils/cli.ts | 6 +- 2 files changed, 176 insertions(+), 8 deletions(-) diff --git a/agents/gemini.ts b/agents/gemini.ts index 1674a75..cc3eb27 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -4,6 +4,144 @@ import { spawn } from "../utils/subprocess.ts"; import { addInstructions } from "./instructions.ts"; import { agent, type ConfigureMcpServersParams, installFromGithub } from "./shared.ts"; +// gemini cli event types inferred from stream-json output (NDJSON format) +interface GeminiInitEvent { + type: "init"; + timestamp?: string; + session_id?: string; + model?: string; + [key: string]: unknown; +} + +interface GeminiMessageEvent { + type: "message"; + timestamp?: string; + role?: "user" | "assistant"; + content?: string; + delta?: boolean; + [key: string]: unknown; +} + +interface GeminiToolUseEvent { + type: "tool_use"; + timestamp?: string; + tool_name?: string; + tool_id?: string; + parameters?: unknown; + [key: string]: unknown; +} + +interface GeminiToolResultEvent { + type: "tool_result"; + timestamp?: string; + tool_id?: string; + status?: "success" | "error"; + output?: string; + [key: string]: unknown; +} + +interface GeminiResultEvent { + type: "result"; + timestamp?: string; + status?: "success" | "error"; + stats?: { + total_tokens?: number; + input_tokens?: number; + output_tokens?: number; + duration_ms?: number; + tool_calls?: number; + }; + [key: string]: unknown; +} + +type GeminiEvent = + | GeminiInitEvent + | GeminiMessageEvent + | GeminiToolUseEvent + | GeminiToolResultEvent + | GeminiResultEvent; + +let assistantMessageBuffer = ""; + +const messageHandlers = { + init: (_event: GeminiInitEvent) => { + // initialization event - no logging needed + assistantMessageBuffer = ""; + }, + message: (event: GeminiMessageEvent) => { + if (event.role === "assistant" && event.content?.trim()) { + if (event.delta) { + // accumulate delta messages + assistantMessageBuffer += event.content; + } else { + // final message - log it + const message = event.content.trim(); + if (message) { + log.box(message, { title: "Gemini" }); + } + assistantMessageBuffer = ""; + } + } else if (event.role === "assistant" && !event.delta && assistantMessageBuffer.trim()) { + // if we have buffered content and get a non-delta message, log the buffer + log.box(assistantMessageBuffer.trim(), { title: "Gemini" }); + assistantMessageBuffer = ""; + } + }, + tool_use: (event: GeminiToolUseEvent) => { + if (event.tool_name) { + // log intent for create_working_comment + if (event.tool_name === "create_working_comment" && event.parameters) { + const params = event.parameters as { intent?: string; [key: string]: unknown }; + if (params.intent) { + log.box(params.intent.trim(), { title: "Intent" }); + } + } + + log.toolCall({ + toolName: event.tool_name, + input: event.parameters || {}, + }); + } + }, + tool_result: (event: GeminiToolResultEvent) => { + if (event.status === "error") { + const errorMsg = + typeof event.output === "string" ? event.output : JSON.stringify(event.output); + log.warning(`Tool call failed: ${errorMsg}`); + } + }, + result: async (event: GeminiResultEvent) => { + // log any remaining buffered assistant message + if (assistantMessageBuffer.trim()) { + log.box(assistantMessageBuffer.trim(), { title: "Gemini" }); + assistantMessageBuffer = ""; + } + + if (event.status === "success" && event.stats) { + const stats = event.stats; + const rows: Array> = [ + [ + { data: "Input Tokens", header: true }, + { data: "Output Tokens", header: true }, + { data: "Total Tokens", header: true }, + { data: "Tool Calls", header: true }, + { data: "Duration (ms)", header: true }, + ], + [ + String(stats.input_tokens || 0), + String(stats.output_tokens || 0), + String(stats.total_tokens || 0), + String(stats.tool_calls || 0), + String(stats.duration_ms || 0), + ], + ]; + await log.summaryTable(rows); + } else if (event.status === "error") { + log.error(`Gemini CLI failed: ${JSON.stringify(event)}`); + } + }, +}; + export const gemini = agent({ name: "gemini", install: async () => { @@ -31,23 +169,45 @@ export const gemini = agent({ try { const result = await spawn({ cmd: "node", - args: [cliPath, "--yolo", "--output-format=text", "-p", sessionPrompt], + args: [cliPath, "--yolo", "--output-format=stream-json", "-p", sessionPrompt], env: { + PATH: process.env.PATH || "", + HOME: process.env.HOME || "", + TMPDIR: process.env.TMPDIR || "/tmp", GEMINI_API_KEY: apiKey, GITHUB_INSTALLATION_TOKEN: githubInstallationToken, LOG_LEVEL: process.env.LOG_LEVEL!, NODE_ENV: process.env.NODE_ENV!, }, - onStdout: (chunk) => { - const trimmed = chunk.trim(); - if (trimmed) { - log.info(trimmed); - finalOutput += trimmed + "\n"; + timeout: 600000, // 10 minutes + onStdout: async (chunk) => { + const text = chunk.toString(); + finalOutput += text; + + // parse each line as JSON (gemini cli outputs one JSON object per line) + const lines = text.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + log.debug(`[gemini stdout] ${trimmed}`); + + try { + const event = JSON.parse(trimmed) as GeminiEvent; + const handler = messageHandlers[event.type as keyof typeof messageHandlers]; + if (handler) { + await handler(event as never); + } + } catch { + console.log("parse error", trimmed); + // ignore parse errors - might be non-JSON output from gemini cli + } } }, onStderr: (chunk) => { const trimmed = chunk.trim(); if (trimmed) { + log.debug(`[gemini stderr] ${trimmed}`); log.warning(trimmed); finalOutput += trimmed + "\n"; } @@ -55,7 +215,11 @@ export const gemini = agent({ }); if (result.exitCode !== 0) { - const errorMessage = result.stderr || result.stdout || "Unknown error"; + const errorMessage = + result.stderr || + finalOutput || + result.stdout || + "Unknown error - no output from Gemini CLI"; log.error(`Gemini CLI exited with code ${result.exitCode}: ${errorMessage}`); return { success: false, diff --git a/utils/cli.ts b/utils/cli.ts index 072ba71..c5736c7 100644 --- a/utils/cli.ts +++ b/utils/cli.ts @@ -79,7 +79,11 @@ function boxString( } const maxLineLength = Math.max(...wrappedLines.map((line) => line.length)); - const boxWidth = maxLineLength + padding * 2; + const contentBoxWidth = maxLineLength + padding * 2; + + // ensure box width is at least as wide as the title line when title exists + const titleLineLength = title ? ` ${title} `.length : 0; + const boxWidth = Math.max(contentBoxWidth, titleLineLength); let result = "";