From feb0fcf64e5818edb621a4ef6183e74a12c16668 Mon Sep 17 00:00:00 2001 From: Norbert515 Date: Thu, 19 Feb 2026 23:36:22 +0100 Subject: [PATCH] feat: add gemini_sdk package wrapping Google Gemini CLI Create a Dart SDK wrapper for Gemini CLI (google-gemini/gemini-cli) that works natively with agent_sdk types. Uses subprocess-per-turn transport with --output-format stream-json for JSONL event streaming and --resume for multi-turn sessions. Key components: - GeminiTransport: spawns one subprocess per turn, parses JSONL events - GeminiEvent sealed hierarchy: init, message, tool_use, tool_result, error, result - GeminiClient: manages conversation state using agent_sdk types directly - GeminiAgentClient: thin bridge implementing AgentClient (pure delegation) - GeminiConfig: PODO with toCliArgs(), defaults to yolo approval mode Auth supports both GEMINI_API_KEY and cached Google login credentials. --- packages/gemini_sdk/lib/gemini_sdk.dart | 7 + .../lib/src/client/gemini_agent_client.dart | 90 ++++ .../lib/src/client/gemini_client.dart | 498 ++++++++++++++++++ .../lib/src/config/gemini_config.dart | 87 +++ .../lib/src/protocol/gemini_event.dart | 236 +++++++++ .../lib/src/transport/gemini_transport.dart | 114 ++++ packages/gemini_sdk/pubspec.yaml | 15 + .../gemini_sdk/test/gemini_event_test.dart | 266 ++++++++++ pubspec.yaml | 1 + 9 files changed, 1314 insertions(+) create mode 100644 packages/gemini_sdk/lib/gemini_sdk.dart create mode 100644 packages/gemini_sdk/lib/src/client/gemini_agent_client.dart create mode 100644 packages/gemini_sdk/lib/src/client/gemini_client.dart create mode 100644 packages/gemini_sdk/lib/src/config/gemini_config.dart create mode 100644 packages/gemini_sdk/lib/src/protocol/gemini_event.dart create mode 100644 packages/gemini_sdk/lib/src/transport/gemini_transport.dart create mode 100644 packages/gemini_sdk/pubspec.yaml create mode 100644 packages/gemini_sdk/test/gemini_event_test.dart diff --git a/packages/gemini_sdk/lib/gemini_sdk.dart b/packages/gemini_sdk/lib/gemini_sdk.dart new file mode 100644 index 0000000..ca1b7f7 --- /dev/null +++ b/packages/gemini_sdk/lib/gemini_sdk.dart @@ -0,0 +1,7 @@ +library gemini_sdk; + +export 'src/client/gemini_agent_client.dart'; +export 'src/client/gemini_client.dart'; +export 'src/config/gemini_config.dart'; +export 'src/protocol/gemini_event.dart'; +export 'src/transport/gemini_transport.dart'; diff --git a/packages/gemini_sdk/lib/src/client/gemini_agent_client.dart b/packages/gemini_sdk/lib/src/client/gemini_agent_client.dart new file mode 100644 index 0000000..392dc0b --- /dev/null +++ b/packages/gemini_sdk/lib/src/client/gemini_agent_client.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import 'package:agent_sdk/agent_sdk.dart'; + +import 'gemini_client.dart'; + +/// Bridge that wraps [GeminiClient] and implements [AgentClient]. +/// +/// Since [GeminiClient] already works natively with `agent_sdk` types, +/// this bridge is pure delegation — no type mapping needed. +/// +/// Also implements [Interruptible] since abort is supported via process kill. +class GeminiAgentClient implements AgentClient, Interruptible { + final GeminiClient _inner; + + GeminiAgentClient(this._inner); + + /// Access the inner [GeminiClient] for SDK-specific operations + /// (e.g. accessing [GeminiClient.geminiSessionId]). + GeminiClient get innerClient => _inner; + + // ── Streams ────────────────────────────────────────────── + + @override + Stream get conversation => _inner.conversation; + + @override + Stream get onTurnComplete => _inner.onTurnComplete; + + @override + Stream get statusStream => _inner.statusStream; + + @override + Stream get initDataStream => _inner.initDataStream; + + @override + Stream get queuedMessage => _inner.queuedMessage; + + // ── Current state ──────────────────────────────────────── + + @override + AgentConversation get currentConversation => _inner.currentConversation; + + @override + AgentProcessingStatus get currentStatus => _inner.currentStatus; + + @override + AgentInitData? get initData => _inner.initData; + + @override + String? get currentQueuedMessage => _inner.currentQueuedMessage; + + @override + String get sessionId => _inner.sessionId; + + @override + String get workingDirectory => _inner.workingDirectory; + + @override + Future get initialized => _inner.initialized; + + // ── Actions ────────────────────────────────────────────── + + @override + void sendMessage(AgentMessage message) => _inner.sendMessage(message); + + @override + Future abort() => _inner.abort(); + + @override + Future close() => _inner.close(); + + @override + Future clearConversation() => _inner.clearConversation(); + + @override + void clearQueuedMessage() => _inner.clearQueuedMessage(); + + @override + void injectToolResult(AgentToolResultResponse toolResult) => + _inner.injectToolResult(toolResult); + + @override + T? getMcpServer(String name) => null; // Gemini CLI manages its own tools + + // ── Interruptible ──────────────────────────────────────── + + @override + Future interrupt() => _inner.abort(); +} diff --git a/packages/gemini_sdk/lib/src/client/gemini_client.dart b/packages/gemini_sdk/lib/src/client/gemini_client.dart new file mode 100644 index 0000000..fe4e3a1 --- /dev/null +++ b/packages/gemini_sdk/lib/src/client/gemini_client.dart @@ -0,0 +1,498 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:agent_sdk/agent_sdk.dart'; +import 'package:uuid/uuid.dart'; + +import '../config/gemini_config.dart'; +import '../protocol/gemini_event.dart'; +import '../transport/gemini_transport.dart'; + +/// Client for the Gemini CLI, communicating via subprocess with +/// `--output-format stream-json`. +/// +/// Works natively with `agent_sdk` types — no intermediate type hierarchy. +/// Multi-turn sessions work via `--resume `: after the first turn, +/// [GeminiInitEvent] provides a session ID that is passed to subsequent turns. +class GeminiClient { + final GeminiConfig config; + final GeminiTransport _transport = GeminiTransport(); + + // Stream controllers (matching AgentClient surface) + final _conversationController = + StreamController.broadcast(); + final _turnCompleteController = StreamController.broadcast(); + final _statusController = StreamController.broadcast(); + final _initDataController = StreamController.broadcast(); + final _queuedMessageController = StreamController.broadcast(); + + AgentConversation _currentConversation = AgentConversation.empty(); + AgentProcessingStatus _currentStatus = AgentProcessingStatus.ready; + AgentInitData? _initData; + + final String _sessionId; + final String _workingDirectory; + + /// Gemini session ID from the CLI (for --resume). Different from our + /// internal _sessionId which is a UUID for this client instance. + String? _geminiSessionId; + + bool _isClosed = false; + bool _isInitialized = false; + final Completer _initializedCompleter = Completer(); + + // Message queueing + String? _queuedMessageText; + List? _queuedAttachments; + + GeminiClient({required this.config}) + : _sessionId = config.sessionId ?? const Uuid().v4(), + _workingDirectory = config.workingDirectory ?? Directory.current.path { + // Auto-flush queued messages when turn completes + _turnCompleteController.stream.listen((_) { + _flushQueuedMessage(); + }); + } + + /// Initialize the client. + /// + /// Verifies that the `gemini` CLI is available on the PATH. + Future init() async { + if (_isInitialized) return; + + // Verify gemini CLI is available + try { + final result = await Process.run('gemini', ['--version']); + if (result.exitCode != 0) { + throw StateError( + 'gemini CLI returned exit code ${result.exitCode}. ' + 'Install with: npm install -g @google/gemini-cli', + ); + } + } on ProcessException { + throw StateError( + 'gemini CLI not found on PATH. ' + 'Install with: npm install -g @google/gemini-cli', + ); + } + + _isInitialized = true; + if (!_initializedCompleter.isCompleted) { + _initializedCompleter.complete(); + } + } + + // ============================================================ + // Public API + // ============================================================ + + String get sessionId => _sessionId; + + String get workingDirectory => _workingDirectory; + + /// The Gemini CLI session ID (for --resume). Available after first turn. + String? get geminiSessionId => _geminiSessionId; + + Stream get conversation => _conversationController.stream; + + AgentConversation get currentConversation => _currentConversation; + + Future get initialized => _initializedCompleter.future; + + Stream get initDataStream => _initDataController.stream; + + AgentInitData? get initData => _initData; + + Stream get onTurnComplete => _turnCompleteController.stream; + + Stream get statusStream => _statusController.stream; + + AgentProcessingStatus get currentStatus => _currentStatus; + + Stream get queuedMessage => _queuedMessageController.stream; + + String? get currentQueuedMessage => _queuedMessageText; + + void clearQueuedMessage() { + _queuedMessageText = null; + _queuedAttachments = null; + if (!_isClosed) _queuedMessageController.add(null); + } + + void sendMessage(AgentMessage message) { + if (message.text.trim().isEmpty && (message.attachments?.isEmpty ?? true)) { + return; + } + + // If currently processing, queue the message + if (_currentConversation.isProcessing) { + _queueMessage(message); + return; + } + + // Add user message to conversation optimistically + final userMessage = AgentConversationMessage.user(content: message.text); + _updateConversation( + _currentConversation + .addMessage(userMessage) + .withState(AgentConversationState.sendingMessage), + ); + + _updateStatus(AgentProcessingStatus.processing); + + _startTurn(message.text); + } + + void injectToolResult(AgentToolResultResponse toolResult) { + // Gemini CLI manages its own tool execution; this is a no-op. + // We still update the conversation for UI consistency. + if (_currentConversation.messages.isEmpty) return; + + final lastIndex = _currentConversation.messages.length - 1; + final lastMessage = _currentConversation.messages[lastIndex]; + if (lastMessage.role != AgentMessageRole.assistant) return; + + final updatedMessage = lastMessage.copyWith( + responses: [...lastMessage.responses, toolResult], + ); + final updatedMessages = [..._currentConversation.messages]; + updatedMessages[lastIndex] = updatedMessage; + _updateConversation( + _currentConversation.copyWith(messages: updatedMessages), + ); + } + + Future abort() async { + _transport.kill(); + _updateStatus(AgentProcessingStatus.ready); + } + + Future close() async { + _isClosed = true; + + _transport.close(); + + await _conversationController.close(); + await _turnCompleteController.close(); + await _statusController.close(); + await _initDataController.close(); + await _queuedMessageController.close(); + + _isInitialized = false; + } + + Future clearConversation() async { + _geminiSessionId = null; + _updateConversation(AgentConversation.empty()); + } + + // ============================================================ + // Private implementation + // ============================================================ + + void _startTurn(String prompt) { + // Build config with the current Gemini session ID for --resume + final turnConfig = _geminiSessionId != null + ? config.copyWith( + sessionId: _geminiSessionId, + workingDirectory: _workingDirectory, + ) + : config.copyWith(workingDirectory: _workingDirectory); + + // Current assistant message being built + final assistantId = 'gemini_${DateTime.now().millisecondsSinceEpoch}'; + var assistantResponses = []; + var assistantText = StringBuffer(); + + _transport + .runTurn(prompt: prompt, config: turnConfig) + .listen( + (event) { + if (_isClosed) return; + _handleEvent( + event, + assistantId: assistantId, + assistantResponses: assistantResponses, + assistantText: assistantText, + ); + }, + onDone: () { + if (_isClosed) return; + + // Finalize the assistant message if we have responses + if (assistantResponses.isNotEmpty) { + _finalizeAssistantMessage( + assistantId: assistantId, + responses: assistantResponses, + ); + } + + _updateStatus(AgentProcessingStatus.ready); + if (!_isClosed) _turnCompleteController.add(null); + }, + onError: (Object error) { + if (_isClosed) return; + _handleError(error.toString()); + }, + ); + } + + void _handleEvent( + GeminiEvent event, { + required String assistantId, + required List assistantResponses, + required StringBuffer assistantText, + }) { + switch (event) { + case GeminiInitEvent(): + _geminiSessionId = event.sessionId; + _initData = AgentInitData( + model: event.model, + sessionId: event.sessionId, + cwd: _workingDirectory, + metadata: event.data, + ); + if (!_isClosed) _initDataController.add(_initData!); + _updateStatus(AgentProcessingStatus.responding); + + case GeminiMessageEvent(): + if (event.isDelta) { + assistantText.write(event.content); + final textResponse = AgentTextResponse( + id: '${assistantId}_text_${assistantResponses.length}', + timestamp: event.timestamp, + content: event.content, + isPartial: true, + ); + assistantResponses.add(textResponse); + } else { + // Full message (non-delta) + assistantText.clear(); + assistantText.write(event.content); + final textResponse = AgentTextResponse( + id: '${assistantId}_text_${assistantResponses.length}', + timestamp: event.timestamp, + content: event.content, + ); + assistantResponses.add(textResponse); + } + + // Update conversation with current state of assistant message + final assistantMessage = AgentConversationMessage( + id: assistantId, + role: AgentMessageRole.assistant, + content: assistantText.toString(), + timestamp: DateTime.now(), + responses: List.of(assistantResponses), + isStreaming: true, + ); + _updateConversation( + _currentConversation + .updateLastMessage(assistantMessage) + .withState(AgentConversationState.receivingResponse), + ); + + // If the last message is a user message, we need to add instead + if (_currentConversation.lastMessage?.role == AgentMessageRole.user) { + _updateConversation( + _currentConversation + .addMessage(assistantMessage) + .withState(AgentConversationState.receivingResponse), + ); + } + + case GeminiToolUseEvent(): + final toolUse = AgentToolUseResponse( + id: '${assistantId}_tool_${event.toolId}', + timestamp: event.timestamp, + toolName: event.toolName, + parameters: event.parameters, + toolUseId: event.toolId, + ); + assistantResponses.add(toolUse); + _updateAssistantMessage( + assistantId: assistantId, + content: assistantText.toString(), + responses: assistantResponses, + isStreaming: true, + ); + + case GeminiToolResultEvent(): + final toolResult = AgentToolResultResponse( + id: '${assistantId}_result_${event.toolId}', + timestamp: event.timestamp, + toolUseId: event.toolId, + content: event.output, + isError: event.status != 'success', + ); + assistantResponses.add(toolResult); + _updateAssistantMessage( + assistantId: assistantId, + content: assistantText.toString(), + responses: assistantResponses, + isStreaming: true, + ); + + case GeminiErrorEvent(): + final errorResponse = AgentErrorResponse( + id: '${assistantId}_error_${assistantResponses.length}', + timestamp: event.timestamp, + error: event.message, + rawData: event.details, + ); + assistantResponses.add(errorResponse); + _updateAssistantMessage( + assistantId: assistantId, + content: assistantText.toString(), + responses: assistantResponses, + isStreaming: true, + ); + + case GeminiResultEvent(): + // Turn completion with stats + if (event.stats != null) { + final completion = AgentCompletionResponse( + id: '${assistantId}_completion', + timestamp: event.timestamp, + stopReason: event.status, + inputTokens: event.stats!.inputTokens, + outputTokens: event.stats!.outputTokens, + ); + assistantResponses.add(completion); + + // Update conversation token totals + _updateConversation( + _currentConversation.copyWith( + totalInputTokens: + _currentConversation.totalInputTokens + + event.stats!.inputTokens, + totalOutputTokens: + _currentConversation.totalOutputTokens + + event.stats!.outputTokens, + currentContextInputTokens: event.stats!.inputTokens, + ), + ); + } + + case GeminiUnknownEvent(): + // Silently ignore unknown events + break; + } + } + + void _finalizeAssistantMessage({ + required String assistantId, + required List responses, + }) { + // Build the final text from all text responses + final textBuffer = StringBuffer(); + for (final response in responses) { + if (response is AgentTextResponse) { + if (response.isPartial) { + textBuffer.write(response.content); + } else { + textBuffer.clear(); + textBuffer.write(response.content); + } + } + } + + _updateAssistantMessage( + assistantId: assistantId, + content: textBuffer.toString(), + responses: responses, + isStreaming: false, + isComplete: true, + ); + } + + void _updateAssistantMessage({ + required String assistantId, + required String content, + required List responses, + required bool isStreaming, + bool isComplete = false, + }) { + final assistantMessage = AgentConversationMessage( + id: assistantId, + role: AgentMessageRole.assistant, + content: content, + timestamp: DateTime.now(), + responses: List.of(responses), + isStreaming: isStreaming, + isComplete: isComplete, + ); + + // Check if we need to add or update the message + final lastMessage = _currentConversation.lastMessage; + if (lastMessage != null && lastMessage.id == assistantId) { + _updateConversation( + _currentConversation + .updateLastMessage(assistantMessage) + .withState( + isComplete + ? AgentConversationState.idle + : AgentConversationState.receivingResponse, + ), + ); + } else { + _updateConversation( + _currentConversation + .addMessage(assistantMessage) + .withState( + isComplete + ? AgentConversationState.idle + : AgentConversationState.receivingResponse, + ), + ); + } + } + + void _handleError(String error) { + if (_isClosed) return; + _updateConversation(_currentConversation.withError(error)); + _updateStatus(AgentProcessingStatus.ready); + if (!_isClosed) _turnCompleteController.add(null); + } + + void _updateConversation(AgentConversation newConversation) { + if (_isClosed) return; + _currentConversation = newConversation; + _conversationController.add(_currentConversation); + } + + void _updateStatus(AgentProcessingStatus status) { + if (_isClosed) return; + if (_currentStatus != status) { + _currentStatus = status; + _statusController.add(status); + } + } + + void _queueMessage(AgentMessage message) { + if (_queuedMessageText == null) { + _queuedMessageText = message.text; + _queuedAttachments = message.attachments; + } else { + _queuedMessageText = '$_queuedMessageText\n${message.text}'; + if (message.attachments != null) { + _queuedAttachments = [ + ...(_queuedAttachments ?? []), + ...message.attachments!, + ]; + } + } + if (!_isClosed) _queuedMessageController.add(_queuedMessageText); + } + + void _flushQueuedMessage() { + if (_queuedMessageText == null) return; + + final text = _queuedMessageText!; + _queuedMessageText = null; + _queuedAttachments = null; + if (!_isClosed) _queuedMessageController.add(null); + + sendMessage(AgentMessage.text(text)); + } +} diff --git a/packages/gemini_sdk/lib/src/config/gemini_config.dart b/packages/gemini_sdk/lib/src/config/gemini_config.dart new file mode 100644 index 0000000..2fab68b --- /dev/null +++ b/packages/gemini_sdk/lib/src/config/gemini_config.dart @@ -0,0 +1,87 @@ +/// Configuration for the Gemini CLI wrapper. +class GeminiConfig { + /// Model to use (e.g. 'gemini-2.5-pro', 'gemini-2.5-flash'). + final String? model; + + /// Working directory for the Gemini CLI subprocess. + final String? workingDirectory; + + /// Session ID for resuming a previous session via `--resume`. + final String? sessionId; + + /// Gemini API key. If null, Gemini CLI uses cached Google login + /// credentials from `~/.gemini/` (LOGIN_WITH_GOOGLE auth type). + final String? apiKey; + + /// Tool approval mode. Defaults to 'yolo' because headless mode has no + /// stdin-based approval protocol — tools requiring confirmation are + /// auto-denied unless a permissive mode is set. + /// + /// Options: 'default', 'auto_edit', 'yolo', 'plan' + final String approvalMode; + + /// Custom system prompt / instructions. + final String? systemPrompt; + + /// Sandbox mode setting. + final String? sandbox; + + const GeminiConfig({ + this.model, + this.workingDirectory, + this.sessionId, + this.apiKey, + this.approvalMode = 'yolo', + this.systemPrompt, + this.sandbox, + }); + + /// Build CLI args for a single turn. + /// + /// Produces args like: + /// `['-p', prompt, '-o', 'stream-json', '-m', model, '--approval-mode', 'yolo']` + List toCliArgs(String prompt) { + final args = [ + '-p', + prompt, + '-o', + 'stream-json', + '--approval-mode', + approvalMode, + ]; + + if (model != null) { + args.addAll(['-m', model!]); + } + + if (sessionId != null) { + args.addAll(['--resume', sessionId!]); + } + + if (sandbox != null) { + args.addAll(['--sandbox', sandbox!]); + } + + return args; + } + + GeminiConfig copyWith({ + String? model, + String? workingDirectory, + String? sessionId, + String? apiKey, + String? approvalMode, + String? systemPrompt, + String? sandbox, + }) { + return GeminiConfig( + model: model ?? this.model, + workingDirectory: workingDirectory ?? this.workingDirectory, + sessionId: sessionId ?? this.sessionId, + apiKey: apiKey ?? this.apiKey, + approvalMode: approvalMode ?? this.approvalMode, + systemPrompt: systemPrompt ?? this.systemPrompt, + sandbox: sandbox ?? this.sandbox, + ); + } +} diff --git a/packages/gemini_sdk/lib/src/protocol/gemini_event.dart b/packages/gemini_sdk/lib/src/protocol/gemini_event.dart new file mode 100644 index 0000000..64a82af --- /dev/null +++ b/packages/gemini_sdk/lib/src/protocol/gemini_event.dart @@ -0,0 +1,236 @@ +/// Gemini CLI stream-json event types. +/// +/// When invoked with `--output-format stream-json`, Gemini CLI emits +/// newline-delimited JSON events to stdout. Each event has a `type` field. +sealed class GeminiEvent { + final DateTime timestamp; + + const GeminiEvent({required this.timestamp}); + + /// Parse a JSON map (from a single JSONL line) into a typed [GeminiEvent]. + factory GeminiEvent.fromJson(Map json) { + final type = json['type'] as String? ?? ''; + final ts = + DateTime.tryParse(json['timestamp'] as String? ?? '') ?? DateTime.now(); + + return switch (type) { + 'init' => GeminiInitEvent.fromJson(json, ts), + 'message' => GeminiMessageEvent.fromJson(json, ts), + 'tool_use' => GeminiToolUseEvent.fromJson(json, ts), + 'tool_result' => GeminiToolResultEvent.fromJson(json, ts), + 'error' => GeminiErrorEvent.fromJson(json, ts), + 'result' => GeminiResultEvent.fromJson(json, ts), + _ => GeminiUnknownEvent(type: type, data: json, timestamp: ts), + }; + } +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +/// Emitted once at the start of a turn. Contains session and model info. +class GeminiInitEvent extends GeminiEvent { + final String sessionId; + final String? model; + final Map data; + + const GeminiInitEvent({ + required this.sessionId, + this.model, + required this.data, + required super.timestamp, + }); + + factory GeminiInitEvent.fromJson(Map json, DateTime ts) { + return GeminiInitEvent( + sessionId: json['session_id'] as String? ?? '', + model: json['model'] as String?, + data: json, + timestamp: ts, + ); + } +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +/// A message chunk (streaming text from the model or a full message). +class GeminiMessageEvent extends GeminiEvent { + final String role; + final String content; + final bool isDelta; + + const GeminiMessageEvent({ + required this.role, + required this.content, + required this.isDelta, + required super.timestamp, + }); + + factory GeminiMessageEvent.fromJson(Map json, DateTime ts) { + return GeminiMessageEvent( + role: json['role'] as String? ?? 'assistant', + content: json['content'] as String? ?? '', + isDelta: json['delta'] as bool? ?? false, + timestamp: ts, + ); + } +} + +// --------------------------------------------------------------------------- +// Tool use +// --------------------------------------------------------------------------- + +/// The model is invoking a tool. +class GeminiToolUseEvent extends GeminiEvent { + final String toolName; + final String toolId; + final Map parameters; + + const GeminiToolUseEvent({ + required this.toolName, + required this.toolId, + required this.parameters, + required super.timestamp, + }); + + factory GeminiToolUseEvent.fromJson(Map json, DateTime ts) { + return GeminiToolUseEvent( + toolName: json['tool_name'] as String? ?? '', + toolId: json['tool_id'] as String? ?? '', + parameters: json['parameters'] as Map? ?? {}, + timestamp: ts, + ); + } +} + +// --------------------------------------------------------------------------- +// Tool result +// --------------------------------------------------------------------------- + +/// The result of a tool invocation. +class GeminiToolResultEvent extends GeminiEvent { + final String toolId; + final String status; + final String output; + + const GeminiToolResultEvent({ + required this.toolId, + required this.status, + required this.output, + required super.timestamp, + }); + + factory GeminiToolResultEvent.fromJson( + Map json, + DateTime ts, + ) { + return GeminiToolResultEvent( + toolId: json['tool_id'] as String? ?? '', + status: json['status'] as String? ?? '', + output: json['output'] as String? ?? '', + timestamp: ts, + ); + } +} + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +/// An error event from the CLI. +class GeminiErrorEvent extends GeminiEvent { + final String severity; + final String message; + final Map? details; + + const GeminiErrorEvent({ + required this.severity, + required this.message, + this.details, + required super.timestamp, + }); + + factory GeminiErrorEvent.fromJson(Map json, DateTime ts) { + return GeminiErrorEvent( + severity: json['severity'] as String? ?? 'error', + message: json['message'] as String? ?? 'Unknown error', + details: json, + timestamp: ts, + ); + } +} + +// --------------------------------------------------------------------------- +// Result (turn completion) +// --------------------------------------------------------------------------- + +/// Emitted at the end of a turn with final status and token statistics. +class GeminiResultEvent extends GeminiEvent { + final String status; + final GeminiStats? stats; + final Map data; + + const GeminiResultEvent({ + required this.status, + this.stats, + required this.data, + required super.timestamp, + }); + + factory GeminiResultEvent.fromJson(Map json, DateTime ts) { + final statsJson = json['stats'] as Map?; + return GeminiResultEvent( + status: json['status'] as String? ?? '', + stats: statsJson != null ? GeminiStats.fromJson(statsJson) : null, + data: json, + timestamp: ts, + ); + } +} + +// --------------------------------------------------------------------------- +// Unknown / catch-all +// --------------------------------------------------------------------------- + +/// An event type not recognized by this SDK version. +class GeminiUnknownEvent extends GeminiEvent { + final String type; + final Map data; + + const GeminiUnknownEvent({ + required this.type, + required this.data, + required super.timestamp, + }); +} + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +/// Token usage and timing statistics from a completed turn. +class GeminiStats { + final int totalTokens; + final int inputTokens; + final int outputTokens; + final int durationMs; + + const GeminiStats({ + required this.totalTokens, + required this.inputTokens, + required this.outputTokens, + required this.durationMs, + }); + + factory GeminiStats.fromJson(Map json) { + return GeminiStats( + totalTokens: json['total_tokens'] as int? ?? 0, + inputTokens: json['input_tokens'] as int? ?? 0, + outputTokens: json['output_tokens'] as int? ?? 0, + durationMs: json['duration_ms'] as int? ?? 0, + ); + } +} diff --git a/packages/gemini_sdk/lib/src/transport/gemini_transport.dart b/packages/gemini_sdk/lib/src/transport/gemini_transport.dart new file mode 100644 index 0000000..66eb381 --- /dev/null +++ b/packages/gemini_sdk/lib/src/transport/gemini_transport.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../config/gemini_config.dart'; +import '../protocol/gemini_event.dart'; + +/// Subprocess-per-turn transport for the Gemini CLI. +/// +/// Unlike Codex CLI's persistent `app-server` subprocess, Gemini CLI has no +/// bidirectional stdin/stdout protocol. Each turn spawns a new process with +/// `gemini -p "..." --output-format stream-json`, streams JSONL events from +/// stdout, and exits when the turn completes. +/// +/// Multi-turn sessions work via `--resume ` — Gemini CLI persists +/// session state on disk and reloads it for subsequent turns. +class GeminiTransport { + Process? _activeProcess; + bool _closed = false; + + /// Whether a turn is currently running. + bool get isRunning => _activeProcess != null; + + /// Run a single turn: spawn `gemini -p "..." --output-format stream-json`, + /// stream [GeminiEvent]s parsed from stdout JSONL, and complete when the + /// process exits. + /// + /// The returned stream emits parsed events and closes when the subprocess + /// finishes. Non-JSON lines from stdout are silently skipped. + Stream runTurn({ + required String prompt, + required GeminiConfig config, + }) async* { + if (_closed) throw StateError('Transport has been closed'); + if (_activeProcess != null) { + throw StateError('A turn is already in progress'); + } + + final args = config.toCliArgs(prompt); + + final environment = { + ...Platform.environment, + if (config.apiKey != null) 'GEMINI_API_KEY': config.apiKey!, + }; + + _activeProcess = await Process.start( + 'gemini', + args, + workingDirectory: config.workingDirectory, + environment: environment, + ); + + // Collect stderr for error diagnostics + final stderrBuffer = StringBuffer(); + _activeProcess!.stderr.transform(utf8.decoder).listen((chunk) { + stderrBuffer.write(chunk); + }); + + // Parse stdout line-by-line as JSONL + final lineBuffer = StringBuffer(); + + await for (final chunk in _activeProcess!.stdout.transform(utf8.decoder)) { + lineBuffer.write(chunk); + final content = lineBuffer.toString(); + final lines = content.split('\n'); + + // Keep the last potentially incomplete line in the buffer + lineBuffer.clear(); + if (!content.endsWith('\n') && lines.isNotEmpty) { + lineBuffer.write(lines.removeLast()); + } else if (lines.isNotEmpty && lines.last.isEmpty) { + lines.removeLast(); + } + + for (final line in lines) { + final trimmed = line.trim(); + if (trimmed.isEmpty) continue; + + try { + final json = jsonDecode(trimmed) as Map; + yield GeminiEvent.fromJson(json); + } on FormatException { + // Skip non-JSON lines (e.g. startup messages) + continue; + } + } + } + + // Wait for process to fully exit + final exitCode = await _activeProcess!.exitCode; + _activeProcess = null; + + if (exitCode != 0 && stderrBuffer.isNotEmpty) { + yield GeminiErrorEvent( + severity: 'error', + message: 'Gemini CLI exited with code $exitCode: $stderrBuffer', + timestamp: DateTime.now(), + ); + } + } + + /// Kill the active process (for abort). + void kill() { + _activeProcess?.kill(ProcessSignal.sigterm); + _activeProcess = null; + } + + /// Close the transport permanently. + void close() { + if (_closed) return; + _closed = true; + kill(); + } +} diff --git a/packages/gemini_sdk/pubspec.yaml b/packages/gemini_sdk/pubspec.yaml new file mode 100644 index 0000000..1368fcf --- /dev/null +++ b/packages/gemini_sdk/pubspec.yaml @@ -0,0 +1,15 @@ +name: gemini_sdk +description: Dart wrapper for Google Gemini CLI headless mode +version: 0.1.0 +resolution: workspace + +environment: + sdk: ^3.8.0 + +dependencies: + agent_sdk: any + uuid: ^4.5.1 + +dev_dependencies: + test: ^1.24.0 + lints: ^3.0.0 diff --git a/packages/gemini_sdk/test/gemini_event_test.dart b/packages/gemini_sdk/test/gemini_event_test.dart new file mode 100644 index 0000000..8ee15ed --- /dev/null +++ b/packages/gemini_sdk/test/gemini_event_test.dart @@ -0,0 +1,266 @@ +import 'package:gemini_sdk/gemini_sdk.dart'; +import 'package:test/test.dart'; + +void main() { + group('GeminiEvent.fromJson', () { + test('parses init event', () { + final json = { + 'type': 'init', + 'timestamp': '2026-02-19T10:30:00.000Z', + 'session_id': 'sess_abc123', + 'model': 'gemini-2.5-pro', + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final init = event as GeminiInitEvent; + expect(init.sessionId, 'sess_abc123'); + expect(init.model, 'gemini-2.5-pro'); + expect(init.timestamp.year, 2026); + }); + + test('parses message event with delta', () { + final json = { + 'type': 'message', + 'timestamp': '2026-02-19T10:30:01.000Z', + 'role': 'assistant', + 'content': 'Hello ', + 'delta': true, + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final msg = event as GeminiMessageEvent; + expect(msg.role, 'assistant'); + expect(msg.content, 'Hello '); + expect(msg.isDelta, true); + }); + + test('parses message event without delta', () { + final json = { + 'type': 'message', + 'timestamp': '2026-02-19T10:30:02.000Z', + 'role': 'assistant', + 'content': 'Hello world!', + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final msg = event as GeminiMessageEvent; + expect(msg.isDelta, false); + expect(msg.content, 'Hello world!'); + }); + + test('parses tool_use event', () { + final json = { + 'type': 'tool_use', + 'timestamp': '2026-02-19T10:30:03.000Z', + 'tool_name': 'ReadFile', + 'tool_id': 'tool_123', + 'parameters': {'path': '/tmp/test.txt'}, + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final tool = event as GeminiToolUseEvent; + expect(tool.toolName, 'ReadFile'); + expect(tool.toolId, 'tool_123'); + expect(tool.parameters, {'path': '/tmp/test.txt'}); + }); + + test('parses tool_result event', () { + final json = { + 'type': 'tool_result', + 'timestamp': '2026-02-19T10:30:04.000Z', + 'tool_id': 'tool_123', + 'status': 'success', + 'output': 'file contents here', + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final result = event as GeminiToolResultEvent; + expect(result.toolId, 'tool_123'); + expect(result.status, 'success'); + expect(result.output, 'file contents here'); + }); + + test('parses error event', () { + final json = { + 'type': 'error', + 'timestamp': '2026-02-19T10:30:05.000Z', + 'severity': 'error', + 'message': 'Rate limit exceeded', + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final err = event as GeminiErrorEvent; + expect(err.severity, 'error'); + expect(err.message, 'Rate limit exceeded'); + }); + + test('parses result event with stats', () { + final json = { + 'type': 'result', + 'timestamp': '2026-02-19T10:30:06.000Z', + 'status': 'completed', + 'stats': { + 'total_tokens': 1500, + 'input_tokens': 1000, + 'output_tokens': 500, + 'duration_ms': 3200, + }, + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final result = event as GeminiResultEvent; + expect(result.status, 'completed'); + expect(result.stats, isNotNull); + expect(result.stats!.totalTokens, 1500); + expect(result.stats!.inputTokens, 1000); + expect(result.stats!.outputTokens, 500); + expect(result.stats!.durationMs, 3200); + }); + + test('parses result event without stats', () { + final json = { + 'type': 'result', + 'timestamp': '2026-02-19T10:30:07.000Z', + 'status': 'error', + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final result = event as GeminiResultEvent; + expect(result.status, 'error'); + expect(result.stats, isNull); + }); + + test('parses unknown event type', () { + final json = { + 'type': 'future_event_type', + 'timestamp': '2026-02-19T10:30:08.000Z', + 'some_field': 'some_value', + }; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final unknown = event as GeminiUnknownEvent; + expect(unknown.type, 'future_event_type'); + expect(unknown.data['some_field'], 'some_value'); + }); + + test('handles missing timestamp', () { + final json = {'type': 'init', 'session_id': 'sess_abc'}; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + // Should use DateTime.now() as fallback + expect( + event.timestamp.difference(DateTime.now()).inSeconds.abs(), + lessThan(2), + ); + }); + + test('handles missing fields gracefully', () { + final json = {'type': 'message'}; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + final msg = event as GeminiMessageEvent; + expect(msg.role, 'assistant'); + expect(msg.content, ''); + expect(msg.isDelta, false); + }); + + test('handles missing type field', () { + final json = {'timestamp': '2026-02-19T10:30:00.000Z'}; + + final event = GeminiEvent.fromJson(json); + expect(event, isA()); + }); + }); + + group('GeminiStats.fromJson', () { + test('parses all fields', () { + final json = { + 'total_tokens': 2000, + 'input_tokens': 1500, + 'output_tokens': 500, + 'duration_ms': 4500, + }; + + final stats = GeminiStats.fromJson(json); + expect(stats.totalTokens, 2000); + expect(stats.inputTokens, 1500); + expect(stats.outputTokens, 500); + expect(stats.durationMs, 4500); + }); + + test('defaults missing fields to zero', () { + final stats = GeminiStats.fromJson({}); + expect(stats.totalTokens, 0); + expect(stats.inputTokens, 0); + expect(stats.outputTokens, 0); + expect(stats.durationMs, 0); + }); + }); + + group('GeminiConfig', () { + test('toCliArgs produces correct basic args', () { + const config = GeminiConfig(); + final args = config.toCliArgs('hello world'); + + expect(args, contains('-p')); + expect(args, contains('hello world')); + expect(args, contains('-o')); + expect(args, contains('stream-json')); + expect(args, contains('--approval-mode')); + expect(args, contains('yolo')); + }); + + test('toCliArgs includes model when set', () { + const config = GeminiConfig(model: 'gemini-2.5-flash'); + final args = config.toCliArgs('test'); + + expect(args, contains('-m')); + expect(args, contains('gemini-2.5-flash')); + }); + + test('toCliArgs includes resume when sessionId is set', () { + const config = GeminiConfig(sessionId: 'sess_abc123'); + final args = config.toCliArgs('test'); + + expect(args, contains('--resume')); + expect(args, contains('sess_abc123')); + }); + + test('toCliArgs includes sandbox when set', () { + const config = GeminiConfig(sandbox: 'strict'); + final args = config.toCliArgs('test'); + + expect(args, contains('--sandbox')); + expect(args, contains('strict')); + }); + + test('default approvalMode is yolo', () { + const config = GeminiConfig(); + expect(config.approvalMode, 'yolo'); + }); + + test('copyWith preserves unchanged fields', () { + const config = GeminiConfig( + model: 'gemini-2.5-pro', + approvalMode: 'yolo', + apiKey: 'key123', + ); + + final copied = config.copyWith(model: 'gemini-2.5-flash'); + expect(copied.model, 'gemini-2.5-flash'); + expect(copied.approvalMode, 'yolo'); + expect(copied.apiKey, 'key123'); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 8bc565f..095aac8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,7 @@ workspace: - packages/agent_sdk - packages/claude_sdk - packages/codex_sdk + - packages/gemini_sdk - packages/flutter_runtime_mcp - packages/moondream_api - packages/vide_interface