Skip to content

Commit 623cfbd

Browse files
authored
feat(core): Agent Adapter protocol definition (#409)
## Summary - Defines `AgentAdapter` Protocol (runtime_checkable) for structural subtyping - `AgentContext` dataclass with all 13 context fields CodeFrame assembles - `AgentResult` dataclass for engine outcomes (status, files, errors, blockers) - `AgentEvent` dataclass for progress streaming - `AdapterTokenUsage` lightweight dataclass decoupled from billing model - 23 unit tests with full protocol compliance verification ## Validation - Review feedback: All addressed (1 round — v2 marker added, protocol mismatch dismissed as by-design) - Demo: All 6 acceptance criteria verified - Tests: 1605 passing - CI: All checks green - Linting: Clean Closes #409
1 parent f0ea4bf commit 623cfbd

2 files changed

Lines changed: 384 additions & 0 deletions

File tree

codeframe/core/agent_adapter.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Agent adapter protocol for CodeFRAME.
2+
3+
Defines the interface that any coding agent (Claude Code, Codex, Aider, built-in)
4+
must implement to be used as a CodeFrame execution engine.
5+
6+
This module is headless - no FastAPI or HTTP dependencies.
7+
"""
8+
9+
from dataclasses import dataclass, field
10+
from datetime import datetime, timezone
11+
from enum import Enum
12+
from pathlib import Path
13+
from typing import Iterator, Protocol, runtime_checkable
14+
15+
16+
class AgentResultStatus(str, Enum):
17+
"""Terminal status from an agent execution."""
18+
19+
COMPLETED = "completed"
20+
FAILED = "failed"
21+
BLOCKED = "blocked"
22+
TIMEOUT = "timeout"
23+
24+
25+
@dataclass
26+
class AdapterTokenUsage:
27+
"""Lightweight token usage for adapter results."""
28+
29+
input_tokens: int
30+
output_tokens: int
31+
model: str | None = None
32+
cost_usd: float | None = None
33+
34+
@property
35+
def total_tokens(self) -> int:
36+
return self.input_tokens + self.output_tokens
37+
38+
39+
@dataclass
40+
class AgentContext:
41+
"""Everything CodeFrame provides to an execution engine."""
42+
43+
task_id: str
44+
task_title: str
45+
task_description: str
46+
prd_content: str | None = None
47+
tech_stack: str | None = None
48+
project_preferences: str | None = None
49+
relevant_files: list[str] = field(default_factory=list)
50+
file_contents: dict[str, str] = field(default_factory=dict)
51+
blocker_history: list[str] = field(default_factory=list)
52+
dependency_context: str | None = None
53+
verification_gates: list[str] = field(default_factory=list)
54+
attempt: int = 0
55+
previous_errors: list[str] = field(default_factory=list)
56+
57+
58+
@dataclass
59+
class AgentResult:
60+
"""What every execution engine returns to CodeFrame."""
61+
62+
status: AgentResultStatus
63+
summary: str
64+
files_modified: list[str] = field(default_factory=list)
65+
files_created: list[str] = field(default_factory=list)
66+
error: str | None = None
67+
blocker_question: str | None = None
68+
token_usage: AdapterTokenUsage | None = None
69+
duration_ms: int = 0
70+
71+
72+
@dataclass
73+
class AgentEvent:
74+
"""Progress event yielded during agent execution."""
75+
76+
type: str
77+
message: str
78+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
79+
metadata: dict = field(default_factory=dict)
80+
81+
82+
@runtime_checkable
83+
class AgentAdapter(Protocol):
84+
"""Interface for any coding agent that CodeFrame can orchestrate."""
85+
86+
def execute(
87+
self,
88+
task_prompt: str,
89+
workspace_path: Path,
90+
context: AgentContext,
91+
timeout_ms: int = 3_600_000,
92+
) -> AgentResult: ...
93+
94+
def stream_events(self) -> Iterator[AgentEvent]: ...
95+
96+
@property
97+
def name(self) -> str: ...
98+
99+
@property
100+
def requires_api_key(self) -> dict[str, str]: ...

tests/core/test_agent_adapter.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""Tests for AgentAdapter protocol and supporting types.
2+
3+
Validates:
4+
- Dataclass construction with defaults and full params
5+
- AgentResultStatus enum values
6+
- AgentAdapter protocol compliance via @runtime_checkable
7+
- Streaming iterator contract
8+
"""
9+
10+
import pytest
11+
from datetime import datetime, timezone
12+
from pathlib import Path
13+
from typing import Iterator
14+
15+
pytestmark = pytest.mark.v2
16+
17+
18+
class TestAgentResultStatus:
19+
"""AgentResultStatus enum covers all terminal states."""
20+
21+
def test_has_completed(self):
22+
from codeframe.core.agent_adapter import AgentResultStatus
23+
assert AgentResultStatus.COMPLETED.value == "completed"
24+
25+
def test_has_failed(self):
26+
from codeframe.core.agent_adapter import AgentResultStatus
27+
assert AgentResultStatus.FAILED.value == "failed"
28+
29+
def test_has_blocked(self):
30+
from codeframe.core.agent_adapter import AgentResultStatus
31+
assert AgentResultStatus.BLOCKED.value == "blocked"
32+
33+
def test_has_timeout(self):
34+
from codeframe.core.agent_adapter import AgentResultStatus
35+
assert AgentResultStatus.TIMEOUT.value == "timeout"
36+
37+
def test_is_str_enum(self):
38+
from codeframe.core.agent_adapter import AgentResultStatus
39+
assert isinstance(AgentResultStatus.COMPLETED, str)
40+
41+
42+
class TestAdapterTokenUsage:
43+
"""Lightweight token usage dataclass."""
44+
45+
def test_minimal_construction(self):
46+
from codeframe.core.agent_adapter import AdapterTokenUsage
47+
usage = AdapterTokenUsage(input_tokens=100, output_tokens=50)
48+
assert usage.input_tokens == 100
49+
assert usage.output_tokens == 50
50+
assert usage.model is None
51+
assert usage.cost_usd is None
52+
53+
def test_full_construction(self):
54+
from codeframe.core.agent_adapter import AdapterTokenUsage
55+
usage = AdapterTokenUsage(
56+
input_tokens=1000,
57+
output_tokens=500,
58+
model="claude-sonnet-4-20250514",
59+
cost_usd=0.015,
60+
)
61+
assert usage.model == "claude-sonnet-4-20250514"
62+
assert usage.cost_usd == 0.015
63+
64+
def test_total_tokens(self):
65+
from codeframe.core.agent_adapter import AdapterTokenUsage
66+
usage = AdapterTokenUsage(input_tokens=100, output_tokens=50)
67+
assert usage.total_tokens == 150
68+
69+
70+
class TestAgentContext:
71+
"""AgentContext captures all context CodeFrame provides to engines."""
72+
73+
def test_minimal_construction(self):
74+
from codeframe.core.agent_adapter import AgentContext
75+
ctx = AgentContext(
76+
task_id="task-1",
77+
task_title="Implement feature X",
78+
task_description="Add X to the system",
79+
)
80+
assert ctx.task_id == "task-1"
81+
assert ctx.prd_content is None
82+
assert ctx.tech_stack is None
83+
assert ctx.project_preferences is None
84+
assert ctx.relevant_files == []
85+
assert ctx.file_contents == {}
86+
assert ctx.blocker_history == []
87+
assert ctx.dependency_context is None
88+
assert ctx.verification_gates == []
89+
assert ctx.attempt == 0
90+
assert ctx.previous_errors == []
91+
92+
def test_full_construction(self):
93+
from codeframe.core.agent_adapter import AgentContext
94+
ctx = AgentContext(
95+
task_id="task-42",
96+
task_title="Fix auth bug",
97+
task_description="Session tokens expire too early",
98+
prd_content="# Auth PRD\nTokens should last 24h",
99+
tech_stack="Python with FastAPI",
100+
project_preferences="Use ruff for linting",
101+
relevant_files=["auth.py", "tests/test_auth.py"],
102+
file_contents={"auth.py": "def login(): pass"},
103+
blocker_history=["Previous: needed DB access"],
104+
dependency_context="Task-41 created the auth module",
105+
verification_gates=["ruff", "pytest"],
106+
attempt=2,
107+
previous_errors=["ImportError: no module named jwt"],
108+
)
109+
assert ctx.task_id == "task-42"
110+
assert len(ctx.relevant_files) == 2
111+
assert ctx.attempt == 2
112+
assert len(ctx.previous_errors) == 1
113+
114+
def test_list_defaults_are_independent(self):
115+
"""Ensure default_factory creates independent lists (no shared mutable state)."""
116+
from codeframe.core.agent_adapter import AgentContext
117+
ctx1 = AgentContext(task_id="1", task_title="A", task_description="A")
118+
ctx2 = AgentContext(task_id="2", task_title="B", task_description="B")
119+
ctx1.relevant_files.append("file.py")
120+
assert ctx2.relevant_files == []
121+
122+
123+
class TestAgentResult:
124+
"""AgentResult captures outcome from any engine."""
125+
126+
def test_minimal_construction(self):
127+
from codeframe.core.agent_adapter import AgentResult, AgentResultStatus
128+
result = AgentResult(
129+
status=AgentResultStatus.COMPLETED,
130+
summary="Added feature X",
131+
)
132+
assert result.status == AgentResultStatus.COMPLETED
133+
assert result.files_modified == []
134+
assert result.files_created == []
135+
assert result.error is None
136+
assert result.blocker_question is None
137+
assert result.token_usage is None
138+
assert result.duration_ms == 0
139+
140+
def test_failed_result(self):
141+
from codeframe.core.agent_adapter import AgentResult, AgentResultStatus
142+
result = AgentResult(
143+
status=AgentResultStatus.FAILED,
144+
summary="Could not implement",
145+
error="ImportError: missing dependency",
146+
duration_ms=5000,
147+
)
148+
assert result.status == AgentResultStatus.FAILED
149+
assert result.error is not None
150+
151+
def test_blocked_result_with_question(self):
152+
from codeframe.core.agent_adapter import AgentResult, AgentResultStatus
153+
result = AgentResult(
154+
status=AgentResultStatus.BLOCKED,
155+
summary="Need clarification on auth approach",
156+
blocker_question="Should we use JWT or session cookies?",
157+
)
158+
assert result.blocker_question is not None
159+
160+
def test_result_with_token_usage(self):
161+
from codeframe.core.agent_adapter import (
162+
AdapterTokenUsage, AgentResult, AgentResultStatus,
163+
)
164+
result = AgentResult(
165+
status=AgentResultStatus.COMPLETED,
166+
summary="Done",
167+
token_usage=AdapterTokenUsage(input_tokens=1000, output_tokens=500),
168+
files_modified=["auth.py"],
169+
files_created=["tests/test_auth.py"],
170+
duration_ms=12000,
171+
)
172+
assert result.token_usage.total_tokens == 1500
173+
assert result.files_modified == ["auth.py"]
174+
assert result.duration_ms == 12000
175+
176+
177+
class TestAgentEvent:
178+
"""AgentEvent supports progress streaming."""
179+
180+
def test_minimal_construction(self):
181+
from codeframe.core.agent_adapter import AgentEvent
182+
event = AgentEvent(type="progress", message="Working on step 1")
183+
assert event.type == "progress"
184+
assert event.message == "Working on step 1"
185+
assert isinstance(event.timestamp, datetime)
186+
assert event.metadata == {}
187+
188+
def test_with_metadata(self):
189+
from codeframe.core.agent_adapter import AgentEvent
190+
ts = datetime(2026, 3, 9, tzinfo=timezone.utc)
191+
event = AgentEvent(
192+
type="file_changed",
193+
message="Modified auth.py",
194+
timestamp=ts,
195+
metadata={"file": "auth.py", "lines_changed": 15},
196+
)
197+
assert event.timestamp == ts
198+
assert event.metadata["lines_changed"] == 15
199+
200+
def test_event_types_are_strings(self):
201+
from codeframe.core.agent_adapter import AgentEvent
202+
for event_type in ("progress", "file_changed", "command_run", "error"):
203+
event = AgentEvent(type=event_type, message="test")
204+
assert event.type == event_type
205+
206+
207+
class TestAgentAdapterProtocol:
208+
"""AgentAdapter protocol compliance via @runtime_checkable."""
209+
210+
def _make_compliant_class(self):
211+
"""Create a minimal class that satisfies AgentAdapter."""
212+
from codeframe.core.agent_adapter import (
213+
AgentContext, AgentEvent, AgentResult, AgentResultStatus,
214+
)
215+
216+
class FakeAdapter:
217+
def execute(
218+
self,
219+
task_prompt: str,
220+
workspace_path: Path,
221+
context: AgentContext,
222+
timeout_ms: int = 3_600_000,
223+
) -> AgentResult:
224+
return AgentResult(
225+
status=AgentResultStatus.COMPLETED,
226+
summary="fake",
227+
duration_ms=100,
228+
)
229+
230+
def stream_events(self) -> Iterator[AgentEvent]:
231+
yield AgentEvent(type="progress", message="working")
232+
233+
@property
234+
def name(self) -> str:
235+
return "fake"
236+
237+
@property
238+
def requires_api_key(self) -> dict[str, str]:
239+
return {}
240+
241+
return FakeAdapter
242+
243+
def test_compliant_class_satisfies_protocol(self):
244+
from codeframe.core.agent_adapter import AgentAdapter
245+
FakeAdapter = self._make_compliant_class()
246+
adapter = FakeAdapter()
247+
assert isinstance(adapter, AgentAdapter)
248+
249+
def test_non_compliant_class_fails(self):
250+
from codeframe.core.agent_adapter import AgentAdapter
251+
252+
class NotAnAdapter:
253+
pass
254+
255+
assert not isinstance(NotAnAdapter(), AgentAdapter)
256+
257+
def test_partial_implementation_fails(self):
258+
from codeframe.core.agent_adapter import AgentAdapter
259+
260+
class PartialAdapter:
261+
def execute(self, task_prompt, workspace_path, context, timeout_ms=0):
262+
pass
263+
# Missing: stream_events, name, requires_api_key
264+
265+
assert not isinstance(PartialAdapter(), AgentAdapter)
266+
267+
def test_execute_returns_agent_result(self):
268+
from codeframe.core.agent_adapter import (
269+
AgentContext, AgentResult, AgentResultStatus,
270+
)
271+
FakeAdapter = self._make_compliant_class()
272+
adapter = FakeAdapter()
273+
ctx = AgentContext(task_id="1", task_title="Test", task_description="Test")
274+
result = adapter.execute("do something", Path("/tmp"), ctx)
275+
assert isinstance(result, AgentResult)
276+
assert result.status == AgentResultStatus.COMPLETED
277+
278+
def test_stream_events_yields_agent_events(self):
279+
from codeframe.core.agent_adapter import AgentEvent
280+
FakeAdapter = self._make_compliant_class()
281+
adapter = FakeAdapter()
282+
events_list = list(adapter.stream_events())
283+
assert len(events_list) == 1
284+
assert isinstance(events_list[0], AgentEvent)

0 commit comments

Comments
 (0)