From cb0f980b974fb82d66bd11b6a32f4a0a07e46373 Mon Sep 17 00:00:00 2001 From: Norbert515 Date: Thu, 19 Feb 2026 19:12:11 +0100 Subject: [PATCH] feat: add agent_sdk package with generic AgentClient interface and Claude bridge Create a provider-agnostic abstraction layer for AI coding agents. The agent_sdk package defines AgentClient, a sealed AgentResponse hierarchy, conversation/message models, and capability interfaces (ModelConfigurable, Interruptible, etc.). ClaudeAgentClient in claude_sdk bridges ClaudeClient into the generic interface with exhaustive type mappers. This enables vide_core to depend on agent_sdk instead of claude_sdk directly (follow-up PR). --- packages/agent_sdk/lib/agent_sdk.dart | 13 + .../lib/src/client/agent_client.dart | 87 ++++ .../src/client/agent_client_capabilities.dart | 80 ++++ .../lib/src/models/agent_conversation.dart | 375 ++++++++++++++++++ .../lib/src/models/agent_init_data.dart | 41 ++ .../lib/src/models/agent_message.dart | 69 ++++ .../lib/src/models/agent_response.dart | 252 ++++++++++++ .../lib/src/models/agent_status.dart | 23 ++ .../agent_sdk/lib/src/models/token_usage.dart | 31 ++ packages/agent_sdk/pubspec.yaml | 7 + packages/claude_sdk/lib/claude_sdk.dart | 5 +- .../lib/src/bridge/type_mappers.dart | 302 ++++++++++++++ .../lib/src/client/claude_agent_client.dart | 156 ++++++++ packages/claude_sdk/pubspec.yaml | 1 + pubspec.yaml | 1 + 15 files changed, 1441 insertions(+), 2 deletions(-) create mode 100644 packages/agent_sdk/lib/agent_sdk.dart create mode 100644 packages/agent_sdk/lib/src/client/agent_client.dart create mode 100644 packages/agent_sdk/lib/src/client/agent_client_capabilities.dart create mode 100644 packages/agent_sdk/lib/src/models/agent_conversation.dart create mode 100644 packages/agent_sdk/lib/src/models/agent_init_data.dart create mode 100644 packages/agent_sdk/lib/src/models/agent_message.dart create mode 100644 packages/agent_sdk/lib/src/models/agent_response.dart create mode 100644 packages/agent_sdk/lib/src/models/agent_status.dart create mode 100644 packages/agent_sdk/lib/src/models/token_usage.dart create mode 100644 packages/agent_sdk/pubspec.yaml create mode 100644 packages/claude_sdk/lib/src/bridge/type_mappers.dart create mode 100644 packages/claude_sdk/lib/src/client/claude_agent_client.dart diff --git a/packages/agent_sdk/lib/agent_sdk.dart b/packages/agent_sdk/lib/agent_sdk.dart new file mode 100644 index 00000000..d41f8df0 --- /dev/null +++ b/packages/agent_sdk/lib/agent_sdk.dart @@ -0,0 +1,13 @@ +library agent_sdk; + +// Client interface +export 'src/client/agent_client.dart'; +export 'src/client/agent_client_capabilities.dart'; + +// Models +export 'src/models/agent_conversation.dart'; +export 'src/models/agent_init_data.dart'; +export 'src/models/agent_message.dart'; +export 'src/models/agent_response.dart'; +export 'src/models/agent_status.dart'; +export 'src/models/token_usage.dart'; diff --git a/packages/agent_sdk/lib/src/client/agent_client.dart b/packages/agent_sdk/lib/src/client/agent_client.dart new file mode 100644 index 00000000..5175baf9 --- /dev/null +++ b/packages/agent_sdk/lib/src/client/agent_client.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import '../models/agent_conversation.dart'; +import '../models/agent_init_data.dart'; +import '../models/agent_message.dart'; +import '../models/agent_response.dart'; +import '../models/agent_status.dart'; + +/// Generic interface for interacting with an AI coding agent. +/// +/// This is the common contract that consumers (like vide_core) depend on. +/// Each agent SDK (claude_sdk, codex_sdk) provides a bridge implementation +/// that wraps their SDK-specific client and maps types into this interface. +/// +/// Extended capabilities (model switching, permission modes, etc.) are +/// expressed as separate interfaces that the bridge may also implement. +/// Use `if (client is ModelConfigurable)` to check for support. +abstract class AgentClient { + // ── Streams ────────────────────────────────────────────── + + /// Stream of conversation state changes. + /// Emits whenever messages are added or updated (including streaming deltas). + Stream get conversation; + + /// Emits when an agent turn completes (assistant finishes responding). + Stream get onTurnComplete; + + /// Stream of processing status updates (thinking, responding, etc.) + Stream get statusStream; + + /// Stream of initialization data (model name, tools, etc.) + /// Emits when the agent CLI sends its init message. + Stream get initDataStream; + + /// Stream of queued message text changes. + /// Emits the queued text, or null when queue is cleared. + Stream get queuedMessage; + + // ── Current state ──────────────────────────────────────── + + /// The current conversation snapshot. + AgentConversation get currentConversation; + + /// The most recent processing status. + AgentProcessingStatus get currentStatus; + + /// The most recent initialization data, or null if not yet received. + AgentInitData? get initData; + + /// The current queued message text, or null if no message is queued. + String? get currentQueuedMessage; + + /// The session ID for this client instance. + String get sessionId; + + /// The working directory for this agent. + String get workingDirectory; + + /// Future that completes when the client has finished initializing. + Future get initialized; + + // ── Actions ────────────────────────────────────────────── + + /// Send a message to the agent. + void sendMessage(AgentMessage message); + + /// Abort the current operation. + Future abort(); + + /// Close the client and release all resources. + Future close(); + + /// Clear the conversation history, starting fresh. + Future clearConversation(); + + /// Clear any queued message without sending it. + void clearQueuedMessage(); + + /// Inject a synthetic tool result into the conversation. + /// Used to mark a pending tool invocation as failed (e.g., when + /// permission is denied). + void injectToolResult(AgentToolResultResponse toolResult); + + /// Get a registered MCP server by name and type. + /// Returns null if not found. + T? getMcpServer(String name); +} diff --git a/packages/agent_sdk/lib/src/client/agent_client_capabilities.dart b/packages/agent_sdk/lib/src/client/agent_client_capabilities.dart new file mode 100644 index 00000000..9935a179 --- /dev/null +++ b/packages/agent_sdk/lib/src/client/agent_client_capabilities.dart @@ -0,0 +1,80 @@ +/// Extended capability interfaces for agent-specific features. +/// +/// Not all agents support the same operations. These interfaces allow +/// consumers to check for specific capabilities using `is` checks: +/// +/// ```dart +/// if (client is ModelConfigurable) { +/// await client.setModel('opus'); +/// } +/// ``` + +/// Agent supports changing the model at runtime. +abstract class ModelConfigurable { + Future setModel(String model); +} + +/// Agent supports setting a maximum thinking token budget. +abstract class ThinkingConfigurable { + Future setMaxThinkingTokens(int maxTokens); +} + +/// Agent supports changing permission mode at runtime. +abstract class PermissionModeConfigurable { + Future setPermissionMode(String mode); +} + +/// Agent supports interrupting the current execution +/// (more graceful than abort — marks current message as complete). +abstract class Interruptible { + Future interrupt(); +} + +/// Agent supports rewinding files to a previous state. +abstract class FileRewindable { + Future rewindFiles(String userMessageId); +} + +/// Agent supports dynamic MCP server configuration. +abstract class McpConfigurable { + Future setMcpServers( + List servers, { + bool replace, + }); + Future getMcpStatus(); +} + +/// Configuration for a dynamically-added MCP server. +class AgentMcpServerConfig { + final String name; + final String command; + final List args; + final Map? env; + + const AgentMcpServerConfig({ + required this.name, + required this.command, + this.args = const [], + this.env, + }); +} + +/// Status information for connected MCP servers. +class AgentMcpStatusInfo { + final List servers; + + const AgentMcpStatusInfo({required this.servers}); +} + +/// Status of a single MCP server. +class AgentMcpServerStatus { + final String name; + final String status; + final List tools; + + const AgentMcpServerStatus({ + required this.name, + required this.status, + this.tools = const [], + }); +} diff --git a/packages/agent_sdk/lib/src/models/agent_conversation.dart b/packages/agent_sdk/lib/src/models/agent_conversation.dart new file mode 100644 index 00000000..bf5d9d30 --- /dev/null +++ b/packages/agent_sdk/lib/src/models/agent_conversation.dart @@ -0,0 +1,375 @@ +import 'agent_message.dart'; +import 'agent_response.dart'; +import 'token_usage.dart'; + +/// The state of a conversation with an agent. +enum AgentConversationState { + idle, + sendingMessage, + receivingResponse, + processing, + error, +} + +/// The role of a message in the conversation. +enum AgentMessageRole { user, assistant, system } + +/// The semantic type of a message, for UI filtering and display. +enum AgentMessageType { + userMessage, + assistantText, + toolUse, + toolResult, + error, + completion, + contextCompacted, + unknown, +} + +/// A paired tool call and its result. +class AgentToolInvocation { + /// The tool call that was made. + final AgentToolUseResponse toolCall; + + /// The result, or null if the tool hasn't completed yet. + final AgentToolResultResponse? toolResult; + + const AgentToolInvocation({required this.toolCall, this.toolResult}); + + bool get hasResult => toolResult != null; + bool get isComplete => toolResult != null; + bool get isError => toolResult?.isError ?? false; + String get toolName => toolCall.toolName; + Map get parameters => toolCall.parameters; + String? get resultContent => toolResult?.content; +} + +/// A single message in an agent conversation. +class AgentConversationMessage { + final String id; + final AgentMessageRole role; + final String content; + final DateTime timestamp; + final List responses; + final bool isStreaming; + final bool isComplete; + final String? error; + final TokenUsage? tokenUsage; + final List? attachments; + final AgentMessageType messageType; + + const AgentConversationMessage({ + required this.id, + required this.role, + required this.content, + required this.timestamp, + this.responses = const [], + this.isStreaming = false, + this.isComplete = false, + this.error, + this.tokenUsage, + this.attachments, + this.messageType = AgentMessageType.assistantText, + }); + + /// Creates a user message. + factory AgentConversationMessage.user({ + required String content, + List? attachments, + }) => AgentConversationMessage( + id: DateTime.now().millisecondsSinceEpoch.toString(), + role: AgentMessageRole.user, + content: content, + timestamp: DateTime.now(), + isComplete: true, + attachments: attachments, + messageType: AgentMessageType.userMessage, + ); + + /// Creates an assistant message from a list of responses. + factory AgentConversationMessage.assistant({ + required String id, + required List responses, + bool isStreaming = false, + bool isComplete = false, + }) { + final textResponses = responses.whereType().toList(); + final hasPartials = textResponses.any((r) => r.isPartial); + + final textBuffer = StringBuffer(); + TokenUsage? usage; + + for (final response in responses) { + if (response is AgentTextResponse) { + if (hasPartials) { + if (response.isPartial) { + textBuffer.write(response.content); + } + } else if (response.isCumulative) { + textBuffer.clear(); + textBuffer.write(response.content); + } else { + textBuffer.write(response.content); + } + } else if (response is AgentCompletionResponse) { + usage = TokenUsage( + inputTokens: response.inputTokens ?? 0, + outputTokens: response.outputTokens ?? 0, + cacheReadInputTokens: response.cacheReadInputTokens ?? 0, + cacheCreationInputTokens: response.cacheCreationInputTokens ?? 0, + ); + } + } + + return AgentConversationMessage( + id: id, + role: AgentMessageRole.assistant, + content: textBuffer.toString(), + timestamp: DateTime.now(), + responses: responses, + isStreaming: isStreaming, + isComplete: isComplete, + tokenUsage: usage, + ); + } + + /// Creates a context compacted marker message. + factory AgentConversationMessage.contextCompacted({ + required String id, + required DateTime timestamp, + required String trigger, + required int preTokens, + }) { + return AgentConversationMessage( + id: id, + role: AgentMessageRole.system, + content: '─────────── Conversation Compacted ($trigger) ───────────', + timestamp: timestamp, + isComplete: true, + messageType: AgentMessageType.contextCompacted, + responses: [ + AgentContextCompactedResponse( + id: id, + timestamp: timestamp, + trigger: trigger, + preTokens: preTokens, + ), + ], + ); + } + + /// Creates an unknown message for unrecognized response types. + factory AgentConversationMessage.unknown({ + required String id, + required DateTime timestamp, + required AgentUnknownResponse response, + }) { + return AgentConversationMessage( + id: id, + role: AgentMessageRole.system, + content: 'Unknown response', + timestamp: timestamp, + isComplete: true, + messageType: AgentMessageType.unknown, + responses: [response], + ); + } + + /// Groups tool calls with their corresponding results. + List get toolInvocations { + final invocations = []; + final toolCalls = {}; + + for (final response in responses) { + if (response is AgentToolUseResponse) { + if (response.toolUseId != null) { + toolCalls[response.toolUseId!] = response; + } else { + invocations.add(AgentToolInvocation(toolCall: response)); + } + } else if (response is AgentToolResultResponse) { + final call = toolCalls[response.toolUseId]; + if (call != null) { + invocations.add( + AgentToolInvocation(toolCall: call, toolResult: response), + ); + toolCalls.remove(response.toolUseId); + } + } + } + + // Add remaining tool calls without results + for (final call in toolCalls.values) { + invocations.add(AgentToolInvocation(toolCall: call)); + } + + return invocations; + } + + /// Gets all text responses. + List get textResponses { + return responses.whereType().toList(); + } + + AgentConversationMessage copyWith({ + String? id, + AgentMessageRole? role, + String? content, + DateTime? timestamp, + List? responses, + bool? isStreaming, + bool? isComplete, + String? error, + TokenUsage? tokenUsage, + List? attachments, + AgentMessageType? messageType, + }) { + return AgentConversationMessage( + id: id ?? this.id, + role: role ?? this.role, + content: content ?? this.content, + timestamp: timestamp ?? this.timestamp, + responses: responses ?? this.responses, + isStreaming: isStreaming ?? this.isStreaming, + isComplete: isComplete ?? this.isComplete, + error: error ?? this.error, + tokenUsage: tokenUsage ?? this.tokenUsage, + attachments: attachments ?? this.attachments, + messageType: messageType ?? this.messageType, + ); + } +} + +/// The full state of a conversation with an agent. +class AgentConversation { + final List messages; + final AgentConversationState state; + final String? currentError; + + // Accumulated totals across all turns (for billing/stats) + final int totalInputTokens; + final int totalOutputTokens; + final int totalCacheReadInputTokens; + final int totalCacheCreationInputTokens; + final double totalCostUsd; + + // Current context window usage (from latest turn, for context % display) + final int currentContextInputTokens; + final int currentContextCacheReadTokens; + final int currentContextCacheCreationTokens; + + const AgentConversation({ + required this.messages, + required this.state, + this.currentError, + this.totalInputTokens = 0, + this.totalOutputTokens = 0, + this.totalCacheReadInputTokens = 0, + this.totalCacheCreationInputTokens = 0, + this.totalCostUsd = 0.0, + this.currentContextInputTokens = 0, + this.currentContextCacheReadTokens = 0, + this.currentContextCacheCreationTokens = 0, + }); + + factory AgentConversation.empty() => + const AgentConversation(messages: [], state: AgentConversationState.idle); + + int get totalTokens => totalInputTokens + totalOutputTokens; + + /// Total context tokens accumulated across all turns. + int get totalContextTokens => + totalInputTokens + + totalCacheReadInputTokens + + totalCacheCreationInputTokens; + + /// Current context window usage (from the latest turn). + int get currentContextWindowTokens => + currentContextInputTokens + + currentContextCacheReadTokens + + currentContextCacheCreationTokens; + + bool get isProcessing => + state == AgentConversationState.sendingMessage || + state == AgentConversationState.receivingResponse || + state == AgentConversationState.processing; + + AgentConversationMessage? get lastMessage => + messages.isNotEmpty ? messages.last : null; + + AgentConversationMessage? get lastUserMessage { + for (int i = messages.length - 1; i >= 0; i--) { + if (messages[i].role == AgentMessageRole.user) return messages[i]; + } + return null; + } + + AgentConversationMessage? get lastAssistantMessage { + for (int i = messages.length - 1; i >= 0; i--) { + if (messages[i].role == AgentMessageRole.assistant) return messages[i]; + } + return null; + } + + AgentConversation copyWith({ + List? messages, + AgentConversationState? state, + String? currentError, + int? totalInputTokens, + int? totalOutputTokens, + int? totalCacheReadInputTokens, + int? totalCacheCreationInputTokens, + double? totalCostUsd, + int? currentContextInputTokens, + int? currentContextCacheReadTokens, + int? currentContextCacheCreationTokens, + }) { + return AgentConversation( + messages: messages ?? this.messages, + state: state ?? this.state, + currentError: currentError ?? this.currentError, + totalInputTokens: totalInputTokens ?? this.totalInputTokens, + totalOutputTokens: totalOutputTokens ?? this.totalOutputTokens, + totalCacheReadInputTokens: + totalCacheReadInputTokens ?? this.totalCacheReadInputTokens, + totalCacheCreationInputTokens: + totalCacheCreationInputTokens ?? this.totalCacheCreationInputTokens, + totalCostUsd: totalCostUsd ?? this.totalCostUsd, + currentContextInputTokens: + currentContextInputTokens ?? this.currentContextInputTokens, + currentContextCacheReadTokens: + currentContextCacheReadTokens ?? this.currentContextCacheReadTokens, + currentContextCacheCreationTokens: + currentContextCacheCreationTokens ?? + this.currentContextCacheCreationTokens, + ); + } + + AgentConversation addMessage(AgentConversationMessage message) { + return copyWith(messages: [...messages, message]); + } + + AgentConversation updateLastMessage(AgentConversationMessage message) { + if (messages.isEmpty) { + return addMessage(message); + } + final updatedMessages = [...messages]; + updatedMessages[updatedMessages.length - 1] = message; + return copyWith(messages: updatedMessages); + } + + AgentConversation withState(AgentConversationState state) { + return copyWith(state: state); + } + + AgentConversation withError(String? error) { + return copyWith( + state: error != null ? AgentConversationState.error : state, + currentError: error, + ); + } + + AgentConversation clearError() { + return copyWith(state: AgentConversationState.idle, currentError: null); + } +} diff --git a/packages/agent_sdk/lib/src/models/agent_init_data.dart b/packages/agent_sdk/lib/src/models/agent_init_data.dart new file mode 100644 index 00000000..736bbaee --- /dev/null +++ b/packages/agent_sdk/lib/src/models/agent_init_data.dart @@ -0,0 +1,41 @@ +/// Initialization data received from the agent CLI after startup. +/// +/// Contains information about the agent's capabilities, model, +/// and session configuration. Agent-specific data that doesn't +/// have a dedicated field is available via [metadata]. +class AgentInitData { + /// The model being used (e.g., 'claude-sonnet-4-5-20250929'). + final String? model; + + /// The session ID assigned by the agent. + final String? sessionId; + + /// The working directory the agent is operating in. + final String? cwd; + + /// The version of the agent CLI. + final String? cliVersion; + + /// The current permission mode (e.g., 'default', 'plan', 'acceptEdits'). + final String? permissionMode; + + /// Available tools the agent can use. + final List? tools; + + /// Available skills (slash commands) the agent supports. + final List? skills; + + /// Catch-all for agent-specific data that doesn't have a dedicated field. + final Map metadata; + + const AgentInitData({ + this.model, + this.sessionId, + this.cwd, + this.cliVersion, + this.permissionMode, + this.tools, + this.skills, + this.metadata = const {}, + }); +} diff --git a/packages/agent_sdk/lib/src/models/agent_message.dart b/packages/agent_sdk/lib/src/models/agent_message.dart new file mode 100644 index 00000000..0d2ba53d --- /dev/null +++ b/packages/agent_sdk/lib/src/models/agent_message.dart @@ -0,0 +1,69 @@ +/// A message to send to an AI coding agent. +class AgentMessage { + /// The text content of the message. + final String text; + + /// Optional file attachments (images, documents, files). + final List? attachments; + + /// Optional metadata for the message. + final Map? metadata; + + const AgentMessage({required this.text, this.attachments, this.metadata}); + + /// Convenience constructor for text-only messages. + const AgentMessage.text(String text) + : text = text, + attachments = null, + metadata = null; +} + +/// An attachment to include with an [AgentMessage]. +class AgentAttachment { + /// The type of attachment: 'file', 'image', or 'document'. + final String type; + + /// File path (for file/image types, also used as title for documents). + final String? path; + + /// Content data (base64 for images, text for documents). + final String? content; + + /// MIME type (e.g., 'image/png', 'text/plain'). + final String? mimeType; + + const AgentAttachment({ + required this.type, + this.path, + this.content, + this.mimeType, + }); + + /// Create a file attachment from a path. + const AgentAttachment.file(String path) + : type = 'file', + path = path, + content = null, + mimeType = null; + + /// Create an image attachment from a path. + const AgentAttachment.image(String path, {String? mimeType}) + : type = 'image', + path = path, + content = null, + mimeType = mimeType; + + /// Create an image attachment from base64 data. + const AgentAttachment.imageBase64(String base64Data, String mediaType) + : type = 'image', + path = null, + content = base64Data, + mimeType = mediaType; + + /// Create a text document attachment. + const AgentAttachment.documentText({required String text, String? title}) + : type = 'document', + path = title, + content = text, + mimeType = 'text/plain'; +} diff --git a/packages/agent_sdk/lib/src/models/agent_response.dart b/packages/agent_sdk/lib/src/models/agent_response.dart new file mode 100644 index 00000000..cfe633a5 --- /dev/null +++ b/packages/agent_sdk/lib/src/models/agent_response.dart @@ -0,0 +1,252 @@ +/// Base class for all response types from an AI coding agent. +/// +/// This sealed hierarchy defines the generic event model that +/// any coding agent (Claude, Codex, etc.) maps into. +sealed class AgentResponse { + /// Unique identifier for this response. + final String id; + + /// When this response was received. + final DateTime timestamp; + + /// Raw protocol data from the underlying agent (for debugging). + final Map? rawData; + + const AgentResponse({ + required this.id, + required this.timestamp, + this.rawData, + }); +} + +/// Streaming or complete text content from the agent. +class AgentTextResponse extends AgentResponse { + /// The text content. + final String content; + + /// Whether this is a partial streaming delta (more text coming). + final bool isPartial; + + /// Whether this contains cumulative content (full text up to this point) + /// rather than a sequential delta. + final bool isCumulative; + + const AgentTextResponse({ + required super.id, + required super.timestamp, + required this.content, + this.isPartial = false, + this.isCumulative = false, + super.rawData, + }); +} + +/// The agent is invoking a tool. +class AgentToolUseResponse extends AgentResponse { + /// Name of the tool being invoked (e.g., 'Bash', 'Read', 'Edit'). + final String toolName; + + /// Parameters passed to the tool. + final Map parameters; + + /// Unique ID for this tool invocation (used to match with results). + final String? toolUseId; + + const AgentToolUseResponse({ + required super.id, + required super.timestamp, + required this.toolName, + required this.parameters, + this.toolUseId, + super.rawData, + }); +} + +/// Result from a tool execution. +class AgentToolResultResponse extends AgentResponse { + /// ID of the tool invocation this result corresponds to. + final String toolUseId; + + /// The result content. + final String content; + + /// Whether the tool execution resulted in an error. + final bool isError; + + /// Standard output from the tool (if available). + final String? stdout; + + /// Standard error from the tool (if available). + final String? stderr; + + /// Whether the tool execution was interrupted. + final bool? interrupted; + + /// Whether the result contains image data. + final bool? isImage; + + const AgentToolResultResponse({ + required super.id, + required super.timestamp, + required this.toolUseId, + required this.content, + this.isError = false, + this.stdout, + this.stderr, + this.interrupted, + this.isImage, + super.rawData, + }); + + /// Whether the tool execution was interrupted. + bool get wasInterrupted => interrupted ?? false; + + /// Whether the result contains image data. + bool get hasImage => isImage ?? false; +} + +/// End-of-turn marker with token usage and billing information. +class AgentCompletionResponse extends AgentResponse { + /// Why the turn ended (e.g., 'end_turn', 'tool_use', 'error'). + final String? stopReason; + + /// Input tokens consumed. + final int? inputTokens; + + /// Output tokens generated. + final int? outputTokens; + + /// Tokens read from cache. + final int? cacheReadInputTokens; + + /// Tokens written to cache. + final int? cacheCreationInputTokens; + + /// Total cost in USD for this turn. + final double? totalCostUsd; + + /// Duration of API calls in milliseconds. + final int? durationApiMs; + + const AgentCompletionResponse({ + required super.id, + required super.timestamp, + this.stopReason, + this.inputTokens, + this.outputTokens, + this.cacheReadInputTokens, + this.cacheCreationInputTokens, + this.totalCostUsd, + this.durationApiMs, + super.rawData, + }); + + /// Total context tokens (input + cache read + cache creation). + int get totalContextTokens => + (inputTokens ?? 0) + + (cacheReadInputTokens ?? 0) + + (cacheCreationInputTokens ?? 0); +} + +/// An error from the agent. +class AgentErrorResponse extends AgentResponse { + /// The error message. + final String error; + + /// Additional error details. + final String? details; + + /// Error code. + final String? code; + + const AgentErrorResponse({ + required super.id, + required super.timestamp, + required this.error, + this.details, + this.code, + super.rawData, + }); +} + +/// A transient API error (rate limit, overload, etc.) that may be retried. +class AgentApiErrorResponse extends AgentResponse { + /// Error severity level (e.g., 'error', 'warning'). + final String level; + + /// Human-readable error message. + final String message; + + /// Error type (e.g., 'rate_limit_error', 'overloaded_error'). + final String? errorType; + + /// Milliseconds before retry (if retrying). + final double? retryInMs; + + /// Current retry attempt number. + final int? retryAttempt; + + /// Maximum number of retries configured. + final int? maxRetries; + + const AgentApiErrorResponse({ + required super.id, + required super.timestamp, + required this.level, + required this.message, + this.errorType, + this.retryInMs, + this.retryAttempt, + this.maxRetries, + super.rawData, + }); + + /// Whether this error will be automatically retried. + bool get willRetry => retryInMs != null && retryInMs! > 0; +} + +/// The agent's context was compacted (compressed) at this point. +/// +/// Any coding agent with context limits may emit this when the +/// conversation history is summarized to free up context space. +class AgentContextCompactedResponse extends AgentResponse { + /// What triggered the compaction: 'manual' or 'auto'. + final String trigger; + + /// Token count before compaction. + final int preTokens; + + const AgentContextCompactedResponse({ + required super.id, + required super.timestamp, + required this.trigger, + required this.preTokens, + super.rawData, + }); +} + +/// A user message from the streaming transcript. +class AgentUserMessageResponse extends AgentResponse { + /// The message content. + final String content; + + /// Whether this is a replay of a previous message. + final bool isReplay; + + const AgentUserMessageResponse({ + required super.id, + required super.timestamp, + required this.content, + this.isReplay = false, + super.rawData, + }); +} + +/// An unrecognized response type (forward compatibility). +class AgentUnknownResponse extends AgentResponse { + const AgentUnknownResponse({ + required super.id, + required super.timestamp, + super.rawData, + }); +} diff --git a/packages/agent_sdk/lib/src/models/agent_status.dart b/packages/agent_sdk/lib/src/models/agent_status.dart new file mode 100644 index 00000000..f6335def --- /dev/null +++ b/packages/agent_sdk/lib/src/models/agent_status.dart @@ -0,0 +1,23 @@ +/// Processing status of the underlying AI coding agent. +enum AgentProcessingStatus { + /// Agent is idle and ready for input. + ready, + + /// Agent is processing the request. + processing, + + /// Agent is in the thinking phase (extended thinking). + thinking, + + /// Agent is generating output. + responding, + + /// Agent finished the current operation. + completed, + + /// Agent encountered an error. + error, + + /// Status not recognized (forward compatibility). + unknown, +} diff --git a/packages/agent_sdk/lib/src/models/token_usage.dart b/packages/agent_sdk/lib/src/models/token_usage.dart new file mode 100644 index 00000000..1a876401 --- /dev/null +++ b/packages/agent_sdk/lib/src/models/token_usage.dart @@ -0,0 +1,31 @@ +/// Token usage statistics for an agent turn or conversation. +class TokenUsage { + final int inputTokens; + final int outputTokens; + final int cacheReadInputTokens; + final int cacheCreationInputTokens; + + const TokenUsage({ + required this.inputTokens, + required this.outputTokens, + this.cacheReadInputTokens = 0, + this.cacheCreationInputTokens = 0, + }); + + int get totalTokens => inputTokens + outputTokens; + + /// Total context tokens (input + cache read + cache creation). + /// Represents the actual context window usage. + int get totalContextTokens => + inputTokens + cacheReadInputTokens + cacheCreationInputTokens; + + TokenUsage operator +(TokenUsage other) { + return TokenUsage( + inputTokens: inputTokens + other.inputTokens, + outputTokens: outputTokens + other.outputTokens, + cacheReadInputTokens: cacheReadInputTokens + other.cacheReadInputTokens, + cacheCreationInputTokens: + cacheCreationInputTokens + other.cacheCreationInputTokens, + ); + } +} diff --git a/packages/agent_sdk/pubspec.yaml b/packages/agent_sdk/pubspec.yaml new file mode 100644 index 00000000..e7f8143a --- /dev/null +++ b/packages/agent_sdk/pubspec.yaml @@ -0,0 +1,7 @@ +name: agent_sdk +description: Generic interface and data model for AI coding agents +version: 0.1.0 +resolution: workspace + +environment: + sdk: ^3.8.0 diff --git a/packages/claude_sdk/lib/claude_sdk.dart b/packages/claude_sdk/lib/claude_sdk.dart index 80ce9051..1655df47 100644 --- a/packages/claude_sdk/lib/claude_sdk.dart +++ b/packages/claude_sdk/lib/claude_sdk.dart @@ -31,5 +31,6 @@ export 'src/mcp/utils/port_manager.dart'; // Utils export 'src/utils/image_validator.dart'; -// Note: ConfidenceServer and ConfidenceUpdate moved to main project at lib/mcp/ -// Note: PermissionServer removed - using hook-based permissions instead +// Agent SDK Bridge +export 'src/client/claude_agent_client.dart'; +export 'src/bridge/type_mappers.dart'; diff --git a/packages/claude_sdk/lib/src/bridge/type_mappers.dart b/packages/claude_sdk/lib/src/bridge/type_mappers.dart new file mode 100644 index 00000000..a6e7e380 --- /dev/null +++ b/packages/claude_sdk/lib/src/bridge/type_mappers.dart @@ -0,0 +1,302 @@ +import 'package:agent_sdk/agent_sdk.dart'; + +import '../control/control_responses.dart'; +import '../models/conversation.dart' as claude; +import '../models/message.dart' as claude; +import '../models/response.dart' as claude; + +/// Maps a [claude.Conversation] to an [AgentConversation]. +class AgentConversationMapper { + static AgentConversation fromClaude(claude.Conversation c) { + return AgentConversation( + messages: c.messages + .map(AgentConversationMessageMapper.fromClaude) + .toList(), + state: _mapState(c.state), + currentError: c.currentError, + totalInputTokens: c.totalInputTokens, + totalOutputTokens: c.totalOutputTokens, + totalCacheReadInputTokens: c.totalCacheReadInputTokens, + totalCacheCreationInputTokens: c.totalCacheCreationInputTokens, + totalCostUsd: c.totalCostUsd, + currentContextInputTokens: c.currentContextInputTokens, + currentContextCacheReadTokens: c.currentContextCacheReadTokens, + currentContextCacheCreationTokens: c.currentContextCacheCreationTokens, + ); + } + + static AgentConversationState _mapState(claude.ConversationState s) { + return switch (s) { + claude.ConversationState.idle => AgentConversationState.idle, + claude.ConversationState.sendingMessage => + AgentConversationState.sendingMessage, + claude.ConversationState.receivingResponse => + AgentConversationState.receivingResponse, + claude.ConversationState.processing => AgentConversationState.processing, + claude.ConversationState.error => AgentConversationState.error, + }; + } +} + +/// Maps a [claude.ConversationMessage] to an [AgentConversationMessage]. +class AgentConversationMessageMapper { + static AgentConversationMessage fromClaude(claude.ConversationMessage m) { + return AgentConversationMessage( + id: m.id, + role: _mapRole(m.role), + content: m.content, + timestamp: m.timestamp, + responses: m.responses.map(AgentResponseMapper.fromClaude).toList(), + isStreaming: m.isStreaming, + isComplete: m.isComplete, + error: m.error, + tokenUsage: m.tokenUsage != null + ? TokenUsage( + inputTokens: m.tokenUsage!.inputTokens, + outputTokens: m.tokenUsage!.outputTokens, + cacheReadInputTokens: m.tokenUsage!.cacheReadInputTokens, + cacheCreationInputTokens: m.tokenUsage!.cacheCreationInputTokens, + ) + : null, + attachments: m.attachments + ?.map( + (a) => AgentAttachment( + type: a.type, + path: a.path, + content: a.content, + mimeType: a.mimeType, + ), + ) + .toList(), + messageType: _mapMessageType(m.messageType), + ); + } + + static AgentMessageRole _mapRole(claude.MessageRole r) { + return switch (r) { + claude.MessageRole.user => AgentMessageRole.user, + claude.MessageRole.assistant => AgentMessageRole.assistant, + claude.MessageRole.system => AgentMessageRole.system, + }; + } + + static AgentMessageType _mapMessageType(claude.MessageType t) { + return switch (t) { + claude.MessageType.userMessage => AgentMessageType.userMessage, + claude.MessageType.assistantText => AgentMessageType.assistantText, + claude.MessageType.toolUse => AgentMessageType.toolUse, + claude.MessageType.toolResult => AgentMessageType.toolResult, + claude.MessageType.error => AgentMessageType.error, + claude.MessageType.completion => AgentMessageType.completion, + claude.MessageType.compactBoundary => AgentMessageType.contextCompacted, + claude.MessageType.compactSummary => AgentMessageType.unknown, + claude.MessageType.status => AgentMessageType.unknown, + claude.MessageType.meta => AgentMessageType.unknown, + claude.MessageType.unknown => AgentMessageType.unknown, + }; + } +} + +/// Maps a [claude.ClaudeResponse] to an [AgentResponse]. +class AgentResponseMapper { + static AgentResponse fromClaude(claude.ClaudeResponse r) { + return switch (r) { + claude.TextResponse() => AgentTextResponse( + id: r.id, + timestamp: r.timestamp, + content: r.content, + isPartial: r.isPartial, + isCumulative: r.isCumulative, + rawData: r.rawData, + ), + claude.ToolUseResponse() => AgentToolUseResponse( + id: r.id, + timestamp: r.timestamp, + toolName: r.toolName, + parameters: r.parameters, + toolUseId: r.toolUseId, + rawData: r.rawData, + ), + claude.ToolResultResponse() => AgentToolResultResponse( + id: r.id, + timestamp: r.timestamp, + toolUseId: r.toolUseId, + content: r.content, + isError: r.isError, + stdout: r.stdout, + stderr: r.stderr, + interrupted: r.interrupted, + isImage: r.isImage, + rawData: r.rawData, + ), + claude.CompletionResponse() => AgentCompletionResponse( + id: r.id, + timestamp: r.timestamp, + stopReason: r.stopReason, + inputTokens: r.inputTokens, + outputTokens: r.outputTokens, + cacheReadInputTokens: r.cacheReadInputTokens, + cacheCreationInputTokens: r.cacheCreationInputTokens, + totalCostUsd: r.totalCostUsd, + durationApiMs: r.durationApiMs, + rawData: r.rawData, + ), + claude.ErrorResponse() => AgentErrorResponse( + id: r.id, + timestamp: r.timestamp, + error: r.error, + details: r.details, + code: r.code, + rawData: r.rawData, + ), + claude.ApiErrorResponse() => AgentApiErrorResponse( + id: r.id, + timestamp: r.timestamp, + level: r.level, + message: r.message, + errorType: r.errorType, + retryInMs: r.retryInMs, + retryAttempt: r.retryAttempt, + maxRetries: r.maxRetries, + rawData: r.rawData, + ), + claude.CompactBoundaryResponse() => AgentContextCompactedResponse( + id: r.id, + timestamp: r.timestamp, + trigger: r.trigger, + preTokens: r.preTokens, + rawData: r.rawData, + ), + claude.UserMessageResponse() => AgentUserMessageResponse( + id: r.id, + timestamp: r.timestamp, + content: r.content, + isReplay: r.isReplay, + rawData: r.rawData, + ), + claude.CompactSummaryResponse() => AgentTextResponse( + id: r.id, + timestamp: r.timestamp, + content: r.content, + rawData: r.rawData, + ), + claude.StatusResponse() => AgentUnknownResponse( + id: r.id, + timestamp: r.timestamp, + rawData: r.rawData, + ), + claude.MetaResponse() => AgentUnknownResponse( + id: r.id, + timestamp: r.timestamp, + rawData: r.rawData, + ), + claude.TurnDurationResponse() => AgentUnknownResponse( + id: r.id, + timestamp: r.timestamp, + rawData: r.rawData, + ), + claude.LocalCommandResponse() => AgentUnknownResponse( + id: r.id, + timestamp: r.timestamp, + rawData: r.rawData, + ), + claude.UnknownResponse() => AgentUnknownResponse( + id: r.id, + timestamp: r.timestamp, + rawData: r.rawData, + ), + }; + } +} + +/// Maps [claude.ClaudeStatus] to [AgentProcessingStatus]. +class AgentStatusMapper { + static AgentProcessingStatus fromClaude(claude.ClaudeStatus s) { + return switch (s) { + claude.ClaudeStatus.ready => AgentProcessingStatus.ready, + claude.ClaudeStatus.processing => AgentProcessingStatus.processing, + claude.ClaudeStatus.thinking => AgentProcessingStatus.thinking, + claude.ClaudeStatus.responding => AgentProcessingStatus.responding, + claude.ClaudeStatus.completed => AgentProcessingStatus.completed, + claude.ClaudeStatus.error => AgentProcessingStatus.error, + claude.ClaudeStatus.unknown => AgentProcessingStatus.unknown, + }; + } +} + +/// Maps [claude.MetaResponse] to [AgentInitData]. +class AgentInitDataMapper { + static AgentInitData fromClaude(claude.MetaResponse r) { + return AgentInitData( + model: r.model, + sessionId: r.sessionId, + cwd: r.cwd, + cliVersion: r.claudeCodeVersion, + permissionMode: r.permissionMode, + tools: r.tools, + skills: r.skills, + metadata: r.metadata, + ); + } +} + +/// Maps [AgentMessage] to [claude.Message]. +class AgentMessageMapper { + static claude.Message toClaude(AgentMessage m) { + return claude.Message( + text: m.text, + attachments: m.attachments + ?.map( + (a) => claude.Attachment( + type: a.type, + path: a.path, + content: a.content, + mimeType: a.mimeType, + ), + ) + .toList(), + metadata: m.metadata, + ); + } +} + +/// Maps [AgentToolResultResponse] to [claude.ToolResultResponse]. +class AgentToolResultMapper { + static claude.ToolResultResponse toClaude(AgentToolResultResponse r) { + return claude.ToolResultResponse( + id: r.id, + timestamp: r.timestamp, + toolUseId: r.toolUseId, + content: r.content, + isError: r.isError, + stdout: r.stdout, + stderr: r.stderr, + interrupted: r.interrupted, + isImage: r.isImage, + rawData: r.rawData, + ); + } +} + +/// Maps [McpStatusResponse] to [AgentMcpStatusInfo]. +class AgentMcpStatusMapper { + static AgentMcpStatusInfo fromClaude(McpStatusResponse r) { + return AgentMcpStatusInfo( + servers: r.servers.map((s) { + return AgentMcpServerStatus(name: s.name, status: s.status.name); + }).toList(), + ); + } +} + +/// Maps [AgentMcpServerConfig] to [McpServerConfig]. +class AgentMcpServerConfigMapper { + static McpServerConfig toClaude(AgentMcpServerConfig c) { + return McpServerConfig( + name: c.name, + command: c.command, + args: c.args, + env: c.env, + ); + } +} diff --git a/packages/claude_sdk/lib/src/client/claude_agent_client.dart b/packages/claude_sdk/lib/src/client/claude_agent_client.dart new file mode 100644 index 00000000..f671ae7d --- /dev/null +++ b/packages/claude_sdk/lib/src/client/claude_agent_client.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:agent_sdk/agent_sdk.dart'; + +import '../bridge/type_mappers.dart'; +import 'claude_client.dart'; + +/// Bridge that wraps a [ClaudeClient] and exposes it as an [AgentClient]. +/// +/// Also implements extended capability interfaces for Claude-specific features +/// like model switching, permission modes, interrupting, and file rewind. +/// +/// Usage: +/// ```dart +/// final claude = ClaudeClient.createNonBlocking(config: config); +/// final agent = ClaudeAgentClient(claude); +/// // Use agent as AgentClient everywhere +/// ``` +class ClaudeAgentClient + implements + AgentClient, + ModelConfigurable, + PermissionModeConfigurable, + ThinkingConfigurable, + Interruptible, + FileRewindable, + McpConfigurable { + final ClaudeClient _inner; + + ClaudeAgentClient(this._inner); + + /// Access the underlying [ClaudeClient] for SDK-specific operations + /// that have no generic equivalent (e.g., hooks, control protocol). + ClaudeClient get innerClient => _inner; + + // ── AgentClient: Streams ───────────────────────────────── + + @override + Stream get conversation => + _inner.conversation.map(AgentConversationMapper.fromClaude); + + @override + Stream get onTurnComplete => _inner.onTurnComplete; + + @override + Stream get statusStream => + _inner.statusStream.map(AgentStatusMapper.fromClaude); + + @override + Stream get initDataStream => + _inner.initDataStream.map(AgentInitDataMapper.fromClaude); + + @override + Stream get queuedMessage => _inner.queuedMessage; + + // ── AgentClient: Current state ─────────────────────────── + + @override + AgentConversation get currentConversation => + AgentConversationMapper.fromClaude(_inner.currentConversation); + + @override + AgentProcessingStatus get currentStatus => + AgentStatusMapper.fromClaude(_inner.currentStatus); + + @override + AgentInitData? get initData => _inner.initData == null + ? null + : AgentInitDataMapper.fromClaude(_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; + + // ── AgentClient: Actions ───────────────────────────────── + + @override + void sendMessage(AgentMessage message) => + _inner.sendMessage(AgentMessageMapper.toClaude(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(AgentToolResultMapper.toClaude(toolResult)); + + @override + T? getMcpServer(String name) { + // ClaudeClient constrains T to McpServerBase. AgentClient uses + // unconstrained T for an SDK-agnostic interface. Use dynamic dispatch + // to bypass the generic bound at the call site. + final dynamic inner = _inner; + try { + return inner.getMcpServer(name) as T?; + } catch (_) { + return null; + } + } + + // ── Extended capabilities ──────────────────────────────── + + @override + Future setModel(String model) async { + await _inner.setModel(model); + } + + @override + Future setPermissionMode(String mode) => _inner.setPermissionMode(mode); + + @override + Future setMaxThinkingTokens(int maxTokens) async { + await _inner.setMaxThinkingTokens(maxTokens); + } + + @override + Future interrupt() => _inner.interrupt(); + + @override + Future rewindFiles(String userMessageId) => + _inner.rewindFiles(userMessageId); + + @override + Future setMcpServers( + List servers, { + bool replace = false, + }) { + return _inner.setMcpServers( + servers.map(AgentMcpServerConfigMapper.toClaude).toList(), + replace: replace, + ); + } + + @override + Future getMcpStatus() async { + final result = await _inner.getMcpStatus(); + return AgentMcpStatusMapper.fromClaude(result); + } +} diff --git a/packages/claude_sdk/pubspec.yaml b/packages/claude_sdk/pubspec.yaml index 57bb614d..8fec0122 100644 --- a/packages/claude_sdk/pubspec.yaml +++ b/packages/claude_sdk/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: ^3.8.0 dependencies: + agent_sdk: any image: ^4.0.0 json_annotation: ^4.8.1 mcp_dart: ^0.6.3 diff --git a/pubspec.yaml b/pubspec.yaml index 58f71f26..8bc565fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: sdk: ^3.8.0 workspace: + - packages/agent_sdk - packages/claude_sdk - packages/codex_sdk - packages/flutter_runtime_mcp