Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions codeframe/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1971,7 +1971,7 @@ def work_start(
False,
"--execute",
"-x",
help="Run the agent to execute the task (requires ANTHROPIC_API_KEY)",
help="Run the agent to execute the task (builtin engines require ANTHROPIC_API_KEY)",
),
dry_run: bool = typer.Option(
False,
Expand All @@ -1997,7 +1997,7 @@ def work_start(
engine: str = typer.Option(
"react",
"--engine",
help="Agent engine: 'react' (default, ReAct tool-use loop) or 'plan' (legacy step-based)",
help="Agent engine: react (default), plan (legacy), claude-code, opencode, or built-in",
),
stall_timeout: int = typer.Option(
300,
Expand Down Expand Up @@ -2049,9 +2049,12 @@ def work_start(
task = matching[0]

# Validate API key before creating run record (avoids dangling IN_PROGRESS state)
# External engines (claude-code, opencode) manage their own authentication
if execute:
from codeframe.cli.validators import require_anthropic_api_key
require_anthropic_api_key()
from codeframe.core.engine_registry import is_external_engine
if not is_external_engine(engine):
from codeframe.cli.validators import require_anthropic_api_key
require_anthropic_api_key()
Comment on lines 2051 to +2057

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate engine before deciding which credentials to require.

An unknown engine currently falls through is_external_engine() as “builtin”, so work start --engine typo and work batch run --engine typo can fail with an ANTHROPIC_API_KEY error instead of the real invalid-engine error whenever the key is unset. Resolve or validate the engine first, then branch on external vs builtin.

Suggested fix
-        if execute:
-            from codeframe.core.engine_registry import is_external_engine
-            if not is_external_engine(engine):
+        if execute:
+            from codeframe.core.engine_registry import is_external_engine, resolve_engine
+            engine = resolve_engine(engine)
+            if not is_external_engine(engine):
                 from codeframe.cli.validators import require_anthropic_api_key
                 require_anthropic_api_key()
-        from codeframe.core.engine_registry import is_external_engine
-        if not is_external_engine(engine):
+        from codeframe.core.engine_registry import is_external_engine, resolve_engine
+        engine = resolve_engine(engine)
+        if not is_external_engine(engine):
             from codeframe.cli.validators import require_anthropic_api_key
             require_anthropic_api_key()

Also applies to: 2972-2977

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/cli/app.py` around lines 2051 - 2057, Validate the requested engine
before checking credentials: call the engine registry validation (e.g.,
get_engine or is_valid_engine from codeframe.core.engine_registry) and
raise/return the proper invalid-engine error if it doesn't exist, then only call
is_external_engine(engine) and require_anthropic_api_key() for builtin engines;
apply the same change to the other occurrence (the block around lines 2972-2977)
so an unknown engine no longer falls through and triggers an ANTHROPIC_API_KEY
error.


# Start the run
run = runtime.start_task_run(workspace, task.id)
Expand Down Expand Up @@ -2875,7 +2878,7 @@ def batch_run(
engine: str = typer.Option(
"react",
"--engine",
help="Agent engine: 'react' (default, ReAct tool-use loop) or 'plan' (legacy step-based)",
help="Agent engine: react (default), plan (legacy), claude-code, opencode, or built-in",
),
stall_timeout: int = typer.Option(
300,
Expand Down Expand Up @@ -2967,8 +2970,11 @@ def batch_run(
return

# Validate API key before batch execution
from codeframe.cli.validators import require_anthropic_api_key
require_anthropic_api_key()
# External engines (claude-code, opencode) manage their own authentication
from codeframe.core.engine_registry import is_external_engine
if not is_external_engine(engine):
from codeframe.cli.validators import require_anthropic_api_key
require_anthropic_api_key()

# Execute batch
if max_retries > 0:
Expand Down
25 changes: 25 additions & 0 deletions codeframe/core/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from codeframe.core.adapters.agent_adapter import (
AgentAdapter,
AgentEvent,
AgentResult,
)
from codeframe.core.adapters.builtin import (
BuiltinPlanAdapter,
BuiltinReactAdapter,
)
from codeframe.core.adapters.claude_code import ClaudeCodeAdapter
from codeframe.core.adapters.opencode import OpenCodeAdapter
from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter
from codeframe.core.adapters.verification_wrapper import VerificationWrapper

__all__ = [
"AgentAdapter",
"AgentEvent",
"AgentResult",
"BuiltinPlanAdapter",
"BuiltinReactAdapter",
"ClaudeCodeAdapter",
"OpenCodeAdapter",
"SubprocessAdapter",
"VerificationWrapper",
]
60 changes: 60 additions & 0 deletions codeframe/core/adapters/agent_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Agent adapter protocol for delegating task execution to external coding agents."""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Literal, Protocol, runtime_checkable


@dataclass
class AgentResult:
"""Result from an agent adapter execution."""

status: Literal["completed", "failed", "blocked"]
output: str = ""
modified_files: list[str] = field(default_factory=list)
error: str | None = None
blocker_question: str | None = None


@dataclass
class AgentEvent:
"""Progress event emitted during agent execution."""

type: str # "progress", "tool_call", "output", "error"
data: dict = field(default_factory=dict)


@runtime_checkable
class AgentAdapter(Protocol):
"""Protocol for agent execution engines.

Any coding agent (Claude Code, OpenCode, Codex, etc.) must implement
this interface to be used as a CodeFRAME execution engine.
"""

@property
def name(self) -> str:
"""Engine name (e.g., 'claude-code', 'opencode')."""
...

def run(
self,
task_id: str,
prompt: str,
workspace_path: Path,
on_event: Callable[[AgentEvent], None] | None = None,
) -> AgentResult:
"""Execute a task and return the result.

Args:
task_id: CodeFRAME task identifier
prompt: Rich context prompt assembled by TaskContextPackager
workspace_path: Path to the workspace/repo root
on_event: Optional callback for streaming progress events

Returns:
AgentResult with status, output, and modified files
"""
...
197 changes: 197 additions & 0 deletions codeframe/core/adapters/builtin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Builtin adapter shims wrapping ReactAgent and Agent behind AgentAdapter protocol."""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional

from codeframe.core.adapters.agent_adapter import AgentEvent, AgentResult

if TYPE_CHECKING:
from codeframe.adapters.llm.base import LLMProvider
from codeframe.core.conductor import GlobalFixCoordinator
from codeframe.core.stall_detector import StallAction
from codeframe.core.streaming import EventPublisher, RunOutputLogger
from codeframe.core.workspace import Workspace


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.
"""

def __init__(
self,
workspace: Workspace,
llm_provider: LLMProvider,
*,
stall_timeout_s: float = 300,
stall_action: Optional[StallAction] = None,
event_publisher: Optional[EventPublisher] = None,
dry_run: bool = False,
verbose: bool = False,
debug: bool = False,
output_logger: Optional[RunOutputLogger] = None,
fix_coordinator: Optional[GlobalFixCoordinator] = None,
) -> None:
self._workspace = workspace
self._llm_provider = llm_provider
self._stall_timeout_s = stall_timeout_s
self._stall_action = stall_action
self._event_publisher = event_publisher
self._dry_run = dry_run
self._verbose = verbose
self._debug = debug
self._output_logger = output_logger
self._fix_coordinator = fix_coordinator

@property
def name(self) -> str:
return "react"

def run(
self,
task_id: str,
prompt: str,
workspace_path: Path,
on_event: Callable[[AgentEvent], None] | None = None,
) -> AgentResult:
"""Run the ReactAgent and map its AgentStatus to AgentResult."""
from codeframe.core.react_agent import ReactAgent

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

agent = ReactAgent(**kwargs)
status = agent.run(task_id)
return self._map_status(status)

@staticmethod
def _map_status(status: object) -> AgentResult:
"""Map AgentStatus enum to AgentResult."""
from codeframe.core.agent import AgentStatus

status_map = {
AgentStatus.COMPLETED: "completed",
AgentStatus.FAILED: "failed",
AgentStatus.BLOCKED: "blocked",
}
result_status = status_map.get(status, "failed") # type: ignore[arg-type]
return AgentResult(
status=result_status,
output=f"ReactAgent finished with status: {status.value}", # type: ignore[union-attr]
)


class BuiltinPlanAdapter:
"""Wraps the existing plan-based Agent behind the AgentAdapter interface."""

def __init__(
self,
workspace: Workspace,
llm_provider: LLMProvider,
*,
dry_run: bool = False,
verbose: bool = False,
debug: bool = False,
output_logger: Optional[RunOutputLogger] = None,
fix_coordinator: Optional[GlobalFixCoordinator] = None,
event_publisher: Optional[EventPublisher] = None,
) -> None:
self._workspace = workspace
self._llm_provider = llm_provider
self._dry_run = dry_run
self._verbose = verbose
self._debug = debug
self._output_logger = output_logger
self._fix_coordinator = fix_coordinator
self._event_publisher = event_publisher

@property
def name(self) -> str:
return "plan"

def run(
self,
task_id: str,
prompt: str,
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

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,
)
state = agent.run(task_id)
return self._map_state(state)

@staticmethod
def _map_state(state: object) -> AgentResult:
"""Map AgentState dataclass to AgentResult."""
from codeframe.core.agent import AgentStatus

status_map = {
AgentStatus.COMPLETED: "completed",
AgentStatus.FAILED: "failed",
AgentStatus.BLOCKED: "blocked",
}
result_status = status_map.get(state.status, "failed") # type: ignore[union-attr]

blocker_question = None
if state.status == AgentStatus.BLOCKED: # type: ignore[union-attr]
blocker = getattr(state, "blocker", None)
if blocker:
blocker_question = getattr(blocker, "question", None) or getattr(
blocker, "reason", None
)

error = None
if state.status == AgentStatus.FAILED: # type: ignore[union-attr]
gate_results = getattr(state, "gate_results", None) or []
error_parts: list[str] = []
for gr in gate_results:
for check in getattr(gr, "checks", []):
output = getattr(check, "output", None)
if output:
error_parts.append(output)
if error_parts:
error = "\n".join(error_parts[:3])

return AgentResult(
status=result_status,
output=f"PlanAgent finished with status: {state.status.value}", # type: ignore[union-attr]
blocker_question=blocker_question,
error=error,
)
Loading
Loading