diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index f062ffef..aea75036 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -26,6 +26,7 @@ from codeframe.cli.auth_commands import auth_app from codeframe.cli.pr_commands import pr_app from codeframe.cli.env_commands import env_app +from codeframe.cli.engines_commands import engines_app # Load environment variables from .env files # Priority: workspace .env > home .env @@ -1994,8 +1995,8 @@ def work_start( "--stub", help="Run stub agent (for testing, does nothing real)", ), - engine: str = typer.Option( - "react", + engine: Optional[str] = typer.Option( + None, "--engine", help="Agent engine: react (default), plan (legacy), claude-code, opencode, or built-in", ), @@ -2029,6 +2030,15 @@ def work_start( path = workspace_path or Path.cwd() + # Resolve engine: CLI flag → env var → workspace config → default "react" + if engine is None: + import os + engine = os.environ.get("CODEFRAME_ENGINE") + if engine is None: + from codeframe.core.config import load_environment_config + env_config = load_environment_config(path) + engine = env_config.engine if env_config else "react" + try: workspace = get_workspace(path) @@ -2875,8 +2885,8 @@ def batch_run( "--review", help="Run verification gates (pytest, ruff) after successful batch completion", ), - engine: str = typer.Option( - "react", + engine: Optional[str] = typer.Option( + None, "--engine", help="Agent engine: react (default), plan (legacy), claude-code, opencode, or built-in", ), @@ -2913,6 +2923,15 @@ def batch_run( path = workspace_path or Path.cwd() + # Resolve engine: CLI flag → env var → workspace config → default "react" + if engine is None: + import os + engine = os.environ.get("CODEFRAME_ENGINE") + if engine is None: + from codeframe.core.config import load_environment_config + env_config = load_environment_config(path) + engine = env_config.engine if env_config else "react" + try: workspace = get_workspace(path) @@ -4824,6 +4843,8 @@ def templates_apply( app.add_typer(pr_app, name="pr") app.add_typer(env_app, name="env") +app.add_typer(engines_app, name="engines") + # ============================================================================= # Version command diff --git a/codeframe/cli/engines_commands.py b/codeframe/cli/engines_commands.py new file mode 100644 index 00000000..8fa989e4 --- /dev/null +++ b/codeframe/cli/engines_commands.py @@ -0,0 +1,84 @@ +"""CLI engine management commands. + +Usage: + codeframe engines list # Show available engines + codeframe engines check # Check engine requirements +""" + +import typer +from rich.console import Console +from rich.table import Table + +console = Console() + +engines_app = typer.Typer( + name="engines", + help="Engine management commands", + no_args_is_help=True, +) + + +@engines_app.command("list") +def engines_list() -> None: + """List all available execution engines and their requirement status.""" + from codeframe.core.engine_registry import VALID_ENGINES, EXTERNAL_ENGINES, check_requirements + + table = Table(title="Available Engines") + table.add_column("Engine", style="cyan") + table.add_column("Type", style="dim") + table.add_column("Requirements", style="green") + + for engine in sorted(VALID_ENGINES): + engine_type = "external" if engine in EXTERNAL_ENGINES else "builtin" + if engine == "built-in": + engine_type = "alias → react" + + try: + reqs = check_requirements(engine) + except ValueError: + reqs = {} + + if not reqs: + req_display = "[green]Ready[/green]" + else: + parts = [] + for key, satisfied in reqs.items(): + mark = "[green]✓[/green]" if satisfied else "[red]✗[/red]" + parts.append(f"{mark} {key}") + req_display = ", ".join(parts) + + table.add_row(engine, engine_type, req_display) + + console.print(table) + + +@engines_app.command("check") +def engines_check( + name: str = typer.Argument(..., help="Engine name to check"), +) -> None: + """Check if an engine's requirements are satisfied.""" + from codeframe.core.engine_registry import check_requirements + + try: + reqs = check_requirements(name) + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if not reqs: + console.print(f"[green]Engine '{name}' has no additional requirements.[/green]") + return + + all_satisfied = True + for key, satisfied in reqs.items(): + if satisfied: + console.print(f" [green]✓[/green] {key}") + else: + console.print(f" [red]✗[/red] {key} — not set") + all_satisfied = False + + if all_satisfied: + console.print(f"\n[green]Engine '{name}' is ready.[/green]") + else: + console.print(f"\n[red]Engine '{name}' has unmet requirements.[/red]") + raise typer.Exit(1) diff --git a/codeframe/core/adapters/builtin.py b/codeframe/core/adapters/builtin.py index da34e521..33421624 100644 --- a/codeframe/core/adapters/builtin.py +++ b/codeframe/core/adapters/builtin.py @@ -1,7 +1,12 @@ -"""Builtin adapter shims wrapping ReactAgent and Agent behind AgentAdapter protocol.""" +"""Builtin adapter shims wrapping ReactAgent and Agent behind AgentAdapter protocol. + +These adapters encapsulate all engine-specific behavior (stall retry for React, +supervisor retry for Plan) so the runtime can treat all engines uniformly. +""" from __future__ import annotations +import logging from pathlib import Path from typing import TYPE_CHECKING, Callable, Optional @@ -14,12 +19,16 @@ from codeframe.core.streaming import EventPublisher, RunOutputLogger from codeframe.core.workspace import Workspace +logger = logging.getLogger(__name__) + +_MAX_STALL_RETRIES = 1 + class BuiltinReactAdapter: """Wraps the existing ReactAgent behind the AgentAdapter interface. - This shim allows the ReactAgent to be used through the unified - engine registry without modifying the ReactAgent class itself. + Handles stall detection and retry internally so the runtime + doesn't need engine-specific branching. """ def __init__( @@ -51,6 +60,11 @@ def __init__( def name(self) -> str: return "react" + @classmethod + def requirements(cls) -> dict[str, str]: + """Return requirement names and descriptions.""" + return {"ANTHROPIC_API_KEY": "Anthropic API key for LLM calls"} + def run( self, task_id: str, @@ -58,31 +72,50 @@ def run( workspace_path: Path, on_event: Callable[[AgentEvent], None] | None = None, ) -> AgentResult: - """Run the ReactAgent and map its AgentStatus to AgentResult.""" + """Run the ReactAgent with stall retry, map AgentStatus to AgentResult.""" from codeframe.core.react_agent import ReactAgent + from codeframe.core.stall_detector import StallDetectedError def _bridge_event(event_type: str, data: dict) -> None: if on_event: on_event(AgentEvent(type=event_type, data=data)) - kwargs: dict = { - "workspace": self._workspace, - "llm_provider": self._llm_provider, - "stall_timeout_s": self._stall_timeout_s, - "event_publisher": self._event_publisher, - "dry_run": self._dry_run, - "verbose": self._verbose, - "on_event": _bridge_event, - "debug": self._debug, - "output_logger": self._output_logger, - "fix_coordinator": self._fix_coordinator, - } - if self._stall_action is not None: - kwargs["stall_action"] = self._stall_action + def _build_agent() -> ReactAgent: + kwargs: dict = { + "workspace": self._workspace, + "llm_provider": self._llm_provider, + "stall_timeout_s": self._stall_timeout_s, + "event_publisher": self._event_publisher, + "dry_run": self._dry_run, + "verbose": self._verbose, + "on_event": _bridge_event, + "debug": self._debug, + "output_logger": self._output_logger, + "fix_coordinator": self._fix_coordinator, + } + if self._stall_action is not None: + kwargs["stall_action"] = self._stall_action + return ReactAgent(**kwargs) + + for stall_attempt in range(1 + _MAX_STALL_RETRIES): + try: + agent = _build_agent() + status = agent.run(task_id) + return self._map_status(status) + except StallDetectedError as exc: + logger.warning( + "Stall detected (attempt %d): %s", + stall_attempt + 1, exc, + ) + if stall_attempt >= _MAX_STALL_RETRIES: + logger.error("Max stall retries exceeded, failing task") + return AgentResult( + status="failed", + error=f"Stall detected after {stall_attempt + 1} attempts: {exc}", + ) + logger.info("Retrying after stall (attempt %d)", stall_attempt + 2) - agent = ReactAgent(**kwargs) - status = agent.run(task_id) - return self._map_status(status) + return AgentResult(status="failed", error="Unexpected: stall retry loop exhausted") @staticmethod def _map_status(status: object) -> AgentResult: @@ -102,7 +135,10 @@ def _map_status(status: object) -> AgentResult: class BuiltinPlanAdapter: - """Wraps the existing plan-based Agent behind the AgentAdapter interface.""" + """Wraps the existing plan-based Agent behind the AgentAdapter interface. + + Handles supervisor BLOCKED-retry and tactical failure recovery internally. + """ def __init__( self, @@ -129,6 +165,11 @@ def __init__( def name(self) -> str: return "plan" + @classmethod + def requirements(cls) -> dict[str, str]: + """Return requirement names and descriptions.""" + return {"ANTHROPIC_API_KEY": "Anthropic API key for LLM calls"} + def run( self, task_id: str, @@ -136,27 +177,114 @@ def run( workspace_path: Path, on_event: Callable[[AgentEvent], None] | None = None, ) -> AgentResult: - """Run the plan-based Agent and map its AgentState to AgentResult.""" - from codeframe.core.agent import Agent + """Run the plan-based Agent with supervisor retry, map to AgentResult.""" + from codeframe.core.agent import Agent, AgentStatus def _bridge_event(event_type: str, data: dict) -> None: if on_event: on_event(AgentEvent(type=event_type, data=data)) - agent = Agent( - workspace=self._workspace, - llm_provider=self._llm_provider, - dry_run=self._dry_run, - on_event=_bridge_event, - debug=self._debug, - verbose=self._verbose, - fix_coordinator=self._fix_coordinator, - output_logger=self._output_logger, - event_publisher=self._event_publisher, - ) + def _build_agent() -> Agent: + return Agent( + workspace=self._workspace, + llm_provider=self._llm_provider, + dry_run=self._dry_run, + on_event=_bridge_event, + debug=self._debug, + verbose=self._verbose, + fix_coordinator=self._fix_coordinator, + output_logger=self._output_logger, + event_publisher=self._event_publisher, + ) + + agent = _build_agent() state = agent.run(task_id) + + # Supervisor BLOCKED-retry (best-effort — failures don't affect result) + if state.status == AgentStatus.BLOCKED: + try: + state = self._try_supervisor_unblock(state, task_id, _build_agent) + except Exception: + logger.debug("Supervisor unblock failed, returning original state", exc_info=True) + + # Supervisor tactical failure recovery (best-effort) + if state.status == AgentStatus.FAILED: + try: + state = self._try_tactical_recovery(state, task_id, _build_agent) + except Exception: + logger.debug("Supervisor tactical recovery failed, returning original state", exc_info=True) + return self._map_state(state) + def _try_supervisor_unblock(self, state, task_id: str, build_agent): + """Try supervisor resolution for BLOCKED tasks.""" + from codeframe.core.conductor import get_supervisor + + supervisor = get_supervisor(self._workspace) + if supervisor.try_resolve_blocked_task(task_id): + logger.info("[Supervisor] Retrying task after auto-resolution...") + agent = build_agent() + state = agent.run(task_id) + return state + + def _try_tactical_recovery(self, state, task_id: str, build_agent): + """Try supervisor tactical recovery for FAILED tasks.""" + from codeframe.core.conductor import get_supervisor, SUPERVISOR_TACTICAL_PATTERNS + + error_msg = self._extract_error(state) + + if not error_msg: + return state + + error_msg_lower = error_msg.lower() + matched_patterns = [p for p in SUPERVISOR_TACTICAL_PATTERNS if p in error_msg_lower] + + if not matched_patterns: + return state + + supervisor = get_supervisor(self._workspace) + resolution = supervisor._generate_tactical_resolution(error_msg) + logger.info( + "Supervisor detected recoverable error, providing guidance: %s...", + resolution[:100], + ) + + from codeframe.core import blockers + blocker = blockers.create( + self._workspace, + task_id=task_id, + question=f"Technical error: {error_msg[:500]}", + ) + blockers.answer(self._workspace, blocker.id, resolution) + + logger.info("Supervisor retrying task with guidance...") + agent = build_agent() + return agent.run(task_id) + + @staticmethod + def _extract_error(state) -> str: + """Extract error message from AgentState for supervisor analysis.""" + blocker = getattr(state, "blocker", None) + if blocker: + return getattr(blocker, "reason", None) or getattr(blocker, "question", None) or "" + + step_results = getattr(state, "step_results", None) or [] + if step_results: + last_result = step_results[-1] + if hasattr(last_result, "error") and last_result.error: + return last_result.error + if hasattr(last_result, "output") and last_result.output: + return last_result.output + + gate_results = getattr(state, "gate_results", None) or [] + for gate in gate_results: + if not gate.passed: + for check in gate.checks: + if check.output: + return check.output + + return "" + @staticmethod def _map_state(state: object) -> AgentResult: """Map AgentState dataclass to AgentResult.""" diff --git a/codeframe/core/config.py b/codeframe/core/config.py index 6cf33ca7..c6a9c211 100644 --- a/codeframe/core/config.py +++ b/codeframe/core/config.py @@ -108,6 +108,9 @@ class EnvironmentConfig: # Agent budget agent_budget: AgentBudgetConfig = dataclass_field(default_factory=AgentBudgetConfig) + # Execution engine + engine: str = "react" + # Custom command overrides custom_commands: dict[str, str] = dataclass_field(default_factory=dict) @@ -144,6 +147,14 @@ def validate(self) -> list[str]: f"Must be one of: {', '.join(valid_lint_tools)}" ) + # Validate engine + from codeframe.core.engine_registry import VALID_ENGINES + if self.engine not in VALID_ENGINES: + errors.append( + f"Invalid engine '{self.engine}'. " + f"Must be one of: {', '.join(sorted(VALID_ENGINES))}" + ) + # Validate agent budget budget = self.agent_budget if any(v <= 0 for v in (budget.base_iterations, budget.min_iterations, budget.max_iterations)): diff --git a/codeframe/core/engine_registry.py b/codeframe/core/engine_registry.py index 1c421c66..ec363a01 100644 --- a/codeframe/core/engine_registry.py +++ b/codeframe/core/engine_registry.py @@ -134,6 +134,67 @@ def get_builtin_adapter( ) +def check_requirements(engine: str) -> dict[str, bool]: + """Check if an engine's requirements are met. + + Args: + engine: Engine name to check. + + Returns: + Dict of requirement-name → bool (True if satisfied). + + Raises: + ValueError: If engine is not valid. + """ + if engine not in VALID_ENGINES: + raise ValueError( + f"Invalid engine '{engine}'. " + f"Must be one of: {', '.join(sorted(VALID_ENGINES))}" + ) + + # Resolve alias + resolved = "react" if engine == "built-in" else engine + + # Get the adapter class and check for a requirements() classmethod + adapter_cls = _get_adapter_class(resolved) + if adapter_cls is None: + return {} + + req_method = getattr(adapter_cls, "requirements", None) + if req_method is None or not callable(req_method): + return {} + + reqs = req_method() + result: dict[str, bool] = {} + for key in reqs: + # Check environment variables for builtin engines + result[key] = bool(os.getenv(key)) + + # External adapters may also have a check_ready() classmethod + check_ready = getattr(adapter_cls, "check_ready", None) + if check_ready and callable(check_ready): + result.update(check_ready()) + + return result + + +def _get_adapter_class(engine: str) -> type | None: + """Get the adapter class for an engine (without instantiating).""" + if engine == "react": + from codeframe.core.adapters.builtin import BuiltinReactAdapter + return BuiltinReactAdapter + elif engine == "plan": + from codeframe.core.adapters.builtin import BuiltinPlanAdapter + return BuiltinPlanAdapter + elif engine == "claude-code": + from codeframe.core.adapters.claude_code import ClaudeCodeAdapter + return ClaudeCodeAdapter + elif engine == "opencode": + from codeframe.core.adapters.opencode import OpenCodeAdapter + return OpenCodeAdapter + return None + + def get_adapter( engine: str, workspace: Any = None, diff --git a/codeframe/core/runtime.py b/codeframe/core/runtime.py index 527b9e8b..39cbcfcc 100644 --- a/codeframe/core/runtime.py +++ b/codeframe/core/runtime.py @@ -624,10 +624,13 @@ def execute_agent( ValueError: If ANTHROPIC_API_KEY is not set (for builtin engines) or engine is invalid """ import os - from codeframe.core.agent import Agent, AgentState, AgentStatus + from codeframe.core.agent import AgentState, AgentStatus from codeframe.adapters.llm import get_provider from codeframe.core.diagnostics import RunLogger, LogCategory - from codeframe.core.engine_registry import is_external_engine, resolve_engine + from codeframe.core.engine_registry import ( + is_external_engine, resolve_engine, get_external_adapter, get_builtin_adapter, + ) + from codeframe.core.adapters.agent_adapter import AgentEvent as AdapterEvent # Resolve engine (handles "built-in" alias and CODEFRAME_ENGINE env var) engine = resolve_engine(engine) @@ -669,13 +672,14 @@ def on_agent_event(event_type: str, data: dict) -> None: category = _event_type_to_category(event_type) run_logger.info(category, f"Agent event: {event_type}", data) - # Create and run agent based on engine selection - # --- External engine path (claude-code, opencode, etc.) --- + # Bridge AgentEvent callbacks to workspace event system + def on_adapter_event(event: AdapterEvent) -> None: + on_agent_event(event.type, event.data) + + # Get adapter via registry and run if is_external_engine(engine): - from codeframe.core.engine_registry import get_external_adapter from codeframe.core.context_packager import TaskContextPackager from codeframe.core.adapters.verification_wrapper import VerificationWrapper - from codeframe.core.adapters.agent_adapter import AgentEvent run_logger.info( LogCategory.AGENT_ACTION, @@ -683,285 +687,98 @@ def on_agent_event(event_type: str, data: dict) -> None: {"engine": engine}, ) - # Build rich context prompt for the external agent packager = TaskContextPackager(workspace) packaged = packager.build(run.task_id) - # Get the adapter and wrap with verification gates adapter = get_external_adapter(engine) wrapper = VerificationWrapper( - adapter, - workspace, - max_correction_rounds=3, - verbose=verbose, + adapter, workspace, max_correction_rounds=3, verbose=verbose, ) - # Bridge AgentEvent callbacks to workspace event system - def on_adapter_event(event: AgentEvent) -> None: - on_agent_event(event.type, event.data) - result = wrapper.run( - run.task_id, - packaged.prompt, - workspace.repo_path, + run.task_id, packaged.prompt, workspace.repo_path, on_event=on_adapter_event, ) + else: + from codeframe.core.stall_detector import StallAction - # Map AgentResult to AgentState for compatibility with rest of runtime - status_map = { - "completed": AgentStatus.COMPLETED, - "failed": AgentStatus.FAILED, - "blocked": AgentStatus.BLOCKED, + resolved_action = StallAction(stall_action) + builtin_kwargs: dict = { + "event_publisher": event_publisher, + "dry_run": dry_run, + "verbose": verbose, + "debug": debug, + "output_logger": output_logger, + "fix_coordinator": fix_coordinator, } - agent_status = status_map.get(result.status, AgentStatus.FAILED) - state = AgentState(status=agent_status) - - # Create blocker if adapter reported one - if result.status == "blocked" and result.blocker_question: - from codeframe.core import blockers - blockers.create( - workspace, - task_id=run.task_id, - question=result.blocker_question, - ) + # Stall detection is only relevant for the react engine + if engine in ("react", "built-in"): + builtin_kwargs["stall_timeout_s"] = stall_timeout_s + builtin_kwargs["stall_action"] = resolved_action - run_logger.info( - LogCategory.AGENT_ACTION, - f"External engine completed: {result.status}", - {"engine": engine, "output_length": len(result.output)}, + adapter = get_builtin_adapter( + engine, workspace, provider, **builtin_kwargs, ) - # --- Builtin engine paths (react, plan) --- - elif engine == "react": - # ReactAgent has a simpler interface — it handles its own - # retries and verification internally. - from codeframe.core.react_agent import ReactAgent - from codeframe.core.stall_detector import StallAction, StallDetectedError + result = adapter.run( + run.task_id, "", workspace.repo_path, + on_event=on_adapter_event, + ) - resolved_action = StallAction(stall_action) + run_logger.info( + LogCategory.AGENT_ACTION, + f"Engine '{engine}' completed: {result.status}", + {"engine": engine, "output_length": len(result.output)}, + ) - def _build_react_agent() -> ReactAgent: - return ReactAgent( - workspace=workspace, - llm_provider=provider, - stall_timeout_s=stall_timeout_s, - stall_action=resolved_action, - event_publisher=event_publisher, - dry_run=dry_run, - verbose=verbose, - on_event=on_agent_event, - debug=debug, - output_logger=output_logger, - fix_coordinator=fix_coordinator, - ) - - max_stall_retries = 1 - for stall_attempt in range(1 + max_stall_retries): - try: - react_agent = _build_react_agent() - react_status = react_agent.run(run.task_id) - # Wrap AgentStatus enum into AgentState dataclass for compatibility - state = AgentState(status=react_status) - break - except StallDetectedError as exc: - run_logger.warning( - LogCategory.AGENT_ACTION, - f"Stall detected (attempt {stall_attempt + 1}): {exc}", - {"elapsed_s": exc.elapsed_s, "iterations": exc.iterations}, - ) - if stall_attempt >= max_stall_retries: - run_logger.error( - LogCategory.AGENT_ACTION, - "Max stall retries exceeded, failing task", - {}, - ) - state = AgentState(status=AgentStatus.FAILED) - break - run_logger.info( - LogCategory.AGENT_ACTION, - "Retrying after stall", - {"attempt": stall_attempt + 2}, - ) - else: - agent = Agent( - workspace=workspace, - llm_provider=provider, - dry_run=dry_run, - on_event=on_agent_event, - debug=debug, - verbose=verbose, - fix_coordinator=fix_coordinator, - output_logger=output_logger, - event_publisher=event_publisher, + # Map AgentResult to AgentState for rest of runtime + status_map = { + "completed": AgentStatus.COMPLETED, + "failed": AgentStatus.FAILED, + "blocked": AgentStatus.BLOCKED, + } + agent_status = status_map.get(result.status, AgentStatus.FAILED) + state = AgentState(status=agent_status) + + # Create blocker if adapter reported one and populate state for CLI + if result.status == "blocked" and result.blocker_question: + from codeframe.core import blockers as blockers_mod + blocker_obj = blockers_mod.create( + workspace, task_id=run.task_id, question=result.blocker_question, ) - - state = agent.run(run.task_id) - - # If agent is BLOCKED, try supervisor resolution - # (only for plan engine — ReactAgent handles retries internally) - if state.status == AgentStatus.BLOCKED: - from codeframe.core.conductor import get_supervisor - - supervisor = get_supervisor(workspace) - if supervisor.try_resolve_blocked_task(run.task_id): - # Supervisor resolved the blocker - retry the agent - print("[Supervisor] Retrying task after auto-resolution...") - - # Create a new agent instance and retry - agent = Agent( - workspace=workspace, - llm_provider=provider, - dry_run=dry_run, - on_event=on_agent_event, - debug=debug, - verbose=verbose, - fix_coordinator=fix_coordinator, - output_logger=output_logger, - event_publisher=event_publisher, - ) - state = agent.run(run.task_id) - - # If agent FAILED, check if supervisor can help with common technical issues - # (only for plan engine — ReactAgent handles retries internally and - # doesn't populate the AgentState fields that supervisor inspection needs) - if state.status == AgentStatus.FAILED and engine == "plan": - from codeframe.core.conductor import get_supervisor, SUPERVISOR_TACTICAL_PATTERNS - - if debug: - logger.debug("Agent FAILED - analyzing for supervisor intervention") - logger.debug("state.blocker: %s", state.blocker) - logger.debug( - "state.step_results count: %d", - len(state.step_results) if state.step_results else 0 - ) - logger.debug( - "state.gate_results count: %d", - len(state.gate_results) if state.gate_results else 0 - ) - - # Extract error message from available sources - error_msg = "" - error_source = "none" - if state.blocker: - error_msg = state.blocker.reason or state.blocker.question or "" - error_source = "blocker" - elif state.step_results: - # Check last step result for error info - last_result = state.step_results[-1] - if debug: - error_preview = last_result.error[:200] if last_result.error else "None" - logger.debug( - "Last step result: status=%s, error=%s", - last_result.status, error_preview - ) - if hasattr(last_result, 'error') and last_result.error: - error_msg = last_result.error - error_source = "step_result.error" - elif hasattr(last_result, 'output') and last_result.output: - error_msg = last_result.output - error_source = "step_result.output" - elif state.gate_results: - # Check gate results for failure info - for gate in state.gate_results: - if debug: - logger.debug("Gate result: passed=%s", gate.passed) - if not gate.passed: - for check in gate.checks: - if debug: - output_preview = check.output[:100] if check.output else "None" - logger.debug( - " Check: %s status=%s output=%s", - check.name, check.status, output_preview - ) - if check.output: - error_msg = check.output - error_source = f"gate.{check.name}" - break - - if debug: - logger.debug("Extracted error from: %s", error_source) - error_preview = error_msg[:300] if error_msg else "EMPTY" - logger.debug("Error message (first 300 chars): %s", error_preview) - - error_msg_lower = error_msg.lower() - matched_patterns = [p for p in SUPERVISOR_TACTICAL_PATTERNS if p in error_msg_lower] - if debug: - logger.debug("Matched tactical patterns: %s", matched_patterns) - - if error_msg and matched_patterns: - supervisor = get_supervisor(workspace) - resolution = supervisor._generate_tactical_resolution(error_msg) - logger.info( - "Supervisor detected recoverable error, providing guidance: %s...", - resolution[:100] - ) - - # Create a blocker with the resolution for the agent's next run - from codeframe.core import blockers - blocker = blockers.create( - workspace, - task_id=run.task_id, - question=f"Technical error: {error_msg[:500]}", - ) - blockers.answer(workspace, blocker.id, resolution) - if debug: - logger.debug("Created blocker %s and answered with resolution", blocker.id[:8]) - - # Retry the agent with the new context - logger.info("Supervisor retrying task with guidance...") - agent = Agent( - workspace=workspace, - llm_provider=provider, - dry_run=dry_run, - on_event=on_agent_event, - debug=debug, - verbose=verbose, - fix_coordinator=fix_coordinator, - output_logger=output_logger, - event_publisher=event_publisher, - ) - state = agent.run(run.task_id) - if debug: - logger.debug("Retry completed with status: %s", state.status) - elif debug: - logger.debug( - "No supervisor intervention - error_msg empty=%s, no pattern match=%s", - not error_msg, not matched_patterns - ) + state.blocker = blocker_obj # Log final status if state.status == AgentStatus.COMPLETED: run_logger.info(LogCategory.STATE_CHANGE, "Agent completed successfully") elif state.status == AgentStatus.BLOCKED: - blocker_reason = state.blocker.question if state.blocker else "Unknown" - run_logger.warning(LogCategory.BLOCKER, f"Agent blocked: {blocker_reason[:200]}", { - "blocker_question": blocker_reason, + run_logger.warning(LogCategory.BLOCKER, f"Agent blocked: {result.blocker_question or 'Unknown'}", { + "blocker_question": result.blocker_question or "Unknown", }) elif state.status == AgentStatus.FAILED: - # Log detailed error information for diagnosis - error_info = {} - if state.step_results: - last_step = state.step_results[-1] - error_info["last_step_status"] = last_step.status.value if hasattr(last_step.status, 'value') else str(last_step.status) - error_info["last_step_error"] = last_step.error[:500] if last_step.error else None - if state.gate_results: - error_info["gate_failures"] = sum(1 for g in state.gate_results if not g.passed) - run_logger.error(LogCategory.ERROR, "Agent execution failed", error_info) + run_logger.error(LogCategory.ERROR, "Agent execution failed", { + "error": (result.error or "")[:500], + }) # Update run status based on agent result if state.status == AgentStatus.COMPLETED: complete_run(workspace, run.id) elif state.status == AgentStatus.BLOCKED: - # Get blocker ID from state if available - blocker_id = "" - if state.blocker and hasattr(state, "_blocker_id"): - blocker_id = state._blocker_id - block_run(workspace, run.id, blocker_id) + block_run(workspace, run.id, "") elif state.status == AgentStatus.FAILED: fail_run(workspace, run.id) return state + except Exception as exc: + # Fail the run so it doesn't stay IN_PROGRESS forever + run_logger.error(LogCategory.ERROR, f"Unhandled error in execute_agent: {exc}", {}) + try: + fail_run(workspace, run.id) + except Exception: + pass # Best-effort — don't mask the original error + return AgentState(status=AgentStatus.FAILED) + finally: # Always close the output logger to ensure file is properly flushed output_logger.close() diff --git a/tests/core/test_engine_registry_extended.py b/tests/core/test_engine_registry_extended.py new file mode 100644 index 00000000..ce28cc99 --- /dev/null +++ b/tests/core/test_engine_registry_extended.py @@ -0,0 +1,243 @@ +"""Extended tests for engine registry: check_requirements, config engine field, CLI commands.""" + +import os +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from codeframe.core.engine_registry import check_requirements, _get_adapter_class + +pytestmark = pytest.mark.v2 + + +class TestCheckRequirements: + def test_react_checks_anthropic_key(self): + with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}): + reqs = check_requirements("react") + assert reqs["ANTHROPIC_API_KEY"] is True + + def test_react_missing_key(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("ANTHROPIC_API_KEY", None) + reqs = check_requirements("react") + assert reqs["ANTHROPIC_API_KEY"] is False + + def test_plan_checks_anthropic_key(self): + with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}): + reqs = check_requirements("plan") + assert reqs["ANTHROPIC_API_KEY"] is True + + def test_built_in_alias_resolves_to_react(self): + with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}): + reqs = check_requirements("built-in") + assert "ANTHROPIC_API_KEY" in reqs + + def test_invalid_engine_raises(self): + with pytest.raises(ValueError, match="Invalid engine"): + check_requirements("nonexistent") + + def test_external_engine_with_requirements(self): + with patch("shutil.which", return_value="/usr/bin/claude"): + reqs = check_requirements("claude-code") + # External adapters may have their own requirements + assert isinstance(reqs, dict) + + +class TestGetAdapterClass: + def test_react_returns_class(self): + cls = _get_adapter_class("react") + assert cls is not None + assert cls.__name__ == "BuiltinReactAdapter" + + def test_plan_returns_class(self): + cls = _get_adapter_class("plan") + assert cls is not None + assert cls.__name__ == "BuiltinPlanAdapter" + + def test_unknown_returns_none(self): + cls = _get_adapter_class("nonexistent") + assert cls is None + + +class TestEnvironmentConfigEngine: + def test_default_engine_is_react(self): + from codeframe.core.config import EnvironmentConfig + config = EnvironmentConfig() + assert config.engine == "react" + + def test_custom_engine(self): + from codeframe.core.config import EnvironmentConfig + config = EnvironmentConfig(engine="plan") + assert config.engine == "plan" + + def test_invalid_engine_fails_validation(self): + from codeframe.core.config import EnvironmentConfig + config = EnvironmentConfig(engine="nonexistent") + errors = config.validate() + assert any("Invalid engine" in e for e in errors) + + def test_valid_engines_pass_validation(self): + from codeframe.core.config import EnvironmentConfig + for engine in ("react", "plan", "claude-code", "opencode", "built-in"): + config = EnvironmentConfig(engine=engine) + errors = config.validate() + assert not any("Invalid engine" in e for e in errors), f"Engine '{engine}' should be valid" + + def test_engine_serialization_roundtrip(self): + from codeframe.core.config import EnvironmentConfig + config = EnvironmentConfig(engine="claude-code") + data = config.to_dict() + restored = EnvironmentConfig.from_dict(data) + assert restored.engine == "claude-code" + + +class TestBuiltinReactAdapterStallRetry: + def test_stall_retry_succeeds_on_second_attempt(self): + from codeframe.core.adapters.builtin import BuiltinReactAdapter + from codeframe.core.agent import AgentStatus + from codeframe.core.stall_detector import StallDetectedError + + ws = MagicMock() + ws.repo_path = Path("/tmp/test") + provider = MagicMock() + + call_count = 0 + + def mock_constructor(**kwargs): + inst = MagicMock() + + nonlocal call_count + call_count += 1 + if call_count == 1: + inst.run.side_effect = StallDetectedError( + elapsed_s=300, iterations=10, last_tool="read_file" + ) + else: + inst.run.return_value = AgentStatus.COMPLETED + return inst + + with patch("codeframe.core.react_agent.ReactAgent") as mock_cls: + mock_cls.side_effect = mock_constructor + adapter = BuiltinReactAdapter(ws, provider) + result = adapter.run("task-1", "", Path("/tmp")) + + assert result.status == "completed" + + def test_stall_retry_exhausted_returns_failed(self): + from codeframe.core.adapters.builtin import BuiltinReactAdapter + from codeframe.core.stall_detector import StallDetectedError + + ws = MagicMock() + ws.repo_path = Path("/tmp/test") + provider = MagicMock() + + def always_stall(**kwargs): + inst = MagicMock() + inst.run.side_effect = StallDetectedError( + elapsed_s=300, iterations=10, last_tool="read_file" + ) + return inst + + with patch("codeframe.core.react_agent.ReactAgent") as mock_cls: + mock_cls.side_effect = always_stall + adapter = BuiltinReactAdapter(ws, provider) + result = adapter.run("task-1", "", Path("/tmp")) + + assert result.status == "failed" + assert "Stall detected" in result.error + + +class TestBuiltinPlanAdapterRetry: + def _make_state(self, status, blocker=None, gate_results=None, step_results=None): + state = MagicMock() + state.status = status + state.blocker = blocker + state.gate_results = gate_results or [] + state.step_results = step_results or [] + return state + + def test_completed_returns_completed(self): + from codeframe.core.adapters.builtin import BuiltinPlanAdapter + from codeframe.core.agent import AgentStatus + + ws = MagicMock() + ws.repo_path = Path("/tmp/test") + provider = MagicMock() + + with patch("codeframe.core.agent.Agent") as mock_cls: + mock_cls.return_value.run.return_value = self._make_state(AgentStatus.COMPLETED) + adapter = BuiltinPlanAdapter(ws, provider) + result = adapter.run("task-1", "", Path("/tmp")) + + assert result.status == "completed" + + def test_blocked_triggers_supervisor_unblock(self): + from codeframe.core.adapters.builtin import BuiltinPlanAdapter + from codeframe.core.agent import AgentStatus + + ws = MagicMock() + ws.repo_path = Path("/tmp/test") + provider = MagicMock() + + with patch("codeframe.core.agent.Agent") as mock_cls: + mock_cls.return_value.run.return_value = self._make_state(AgentStatus.BLOCKED) + with patch("codeframe.core.conductor.get_supervisor") as mock_sup: + mock_sup.return_value.try_resolve_blocked_task.return_value = False + adapter = BuiltinPlanAdapter(ws, provider) + result = adapter.run("task-1", "", Path("/tmp")) + + assert result.status == "blocked" + mock_sup.return_value.try_resolve_blocked_task.assert_called_once_with("task-1") + + def test_supervisor_exception_returns_original_state(self): + from codeframe.core.adapters.builtin import BuiltinPlanAdapter + from codeframe.core.agent import AgentStatus + + ws = MagicMock() + ws.repo_path = Path("/tmp/test") + provider = MagicMock() + + with patch("codeframe.core.agent.Agent") as mock_cls: + mock_cls.return_value.run.return_value = self._make_state(AgentStatus.BLOCKED) + with patch("codeframe.core.conductor.get_supervisor") as mock_sup: + mock_sup.side_effect = Exception("Database error") + adapter = BuiltinPlanAdapter(ws, provider) + result = adapter.run("task-1", "", Path("/tmp")) + + assert result.status == "blocked" + + def test_failed_triggers_tactical_recovery(self): + from codeframe.core.adapters.builtin import BuiltinPlanAdapter + from codeframe.core.agent import AgentStatus + + ws = MagicMock() + ws.repo_path = Path("/tmp/test") + provider = MagicMock() + + step_result = MagicMock() + step_result.error = "modulenotfounderror: No module named 'foo'" + step_result.output = "" + + with patch("codeframe.core.agent.Agent") as mock_cls: + mock_cls.return_value.run.return_value = self._make_state( + AgentStatus.FAILED, step_results=[step_result] + ) + with patch("codeframe.core.conductor.get_supervisor") as mock_sup: + mock_sup.side_effect = Exception("Database error") + adapter = BuiltinPlanAdapter(ws, provider) + result = adapter.run("task-1", "", Path("/tmp")) + + # Even with supervisor failure, the adapter returns gracefully + assert result.status == "failed" + + +class TestBuiltinAdapterRequirements: + def test_react_has_requirements(self): + from codeframe.core.adapters.builtin import BuiltinReactAdapter + reqs = BuiltinReactAdapter.requirements() + assert "ANTHROPIC_API_KEY" in reqs + + def test_plan_has_requirements(self): + from codeframe.core.adapters.builtin import BuiltinPlanAdapter + reqs = BuiltinPlanAdapter.requirements() + assert "ANTHROPIC_API_KEY" in reqs diff --git a/tests/e2e/cli/test_engines_cli.py b/tests/e2e/cli/test_engines_cli.py new file mode 100644 index 00000000..71e65408 --- /dev/null +++ b/tests/e2e/cli/test_engines_cli.py @@ -0,0 +1,57 @@ +"""CLI tests for cf engines list/check commands.""" + +import pytest +from unittest.mock import patch + +from typer.testing import CliRunner + +from codeframe.cli.app import app + +pytestmark = pytest.mark.v2 + +runner = CliRunner() + + +class TestEnginesList: + def test_engines_list_runs(self): + result = runner.invoke(app, ["engines", "list"]) + assert result.exit_code == 0 + assert "react" in result.output + assert "plan" in result.output + + def test_engines_list_shows_external(self): + result = runner.invoke(app, ["engines", "list"]) + assert result.exit_code == 0 + assert "claude-code" in result.output + + def test_engines_list_shows_builtin(self): + result = runner.invoke(app, ["engines", "list"]) + assert "builtin" in result.output or "alias" in result.output + + +class TestEnginesCheck: + def test_check_valid_engine(self): + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test"}): + result = runner.invoke(app, ["engines", "check", "react"]) + assert result.exit_code == 0 + + def test_check_invalid_engine(self): + result = runner.invoke(app, ["engines", "check", "nonexistent"]) + assert result.exit_code == 1 + assert "Error" in result.output + + def test_check_missing_requirements(self): + with patch.dict("os.environ", {}, clear=True): + import os + os.environ.pop("ANTHROPIC_API_KEY", None) + result = runner.invoke(app, ["engines", "check", "react"]) + assert result.exit_code == 1 + assert "not set" in result.output + + +class TestEnginesNoArgs: + def test_no_args_shows_help(self): + result = runner.invoke(app, ["engines"]) + # Typer no_args_is_help exits with code 0 or 2 depending on version + assert result.exit_code in (0, 2) + assert "list" in result.output or "check" in result.output or "Usage" in result.output