diff --git a/.env.example b/.env.example index 84587263..3995ce4d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,15 @@ # Optional: N8N webhook for progress notifications # PROGRESS_N8N_WEBHOOK_URL=https://your-n8n-instance.com/webhook/... +# =================== +# SDK Selection +# =================== +# Choose which agent SDK to use: +# - claude: Claude Agent SDK (default) - uses Claude Code CLI +# - codex: OpenAI Codex SDK - uses Codex CLI +# +# AUTOFORGE_SDK=claude + # Playwright Browser Configuration # # PLAYWRIGHT_BROWSER: Which browser to use for testing diff --git a/agent.py b/agent.py index a3daaf88..60177ef5 100644 --- a/agent.py +++ b/agent.py @@ -14,7 +14,8 @@ from typing import Optional from zoneinfo import ZoneInfo -from claude_agent_sdk import ClaudeSDKClient +from sdk_adapter import SDKAdapter +from sdk_adapter.types import EventType # Fix Windows console encoding for Unicode characters (emoji, etc.) # Without this, print() crashes when Claude outputs emoji like ✅ @@ -50,15 +51,15 @@ async def run_agent_session( - client: ClaudeSDKClient, + client: SDKAdapter, message: str, project_dir: Path, ) -> tuple[str, str]: """ - Run a single agent session using Claude Agent SDK. + Run a single agent session using SDK adapter. Args: - client: Claude SDK client + client: SDK adapter (Claude or Codex) message: The prompt to send project_dir: Project directory path @@ -67,53 +68,48 @@ async def run_agent_session( - "continue" if agent should continue working - "error" if an error occurred """ - print("Sending prompt to Claude Agent SDK...\n") + from sdk_adapter.factory import get_sdk_type + + sdk_type = get_sdk_type() + print(f"Sending prompt to {sdk_type.upper()} Agent SDK...\n") try: # Send the query await client.query(message) - # Collect response text and show tool use + # Collect response text using unified event model response_text = "" - async for msg in client.receive_response(): - msg_type = type(msg).__name__ - - # Handle AssistantMessage (text and tool use) - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "TextBlock" and hasattr(block, "text"): - response_text += block.text - print(block.text, end="", flush=True) - elif block_type == "ToolUseBlock" and hasattr(block, "name"): - print(f"\n[Tool: {block.name}]", flush=True) - if hasattr(block, "input"): - input_str = str(block.input) - if len(input_str) > 200: - print(f" Input: {input_str[:200]}...", flush=True) - else: - print(f" Input: {input_str}", flush=True) - - # Handle UserMessage (tool results) - elif msg_type == "UserMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "ToolResultBlock": - result_content = getattr(block, "content", "") - is_error = getattr(block, "is_error", False) - - # Check if command was blocked by security hook - if "blocked" in str(result_content).lower(): - print(f" [BLOCKED] {result_content}", flush=True) - elif is_error: - # Show errors (truncated) - error_str = str(result_content)[:500] - print(f" [Error] {error_str}", flush=True) - else: - # Tool succeeded - just show brief confirmation - print(" [Done]", flush=True) + async for event in client.receive_events(): + if event.type == EventType.TEXT: + response_text += event.content + print(event.content, end="", flush=True) + + elif event.type == EventType.TOOL_CALL: + print(f"\n[Tool: {event.tool_name}]", flush=True) + if event.tool_input: + input_str = str(event.tool_input) + if len(input_str) > 200: + print(f" Input: {input_str[:200]}...", flush=True) + else: + print(f" Input: {input_str}", flush=True) + + elif event.type == EventType.TOOL_RESULT: + # Check if command was blocked by security hook + if "blocked" in event.content.lower(): + print(f" [BLOCKED] {event.content}", flush=True) + elif event.is_error: + # Show errors (truncated) + error_str = event.content[:500] + print(f" [Error] {error_str}", flush=True) + else: + # Tool succeeded - just show brief confirmation + print(" [Done]", flush=True) + + elif event.type == EventType.ERROR: + print(f"\n[Error] {event.content}", flush=True) + + elif event.type == EventType.DONE: + break print("\n" + "-" * 70 + "\n") return "continue", response_text diff --git a/client.py b/client.py index 4d06816a..54958a18 100644 --- a/client.py +++ b/client.py @@ -1,8 +1,11 @@ """ -Claude SDK Client Configuration -=============================== +SDK Client Configuration +======================== -Functions for creating and configuring the Claude Agent SDK client. +Functions for creating and configuring SDK adapters (Claude or Codex). +Uses AUTOFORGE_SDK environment variable to select the SDK: +- "claude" (default): Claude Agent SDK +- "codex": OpenAI Codex SDK """ import json @@ -16,6 +19,7 @@ from claude_agent_sdk.types import HookContext, HookInput, HookMatcher, SyncHookJSONOutput from dotenv import load_dotenv +from sdk_adapter import AdapterOptions, SDKAdapter, create_adapter, get_sdk_type from security import SENSITIVE_DIRECTORIES, bash_security_hook # Load environment variables from .env file if present @@ -284,13 +288,17 @@ def create_client( yolo_mode: bool = False, agent_id: str | None = None, agent_type: str = "coding", -): +) -> SDKAdapter: """ - Create a Claude Agent SDK client with multi-layered security. + Create an SDK adapter (Claude or Codex) with multi-layered security. + + Uses AUTOFORGE_SDK environment variable to select SDK: + - "claude" (default): Claude Agent SDK + - "codex": OpenAI Codex SDK Args: project_dir: Directory for the project - model: Claude model to use + model: Model to use (Claude or Codex model name) yolo_mode: If True, skip Playwright MCP server for rapid prototyping agent_id: Optional unique identifier for browser isolation in parallel mode. When provided, each agent gets its own browser profile. @@ -298,17 +306,20 @@ def create_client( MCP tools are exposed and the max_turns limit. Returns: - Configured ClaudeSDKClient (from claude_agent_sdk) + Configured SDKAdapter (Claude or Codex) Security layers (defense in depth): 1. Sandbox - OS-level bash command isolation prevents filesystem escape 2. Permissions - File operations restricted to project_dir only 3. Security hooks - Bash commands validated against an allowlist (see security.py for ALLOWED_COMMANDS) + Note: Codex SDK does not support pre-tool-use hooks; relies on built-in sandboxing. Note: Authentication is handled by start.bat/start.sh before this runs. - The Claude SDK auto-detects credentials from the Claude CLI configuration + The SDK auto-detects credentials from the respective CLI configuration. """ + sdk_type = get_sdk_type() + print(f" - SDK type: {sdk_type.upper()}") # Select the feature MCP tools appropriate for this agent type feature_tools_map = { "coding": CODING_AGENT_TOOLS, @@ -452,16 +463,25 @@ def create_client( } # Build environment overrides for API endpoint configuration - # Uses get_effective_sdk_env() which reads provider settings from the database, - # ensuring UI-configured alternative providers (GLM, Ollama, Kimi, Custom) propagate - # correctly to the Claude CLI subprocess - from registry import get_effective_sdk_env - sdk_env = get_effective_sdk_env() + # Uses get_effective_sdk_env() for Claude or get_effective_sdk_env_for_codex() for Codex + # These read provider settings from the database, ensuring UI-configured + # alternative providers (GLM, Ollama, Kimi, Custom) propagate correctly + from registry import get_effective_sdk_env, get_effective_sdk_env_for_codex + if sdk_type == "codex": + sdk_env = get_effective_sdk_env_for_codex() + else: + sdk_env = get_effective_sdk_env() # Detect alternative API mode (Ollama, GLM, or Vertex AI) - base_url = sdk_env.get("ANTHROPIC_BASE_URL", "") - is_vertex = sdk_env.get("CLAUDE_CODE_USE_VERTEX") == "1" - is_alternative_api = bool(base_url) or is_vertex + # For Codex: check OPENAI_BASE_URL; for Claude: check ANTHROPIC_BASE_URL + if sdk_type == "codex": + base_url = sdk_env.get("OPENAI_BASE_URL", "") + is_vertex = False # Vertex AI is Claude-specific + is_alternative_api = bool(base_url) + else: + base_url = sdk_env.get("ANTHROPIC_BASE_URL", "") + is_vertex = sdk_env.get("CLAUDE_CODE_USE_VERTEX") == "1" + is_alternative_api = bool(base_url) or is_vertex is_ollama = "localhost:11434" in base_url or "127.0.0.1:11434" in base_url model = convert_model_for_vertex(model) if sdk_env: @@ -472,6 +492,8 @@ def create_client( print(f" - Vertex AI Mode: Using GCP project '{project_id}' with model '{model}' in region '{region}'") elif is_ollama: print(" - Ollama Mode: Using local models") + elif sdk_type == "codex" and "OPENAI_BASE_URL" in sdk_env: + print(f" - Custom API: Using {sdk_env['OPENAI_BASE_URL']}") elif "ANTHROPIC_BASE_URL" in sdk_env: print(f" - GLM Mode: Using {sdk_env['ANTHROPIC_BASE_URL']}") @@ -559,49 +581,78 @@ async def pre_compact_hook( # Our system_prompt benefits from automatic caching without explicit configuration. # If explicit cache_control is needed, the SDK would need to accept content blocks # with cache_control fields (not currently supported in v0.1.x). - return ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - cli_path=system_cli, # Use system CLI to avoid bundled Bun crash (exit code 3) + + # Branch based on SDK type + if sdk_type == "claude": + # Claude SDK: use ClaudeSDKClient with full hook support + # Note: ClaudeSDKClient has different API than SDKAdapter protocol but works at runtime + return ClaudeSDKClient( # type: ignore[return-value] + options=ClaudeAgentOptions( + model=model, + cli_path=system_cli, # Use system CLI to avoid bundled Bun crash (exit code 3) + system_prompt="You are an expert full-stack developer building a production-quality web application.", + setting_sources=["project"], # Enable skills, commands, and CLAUDE.md from project dir + max_buffer_size=10 * 1024 * 1024, # 10MB for large Playwright screenshots + allowed_tools=allowed_tools, + mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime + hooks={ + "PreToolUse": [ + HookMatcher(matcher="Bash", hooks=[bash_hook_with_context]), + ], + # PreCompact hook for context management during long sessions. + # Compaction is automatic when context approaches token limits. + # This hook logs compaction events and can customize summarization. + "PreCompact": [ + HookMatcher(hooks=[pre_compact_hook]), + ], + }, + max_turns=max_turns, + cwd=str(project_dir.resolve()), + settings=str(settings_file.resolve()), # Use absolute path + env=sdk_env, # Pass API configuration overrides to CLI subprocess + # Enable extended context beta for better handling of long sessions. + # This provides up to 1M tokens of context with automatic compaction. + # See: https://docs.anthropic.com/en/api/beta-headers + # Disabled for alternative APIs (Ollama, GLM, Vertex AI) as they don't support this beta. + betas=[] if is_alternative_api else ["context-1m-2025-08-07"], + # Note on context management: + # The Claude Agent SDK handles context management automatically through the + # underlying Claude Code CLI. When context approaches limits, the CLI + # automatically compacts/summarizes previous messages. + # + # The SDK does NOT expose explicit compaction_control or context_management + # parameters. Instead, context is managed via: + # 1. betas=["context-1m-2025-08-07"] - Extended context window + # 2. PreCompact hook - Intercept and customize compaction behavior + # 3. max_turns - Limit conversation turns (per agent type: coding=300, testing=100) + # + # Future SDK versions may add explicit compaction controls. When available, + # consider adding: + # - compaction_control={"enabled": True, "context_token_threshold": 80000} + # - context_management={"edits": [...]} for tool use clearing + ) + ) + else: + # Codex SDK: use factory with unified adapter interface + # Note: Codex does not support PreToolUse hooks - relies on built-in sandboxing + print(" - Note: Bash command hooks not available with Codex SDK") + print(" - Security relies on Codex's built-in sandboxing") + + options = AdapterOptions( + model=None, # Codex SDK uses its own default model + project_dir=project_dir, system_prompt="You are an expert full-stack developer building a production-quality web application.", - setting_sources=["project"], # Enable skills, commands, and CLAUDE.md from project dir - max_buffer_size=10 * 1024 * 1024, # 10MB for large Playwright screenshots - allowed_tools=allowed_tools, - mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime - hooks={ - "PreToolUse": [ - HookMatcher(matcher="Bash", hooks=[bash_hook_with_context]), - ], - # PreCompact hook for context management during long sessions. - # Compaction is automatic when context approaches token limits. - # This hook logs compaction events and can customize summarization. - "PreCompact": [ - HookMatcher(hooks=[pre_compact_hook]), - ], - }, max_turns=max_turns, + agent_type=agent_type, + cli_path=system_cli, + setting_sources=["project"], + max_buffer_size=10 * 1024 * 1024, + allowed_tools=allowed_tools, + mcp_servers=mcp_servers, + settings_file=str(settings_file.resolve()), cwd=str(project_dir.resolve()), - settings=str(settings_file.resolve()), # Use absolute path - env=sdk_env, # Pass API configuration overrides to CLI subprocess - # Enable extended context beta for better handling of long sessions. - # This provides up to 1M tokens of context with automatic compaction. - # See: https://docs.anthropic.com/en/api/beta-headers - # Disabled for alternative APIs (Ollama, GLM, Vertex AI) as they don't support this beta. + env=sdk_env, + yolo_mode=yolo_mode, betas=[] if is_alternative_api else ["context-1m-2025-08-07"], - # Note on context management: - # The Claude Agent SDK handles context management automatically through the - # underlying Claude Code CLI. When context approaches limits, the CLI - # automatically compacts/summarizes previous messages. - # - # The SDK does NOT expose explicit compaction_control or context_management - # parameters. Instead, context is managed via: - # 1. betas=["context-1m-2025-08-07"] - Extended context window - # 2. PreCompact hook - Intercept and customize compaction behavior - # 3. max_turns - Limit conversation turns (per agent type: coding=300, testing=100) - # - # Future SDK versions may add explicit compaction controls. When available, - # consider adding: - # - compaction_control={"enabled": True, "context_token_threshold": 80000} - # - context_management={"edits": [...]} for tool use clearing ) - ) + return create_adapter(options) diff --git a/registry.py b/registry.py index 30765198..4ae364fe 100644 --- a/registry.py +++ b/registry.py @@ -731,7 +731,7 @@ def get_effective_sdk_env() -> dict[str, str]: sdk_env[var] = value return sdk_env - sdk_env: dict[str, str] = {} + sdk_env = {} # Explicitly clear credentials that could leak from the server process env. # For providers using ANTHROPIC_AUTH_TOKEN (GLM, Custom), clear ANTHROPIC_API_KEY. @@ -771,3 +771,51 @@ def get_effective_sdk_env() -> dict[str, str]: sdk_env["API_TIMEOUT_MS"] = timeout return sdk_env + + +def get_effective_sdk_env_for_codex() -> dict[str, str]: + """Build environment variable dict for Codex SDK based on current API provider settings. + + Maps provider settings to OpenAI-compatible environment variables: + - OPENAI_BASE_URL (instead of ANTHROPIC_BASE_URL) + - OPENAI_API_KEY (instead of ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY) + + For the default provider (claude/openai), uses standard env vars. + For alternative providers (e.g., custom), builds from stored settings. + + Returns: + Dict ready to pass to Codex SDK as environment overrides. + """ + all_settings = get_all_settings() + provider_id = all_settings.get("api_provider", "claude") + + sdk_env: dict[str, str] = {} + + if provider_id in ("claude", "openai"): + # Default behavior: use standard OpenAI env vars + api_key = os.getenv("OPENAI_API_KEY") + if api_key: + sdk_env["OPENAI_API_KEY"] = api_key + + base_url = os.getenv("OPENAI_BASE_URL") + if base_url: + sdk_env["OPENAI_BASE_URL"] = base_url + + return sdk_env + + # Alternative provider: build env from settings + # Map ANTHROPIC settings to OpenAI equivalents + base_url = all_settings.get("api_base_url") + if base_url: + sdk_env["OPENAI_BASE_URL"] = base_url + + auth_token = all_settings.get("api_auth_token") + if auth_token: + sdk_env["OPENAI_API_KEY"] = auth_token + + # Model (Codex uses ThreadOptions.model, but env var can set default) + model = all_settings.get("api_model") + if model: + sdk_env["OPENAI_MODEL"] = model + + return sdk_env diff --git a/requirements-prod.txt b/requirements-prod.txt index 05e7f4cc..ebe2c8cb 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -1,6 +1,7 @@ # Production runtime dependencies only # For development, use requirements.txt (includes ruff, mypy, pytest) claude-agent-sdk>=0.1.0,<0.2.0 +codex-sdk-py>=0.0.6 # Optional: Required only when AUTOFORGE_SDK=codex python-dotenv>=1.0.0 sqlalchemy>=2.0.0 fastapi>=0.115.0 diff --git a/requirements.txt b/requirements.txt index 5d57a398..457596e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ claude-agent-sdk>=0.1.0,<0.2.0 +codex-sdk-py>=0.0.6 # Optional: Required only when AUTOFORGE_SDK=codex python-dotenv>=1.0.0 sqlalchemy>=2.0.0 fastapi>=0.115.0 diff --git a/sdk_adapter/__init__.py b/sdk_adapter/__init__.py new file mode 100644 index 00000000..b76c75ce --- /dev/null +++ b/sdk_adapter/__init__.py @@ -0,0 +1,31 @@ +""" +SDK Adapter Package +==================== + +Provides a unified interface for multiple agent SDKs (Claude, Codex). +Use the factory function to create an adapter based on AUTOFORGE_SDK env var. + +Usage: + from sdk_adapter import create_adapter, AdapterOptions + + options = AdapterOptions(model="...", project_dir=Path(...)) + adapter = create_adapter(options) + + async with adapter: + await adapter.query("Your prompt") + async for event in adapter.receive_events(): + print(event) +""" + +from sdk_adapter.factory import create_adapter, get_sdk_type +from sdk_adapter.protocols import SDKAdapter +from sdk_adapter.types import AdapterOptions, AgentEvent, EventType + +__all__ = [ + "create_adapter", + "get_sdk_type", + "SDKAdapter", + "AdapterOptions", + "AgentEvent", + "EventType", +] diff --git a/sdk_adapter/claude_adapter.py b/sdk_adapter/claude_adapter.py new file mode 100644 index 00000000..53c6ab81 --- /dev/null +++ b/sdk_adapter/claude_adapter.py @@ -0,0 +1,164 @@ +""" +Claude Agent SDK Adapter +======================== + +Wraps the Claude Agent SDK (claude_agent_sdk) to provide +the unified SDKAdapter interface. +""" + +from typing import Any, AsyncIterator + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient +from claude_agent_sdk.types import HookMatcher + +from sdk_adapter.types import AdapterOptions, AgentEvent, EventType + + +class ClaudeAdapter: + """ + Adapter for Claude Agent SDK. + + Translates between the unified SDKAdapter interface and + the Claude Agent SDK's native API. + """ + + def __init__(self, options: AdapterOptions): + """ + Initialize the Claude adapter. + + Args: + options: Unified adapter options to configure the client. + """ + self.options = options + self._client: ClaudeSDKClient | None = None + self._build_client() + + def _build_client(self) -> None: + """Create the underlying ClaudeSDKClient.""" + # Build hooks dict if hook functions are provided + hooks: dict[str, list[HookMatcher]] = {} + + if self.options.bash_hook: + hooks["PreToolUse"] = [ + HookMatcher(matcher="Bash", hooks=[self.options.bash_hook]) # type: ignore[list-item] + ] + + if self.options.compact_hook: + if "PreCompact" not in hooks: + hooks["PreCompact"] = [] + hooks["PreCompact"].append(HookMatcher(hooks=[self.options.compact_hook])) # type: ignore[list-item] + + # Build setting_sources: add "user" if include_user_settings is True + if self.options.include_user_settings: + setting_sources = ["project", "user"] + else: + setting_sources = self.options.setting_sources + + # Create the Claude SDK client + self._client = ClaudeSDKClient( + options=ClaudeAgentOptions( + model=self.options.model, + cli_path=self.options.cli_path, + system_prompt=self.options.system_prompt, + setting_sources=setting_sources, # type: ignore[arg-type] + max_buffer_size=self.options.max_buffer_size, + allowed_tools=self.options.allowed_tools, + mcp_servers=self.options.mcp_servers, + hooks=hooks if hooks else None, # type: ignore[arg-type] + max_turns=self.options.max_turns, + cwd=self.options.cwd, + settings=self.options.settings_file, + env=self.options.env, + betas=self.options.betas if self.options.betas else None, # type: ignore[arg-type] + permission_mode=self.options.permission_mode, # type: ignore[arg-type] + ) + ) + + async def __aenter__(self) -> "ClaudeAdapter": + """Enter async context manager.""" + if self._client: + await self._client.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object | None, + ) -> None: + """Exit async context manager.""" + if self._client: + await self._client.__aexit__(exc_type, exc_val, exc_tb) + + async def query(self, message: str | list[dict[str, Any]]) -> None: + """ + Send a query to the Claude agent. + + Args: + message: The prompt text or multimodal content blocks. + """ + if self._client: + await self._client.query(message) # type: ignore[arg-type] + + async def receive_events(self) -> AsyncIterator[AgentEvent]: + """ + Stream events from Claude agent, translating to unified AgentEvent. + + The Claude SDK emits AssistantMessage and UserMessage objects + containing TextBlock, ToolUseBlock, and ToolResultBlock. + This method translates them to unified AgentEvent objects. + """ + if not self._client: + yield AgentEvent( + type=EventType.ERROR, + content="Client not initialized", + is_error=True, + ) + return + + async for msg in self._client.receive_response(): + msg_type = type(msg).__name__ + + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + + if block_type == "TextBlock" and hasattr(block, "text"): + yield AgentEvent( + type=EventType.TEXT, + content=block.text, + raw=block, + ) + + elif block_type == "ToolUseBlock" and hasattr(block, "name"): + yield AgentEvent( + type=EventType.TOOL_CALL, + tool_name=block.name, + tool_input=getattr(block, "input", {}) or {}, + tool_id=getattr(block, "id", None), + raw=block, + ) + + elif msg_type == "UserMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + + if block_type == "ToolResultBlock": + result_content = getattr(block, "content", "") + is_error = getattr(block, "is_error", False) + + yield AgentEvent( + type=EventType.TOOL_RESULT, + content=str(result_content), + tool_id=getattr(block, "tool_use_id", None), + is_error=is_error, + raw=block, + ) + + # Signal completion + yield AgentEvent(type=EventType.DONE) + + @property + def supports_hooks(self) -> bool: + """Claude SDK supports PreToolUse and PreCompact hooks.""" + return True diff --git a/sdk_adapter/codex_adapter.py b/sdk_adapter/codex_adapter.py new file mode 100644 index 00000000..e12688fc --- /dev/null +++ b/sdk_adapter/codex_adapter.py @@ -0,0 +1,338 @@ +""" +Codex SDK Adapter +================= + +Wraps the Codex SDK (codex_sdk) to provide +the unified SDKAdapter interface. + +Note: Codex SDK uses an event-based model without blocking hooks. +Security relies on Codex's built-in sandboxing and approval policies. +""" + +import logging +import shutil +from typing import Any, AsyncIterator + +from sdk_adapter.types import AdapterOptions, AgentEvent, EventType + +logger = logging.getLogger(__name__) + + +class CodexAdapter: + """ + Adapter for OpenAI Codex SDK. + + Translates between the unified SDKAdapter interface and + the Codex SDK's native API. + + Key differences from Claude: + - No PreToolUse hooks (event-based only) + - Uses Thread.run_streamed() instead of query()/receive_response() + - MCP servers configured via TOML, not dict + """ + + def __init__(self, options: AdapterOptions): + """ + Initialize the Codex adapter. + + Args: + options: Unified adapter options to configure the client. + """ + self.options = options + self._codex = None + self._thread = None + self._pending_message: str | list[dict[str, Any]] = "" + self._build_client() + + def _build_client(self) -> None: + """Create the underlying Codex client and thread.""" + try: + from codex_sdk import ApprovalMode, Codex, SandboxMode + except ImportError: + raise ImportError( + "codex-sdk-py not installed. Install with: pip install codex-sdk-py" + ) + + # Map sandbox mode (Codex uses different enum values) + sandbox_mode = SandboxMode.WORKSPACE_WRITE + if self.options.yolo_mode: + # In YOLO mode, still use workspace-write for safety + sandbox_mode = SandboxMode.WORKSPACE_WRITE + + # Map permission_mode to ApprovalMode + # "bypassPermissions" -> NEVER (auto-approve all) + # "acceptEdits" -> ON_FAILURE (auto-approve, ask on failure) + if self.options.permission_mode == "bypassPermissions": + approval_policy = ApprovalMode.NEVER + else: + # Default to NEVER for automation (similar to "acceptEdits" behavior) + approval_policy = ApprovalMode.NEVER + + # Build Codex client options + codex_options: dict[str, Any] = {} + + if self.options.env: + codex_options["env"] = self.options.env + + # Always find and set the codex CLI path explicitly + # (cli_path option is for Claude CLI, not Codex) + codex_cli = shutil.which("codex") + if codex_cli: + codex_options["codex_path_override"] = codex_cli + print(f" - Using codex CLI at: {codex_cli}", flush=True) + + # Convert mcp_servers dict to config_overrides for Codex CLI + # Claude SDK format: {"features": {"command": "...", "args": [...], "env": {...}}} + # Codex config format: {"mcp_servers": {"features": {"command": "...", "args": [...]}}} + if self.options.mcp_servers: + config_overrides: dict[str, Any] = {"mcp_servers": {}} + for server_name, server_config in self.options.mcp_servers.items(): + config_overrides["mcp_servers"][server_name] = { + "command": server_config.get("command", ""), + "args": server_config.get("args", []), + } + # Add env if present + if server_config.get("env"): + config_overrides["mcp_servers"][server_name]["env"] = server_config["env"] + codex_options["config"] = config_overrides + print(f" - Configured MCP servers for Codex: {list(self.options.mcp_servers.keys())}", flush=True) + + # Create Codex client + self._codex = Codex(codex_options if codex_options else None) + + # Build thread options + thread_options: dict[str, Any] = { + "sandbox_mode": sandbox_mode, + "skip_git_repo_check": True, + "approval_policy": approval_policy, + } + + # Only set model if explicitly specified (Codex has its own default) + if self.options.model: + thread_options["model"] = self.options.model + + if self.options.cwd: + thread_options["working_directory"] = self.options.cwd + elif self.options.project_dir: + thread_options["working_directory"] = str(self.options.project_dir.resolve()) + + # Create thread + if self._codex is not None: + self._thread = self._codex.start_thread(thread_options) + + # Log limitations for chat session features + if self.options.include_user_settings: + print(" - Note: Codex SDK does not support user-level settings. Using project settings only.", flush=True) + + if self.options.allowed_tools: + print(" - Note: Codex SDK uses SandboxMode instead of allowed_tools. Tool allowlist is advisory only.", flush=True) + + # Log hook limitation warning + if self.options.bash_hook or self.options.compact_hook: + logger.warning( + "Codex SDK does not support PreToolUse/PreCompact hooks. " + "Security relies on Codex's built-in sandboxing." + ) + print(" - Note: Bash command hooks not available with Codex SDK", flush=True) + print(" - Security relies on Codex's built-in sandboxing", flush=True) + + async def __aenter__(self) -> "CodexAdapter": + """Enter async context manager.""" + # Codex SDK doesn't require async context initialization + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object | None, + ) -> None: + """Exit async context manager.""" + # Codex SDK handles cleanup automatically + pass + + async def query(self, message: str | list[dict[str, Any]]) -> None: + """ + Store the query for run_streamed. + + Codex SDK combines query and response in run_streamed(), + so we store the message and process it in receive_events(). + + Args: + message: The prompt text or multimodal content blocks. + """ + self._pending_message = message + + async def receive_events(self) -> AsyncIterator[AgentEvent]: + """ + Stream events from Codex agent, translating to unified AgentEvent. + + The Codex SDK emits JSONL events like: + - thread.started + - item.started / item.updated / item.completed + - turn.completed / turn.failed + """ + if not self._thread: + yield AgentEvent( + type=EventType.ERROR, + content="Thread not initialized", + is_error=True, + ) + return + + # Convert message to proper format + if isinstance(self._pending_message, list): + # Multimodal input: extract text parts, warn about images + text_parts: list[str] = [] + has_images = False + + for block in self._pending_message: + if isinstance(block, dict): + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif block.get("type") == "image": + has_images = True + + if has_images: + print(" - Warning: Codex SDK does not support image attachments, using text only", flush=True) + + input_data = " ".join(text_parts) if text_parts else "" + else: + input_data = self._pending_message + + try: + streamed = await self._thread.run_streamed(input_data) + + async for event in streamed.events: + event_type = event.get("type", "") + + if event_type == "item.started": + item = event.get("item", {}) + item_type = item.get("type", "") + + if item_type == "command_execution": + yield AgentEvent( + type=EventType.TOOL_CALL, + tool_name="Bash", + tool_input={"command": item.get("command", "")}, + tool_id=item.get("id"), + raw=event, + ) + elif item_type == "mcp_tool_call": + yield AgentEvent( + type=EventType.TOOL_CALL, + tool_name=f"mcp__{item.get('server', '')}__{item.get('tool', '')}", + tool_input=item.get("arguments", {}), + tool_id=item.get("id"), + raw=event, + ) + + elif event_type == "item.completed": + item = event.get("item", {}) + item_type = item.get("type", "") + + if item_type == "agent_message": + yield AgentEvent( + type=EventType.TEXT, + content=item.get("text", ""), + raw=event, + ) + + elif item_type == "command_execution": + exit_code = item.get("exit_code", 0) + yield AgentEvent( + type=EventType.TOOL_RESULT, + tool_name="Bash", + content=item.get("aggregated_output", ""), + tool_id=item.get("id"), + is_error=exit_code != 0, + raw=event, + ) + + elif item_type == "file_change": + changes = item.get("changes", []) + change_summary = ", ".join( + f"{c.get('kind', 'modify')} {c.get('path', '')}" + for c in changes + ) + yield AgentEvent( + type=EventType.TOOL_RESULT, + tool_name="Edit", + content=f"File changes: {change_summary}", + tool_id=item.get("id"), + is_error=item.get("status") == "failed", + raw=event, + ) + + elif item_type == "mcp_tool_call": + result = item.get("result", {}) + error = item.get("error") + content = "" + + if result: + result_content = result.get("content", []) + if result_content: + content = str(result_content[0].get("text", "")) + if error: + content = error.get("message", "MCP tool error") + + yield AgentEvent( + type=EventType.TOOL_RESULT, + tool_name=f"mcp__{item.get('server', '')}__{item.get('tool', '')}", + content=content, + tool_id=item.get("id"), + is_error=item.get("status") == "failed", + raw=event, + ) + + elif item_type == "reasoning": + # Reasoning is informational, emit as text + yield AgentEvent( + type=EventType.TEXT, + content=f"[Reasoning] {item.get('text', '')}", + raw=event, + ) + + elif item_type == "error": + yield AgentEvent( + type=EventType.ERROR, + content=item.get("message", "Unknown error"), + is_error=True, + raw=event, + ) + + elif event_type == "turn.completed": + # Turn complete - emit DONE event + yield AgentEvent(type=EventType.DONE, raw=event) + + elif event_type == "turn.failed": + error = event.get("error", {}) + yield AgentEvent( + type=EventType.ERROR, + content=error.get("message", "Turn failed"), + is_error=True, + raw=event, + ) + yield AgentEvent(type=EventType.DONE, raw=event) + + elif event_type == "error": + yield AgentEvent( + type=EventType.ERROR, + content=event.get("message", "Stream error"), + is_error=True, + raw=event, + ) + + except Exception as e: + logger.error(f"Codex SDK error: {e}") + yield AgentEvent( + type=EventType.ERROR, + content=str(e), + is_error=True, + ) + yield AgentEvent(type=EventType.DONE) + + @property + def supports_hooks(self) -> bool: + """Codex SDK does not support blocking hooks.""" + return False diff --git a/sdk_adapter/factory.py b/sdk_adapter/factory.py new file mode 100644 index 00000000..f01482ad --- /dev/null +++ b/sdk_adapter/factory.py @@ -0,0 +1,112 @@ +""" +SDK Adapter Factory +=================== + +Factory function to create the appropriate SDK adapter +based on the AUTOFORGE_SDK environment variable. +""" + +import os +from pathlib import Path +from typing import Literal + +from dotenv import load_dotenv + +from sdk_adapter.protocols import SDKAdapter +from sdk_adapter.types import AdapterOptions + +# Load .env file from project root (parent of sdk_adapter directory) +# This ensures AUTOFORGE_SDK is available regardless of current working directory +_project_root = Path(__file__).parent.parent +_env_file = _project_root / ".env" +if _env_file.exists(): + load_dotenv(_env_file) +else: + # Fallback to current working directory + load_dotenv() + +# Environment variable for SDK selection +SDK_TYPE_VAR = "AUTOFORGE_SDK" + +# Valid SDK types +SDKType = Literal["claude", "codex"] + +# Default SDK +DEFAULT_SDK: SDKType = "claude" + + +def get_sdk_type() -> SDKType: + """ + Get the SDK type from DB settings, falling back to environment variable. + + Priority: + 1. DB setting (sdk_type) from Settings UI + 2. AUTOFORGE_SDK environment variable + 3. Default: "claude" + + Returns: + "claude" (default) or "codex". + """ + # Check DB settings first + try: + import sys + root = str(Path(__file__).parent.parent) + if root not in sys.path: + sys.path.insert(0, root) + from registry import get_setting + db_value = get_setting("sdk_type") + if db_value and db_value.lower() in ("claude", "codex"): + return db_value.lower() # type: ignore[return-value] + except Exception: + pass # DB not available, fall back to env var + + # Fall back to environment variable + raw_value = os.getenv(SDK_TYPE_VAR) + sdk_type = (raw_value or DEFAULT_SDK).lower() + + if sdk_type not in ("claude", "codex"): + print(f" - Warning: Invalid AUTOFORGE_SDK='{sdk_type}', using default: {DEFAULT_SDK}") + return DEFAULT_SDK + + return sdk_type # type: ignore[return-value] + + +def create_adapter(options: AdapterOptions) -> SDKAdapter: + """ + Factory function to create the appropriate SDK adapter. + + Uses AUTOFORGE_SDK environment variable to select: + - "claude" (default): Claude Agent SDK + - "codex": OpenAI Codex SDK + + Args: + options: Unified adapter options. + + Returns: + An SDK adapter implementing the SDKAdapter protocol. + + Raises: + ImportError: If the selected SDK package is not installed. + ValueError: If an unknown SDK type is specified. + """ + sdk_type = get_sdk_type() + + if sdk_type == "claude": + from sdk_adapter.claude_adapter import ClaudeAdapter + + return ClaudeAdapter(options) + + elif sdk_type == "codex": + try: + from sdk_adapter.codex_adapter import CodexAdapter + + return CodexAdapter(options) + except ImportError as e: + raise ImportError( + "codex-sdk-py not installed. Install with: pip install codex-sdk-py\n" + f"Original error: {e}" + ) from e + + else: + # Should not reach here due to get_sdk_type() validation + raise ValueError(f"Unknown SDK type: {sdk_type}. Valid options: claude, codex") diff --git a/sdk_adapter/protocols.py b/sdk_adapter/protocols.py new file mode 100644 index 00000000..b65fd092 --- /dev/null +++ b/sdk_adapter/protocols.py @@ -0,0 +1,81 @@ +""" +SDK Adapter Protocol Definition +=============================== + +Defines the Protocol (interface) that all SDK adapters must implement. +Using Protocol instead of ABC allows structural subtyping (duck typing). +""" + +from typing import AsyncIterator, Protocol, runtime_checkable + +from sdk_adapter.types import AgentEvent + + +@runtime_checkable +class SDKAdapter(Protocol): + """ + Protocol for SDK adapters providing a unified agent interface. + + All SDK adapters (Claude, Codex) must implement this interface + to be used interchangeably in AutoForge. + + Usage: + async with adapter: + await adapter.query("Your prompt here") + async for event in adapter.receive_events(): + if event.type == EventType.TEXT: + print(event.content) + """ + + async def __aenter__(self) -> "SDKAdapter": + """Enter async context manager. Initialize SDK resources.""" + ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object | None, + ) -> None: + """Exit async context manager. Clean up SDK resources.""" + ... + + async def query(self, message: str | list[dict]) -> None: + """ + Send a query/prompt to the agent. + + Args: + message: The prompt text, or a list of content blocks + for multimodal input (text + images). + """ + ... + + def receive_events(self) -> AsyncIterator[AgentEvent]: + """ + Stream events from the agent response. + + This is an async generator that yields unified AgentEvent objects + that abstract away SDK-specific event structures. + + Yields: + AgentEvent objects with types: + - TEXT: Text content from agent + - TOOL_CALL: Tool execution started + - TOOL_RESULT: Tool execution result + - DONE: Response complete + - ERROR: Error occurred + """ + ... + + @property + def supports_hooks(self) -> bool: + """ + Whether this SDK supports pre-tool-use hooks. + + Claude SDK: True (supports PreToolUse, PreCompact hooks) + Codex SDK: False (event-based only, no blocking hooks) + + Returns: + True if hooks can block tool execution, False otherwise. + """ + ... diff --git a/sdk_adapter/types.py b/sdk_adapter/types.py new file mode 100644 index 00000000..2773427f --- /dev/null +++ b/sdk_adapter/types.py @@ -0,0 +1,102 @@ +""" +Unified Types for SDK Adapters +============================== + +Defines common event types and configuration options that work across +both Claude Agent SDK and Codex SDK. +""" + +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Coroutine + + +class EventType(str, Enum): + """Unified event types for agent responses.""" + + TEXT = "text" # Text content from agent + TOOL_CALL = "tool_call" # Tool/command execution started + TOOL_RESULT = "tool_result" # Tool/command execution result + DONE = "done" # Response complete + ERROR = "error" # Error occurred + + +@dataclass +class AgentEvent: + """ + Unified event type for SDK responses. + + Both Claude and Codex SDKs emit different event structures. + This class provides a common interface for handling events + from either SDK. + """ + + type: EventType + content: str = "" + tool_name: str | None = None + tool_input: dict[str, Any] = field(default_factory=dict) + tool_id: str | None = None + is_error: bool = False + raw: Any = None # Original SDK-specific event for debugging + + +# Type alias for hook functions (Claude SDK style) +HookFunction = Callable[ + [dict[str, Any], str | None, dict[str, Any] | None], + Coroutine[Any, Any, dict[str, Any]], +] + + +@dataclass +class AdapterOptions: + """ + Configuration options for SDK adapters. + + These options are translated to SDK-specific configurations + by each adapter implementation. + """ + + # Required + model: str + project_dir: Path + + # Agent configuration + system_prompt: str = "You are an expert full-stack developer building a production-quality web application." + max_turns: int = 300 + agent_type: str = "coding" # "coding", "testing", or "initializer" + agent_id: str | None = None # For browser isolation in parallel mode + + # Tool configuration + allowed_tools: list[str] = field(default_factory=list) + mcp_servers: dict[str, Any] = field(default_factory=dict) + + # Environment + cwd: str | None = None + env: dict[str, str] = field(default_factory=dict) + settings_file: str | None = None + + # Mode flags + yolo_mode: bool = False + + # Hooks (Claude SDK only - Codex uses event-based model) + bash_hook: HookFunction | None = None + compact_hook: HookFunction | None = None + + # Extended context (Claude SDK only) + betas: list[str] = field(default_factory=list) + + # Buffer size for large responses (screenshots, etc.) + max_buffer_size: int = 10 * 1024 * 1024 # 10MB + + # CLI path override + cli_path: str | None = None + + # Setting sources for project config + setting_sources: list[str] = field(default_factory=lambda: ["project"]) + + # Chat session options + # Permission mode: "acceptEdits" or "bypassPermissions" + permission_mode: str = "acceptEdits" + # If True, setting_sources will include "user" for global skills/settings + include_user_settings: bool = False diff --git a/server/routers/settings.py b/server/routers/settings.py index 6137c63c..ce1e23ba 100644 --- a/server/routers/settings.py +++ b/server/routers/settings.py @@ -113,6 +113,7 @@ async def get_settings(): testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1), playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True), batch_size=_parse_int(all_settings.get("batch_size"), 3), + sdk_type=all_settings.get("sdk_type", "claude"), api_provider=api_provider, api_base_url=all_settings.get("api_base_url"), api_has_auth_token=bool(all_settings.get("api_auth_token")), @@ -138,6 +139,10 @@ async def update_settings(update: SettingsUpdate): if update.batch_size is not None: set_setting("batch_size", str(update.batch_size)) + if update.sdk_type is not None: + if update.sdk_type in ("claude", "codex"): + set_setting("sdk_type", update.sdk_type) + # API provider settings if update.api_provider is not None: old_provider = get_setting("api_provider", "claude") @@ -177,6 +182,7 @@ async def update_settings(update: SettingsUpdate): testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1), playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True), batch_size=_parse_int(all_settings.get("batch_size"), 3), + sdk_type=all_settings.get("sdk_type", "claude"), api_provider=api_provider, api_base_url=all_settings.get("api_base_url"), api_has_auth_token=bool(all_settings.get("api_auth_token")), diff --git a/server/schemas.py b/server/schemas.py index 5f546e2b..ef93ea65 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -419,6 +419,7 @@ class SettingsResponse(BaseModel): testing_agent_ratio: int = 1 # Regression testing agents (0-3) playwright_headless: bool = True batch_size: int = 3 # Features per coding agent batch (1-3) + sdk_type: str = "claude" # "claude" or "codex" api_provider: str = "claude" api_base_url: str | None = None api_has_auth_token: bool = False # Never expose actual token @@ -438,6 +439,7 @@ class SettingsUpdate(BaseModel): testing_agent_ratio: int | None = None # 0-3 playwright_headless: bool | None = None batch_size: int | None = None # Features per agent batch (1-3) + sdk_type: str | None = None # "claude" or "codex" api_provider: str | None = None api_base_url: str | None = Field(None, max_length=500) api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index f030aa4b..0030df25 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -17,9 +17,10 @@ from pathlib import Path from typing import AsyncGenerator, Optional -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from dotenv import load_dotenv +from sdk_adapter import AdapterOptions, EventType, SDKAdapter, create_adapter + from .assistant_database import ( add_message, create_conversation, @@ -181,21 +182,21 @@ def __init__(self, project_name: str, project_dir: Path, conversation_id: Option self.project_name = project_name self.project_dir = project_dir self.conversation_id = conversation_id - self.client: Optional[ClaudeSDKClient] = None - self._client_entered: bool = False + self.adapter: Optional[SDKAdapter] = None + self._adapter_entered: bool = False self.created_at = datetime.now() self._history_loaded: bool = False # Track if we've loaded history for resumed conversations async def close(self) -> None: - """Clean up resources and close the Claude client.""" - if self.client and self._client_entered: + """Clean up resources and close the adapter.""" + if self.adapter and self._adapter_entered: try: - await self.client.__aexit__(None, None, None) + await self.adapter.__aexit__(None, None, None) except Exception as e: - logger.warning(f"Error closing Claude client: {e}") + logger.warning(f"Error closing adapter: {e}") finally: - self._client_entered = False - self.client = None + self._adapter_entered = False + self.adapter = None async def start(self) -> AsyncGenerator[dict, None]: """ @@ -262,40 +263,48 @@ async def start(self) -> AsyncGenerator[dict, None]: f.write(system_prompt) logger.info(f"Wrote assistant system prompt to {claude_md_path}") - # Use system Claude CLI + # Use system Claude CLI (for Claude SDK) system_cli = shutil.which("claude") # Build environment overrides for API configuration from registry import DEFAULT_MODEL, get_effective_sdk_env + from sdk_adapter.factory import get_sdk_type sdk_env = get_effective_sdk_env() - # Determine model from SDK env (provider-aware) or fallback to env/default - model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL) + # Determine model based on SDK type + sdk_type = get_sdk_type() + if sdk_type == "codex": + # Codex SDK uses its own default model - don't specify + model = None + else: + # Claude SDK uses Anthropic models + model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL") or DEFAULT_MODEL or "claude-opus-4-6" try: - logger.info("Creating ClaudeSDKClient...") - self.client = ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - cli_path=system_cli, - # System prompt loaded from CLAUDE.md via setting_sources - # This avoids Windows command line length limit (~8191 chars) - setting_sources=["project"], - allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], - mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime - permission_mode="bypassPermissions", - max_turns=100, - cwd=str(self.project_dir.resolve()), - settings=str(settings_file.resolve()), - env=sdk_env, - ) + logger.info("Creating SDK adapter...") + options = AdapterOptions( + model=model, + project_dir=self.project_dir, + cli_path=system_cli, + # System prompt loaded from CLAUDE.md via setting_sources + # This avoids Windows command line length limit (~8191 chars) + setting_sources=["project"], + allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], + mcp_servers=mcp_servers, + permission_mode="bypassPermissions", + include_user_settings=False, + max_turns=100, + cwd=str(self.project_dir.resolve()), + settings_file=str(settings_file.resolve()), + env=sdk_env, ) - logger.info("Entering Claude client context...") - await self.client.__aenter__() - self._client_entered = True - logger.info("Claude client ready") + self.adapter = create_adapter(options) + logger.info("Entering adapter context...") + await self.adapter.__aenter__() + self._adapter_entered = True + logger.info("Adapter ready") except Exception as e: - logger.exception("Failed to create Claude client") + logger.exception("Failed to create adapter") yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"} return @@ -336,7 +345,7 @@ async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]: - {"type": "response_done"} - {"type": "error", "content": str} """ - if not self.client: + if not self.adapter: yield {"type": "error", "content": "Session not initialized. Call start() first."} return @@ -381,51 +390,50 @@ async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]: async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]: """ - Internal method to query Claude and stream responses. + Internal method to query the adapter and stream responses. - Handles tool calls and text responses. + Handles tool calls and text responses using unified event model. """ - if not self.client: + if not self.adapter: return - # Send message to Claude - await self.client.query(message) + # Send message to adapter + await self.adapter.query(message) full_response = "" - # Stream the response - async for msg in self.client.receive_response(): - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "TextBlock" and hasattr(block, "text"): - text = block.text - if text: - full_response += text - yield {"type": "text", "content": text} - - elif block_type == "ToolUseBlock" and hasattr(block, "name"): - tool_name = block.name - tool_input = getattr(block, "input", {}) - - # Intercept ask_user tool calls -> yield as question message - if tool_name == "mcp__features__ask_user": - questions = tool_input.get("questions", []) - if questions: - yield { - "type": "question", - "questions": questions, - } - continue - + # Stream the response using unified events + async for event in self.adapter.receive_events(): + if event.type == EventType.TEXT: + if event.content: + full_response += event.content + yield {"type": "text", "content": event.content} + + elif event.type == EventType.TOOL_CALL: + tool_name = event.tool_name or "" + tool_input = event.tool_input + + # Intercept ask_user tool calls -> yield as question message + if tool_name == "mcp__features__ask_user": + questions = tool_input.get("questions", []) + if questions: yield { - "type": "tool_call", - "tool": tool_name, - "input": tool_input, + "type": "question", + "questions": questions, } + continue + + yield { + "type": "tool_call", + "tool": tool_name, + "input": tool_input, + } + + elif event.type == EventType.ERROR: + yield {"type": "error", "content": event.content} + + elif event.type == EventType.DONE: + break # Store the complete response in the database if full_response and self.conversation_id: diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index b06e9d85..c2f07499 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -18,11 +18,12 @@ from pathlib import Path from typing import Any, AsyncGenerator, Optional -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from dotenv import load_dotenv +from sdk_adapter import AdapterOptions, EventType, SDKAdapter, create_adapter + from ..schemas import ImageAttachment -from .chat_constants import ROOT_DIR, make_multimodal_message +from .chat_constants import ROOT_DIR # Load environment variables from .env file if present load_dotenv() @@ -57,27 +58,27 @@ def __init__(self, project_name: str, project_dir: Path): """ self.project_name = project_name self.project_dir = project_dir - self.client: Optional[ClaudeSDKClient] = None + self.adapter: Optional[SDKAdapter] = None self.messages: list[dict] = [] self.complete: bool = False self.created_at = datetime.now() self._conversation_id: Optional[str] = None - self._client_entered: bool = False + self._adapter_entered: bool = False self.features_created: int = 0 self.created_feature_ids: list[int] = [] self._settings_file: Optional[Path] = None self._query_lock = asyncio.Lock() async def close(self) -> None: - """Clean up resources and close the Claude client.""" - if self.client and self._client_entered: + """Clean up resources and close the adapter.""" + if self.adapter and self._adapter_entered: try: - await self.client.__aexit__(None, None, None) + await self.adapter.__aexit__(None, None, None) except Exception as e: - logger.warning(f"Error closing Claude client: {e}") + logger.warning(f"Error closing adapter: {e}") finally: - self._client_entered = False - self.client = None + self._adapter_entered = False + self.adapter = None # Clean up temporary settings file if self._settings_file and self._settings_file.exists(): @@ -155,10 +156,17 @@ async def start(self) -> AsyncGenerator[dict, None]: # Build environment overrides for API configuration from registry import DEFAULT_MODEL, get_effective_sdk_env + from sdk_adapter.factory import get_sdk_type sdk_env = get_effective_sdk_env() - # Determine model from SDK env (provider-aware) or fallback to env/default - model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL) + # Determine model based on SDK type + sdk_type = get_sdk_type() + if sdk_type == "codex": + # Codex SDK uses its own default model - don't specify + model = None + else: + # Claude SDK uses Anthropic models + model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL") or DEFAULT_MODEL or "claude-opus-4-6" # Build MCP servers config for feature creation mcp_servers = { @@ -172,36 +180,37 @@ async def start(self) -> AsyncGenerator[dict, None]: }, } - # Create Claude SDK client + # Create SDK adapter try: - self.client = ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - cli_path=system_cli, - system_prompt=system_prompt, - allowed_tools=[ - "Read", - "Glob", - "Grep", - "WebFetch", - "WebSearch", - *EXPAND_FEATURE_TOOLS, - ], - mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime - permission_mode="bypassPermissions", - max_turns=100, - cwd=str(self.project_dir.resolve()), - settings=str(settings_file.resolve()), - env=sdk_env, - ) + options = AdapterOptions( + model=model, + project_dir=self.project_dir, + cli_path=system_cli, + system_prompt=system_prompt, + allowed_tools=[ + "Read", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + *EXPAND_FEATURE_TOOLS, + ], + mcp_servers=mcp_servers, + permission_mode="bypassPermissions", + include_user_settings=False, + max_turns=100, + cwd=str(self.project_dir.resolve()), + settings_file=str(settings_file.resolve()), + env=sdk_env, ) - await self.client.__aenter__() - self._client_entered = True + self.adapter = create_adapter(options) + await self.adapter.__aenter__() + self._adapter_entered = True except Exception: - logger.exception("Failed to create Claude client") + logger.exception("Failed to create adapter") yield { "type": "error", - "content": "Failed to initialize Claude" + "content": "Failed to initialize adapter" } return @@ -237,7 +246,7 @@ async def send_message( - {"type": "expansion_complete", "total_added": N} - {"type": "error", "content": str} """ - if not self.client: + if not self.adapter: yield { "type": "error", "content": "Session not initialized. Call start() first." @@ -271,12 +280,12 @@ async def _query_claude( attachments: list[ImageAttachment] | None = None ) -> AsyncGenerator[dict, None]: """ - Internal method to query Claude and stream responses. + Internal method to query the adapter and stream responses. - Feature creation is handled by Claude calling the feature_create_bulk + Feature creation is handled by the adapter calling the feature_create_bulk MCP tool directly -- no text parsing needed. """ - if not self.client: + if not self.adapter: return # Build the message content @@ -293,29 +302,28 @@ async def _query_claude( "data": att.base64Data, } }) - await self.client.query(make_multimodal_message(content_blocks)) + await self.adapter.query(content_blocks) logger.info(f"Sent multimodal message with {len(attachments)} image(s)") else: - await self.client.query(message) - - # Stream the response - async for msg in self.client.receive_response(): - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "TextBlock" and hasattr(block, "text"): - text = block.text - if text: - yield {"type": "text", "content": text} - - self.messages.append({ - "role": "assistant", - "content": text, - "timestamp": datetime.now().isoformat() - }) + await self.adapter.query(message) + + # Stream the response using unified events + async for event in self.adapter.receive_events(): + if event.type == EventType.TEXT: + if event.content: + yield {"type": "text", "content": event.content} + + self.messages.append({ + "role": "assistant", + "content": event.content, + "timestamp": datetime.now().isoformat() + }) + + elif event.type == EventType.ERROR: + yield {"type": "error", "content": event.content} + + elif event.type == EventType.DONE: + break def get_features_created(self) -> int: """Get the total number of features created in this session.""" diff --git a/server/services/process_manager.py b/server/services/process_manager.py index 9a4bd5ca..e8b9662e 100644 --- a/server/services/process_manager.py +++ b/server/services/process_manager.py @@ -406,6 +406,7 @@ async def start( # Build subprocess environment with API provider settings from registry import get_effective_sdk_env api_env = get_effective_sdk_env() + subprocess_env = { **os.environ, "PYTHONUNBUFFERED": "1", diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index d3556173..5f3e2dc6 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -15,11 +15,12 @@ from pathlib import Path from typing import Any, AsyncGenerator, Optional -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from dotenv import load_dotenv +from sdk_adapter import AdapterOptions, EventType, SDKAdapter, create_adapter + from ..schemas import ImageAttachment -from .chat_constants import ROOT_DIR, make_multimodal_message +from .chat_constants import ROOT_DIR # Load environment variables from .env file if present load_dotenv() @@ -50,23 +51,23 @@ def __init__(self, project_name: str, project_dir: Path): """ self.project_name = project_name self.project_dir = project_dir - self.client: Optional[ClaudeSDKClient] = None + self.adapter: Optional[SDKAdapter] = None self.messages: list[dict] = [] self.complete: bool = False self.created_at = datetime.now() self._conversation_id: Optional[str] = None - self._client_entered: bool = False # Track if context manager is active + self._adapter_entered: bool = False # Track if context manager is active async def close(self) -> None: - """Clean up resources and close the Claude client.""" - if self.client and self._client_entered: + """Clean up resources and close the adapter.""" + if self.adapter and self._adapter_entered: try: - await self.client.__aexit__(None, None, None) + await self.adapter.__aexit__(None, None, None) except Exception as e: - logger.warning(f"Error closing Claude client: {e}") + logger.warning(f"Error closing adapter: {e}") finally: - self._client_entered = False - self.client = None + self._adapter_entered = False + self.adapter = None async def start(self) -> AsyncGenerator[dict, None]: """ @@ -141,48 +142,56 @@ async def start(self) -> AsyncGenerator[dict, None]: # Build environment overrides for API configuration from registry import DEFAULT_MODEL, get_effective_sdk_env + from sdk_adapter.factory import get_sdk_type sdk_env = get_effective_sdk_env() - # Determine model from SDK env (provider-aware) or fallback to env/default - model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL) + # Determine model based on SDK type + sdk_type = get_sdk_type() + if sdk_type == "codex": + # Codex SDK uses its own default model - don't specify + model = None + else: + # Claude SDK uses Anthropic models + model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL") or DEFAULT_MODEL or "claude-opus-4-6" try: - self.client = ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - cli_path=system_cli, - # System prompt loaded from CLAUDE.md via setting_sources - # Include "user" for global skills and subagents from ~/.claude/ - setting_sources=["project", "user"], - allowed_tools=[ - "Read", - "Write", - "Edit", - "Glob", - "WebFetch", - "WebSearch", - ], - permission_mode="acceptEdits", # Auto-approve file writes for spec creation - max_turns=100, - cwd=str(self.project_dir.resolve()), - settings=str(settings_file.resolve()), - env=sdk_env, - ) + options = AdapterOptions( + model=model, + project_dir=self.project_dir, + cli_path=system_cli, + # System prompt loaded from CLAUDE.md via setting_sources + # Include "user" for global skills and subagents from ~/.claude/ + setting_sources=["project"], + include_user_settings=True, # Adds "user" to setting_sources + allowed_tools=[ + "Read", + "Write", + "Edit", + "Glob", + "WebFetch", + "WebSearch", + ], + permission_mode="acceptEdits", # Auto-approve file writes for spec creation + max_turns=100, + cwd=str(self.project_dir.resolve()), + settings_file=str(settings_file.resolve()), + env=sdk_env, ) + self.adapter = create_adapter(options) # Enter the async context and track it - await self.client.__aenter__() - self._client_entered = True + await self.adapter.__aenter__() + self._adapter_entered = True except Exception as e: - logger.exception("Failed to create Claude client") + logger.exception("Failed to create adapter") yield { "type": "error", - "content": f"Failed to initialize Claude: {str(e)}" + "content": f"Failed to initialize adapter: {str(e)}" } return - # Start the conversation - Claude will send the Phase 1 greeting + # Start the conversation - the agent will send the Phase 1 greeting try: - async for chunk in self._query_claude("Begin the spec creation process."): + async for chunk in self._query_adapter("Begin the spec creation process."): yield chunk # Signal that the response is complete (for UI to hide loading indicator) yield {"type": "response_done"} @@ -212,7 +221,7 @@ async def send_message( - {"type": "spec_complete", "path": str} - {"type": "error", "content": str} """ - if not self.client: + if not self.adapter: yield { "type": "error", "content": "Session not initialized. Call start() first." @@ -228,26 +237,26 @@ async def send_message( }) try: - async for chunk in self._query_claude(user_message, attachments): + async for chunk in self._query_adapter(user_message, attachments): yield chunk # Signal that the response is complete (for UI to hide loading indicator) yield {"type": "response_done"} except Exception as e: - logger.exception("Error during Claude query") + logger.exception("Error during adapter query") yield { "type": "error", "content": f"Error: {str(e)}" } - async def _query_claude( + async def _query_adapter( self, message: str, attachments: list[ImageAttachment] | None = None ) -> AsyncGenerator[dict, None]: """ - Internal method to query Claude and stream responses. + Internal method to query the adapter and stream responses. - Handles tool calls (Write) and text responses. + Handles tool calls (Write) and text responses using unified event model. Supports multimodal content with image attachments. IMPORTANT: Spec creation requires BOTH files to be written: @@ -256,7 +265,7 @@ async def _query_claude( We only signal spec_complete when BOTH files are verified on disk. """ - if not self.client: + if not self.adapter: return # Build the message content @@ -279,13 +288,12 @@ async def _query_claude( } }) - # Send multimodal content to Claude using async generator format - # The SDK's query() accepts AsyncIterable[dict] for custom message formats - await self.client.query(make_multimodal_message(content_blocks)) + # Send multimodal content to adapter + await self.adapter.query(content_blocks) logger.info(f"Sent multimodal message with {len(attachments)} image(s)") else: # Text-only message: use string format - await self.client.query(message) + await self.adapter.query(message) current_text = "" @@ -304,117 +312,110 @@ async def _query_claude( # Store paths for the completion message spec_path = None - # Stream the response using receive_response - async for msg in self.client.receive_response(): - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - # Process content blocks in the assistant message - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "TextBlock" and hasattr(block, "text"): - # Accumulate text and yield it - text = block.text - if text: - current_text += text - yield {"type": "text", "content": text} - - # Store in message history - self.messages.append({ - "role": "assistant", - "content": text, - "timestamp": datetime.now().isoformat() - }) - - elif block_type == "ToolUseBlock" and hasattr(block, "name"): - tool_name = block.name - tool_input = getattr(block, "input", {}) - tool_id = getattr(block, "id", "") - - if tool_name in ("Write", "Edit"): - # File being written or edited - track for verification - file_path = tool_input.get("file_path", "") - - # Track app_spec.txt - if "app_spec.txt" in str(file_path): - pending_writes["app_spec"] = { - "tool_id": tool_id, - "path": file_path - } - logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}") - - # Track initializer_prompt.md - elif "initializer_prompt.md" in str(file_path): - pending_writes["initializer"] = { - "tool_id": tool_id, - "path": file_path - } - logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}") - - elif msg_type == "UserMessage" and hasattr(msg, "content"): - # Tool results - check for write confirmations and errors - for block in msg.content: - block_type = type(block).__name__ - if block_type == "ToolResultBlock": - is_error = getattr(block, "is_error", False) - tool_use_id = getattr(block, "tool_use_id", "") - - if is_error: - content = getattr(block, "content", "Unknown error") - logger.warning(f"Tool error: {content}") - # Clear any pending writes that failed - for key in pending_writes: - pending_write = pending_writes[key] - if pending_write is not None and tool_use_id == pending_write.get("tool_id"): - logger.error(f"{key} write failed: {content}") - pending_writes[key] = None + # Stream the response using unified events + async for event in self.adapter.receive_events(): + if event.type == EventType.TEXT: + # Accumulate text and yield it + if event.content: + current_text += event.content + yield {"type": "text", "content": event.content} + + # Store in message history + self.messages.append({ + "role": "assistant", + "content": event.content, + "timestamp": datetime.now().isoformat() + }) + + elif event.type == EventType.TOOL_CALL: + tool_name = event.tool_name or "" + tool_input = event.tool_input + tool_id = event.tool_id or "" + + if tool_name in ("Write", "Edit"): + # File being written or edited - track for verification + file_path = tool_input.get("file_path", "") + + # Track app_spec.txt + if "app_spec.txt" in str(file_path): + pending_writes["app_spec"] = { + "tool_id": tool_id, + "path": file_path + } + logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}") + + # Track initializer_prompt.md + elif "initializer_prompt.md" in str(file_path): + pending_writes["initializer"] = { + "tool_id": tool_id, + "path": file_path + } + logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}") + + elif event.type == EventType.TOOL_RESULT: + tool_use_id = event.tool_id or "" + is_error = event.is_error + + if is_error: + logger.warning(f"Tool error: {event.content}") + # Clear any pending writes that failed + for key in pending_writes: + pending_write = pending_writes[key] + if pending_write is not None and tool_use_id == pending_write.get("tool_id"): + logger.error(f"{key} write failed: {event.content}") + pending_writes[key] = None + else: + # Tool succeeded - check which file was written + + # Check app_spec.txt + if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"): + file_path = pending_writes["app_spec"]["path"] + full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path + if full_path.exists(): + logger.info(f"app_spec.txt verified at: {full_path}") + files_written["app_spec"] = True + spec_path = file_path + + # Notify about file write (but NOT completion yet) + yield { + "type": "file_written", + "path": str(file_path) + } + else: + logger.error(f"app_spec.txt not found after write: {full_path}") + pending_writes["app_spec"] = None + + # Check initializer_prompt.md + if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"): + file_path = pending_writes["initializer"]["path"] + full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path + if full_path.exists(): + logger.info(f"initializer_prompt.md verified at: {full_path}") + files_written["initializer"] = True + + # Notify about file write + yield { + "type": "file_written", + "path": str(file_path) + } else: - # Tool succeeded - check which file was written - - # Check app_spec.txt - if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"): - file_path = pending_writes["app_spec"]["path"] - full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path - if full_path.exists(): - logger.info(f"app_spec.txt verified at: {full_path}") - files_written["app_spec"] = True - spec_path = file_path - - # Notify about file write (but NOT completion yet) - yield { - "type": "file_written", - "path": str(file_path) - } - else: - logger.error(f"app_spec.txt not found after write: {full_path}") - pending_writes["app_spec"] = None - - # Check initializer_prompt.md - if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"): - file_path = pending_writes["initializer"]["path"] - full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path - if full_path.exists(): - logger.info(f"initializer_prompt.md verified at: {full_path}") - files_written["initializer"] = True - - # Notify about file write - yield { - "type": "file_written", - "path": str(file_path) - } - else: - logger.error(f"initializer_prompt.md not found after write: {full_path}") - pending_writes["initializer"] = None - - # Check if BOTH files are now written - only then signal completion - if files_written["app_spec"] and files_written["initializer"]: - logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion") - self.complete = True - yield { - "type": "spec_complete", - "path": str(spec_path) - } + logger.error(f"initializer_prompt.md not found after write: {full_path}") + pending_writes["initializer"] = None + + # Check if BOTH files are now written - only then signal completion + if files_written["app_spec"] and files_written["initializer"]: + logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion") + self.complete = True + yield { + "type": "spec_complete", + "path": str(spec_path) + } + + elif event.type == EventType.ERROR: + yield {"type": "error", "content": event.content} + + elif event.type == EventType.DONE: + break def is_complete(self) -> bool: """Check if spec creation is complete.""" diff --git a/start_ui.py b/start_ui.py index e9b08be4..4d414a70 100644 --- a/start_ui.py +++ b/start_ui.py @@ -356,7 +356,8 @@ def main() -> None: # Load environment variables now that dotenv is installed try: from dotenv import load_dotenv - load_dotenv(ROOT / ".env") + env_file = ROOT / ".env" + load_dotenv(env_file) except ImportError: pass # dotenv is optional for basic functionality diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index 0a2b9eec..b5db4769 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -214,6 +214,32 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
+ {(settings.sdk_type ?? 'claude') === 'claude' + ? 'Claude Agent SDK (default)' + : 'OpenAI Codex SDK'} +
+