codeframe-ai 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,483 @@
1
+ """Streaming chat adapter — provider-agnostic.
2
+
3
+ Wraps any :class:`LLMProvider` that implements ``async_stream()`` and emits
4
+ typed ``ChatEvent`` objects for consumption by the WebSocket relay layer.
5
+
6
+ Supports:
7
+ - Token-by-token text streaming (TEXT_DELTA)
8
+ - Extended thinking tokens (THINKING) on providers that support it
9
+ - Safe read-only tool calls: read_file, list_files, search_codebase
10
+ - Interrupt via asyncio.Event
11
+ - Message persistence to session_messages after each complete turn
12
+ - Context-window management via tiktoken token counting
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+ from pathlib import Path
22
+ from typing import AsyncIterator, Optional
23
+
24
+ from codeframe.adapters.llm.base import LLMProvider, Tool, ToolCall, ToolResult
25
+ from codeframe.core.tools import (
26
+ execute_tool,
27
+ _READ_FILE_SCHEMA,
28
+ _LIST_FILES_SCHEMA,
29
+ _SEARCH_CODEBASE_SCHEMA,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Default model (matches DEFAULT_PLANNING_MODEL in base.py)
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _DEFAULT_MODEL = "claude-sonnet-4-20250514"
39
+
40
+ # Maximum token budget for conversation history passed to the API.
41
+ # claude-sonnet-4 context window is 200k; leave 20k for response headroom.
42
+ _MAX_HISTORY_TOKENS = 180_000
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Event types
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class ChatEventType(str, Enum):
50
+ TEXT_DELTA = "text_delta"
51
+ TOOL_USE_START = "tool_use_start"
52
+ TOOL_RESULT = "tool_result"
53
+ THINKING = "thinking"
54
+ COST_UPDATE = "cost_update"
55
+ DONE = "done"
56
+ ERROR = "error"
57
+
58
+
59
+ @dataclass
60
+ class ChatEvent:
61
+ type: ChatEventType
62
+ content: Optional[str] = None
63
+ tool_name: Optional[str] = None
64
+ tool_input: Optional[dict] = None
65
+ cost_usd: Optional[float] = None
66
+ input_tokens: Optional[int] = None
67
+ output_tokens: Optional[int] = None
68
+
69
+ def to_dict(self) -> dict:
70
+ """Serialise to a dict suitable for JSON transmission."""
71
+ d: dict = {"type": self.type.value}
72
+ if self.content is not None:
73
+ d["content"] = self.content
74
+ if self.tool_name is not None:
75
+ d["tool_name"] = self.tool_name
76
+ if self.tool_input is not None:
77
+ d["tool_input"] = self.tool_input
78
+ if self.cost_usd is not None:
79
+ d["cost_usd"] = self.cost_usd
80
+ if self.input_tokens is not None:
81
+ d["input_tokens"] = self.input_tokens
82
+ if self.output_tokens is not None:
83
+ d["output_tokens"] = self.output_tokens
84
+ return d
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Safe read-only tool set for interactive sessions
89
+ # ---------------------------------------------------------------------------
90
+
91
+ STREAMING_SAFE_TOOLS: list[Tool] = [
92
+ Tool(
93
+ name="read_file",
94
+ description=(
95
+ "Read the contents of a file from the workspace. "
96
+ "Supports optional line range selection."
97
+ ),
98
+ input_schema=_READ_FILE_SCHEMA,
99
+ ),
100
+ Tool(
101
+ name="list_files",
102
+ description=(
103
+ "List files in the workspace directory. "
104
+ "Respects standard ignore rules. Returns file paths with sizes."
105
+ ),
106
+ input_schema=_LIST_FILES_SCHEMA,
107
+ ),
108
+ Tool(
109
+ name="search_codebase",
110
+ description=(
111
+ "Search for a regex pattern across the codebase. "
112
+ "Returns matching lines with file paths and line numbers."
113
+ ),
114
+ input_schema=_SEARCH_CODEBASE_SCHEMA,
115
+ ),
116
+ ]
117
+
118
+ _TOOLS_FOR_API: list[dict] = [
119
+ {
120
+ "name": t.name,
121
+ "description": t.description,
122
+ "input_schema": t.input_schema,
123
+ }
124
+ for t in STREAMING_SAFE_TOOLS
125
+ ]
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Adapter
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ class StreamingChatAdapter:
134
+ """Async streaming adapter over any :class:`LLMProvider` with ``async_stream()``.
135
+
136
+ Each call to :meth:`send_message` is a single conversational turn.
137
+ History is loaded from the DB at call time and persisted after the turn
138
+ completes.
139
+
140
+ Only read-only tools (read_file, list_files, search_codebase) are exposed
141
+ to the model. Write operations and shell execution are intentionally
142
+ excluded from interactive sessions.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ session_id: str,
148
+ db_repo,
149
+ workspace_path: Path,
150
+ model: str = _DEFAULT_MODEL,
151
+ api_key: Optional[str] = None,
152
+ provider: Optional[LLMProvider] = None,
153
+ ) -> None:
154
+ """Initialise the adapter.
155
+
156
+ Args:
157
+ session_id: ID of the interactive session (used for DB access).
158
+ db_repo: ``InteractiveSessionRepository`` instance.
159
+ workspace_path: Absolute path used to scope file-system tool calls.
160
+ model: Model identifier passed to the provider's ``async_stream()``.
161
+ api_key: API key used when constructing the default
162
+ ``AnthropicProvider`` (when ``provider`` is ``None``).
163
+ provider: LLM provider to use for streaming. When ``None``,
164
+ an ``AnthropicProvider`` is constructed automatically using
165
+ ``api_key`` or the ``ANTHROPIC_API_KEY`` environment variable.
166
+
167
+ Raises:
168
+ ValueError: If no provider is given and no Anthropic API key is
169
+ available.
170
+ """
171
+ if provider is None:
172
+ # Backward-compatibility fallback: callers that haven't been
173
+ # updated to pass an explicit provider (e.g. tests using the old
174
+ # api_key= constructor argument) still get an AnthropicProvider.
175
+ # New callers should construct the provider themselves and pass it
176
+ # in — see session_chat_ws.py for the recommended pattern.
177
+ from codeframe.adapters.llm.anthropic import AnthropicProvider
178
+ provider = AnthropicProvider(api_key=api_key)
179
+
180
+ self._session_id = session_id
181
+ self._db_repo = db_repo
182
+ self._workspace_path = workspace_path
183
+ self._model = model
184
+ self._provider = provider
185
+
186
+ # ------------------------------------------------------------------
187
+ # History helpers
188
+ # ------------------------------------------------------------------
189
+
190
+ def _load_history(self) -> list[dict]:
191
+ """Load conversation history from the DB for this session.
192
+
193
+ Returns:
194
+ List of ``{"role": str, "content": str}`` dicts in chronological order.
195
+ """
196
+ rows = self._db_repo.get_messages(self._session_id)
197
+ return [{"role": r["role"], "content": r["content"]} for r in rows]
198
+
199
+ def _truncate_history(self, messages: list[dict]) -> list[dict]:
200
+ """Drop oldest messages when the history exceeds the token budget.
201
+
202
+ Uses tiktoken for counting. If tiktoken is unavailable (e.g., in CI),
203
+ falls back to a character-based estimate (4 chars ≈ 1 token).
204
+
205
+ Args:
206
+ messages: Full message list (oldest first).
207
+
208
+ Returns:
209
+ Trimmed message list that fits within ``_MAX_HISTORY_TOKENS``.
210
+ """
211
+ try:
212
+ import tiktoken
213
+ enc = tiktoken.get_encoding("cl100k_base")
214
+
215
+ def _count(msgs: list[dict]) -> int:
216
+ return sum(
217
+ len(enc.encode(m.get("content") or ""))
218
+ for m in msgs
219
+ )
220
+ except Exception:
221
+ def _count(msgs: list[dict]) -> int:
222
+ return sum(len(m.get("content") or "") // 4 for m in msgs)
223
+
224
+ while messages and _count(messages) > _MAX_HISTORY_TOKENS:
225
+ # Drop in pairs so we don't strand an assistant message at index 0
226
+ messages = messages[2:] if len(messages) >= 2 else messages[1:]
227
+
228
+ # First message must have role "user"
229
+ while messages and messages[0].get("role") != "user":
230
+ messages = messages[1:]
231
+
232
+ return messages
233
+
234
+ # ------------------------------------------------------------------
235
+ # Persistence
236
+ # ------------------------------------------------------------------
237
+
238
+ async def _persist_turn(self, user_content: str, assistant_content: str) -> None:
239
+ """Persist user message and assistant response to session_messages.
240
+
241
+ Args:
242
+ user_content: The user's message text.
243
+ assistant_content: The complete assistant response accumulated
244
+ across all TEXT_DELTA events in this turn.
245
+ """
246
+ await asyncio.to_thread(
247
+ self._db_repo.add_message,
248
+ session_id=self._session_id,
249
+ role="user",
250
+ content=user_content,
251
+ )
252
+ await asyncio.to_thread(
253
+ self._db_repo.add_message,
254
+ session_id=self._session_id,
255
+ role="assistant",
256
+ content=assistant_content,
257
+ )
258
+
259
+ # ------------------------------------------------------------------
260
+ # Tool execution
261
+ # ------------------------------------------------------------------
262
+
263
+ async def _execute_tool(self, tool_call: ToolCall) -> str:
264
+ """Execute a safe tool call in a thread pool and return the result string.
265
+
266
+ Only tools in ``STREAMING_SAFE_TOOLS`` are permitted. Any attempt to
267
+ call an unlisted tool returns an error string.
268
+
269
+ Args:
270
+ tool_call: The tool call object from the LLM.
271
+
272
+ Returns:
273
+ String result of the tool execution (may start with "Error:" on
274
+ failure — the caller should still include it as a tool result so
275
+ the model can react).
276
+ """
277
+ allowed = {t.name for t in STREAMING_SAFE_TOOLS}
278
+ if tool_call.name not in allowed:
279
+ return f"Error: tool '{tool_call.name}' is not available in interactive sessions."
280
+
281
+ result: ToolResult = await asyncio.to_thread(
282
+ execute_tool, tool_call, self._workspace_path
283
+ )
284
+ return result.content
285
+
286
+ # ------------------------------------------------------------------
287
+ # Main streaming entry point
288
+ # ------------------------------------------------------------------
289
+
290
+ async def send_message(
291
+ self,
292
+ content: str,
293
+ history: list[dict],
294
+ interrupt_event: Optional[asyncio.Event] = None,
295
+ ) -> AsyncIterator[ChatEvent]:
296
+ """Stream a single conversational turn.
297
+
298
+ Yields ``ChatEvent`` objects as the model generates. The caller is
299
+ responsible for forwarding these to the WebSocket client.
300
+
301
+ The method loops internally to handle tool calls (tool_use → tool_result
302
+ → re-enter stream) until ``stop_reason == "end_turn"`` or the interrupt
303
+ fires.
304
+
305
+ Args:
306
+ content: The user's message for this turn.
307
+ history: Prior messages to include as context. If empty, history
308
+ is loaded from the DB automatically.
309
+ interrupt_event: Optional event; when set the stream stops within
310
+ the current chunk iteration.
311
+
312
+ Yields:
313
+ ``ChatEvent`` with types from ``ChatEventType``.
314
+ """
315
+ # Load history from DB if caller didn't supply it
316
+ if not history:
317
+ history = self._load_history()
318
+
319
+ # Build the message list for this turn
320
+ messages: list[dict] = list(history) + [{"role": "user", "content": content}]
321
+ messages = self._truncate_history(messages)
322
+
323
+ accumulated_text = ""
324
+
325
+ try:
326
+ async for event in self._stream_turn(
327
+ messages=messages,
328
+ interrupt_event=interrupt_event,
329
+ ):
330
+ if event.type == ChatEventType.TEXT_DELTA and event.content:
331
+ accumulated_text += event.content
332
+ yield event
333
+
334
+ except Exception as exc:
335
+ logger.error("StreamingChatAdapter error: %s", exc, exc_info=True)
336
+ yield ChatEvent(type=ChatEventType.ERROR, content=str(exc))
337
+ return
338
+
339
+ # Persist the turn (errors are logged, not raised)
340
+ try:
341
+ await self._persist_turn(content, accumulated_text)
342
+ except Exception as exc:
343
+ logger.error("StreamingChatAdapter persistence error: %s", exc)
344
+
345
+ async def _stream_turn(
346
+ self,
347
+ messages: list[dict],
348
+ interrupt_event: Optional[asyncio.Event],
349
+ ) -> AsyncIterator[ChatEvent]:
350
+ """Execute one API turn, handling tool loops internally.
351
+
352
+ Yields ``ChatEvent`` objects for all events in the turn, including
353
+ any tool sub-turns.
354
+ """
355
+ current_messages = list(messages)
356
+
357
+ system_prompt = (
358
+ "You are a CodeFrame assistant helping the user understand and navigate "
359
+ "their codebase. You have read-only access to the current workspace. "
360
+ "Available tools: read_file, list_files, search_codebase. "
361
+ "Do not attempt to modify files or execute shell commands."
362
+ )
363
+
364
+ use_extended_thinking = self._provider.supports("extended_thinking")
365
+
366
+ while True:
367
+ pending_tool_calls: list[dict] = [] # {id, name, input}
368
+ stop_reason = "end_turn"
369
+
370
+ async for chunk in self._provider.async_stream(
371
+ messages=current_messages,
372
+ system=system_prompt,
373
+ tools=_TOOLS_FOR_API,
374
+ model=self._model,
375
+ max_tokens=4096,
376
+ interrupt_event=interrupt_event,
377
+ extended_thinking=use_extended_thinking,
378
+ ):
379
+ if interrupt_event and interrupt_event.is_set():
380
+ return
381
+
382
+ if chunk.type == "text_delta":
383
+ yield ChatEvent(type=ChatEventType.TEXT_DELTA, content=chunk.text)
384
+
385
+ elif chunk.type == "thinking_delta":
386
+ yield ChatEvent(type=ChatEventType.THINKING, content=chunk.text)
387
+
388
+ elif chunk.type == "tool_use_start":
389
+ pending_tool_calls.append({
390
+ "id": chunk.tool_id,
391
+ "name": chunk.tool_name,
392
+ "input": chunk.tool_input or {},
393
+ })
394
+ yield ChatEvent(
395
+ type=ChatEventType.TOOL_USE_START,
396
+ tool_name=chunk.tool_name,
397
+ tool_input=chunk.tool_input or {},
398
+ )
399
+
400
+ elif chunk.type == "message_stop":
401
+ stop_reason = chunk.stop_reason or "end_turn"
402
+
403
+ # Back-fill tool inputs from final message (more reliable)
404
+ if chunk.tool_inputs_by_id and pending_tool_calls:
405
+ for tc in pending_tool_calls:
406
+ if tc["id"] in chunk.tool_inputs_by_id:
407
+ tc["input"] = chunk.tool_inputs_by_id[tc["id"]]
408
+
409
+ yield ChatEvent(
410
+ type=ChatEventType.COST_UPDATE,
411
+ input_tokens=chunk.input_tokens,
412
+ output_tokens=chunk.output_tokens,
413
+ cost_usd=_estimate_cost(
414
+ chunk.input_tokens or 0,
415
+ chunk.output_tokens or 0,
416
+ self._model,
417
+ ),
418
+ )
419
+
420
+ # tool_use_stop is informational only — no ChatEvent needed
421
+
422
+ if stop_reason == "end_turn" or not pending_tool_calls:
423
+ yield ChatEvent(type=ChatEventType.DONE)
424
+ return
425
+
426
+ # Execute pending tool calls and loop for another API turn
427
+ tool_result_blocks = []
428
+ for tc in pending_tool_calls:
429
+ if interrupt_event and interrupt_event.is_set():
430
+ yield ChatEvent(type=ChatEventType.DONE)
431
+ return
432
+
433
+ tool_call = ToolCall(
434
+ id=tc["id"],
435
+ name=tc["name"],
436
+ input=tc.get("input") or {},
437
+ )
438
+ result_text = await self._execute_tool(tool_call)
439
+
440
+ yield ChatEvent(
441
+ type=ChatEventType.TOOL_RESULT,
442
+ tool_name=tc["name"],
443
+ content=result_text,
444
+ )
445
+
446
+ tool_result_blocks.append(
447
+ {
448
+ "type": "tool_result",
449
+ "tool_use_id": tc["id"],
450
+ "content": result_text,
451
+ }
452
+ )
453
+
454
+ # Append the tool results as a user message for the next turn
455
+ current_messages = current_messages + [
456
+ {"role": "user", "content": tool_result_blocks}
457
+ ]
458
+
459
+
460
+ # ---------------------------------------------------------------------------
461
+ # Helpers
462
+ # ---------------------------------------------------------------------------
463
+
464
+
465
+ def _estimate_cost(input_tokens: int, output_tokens: int, model: str) -> float:
466
+ """Rough cost estimate in USD.
467
+
468
+ Uses approximate pricing for claude-sonnet-4. Returns 0.0 for unknown models
469
+ rather than raising — cost tracking is best-effort.
470
+ """
471
+ # Per-million-token pricing (input, output) in USD.
472
+ # Last verified: 2026-03-31. Anthropic pricing changes without notice —
473
+ # treat these as best-effort estimates, not billing-accurate figures.
474
+ _PRICING: dict[str, tuple[float, float]] = {
475
+ "claude-sonnet-4-20250514": (3.0, 15.0),
476
+ "claude-opus-4-20250514": (15.0, 75.0),
477
+ "claude-3-5-haiku-20241022": (0.8, 4.0),
478
+ }
479
+ # Match by prefix to handle minor model variant suffixes
480
+ for prefix, (in_price, out_price) in _PRICING.items():
481
+ if model.startswith(prefix):
482
+ return (input_tokens * in_price + output_tokens * out_price) / 1_000_000
483
+ return 0.0
@@ -0,0 +1,213 @@
1
+ """Base subprocess adapter for external coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import threading
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+ from codeframe.core.adapters.agent_adapter import AgentEvent, AgentResult
12
+ from codeframe.core.adapters.git_utils import detect_modified_files
13
+ from codeframe.core.blocker_detection import classify_error_for_blocker
14
+
15
+
16
+ class SubprocessAdapter:
17
+ """Base adapter for coding agents invoked as subprocesses.
18
+
19
+ Provides shared infrastructure: binary availability check, subprocess
20
+ execution with stdout streaming, and exit code to AgentResult mapping.
21
+
22
+ Subclasses override build_command() to customize CLI invocation.
23
+ """
24
+
25
+ # Default timeout: 30 minutes (coding agents can be long-running)
26
+ DEFAULT_TIMEOUT_S = 1800
27
+
28
+ def __init__(
29
+ self,
30
+ binary: str,
31
+ cli_args: list[str] | None = None,
32
+ timeout_s: int | None = None,
33
+ ) -> None:
34
+ """Initialize with the binary name and default CLI args.
35
+
36
+ Args:
37
+ binary: Name of the CLI binary (e.g., 'claude', 'opencode')
38
+ cli_args: Default CLI arguments appended to every invocation
39
+ timeout_s: Max execution time in seconds (default: 1800, None = no limit)
40
+
41
+ Raises:
42
+ EnvironmentError: If the binary is not found on PATH
43
+ """
44
+ self._binary = binary
45
+ self._cli_args = cli_args or []
46
+ self._timeout_s = timeout_s if timeout_s is not None else self.DEFAULT_TIMEOUT_S
47
+
48
+ resolved = shutil.which(binary)
49
+ if resolved is None:
50
+ raise EnvironmentError(
51
+ f"'{binary}' not found on PATH. "
52
+ f"Install it or ensure it is available in your environment."
53
+ )
54
+ self._binary_path = resolved
55
+
56
+ @property
57
+ def name(self) -> str:
58
+ """Engine name derived from the binary."""
59
+ return self._binary
60
+
61
+ def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
62
+ """Build the subprocess command list.
63
+
64
+ Override in subclasses for custom CLI invocation.
65
+ Default: [binary, *cli_args] with prompt on stdin.
66
+
67
+ Args:
68
+ prompt: The task prompt to send to the agent
69
+ workspace_path: Path to the workspace root
70
+
71
+ Returns:
72
+ Command list for subprocess.Popen
73
+ """
74
+ return [self._binary_path, *self._cli_args]
75
+
76
+ def get_stdin(self, prompt: str) -> str | None:
77
+ """Return stdin content for the subprocess, or None to not pipe stdin.
78
+
79
+ Override in subclasses if the agent reads prompt from a file instead.
80
+ Default: returns the prompt string (piped via stdin).
81
+ """
82
+ return prompt
83
+
84
+ def run(
85
+ self,
86
+ task_id: str,
87
+ prompt: str,
88
+ workspace_path: Path,
89
+ on_event: Callable[[AgentEvent], None] | None = None,
90
+ ) -> AgentResult:
91
+ """Execute the agent subprocess and return the result."""
92
+ cmd = self.build_command(prompt, workspace_path)
93
+ stdin_content = self.get_stdin(prompt)
94
+
95
+ stdout_lines: list[str] = []
96
+ stderr_chunks: list[str] = []
97
+
98
+ try:
99
+ process = subprocess.Popen(
100
+ cmd,
101
+ stdin=subprocess.PIPE if stdin_content else None,
102
+ stdout=subprocess.PIPE,
103
+ stderr=subprocess.PIPE,
104
+ cwd=str(workspace_path),
105
+ text=True,
106
+ )
107
+
108
+ # Write stdin and close
109
+ if stdin_content and process.stdin:
110
+ process.stdin.write(stdin_content)
111
+ process.stdin.close()
112
+
113
+ # Drain stderr in a background thread to prevent deadlock.
114
+ # Without this, if the child fills the stderr pipe buffer (~64KB)
115
+ # before finishing stdout, both processes block indefinitely.
116
+ def _drain_stderr() -> None:
117
+ if process.stderr:
118
+ stderr_chunks.append(process.stderr.read())
119
+
120
+ stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
121
+ stderr_thread.start()
122
+
123
+ # Stream stdout line-by-line
124
+ if process.stdout:
125
+ for line in process.stdout:
126
+ stripped = line.rstrip("\n")
127
+ stdout_lines.append(stripped)
128
+ if on_event:
129
+ on_event(AgentEvent(type="output", data={"line": stripped}))
130
+
131
+ # Wait for stderr thread and process to finish
132
+ stderr_thread.join(timeout=10)
133
+ try:
134
+ process.wait(timeout=self._timeout_s)
135
+ except subprocess.TimeoutExpired:
136
+ process.kill()
137
+ process.wait()
138
+ return AgentResult(
139
+ status="failed",
140
+ output="\n".join(stdout_lines),
141
+ error=f"Process timed out after {self._timeout_s}s",
142
+ )
143
+
144
+ except FileNotFoundError:
145
+ return AgentResult(
146
+ status="failed",
147
+ error=f"Binary '{self._binary}' not found during execution",
148
+ )
149
+ except OSError as e:
150
+ return AgentResult(
151
+ status="failed",
152
+ error=f"Failed to start '{self._binary}': {e}",
153
+ )
154
+
155
+ stderr_output = "".join(stderr_chunks)
156
+
157
+ modified_files = self._detect_modified_files(workspace_path)
158
+
159
+ result = self._map_result(
160
+ exit_code=process.returncode,
161
+ stdout="\n".join(stdout_lines),
162
+ stderr=stderr_output,
163
+ workspace_path=workspace_path,
164
+ )
165
+ result.modified_files = modified_files
166
+ return result
167
+
168
+ def _map_result(
169
+ self,
170
+ exit_code: int,
171
+ stdout: str,
172
+ stderr: str,
173
+ workspace_path: Path,
174
+ ) -> AgentResult:
175
+ """Map subprocess exit code and output to AgentResult.
176
+
177
+ Override in subclasses for custom exit code interpretation.
178
+ Default: 0 = completed, non-zero = failed.
179
+ Uses blocker_detection.classify_error_for_blocker for blocker detection.
180
+ """
181
+ if exit_code == 0:
182
+ return AgentResult(
183
+ status="completed",
184
+ output=stdout,
185
+ )
186
+
187
+ # Check if the error looks like a blocker using the shared classifier
188
+ combined_output = f"{stdout}\n{stderr}".strip()
189
+ category = classify_error_for_blocker(combined_output)
190
+ if category is not None:
191
+ return AgentResult(
192
+ status="blocked",
193
+ output=stdout,
194
+ error=stderr or None,
195
+ blocker_question=self._extract_blocker_question(combined_output),
196
+ )
197
+
198
+ return AgentResult(
199
+ status="failed",
200
+ output=stdout,
201
+ error=stderr or f"Process exited with code {exit_code}",
202
+ )
203
+
204
+ def _detect_modified_files(self, workspace_path: Path) -> list[str]:
205
+ """Detect files modified by the subprocess via git diff."""
206
+ return detect_modified_files(workspace_path)
207
+
208
+ def _extract_blocker_question(self, output: str) -> str:
209
+ """Extract a meaningful blocker question from output."""
210
+ lines = [line.strip() for line in output.splitlines() if line.strip()]
211
+ if lines:
212
+ return lines[-1]
213
+ return "Agent encountered a blocker but no details were provided."