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,1650 @@
1
+ """ReAct-style agent for CodeFRAME v3.
2
+
3
+ Implements a tool-use loop where the LLM reasons, acts (via tools),
4
+ and observes results iteratively until the task is complete.
5
+
6
+ This module is headless - no FastAPI or HTTP dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import threading
15
+ from pathlib import Path
16
+ from datetime import datetime, timezone
17
+ from typing import TYPE_CHECKING, Callable, Optional
18
+
19
+ from codeframe.adapters.llm.base import LLMProvider, Purpose, ToolResult
20
+ from codeframe.core import blockers, events, gates
21
+ from codeframe.core.agent import AgentStatus
22
+ from codeframe.core.blocker_detection import classify_error_for_blocker
23
+ from codeframe.core.context import TaskContext
24
+ from codeframe.core.context_packager import TaskContextPackager
25
+ from codeframe.core.events import EventType
26
+ from codeframe.core.fix_tracker import (
27
+ EscalationDecision,
28
+ FixAttemptTracker,
29
+ FixOutcome,
30
+ build_escalation_question,
31
+ )
32
+ from codeframe.core.stall_detector import StallAction, StallDetectedError
33
+ from codeframe.core.stall_monitor import StallEvent, StallMonitor
34
+ from codeframe.core.models import AgentPhase, CompletionEvent, ErrorEvent, ProgressEvent
35
+ from codeframe.core.quick_fixes import apply_quick_fix, find_quick_fix
36
+ from codeframe.core.tools import AGENT_TOOLS, execute_tool
37
+ from codeframe.core.workspace import Workspace
38
+
39
+ if TYPE_CHECKING:
40
+ from codeframe.core.conductor import GlobalFixCoordinator
41
+ from codeframe.core.replay import ExecutionRecorder
42
+ from codeframe.core.streaming import EventPublisher, RunOutputLogger
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # Rough token budget for conversation history to avoid overflowing LLM context.
47
+ _MAX_HISTORY_CHARS = 400_000 # ~100K tokens at ~4 chars/token
48
+
49
+ # Token budget compaction constants
50
+ DEFAULT_COMPACTION_THRESHOLD = 0.85
51
+ PRESERVE_RECENT_PAIRS = 5
52
+ DEFAULT_CONTEXT_WINDOW = 200_000 # All Claude 4.x models
53
+
54
+ # Reason string emitted when a stall timeout triggers a blocker — used to
55
+ # set the correct BlockerOrigin ("system") vs agent-generated blockers.
56
+ _REASON_STALL_DETECTED = "stall_detected"
57
+
58
+ # Map tool names to agent phases for progress reporting.
59
+ _TOOL_PHASE_MAP = {
60
+ "read_file": AgentPhase.EXPLORING,
61
+ "list_files": AgentPhase.EXPLORING,
62
+ "search_codebase": AgentPhase.EXPLORING,
63
+ "create_file": AgentPhase.CREATING,
64
+ "edit_file": AgentPhase.EDITING,
65
+ "run_tests": AgentPhase.TESTING,
66
+ "run_command": AgentPhase.TESTING,
67
+ }
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Layer 1: Base rules (adapted from AGENT_V3_UNIFIED_PLAN.md)
71
+ # ---------------------------------------------------------------------------
72
+
73
+ _LAYER_1_RULES = """\
74
+ You are CodeFRAME, an autonomous software engineering agent.
75
+
76
+ ## Rules
77
+
78
+ - Read files before editing them unless their contents are already provided in \
79
+ the "Relevant Source Files" section below. Never assume file contents that aren't shown.
80
+ - Make small, targeted edits. Do not rewrite entire files.
81
+ - For NEW files: use create_file. For EXISTING files: use edit_file with search/replace.
82
+ - Never edit_file on a file you haven't seen in this session (either via \
83
+ read_file tool or in the Relevant Source Files section).
84
+ - Run tests after implementing each major feature, not after every line change.
85
+ - Keep solutions simple. Do not add features beyond what was asked.
86
+ - Do not change configuration files (pyproject.toml, package.json, etc.) unless
87
+ the task explicitly requires it. If you must edit them, read first and make
88
+ minimal, targeted changes.
89
+
90
+ ## Code Quality
91
+
92
+ - No trailing whitespace
93
+ - Use 'raise X from Y' not bare 'raise X' after catching exceptions
94
+ - Follow the project's existing code style (read existing files first)
95
+ - All imports at the top of file, organized: stdlib -> third-party -> local
96
+
97
+ ## When You're Done
98
+
99
+ Respond with a brief summary. Do not call any more tools.
100
+
101
+ ## When You're Stuck
102
+
103
+ If you encounter a genuine blocker (conflicting requirements, missing credentials,
104
+ unclear business logic), explain clearly. Do NOT stop for trivial decisions.
105
+ """
106
+
107
+
108
+ class ReactAgent:
109
+ """ReAct agent that iterates: LLM call -> tool execution -> observe.
110
+
111
+ Attributes:
112
+ workspace: Target workspace.
113
+ llm_provider: LLM provider for completions.
114
+ max_iterations: Hard cap on LLM calls in the main loop.
115
+ max_verification_retries: How many times to retry after failed gates.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ workspace: Workspace,
121
+ llm_provider: LLMProvider,
122
+ max_iterations: int = 30,
123
+ max_verification_retries: int = 5,
124
+ stall_timeout_s: float = 300,
125
+ stall_action: StallAction = StallAction.BLOCKER,
126
+ event_publisher: Optional[EventPublisher] = None,
127
+ dry_run: bool = False,
128
+ verbose: bool = False,
129
+ on_event: Optional[Callable[[str, dict], None]] = None,
130
+ debug: bool = False,
131
+ output_logger: Optional[RunOutputLogger] = None,
132
+ fix_coordinator: Optional[GlobalFixCoordinator] = None,
133
+ execution_recorder: Optional[ExecutionRecorder] = None,
134
+ ) -> None:
135
+ self.workspace = workspace
136
+ self.llm_provider = llm_provider
137
+ self.max_iterations = max_iterations
138
+ self.max_verification_retries = max_verification_retries
139
+ self._stall_timeout_s = stall_timeout_s
140
+ self._stall_action = stall_action
141
+ self.event_publisher = event_publisher
142
+ self.dry_run = dry_run
143
+ self.verbose = verbose
144
+ self.on_event = on_event
145
+ self.debug = debug
146
+ self.output_logger = output_logger
147
+ self.fix_coordinator = fix_coordinator
148
+ self.execution_recorder = execution_recorder
149
+ self.fix_tracker = FixAttemptTracker()
150
+ self.blocker_id: Optional[str] = None
151
+
152
+ # Token usage tracking: accumulate per-call records across the run.
153
+ self._token_records: list[dict] = []
154
+
155
+ # Stall detection
156
+ self._stall_triggered = threading.Event()
157
+ self._stall_event: Optional[StallEvent] = None
158
+ self._stall_monitor = StallMonitor(
159
+ stall_timeout_s=stall_timeout_s,
160
+ on_stall=self._on_stall,
161
+ )
162
+
163
+ # Token budget tracking for conversation compaction
164
+ self._context_window_size: int = DEFAULT_CONTEXT_WINDOW
165
+ self._compaction_threshold: float = self._read_compaction_threshold()
166
+ self._total_tokens_used: int = 0
167
+ self._compaction_count: int = 0
168
+
169
+ # Debug logging setup
170
+ self._debug_log_path: Optional[Path] = None
171
+ self._failure_count = 0
172
+ if debug:
173
+ self._setup_debug_log()
174
+
175
+ # ------------------------------------------------------------------
176
+ # Public API
177
+ # ------------------------------------------------------------------
178
+
179
+ def run(self, task_id: str) -> AgentStatus:
180
+ """Execute the full agent workflow for a task.
181
+
182
+ 1. Load context
183
+ 2. Build system prompt (3 layers)
184
+ 3. Enter ReAct loop
185
+ 4. Run final verification (with retry)
186
+
187
+ Returns:
188
+ AgentStatus.COMPLETED — task finished successfully.
189
+ AgentStatus.BLOCKED — a blocker was created (check self.blocker_id).
190
+ AgentStatus.FAILED — max iterations or verification exhausted.
191
+ """
192
+ self._current_task_id = task_id
193
+ self._verbose_print(f"[ReactAgent] Starting task {task_id}")
194
+ self._emit(EventType.AGENT_STARTED, {"task_id": task_id})
195
+
196
+ try:
197
+ self._emit_progress(AgentPhase.EXPLORING, message="Loading task context")
198
+
199
+ packager = TaskContextPackager(self.workspace)
200
+ context = packager.load_context(task_id)
201
+
202
+ # Adaptive budget based on task complexity
203
+ adaptive = self._calculate_adaptive_budget(context)
204
+ if adaptive != self.max_iterations:
205
+ self._verbose_print(
206
+ f"[ReactAgent] Adaptive budget: {adaptive} iterations "
207
+ f"(complexity={getattr(context.task, 'complexity_score', None)})"
208
+ )
209
+ self.max_iterations = adaptive
210
+
211
+ self._emit(EventType.AGENT_BUDGET_CALCULATED, {
212
+ "task_id": task_id,
213
+ "budget": self.max_iterations,
214
+ "complexity_score": getattr(context.task, "complexity_score", None),
215
+ })
216
+
217
+ self._emit_progress(AgentPhase.PLANNING, message="Building system prompt")
218
+
219
+ system_prompt = self._build_system_prompt(context)
220
+
221
+ self._stall_triggered.clear()
222
+ self._stall_event = None
223
+ self._stall_monitor.start(task_id)
224
+ try:
225
+ status = self._react_loop(system_prompt)
226
+ if status == AgentStatus.FAILED:
227
+ reason = _REASON_STALL_DETECTED if self._stall_triggered.is_set() else "max_iterations_reached"
228
+ self._emit(EventType.AGENT_FAILED, {
229
+ "task_id": task_id,
230
+ "reason": reason,
231
+ })
232
+ self._emit_stream_error(task_id, reason)
233
+ return status
234
+
235
+ if status == AgentStatus.BLOCKED:
236
+ self._emit(EventType.AGENT_FAILED, {
237
+ "task_id": task_id,
238
+ "reason": "blocked",
239
+ })
240
+ self._emit_stream_error(task_id, "blocked")
241
+ return AgentStatus.BLOCKED
242
+
243
+ # Final verification with retry
244
+ passed, reason = self._run_final_verification(system_prompt)
245
+ if passed:
246
+ self._verbose_print(f"[ReactAgent] Task {task_id} completed: {AgentStatus.COMPLETED.name}")
247
+ self._emit(EventType.AGENT_COMPLETED, {"task_id": task_id})
248
+ self._emit_stream_completion(task_id)
249
+ return AgentStatus.COMPLETED
250
+
251
+ if reason == "escalated_to_blocker" or reason == _REASON_STALL_DETECTED:
252
+ self._emit(EventType.AGENT_FAILED, {
253
+ "task_id": task_id,
254
+ "reason": "blocked",
255
+ })
256
+ self._emit_stream_error(task_id, "blocked")
257
+ return AgentStatus.BLOCKED
258
+
259
+ self._emit(EventType.AGENT_FAILED, {
260
+ "task_id": task_id,
261
+ "reason": "verification_failed",
262
+ })
263
+ self._emit_stream_error(task_id, "verification_failed")
264
+ return AgentStatus.FAILED
265
+ finally:
266
+ self._stall_monitor.stop()
267
+ try:
268
+ self._persist_token_usage(task_id)
269
+ except Exception:
270
+ logger.debug(
271
+ "Failed to persist token usage for task %s",
272
+ task_id,
273
+ exc_info=True,
274
+ )
275
+ if self.execution_recorder is not None:
276
+ try:
277
+ self.execution_recorder.flush()
278
+ except Exception:
279
+ logger.debug(
280
+ "Failed to flush execution recorder for task %s",
281
+ task_id,
282
+ exc_info=True,
283
+ )
284
+ except StallDetectedError:
285
+ raise # Monitor stopped by finally above; let runtime handle retry
286
+ except Exception:
287
+ logger.exception("ReactAgent.run() failed for task %s", task_id)
288
+ self._emit(EventType.AGENT_FAILED, {
289
+ "task_id": task_id,
290
+ "reason": "exception",
291
+ })
292
+ self._emit_stream_error(task_id, "exception")
293
+ return AgentStatus.FAILED
294
+
295
+ def get_token_usage(self) -> list[dict]:
296
+ """Return the accumulated per-call token usage records.
297
+
298
+ Each record is a dict with keys: input_tokens, output_tokens, model,
299
+ call_type, iteration.
300
+ """
301
+ return list(self._token_records)
302
+
303
+ def get_total_tokens(self) -> dict:
304
+ """Return aggregated token totals and estimated cost.
305
+
306
+ Returns:
307
+ Dict with input_tokens, output_tokens, total_tokens,
308
+ and estimated_cost_usd.
309
+ """
310
+ total_in = sum(r["input_tokens"] for r in self._token_records)
311
+ total_out = sum(r["output_tokens"] for r in self._token_records)
312
+ return {
313
+ "input_tokens": total_in,
314
+ "output_tokens": total_out,
315
+ "total_tokens": total_in + total_out,
316
+ "estimated_cost_usd": self._estimate_total_cost(),
317
+ }
318
+
319
+ # ------------------------------------------------------------------
320
+ # Token persistence
321
+ # ------------------------------------------------------------------
322
+
323
+ def _estimate_total_cost(self) -> float:
324
+ """Estimate total USD cost from accumulated token records.
325
+
326
+ Delegates per-model pricing to MetricsTracker.calculate_cost to keep
327
+ pricing logic in a single location. Falls back to 0.0 on any error.
328
+ """
329
+ try:
330
+ from codeframe.lib.metrics_tracker import MetricsTracker
331
+
332
+ total = 0.0
333
+ for record in self._token_records:
334
+ total += MetricsTracker.calculate_cost(
335
+ record["model"],
336
+ record["input_tokens"],
337
+ record["output_tokens"],
338
+ )
339
+ return round(total, 6)
340
+ except Exception:
341
+ return 0.0
342
+
343
+ def _persist_token_usage(self, task_id: str) -> None:
344
+ """Persist accumulated token records to the workspace database.
345
+
346
+ Uses MetricsTracker.record_token_usage_sync for synchronous writes.
347
+ Failures are logged but never propagated to the caller.
348
+ The database connection is always closed via try/finally.
349
+ """
350
+ if not self._token_records:
351
+ return
352
+
353
+ db = None
354
+ try:
355
+ from codeframe.lib.metrics_tracker import MetricsTracker
356
+ from codeframe.platform_store.database import Database
357
+
358
+ db = Database(str(self.workspace.db_path))
359
+ db.initialize()
360
+ tracker = MetricsTracker(db=db)
361
+
362
+ # v1 tasks have integer PKs; v2 workspaces use UUID strings.
363
+ # Pass the raw value — SQLite preserves the type, and downstream
364
+ # analytics (issue #558) group by whatever was stored. Forcing
365
+ # int() here used to drop every v2 record's task linkage.
366
+ persist_task_id: int | str
367
+ try:
368
+ persist_task_id = int(task_id)
369
+ except (ValueError, TypeError):
370
+ persist_task_id = str(task_id)
371
+
372
+ for record in self._token_records:
373
+ tracker.record_token_usage_sync(
374
+ task_id=persist_task_id,
375
+ agent_id="react-agent",
376
+ project_id=0,
377
+ model_name=record["model"],
378
+ input_tokens=record["input_tokens"],
379
+ output_tokens=record["output_tokens"],
380
+ call_type=record["call_type"],
381
+ )
382
+ except Exception:
383
+ logger.debug(
384
+ "Token usage persistence failed for task %s", task_id, exc_info=True,
385
+ )
386
+ finally:
387
+ if db is not None:
388
+ try:
389
+ db.close()
390
+ except Exception:
391
+ pass
392
+
393
+ # ------------------------------------------------------------------
394
+ # ReAct loop
395
+ # ------------------------------------------------------------------
396
+
397
+ def _react_loop(self, system_prompt: str) -> AgentStatus:
398
+ """Core ReAct loop: iterate LLM calls until text-only or max iterations.
399
+
400
+ Returns AgentStatus.COMPLETED when the LLM responds with text only.
401
+ Returns AgentStatus.BLOCKED when a blocker pattern is detected.
402
+ Returns AgentStatus.FAILED when max_iterations is reached.
403
+ """
404
+ messages: list[dict] = [
405
+ {
406
+ "role": "user",
407
+ "content": (
408
+ "Implement the task described in the system prompt. "
409
+ "Relevant source files are provided above when available. "
410
+ "Use tools to explore further only if needed. "
411
+ "When you are done, respond with a brief summary."
412
+ ),
413
+ }
414
+ ]
415
+ iterations = 0
416
+ recent_tool_signatures: list[tuple[str, ...]] = []
417
+ prompt_summary = system_prompt[:200]
418
+
419
+ while iterations < self.max_iterations:
420
+ # Check for stall before each iteration
421
+ if self._stall_triggered.is_set():
422
+ stall_ctx = ""
423
+ elapsed_s = 0.0
424
+ if self._stall_event:
425
+ elapsed_s = self._stall_event.elapsed_s
426
+ stall_ctx = (
427
+ f"Agent stalled: no tool call for {elapsed_s:.0f}s "
428
+ f"(timeout: {self._stall_event.stall_timeout_s}s)"
429
+ )
430
+
431
+ if self._stall_action == StallAction.RETRY:
432
+ raise StallDetectedError(
433
+ elapsed_s=elapsed_s,
434
+ iterations=iterations,
435
+ last_tool=recent_tool_signatures[-1][0] if recent_tool_signatures else "",
436
+ )
437
+ elif self._stall_action == StallAction.FAIL:
438
+ self._verbose_print(f"[ReactAgent] Stall → FAILED: {stall_ctx}")
439
+ return AgentStatus.FAILED
440
+ else:
441
+ # StallAction.BLOCKER (default)
442
+ self._create_text_blocker(
443
+ stall_ctx or "Agent stalled with no tool activity",
444
+ _REASON_STALL_DETECTED,
445
+ )
446
+ return AgentStatus.BLOCKED
447
+
448
+ self._verbose_print(f"[ReactAgent] Iteration {iterations + 1}/{self.max_iterations}")
449
+ self._emit(EventType.AGENT_ITERATION_STARTED, {
450
+ "task_id": self._current_task_id,
451
+ "iteration": iterations,
452
+ "system_prompt_summary": prompt_summary,
453
+ })
454
+
455
+ response = self.llm_provider.complete(
456
+ messages=messages,
457
+ purpose=Purpose.EXECUTION,
458
+ tools=AGENT_TOOLS,
459
+ temperature=0.0,
460
+ system=system_prompt,
461
+ )
462
+ iterations += 1
463
+
464
+ # Record token usage for this LLM call.
465
+ self._token_records.append({
466
+ "input_tokens": response.input_tokens,
467
+ "output_tokens": response.output_tokens,
468
+ "model": response.model,
469
+ "call_type": "task_execution",
470
+ "iteration": iterations,
471
+ })
472
+
473
+ # --- Execution recording: LLM call ---
474
+ _rec_step_id: Optional[str] = None
475
+ if self.execution_recorder is not None:
476
+ # Build condensed summaries for the trace
477
+ _rec_prompt = f"System: {prompt_summary} | Messages: {len(messages)}"
478
+ if response.has_tool_calls:
479
+ _rec_response = "Tool calls: " + ", ".join(
480
+ tc.name for tc in response.tool_calls
481
+ )
482
+ else:
483
+ _rec_response = (response.content or "")[:200]
484
+ _rec_step_id = self.execution_recorder.record_iteration(
485
+ step_number=iterations,
486
+ tool_names=[tc.name for tc in response.tool_calls],
487
+ llm_response_summary=_rec_response,
488
+ )
489
+ self.execution_recorder.record_llm_call(
490
+ step_id=_rec_step_id,
491
+ prompt_summary=_rec_prompt,
492
+ response_summary=_rec_response,
493
+ model=response.model or "",
494
+ tokens_used=response.input_tokens + response.output_tokens,
495
+ purpose="execution",
496
+ )
497
+
498
+ if not response.has_tool_calls:
499
+ # Text-only response — agent thinks it's done.
500
+ # Check for blocker patterns before accepting completion.
501
+ # Use classify_error_for_blocker directly (not should_create_blocker)
502
+ # because a text-only response means the LLM stopped calling tools.
503
+ # All blocker categories — including external_service — are immediate
504
+ # blockers here since the agent has no retry mechanism for text.
505
+ text = response.content or ""
506
+ category = classify_error_for_blocker(text)
507
+ if category is not None:
508
+ reason = f"{category} issue detected in agent response"
509
+ self._create_text_blocker(text, reason)
510
+ self._emit(EventType.AGENT_ITERATION_COMPLETED, {
511
+ "task_id": self._current_task_id,
512
+ "iteration": iterations,
513
+ "has_tool_calls": False,
514
+ })
515
+ return AgentStatus.BLOCKED
516
+
517
+ self._emit(EventType.AGENT_ITERATION_COMPLETED, {
518
+ "task_id": self._current_task_id,
519
+ "iteration": iterations,
520
+ "has_tool_calls": False,
521
+ })
522
+ return AgentStatus.COMPLETED
523
+
524
+ # Build assistant message with tool calls
525
+ assistant_msg: dict = {
526
+ "role": "assistant",
527
+ "content": response.content or "",
528
+ "tool_calls": [
529
+ {"id": tc.id, "name": tc.name, "input": tc.input}
530
+ for tc in response.tool_calls
531
+ ],
532
+ }
533
+ messages.append(assistant_msg)
534
+
535
+ # Execute each tool call and collect results
536
+ tool_results = []
537
+ for tc in response.tool_calls:
538
+ phase = _TOOL_PHASE_MAP.get(tc.name, AgentPhase.EXPLORING)
539
+ tc_file_path = tc.input.get("path", "") or tc.input.get("test_path", "")
540
+ self._emit_progress(
541
+ phase,
542
+ tool_name=tc.name,
543
+ file_path=tc_file_path or None,
544
+ iteration=iterations,
545
+ message=f"{tc.name}: {tc_file_path}" if tc_file_path else tc.name,
546
+ )
547
+
548
+ self._verbose_print(f"[ReactAgent] Tool: {tc.name}")
549
+ self._emit(EventType.AGENT_TOOL_DISPATCHED, {
550
+ "task_id": self._current_task_id,
551
+ "tool_name": tc.name,
552
+ "tool_call_id": tc.id,
553
+ })
554
+
555
+ result = self._execute_tool_with_lint(tc)
556
+
557
+ if not result.is_error:
558
+ self._stall_monitor.notify_tool_executed(
559
+ self._current_task_id, iterations,
560
+ )
561
+
562
+ self._emit(EventType.AGENT_TOOL_RESULT, {
563
+ "task_id": self._current_task_id,
564
+ "tool_call_id": result.tool_call_id,
565
+ "is_error": result.is_error,
566
+ "has_lint_errors": "LINT ERRORS" in result.content,
567
+ })
568
+
569
+ tool_results.append(
570
+ {
571
+ "tool_call_id": result.tool_call_id,
572
+ "content": result.content,
573
+ "is_error": result.is_error,
574
+ }
575
+ )
576
+
577
+ # --- Execution recording: file operations ---
578
+ if (
579
+ self.execution_recorder is not None
580
+ and _rec_step_id is not None
581
+ and tc.name in ("edit_file", "create_file")
582
+ and not result.is_error
583
+ ):
584
+ _op_type = "create" if tc.name == "create_file" else "edit"
585
+ _op_path = tc.input.get("path", "")
586
+ if tc.name == "create_file":
587
+ # create_file input has the full content
588
+ _op_after = tc.input.get("content", "")
589
+ else:
590
+ # edit_file uses search/replace snippets — read the
591
+ # actual file content after the edit for accurate state.
592
+ _op_after = None
593
+ try:
594
+ _full_path = self.workspace.repo_path / _op_path
595
+ if _full_path.is_file():
596
+ _op_after = _full_path.read_text(errors="replace")
597
+ except OSError:
598
+ pass
599
+ self.execution_recorder.record_file_operation(
600
+ step_id=_rec_step_id,
601
+ op_type=_op_type,
602
+ path=_op_path,
603
+ before=None,
604
+ after=_op_after,
605
+ )
606
+
607
+ # Check error tool results for immediate blocker patterns
608
+ if result.is_error:
609
+ self._failure_count += 1
610
+ category = classify_error_for_blocker(result.content)
611
+ if category in ("requirements", "access"):
612
+ self._create_text_blocker(
613
+ result.content,
614
+ f"{category} issue detected in tool result",
615
+ )
616
+ self._emit(EventType.AGENT_ITERATION_COMPLETED, {
617
+ "task_id": self._current_task_id,
618
+ "iteration": iterations,
619
+ "has_tool_calls": True,
620
+ })
621
+ return AgentStatus.BLOCKED
622
+
623
+ # Loop detection: track tool call patterns (preserve order,
624
+ # include key input to distinguish e.g. read_file("a.py") from
625
+ # read_file("b.py")).
626
+ def _tool_sig(tc) -> str:
627
+ key = tc.input.get("path") or tc.input.get("pattern") or tc.input.get("command") or ""
628
+ return f"{tc.name}:{key}"
629
+
630
+ sig = tuple(_tool_sig(tc) for tc in response.tool_calls)
631
+ recent_tool_signatures.append(sig)
632
+
633
+ # Check for stuck loop: 3 identical consecutive patterns
634
+ if len(recent_tool_signatures) >= 3:
635
+ last_three = recent_tool_signatures[-3:]
636
+ if last_three[0] == last_three[1] == last_three[2]:
637
+ self._verbose_print(
638
+ f"[ReactAgent] Loop detected: same tool pattern repeated 3 times ({sig})"
639
+ )
640
+ self._emit(EventType.AGENT_EARLY_TERMINATION, {
641
+ "task_id": self._current_task_id,
642
+ "iteration": iterations,
643
+ "reason": "loop_detected",
644
+ "pattern": list(sig),
645
+ })
646
+ return AgentStatus.COMPLETED
647
+
648
+ # Add tool results as user message
649
+ messages.append({"role": "user", "content": "", "tool_results": tool_results})
650
+
651
+ self._emit(EventType.AGENT_ITERATION_COMPLETED, {
652
+ "task_id": self._current_task_id,
653
+ "iteration": iterations,
654
+ "has_tool_calls": True,
655
+ "tool_count": len(response.tool_calls),
656
+ })
657
+
658
+ # Compact conversation when approaching context window limit
659
+ messages, compact_stats = self.compact_conversation(messages)
660
+ if compact_stats.get("compacted"):
661
+ self._verbose_print(
662
+ f"[ReactAgent] Compacted: saved {compact_stats['tokens_saved']} tokens "
663
+ f"(tiers: {compact_stats['tiers_used']})"
664
+ )
665
+ self._emit(EventType.AGENT_COMPACTION, {
666
+ **compact_stats,
667
+ "task_id": self._current_task_id,
668
+ })
669
+
670
+ # Exhausted iterations
671
+ return AgentStatus.FAILED
672
+
673
+ # ------------------------------------------------------------------
674
+ # Final verification
675
+ # ------------------------------------------------------------------
676
+
677
+ def _run_final_verification(
678
+ self, system_prompt: str
679
+ ) -> tuple[bool, Optional[str]]:
680
+ """Run gates and retry if they fail.
681
+
682
+ When verification fails, runs a bounded mini ReAct loop (up to 5
683
+ LLM turns per retry) so the agent can read files, apply fixes, and
684
+ see tool results before the next gate check.
685
+
686
+ Returns:
687
+ (passed, error_summary_or_none)
688
+ """
689
+ max_fix_turns = 5 # LLM turns per retry attempt
690
+
691
+ for attempt in range(1 + self.max_verification_retries):
692
+ if self._stall_triggered.is_set():
693
+ if self._stall_action == StallAction.RETRY:
694
+ raise StallDetectedError(
695
+ elapsed_s=self._stall_event.elapsed_s if self._stall_event else 0,
696
+ iterations=0,
697
+ )
698
+ elif self._stall_action == StallAction.FAIL:
699
+ return (False, "stall_failed")
700
+ return (False, _REASON_STALL_DETECTED)
701
+
702
+ self._verbose_print("[ReactAgent] Running final verification...")
703
+ self._emit_progress(
704
+ AgentPhase.VERIFYING,
705
+ message="Running verification gates",
706
+ iteration=attempt,
707
+ )
708
+ gate_result = gates.run(self.workspace)
709
+ if gate_result.passed:
710
+ return (True, None)
711
+
712
+ if attempt >= self.max_verification_retries:
713
+ return (False, gate_result.summary)
714
+
715
+ # Use structured error text for pattern matching (quick fixes,
716
+ # fix_tracker dedup). Fall back to the human-readable summary
717
+ # when detailed_errors are not available.
718
+ error_summary = gate_result.get_error_summary() or gate_result.summary
719
+
720
+ # 1. Try quick fix first (no LLM needed)
721
+ if self._try_quick_fix(error_summary):
722
+ # Quick fix applied — re-run gates immediately (skip LLM)
723
+ self.fix_tracker.record_attempt(error_summary, "quick_fix")
724
+ self.fix_tracker.record_outcome(error_summary, "quick_fix", FixOutcome.SUCCESS)
725
+ continue
726
+
727
+ # 2. Record the gate failure and check for escalation
728
+ self._failure_count += 1
729
+ self.fix_tracker.record_attempt(error_summary, "verification_gate")
730
+ self.fix_tracker.record_outcome(error_summary, "verification_gate", FixOutcome.FAILED)
731
+
732
+ escalation = self.fix_tracker.should_escalate(error_summary)
733
+ if escalation.should_escalate:
734
+ self._create_escalation_blocker(error_summary, escalation)
735
+ return (False, "escalated_to_blocker")
736
+
737
+ # 3. Mini ReAct loop to fix the issues
738
+ fix_messages: list[dict] = [
739
+ {
740
+ "role": "user",
741
+ "content": (
742
+ f"Final verification failed: {error_summary}\n"
743
+ "Please fix the issues and respond when done."
744
+ ),
745
+ }
746
+ ]
747
+
748
+ for _turn in range(max_fix_turns):
749
+ response = self.llm_provider.complete(
750
+ messages=fix_messages,
751
+ purpose=Purpose.CORRECTION,
752
+ tools=AGENT_TOOLS,
753
+ temperature=0.0,
754
+ system=system_prompt,
755
+ )
756
+
757
+ # Record token usage for verification fix calls.
758
+ self._token_records.append({
759
+ "input_tokens": response.input_tokens,
760
+ "output_tokens": response.output_tokens,
761
+ "model": response.model,
762
+ "call_type": "verification_fix",
763
+ "iteration": attempt,
764
+ })
765
+
766
+ if not response.has_tool_calls:
767
+ break # Agent done fixing → re-run gates
768
+
769
+ # Append assistant message with tool calls
770
+ fix_messages.append(
771
+ {
772
+ "role": "assistant",
773
+ "content": response.content or "",
774
+ "tool_calls": [
775
+ {"id": tc.id, "name": tc.name, "input": tc.input}
776
+ for tc in response.tool_calls
777
+ ],
778
+ }
779
+ )
780
+
781
+ # Execute tools (with lint) and collect results
782
+ tool_results = []
783
+ for tc in response.tool_calls:
784
+ tc_file_path = tc.input.get("path", "") or tc.input.get("test_path", "")
785
+ self._emit_progress(
786
+ AgentPhase.FIXING,
787
+ tool_name=tc.name,
788
+ file_path=tc_file_path or None,
789
+ iteration=attempt,
790
+ message=f"Fixing: {tc.name}",
791
+ )
792
+ result = self._execute_tool_with_lint(tc)
793
+ tool_results.append(
794
+ {
795
+ "tool_call_id": result.tool_call_id,
796
+ "content": result.content,
797
+ "is_error": result.is_error,
798
+ }
799
+ )
800
+
801
+ fix_messages.append(
802
+ {"role": "user", "content": "", "tool_results": tool_results}
803
+ )
804
+
805
+ return (False, "Verification retries exhausted")
806
+
807
+ # ------------------------------------------------------------------
808
+ # System prompt construction
809
+ # ------------------------------------------------------------------
810
+
811
+ def _build_system_prompt(self, context: TaskContext) -> str:
812
+ """Build the 3-layer system prompt.
813
+
814
+ Layer 1: Base rules (verbatim)
815
+ Layer 2: Project preferences + tech stack + file tree summary
816
+ + loaded file contents (reduces redundant read_file calls)
817
+ Layer 3: Task title/description + PRD + answered blockers
818
+ """
819
+ sections: list[str] = []
820
+
821
+ # Layer 1: Base rules
822
+ sections.append(_LAYER_1_RULES)
823
+
824
+ # Layer 2: Preferences, tech stack, file tree, loaded file contents
825
+ if context.preferences and context.preferences.has_preferences():
826
+ pref_section = context.preferences.to_prompt_section()
827
+ if pref_section:
828
+ sections.append(pref_section)
829
+
830
+ if context.tech_stack:
831
+ sections.append(f"## Project Tech Stack\n{context.tech_stack}")
832
+
833
+ if context.file_tree:
834
+ tree_lines = [f"## Repository Structure ({len(context.file_tree)} files)"]
835
+ for fi in context.file_tree[:50]:
836
+ tree_lines.append(f" {fi.path}")
837
+ if len(context.file_tree) > 50:
838
+ tree_lines.append(f" ... and {len(context.file_tree) - 50} more")
839
+ sections.append("\n".join(tree_lines))
840
+
841
+ if context.loaded_files:
842
+ file_lines = ["## Relevant Source Files"]
843
+ for f in context.loaded_files:
844
+ file_lines.append(f"### {f.path}")
845
+ file_lines.append("```")
846
+ file_lines.append(f.content)
847
+ file_lines.append("```")
848
+ file_lines.append("")
849
+ sections.append("\n".join(file_lines))
850
+
851
+ # Layer 3: Task info
852
+ sections.append(f"## Current Task\n**Title:** {context.task.title}")
853
+ if context.task.description:
854
+ sections.append(f"**Description:** {context.task.description}")
855
+
856
+ if context.prd:
857
+ prd_content = context.prd.content[:5000]
858
+ sections.append(f"## Requirements (PRD)\n{prd_content}")
859
+
860
+ if context.answered_blockers:
861
+ blocker_lines = ["## Previous Clarifications"]
862
+ for b in context.answered_blockers:
863
+ blocker_lines.append(f"**Q:** {b.question}")
864
+ blocker_lines.append(f"**A:** {b.answer}")
865
+ sections.append("\n".join(blocker_lines))
866
+
867
+ # Intent preview for high-complexity tasks
868
+ complexity = getattr(context.task, "complexity_score", None)
869
+ if complexity is not None and complexity >= 4:
870
+ sections.append(
871
+ "## High-Complexity Task\n"
872
+ "Before writing code, outline your plan: list the files you will "
873
+ "create or modify, the approach, and key design decisions."
874
+ )
875
+
876
+ return "\n\n".join(sections)
877
+
878
+ # ------------------------------------------------------------------
879
+ # Tool execution with lint
880
+ # ------------------------------------------------------------------
881
+
882
+ # Must stay in sync with AGENT_TOOLS in tools.py.
883
+ # Unknown tools default to write (safe: they get blocked in dry-run).
884
+ # run_tests is classified as read because it doesn't modify workspace
885
+ # files, even though it has side effects (process execution).
886
+ _WRITE_TOOLS = {"edit_file", "create_file", "run_command"}
887
+ _READ_TOOLS = {"read_file", "list_files", "search_codebase", "run_tests"}
888
+
889
+ def _execute_tool_with_lint(self, tc) -> ToolResult:
890
+ """Execute a tool call and append lint errors for edit/create.
891
+
892
+ After a successful ``edit_file`` or ``create_file``, runs the
893
+ appropriate linter (language-aware) and appends any errors to the
894
+ tool result so the LLM can fix them immediately.
895
+
896
+ In dry_run mode, write tools are skipped (returning a stub result)
897
+ while read tools are executed normally.
898
+ """
899
+ if self.dry_run and tc.name not in self._READ_TOOLS:
900
+ return ToolResult(
901
+ tool_call_id=tc.id,
902
+ content=f"[DRY RUN] Would execute {tc.name}",
903
+ )
904
+
905
+ result = execute_tool(tc, self.workspace.repo_path)
906
+
907
+ if tc.name in ("edit_file", "create_file") and not result.is_error:
908
+ rel_path = tc.input.get("path", "")
909
+ # Auto-fix first (reduces lint errors the agent has to fix manually)
910
+ self._run_autofix_on_file(rel_path)
911
+ # Then lint check for remaining issues
912
+ lint_output = self._run_lint_on_file(rel_path)
913
+ if lint_output:
914
+ result = ToolResult(
915
+ tool_call_id=result.tool_call_id,
916
+ content=result.content + f"\n\nLINT ERRORS (must fix before continuing):\n{lint_output}",
917
+ is_error=result.is_error,
918
+ )
919
+
920
+ return result
921
+
922
+ def _run_lint_on_file(self, rel_path: str) -> str:
923
+ """Run the appropriate linter on a single file within the workspace.
924
+
925
+ Delegates to ``gates.run_lint_on_file()`` for language-aware linting.
926
+ Returns lint error output, or empty string if clean / skipped.
927
+ """
928
+ if not rel_path:
929
+ return ""
930
+
931
+ file_path = (self.workspace.repo_path / rel_path).resolve()
932
+
933
+ # Prevent path traversal outside workspace
934
+ try:
935
+ file_path.relative_to(self.workspace.repo_path.resolve())
936
+ except ValueError:
937
+ return ""
938
+
939
+ if not file_path.exists():
940
+ return ""
941
+
942
+ self._emit(EventType.GATES_STARTED, {
943
+ "gate": "lint",
944
+ "path": rel_path,
945
+ })
946
+
947
+ check = gates.run_lint_on_file(file_path, self.workspace.repo_path)
948
+
949
+ passed = check.status == gates.GateStatus.PASSED
950
+ failed = check.status == gates.GateStatus.FAILED
951
+ errored = check.status == gates.GateStatus.ERROR
952
+
953
+ payload: dict = {
954
+ "gate": "lint",
955
+ "linter": check.name,
956
+ "path": rel_path,
957
+ "status": check.status.value,
958
+ "passed": passed,
959
+ "diagnostics": check.output[:500] if check.output else None,
960
+ }
961
+ if failed:
962
+ payload["suggestions"] = [
963
+ f"run the linter on `{rel_path}` locally to see violations",
964
+ f"linter: {check.name}",
965
+ ]
966
+ elif errored:
967
+ payload["suggestions"] = [
968
+ f"lint check failed to run: {check.output[:100] if check.output else 'unknown error'}",
969
+ f"verify `{check.name}` is installed and working",
970
+ ]
971
+ self._emit(EventType.GATES_COMPLETED, payload)
972
+
973
+ # Only surface actionable lint failures to the LLM — not
974
+ # infrastructure errors (ERROR) or skipped checks (SKIPPED).
975
+ if failed:
976
+ return check.output[:2000]
977
+
978
+ return ""
979
+
980
+ def _calculate_adaptive_budget(self, context: TaskContext) -> int:
981
+ """Calculate iteration budget based on task complexity.
982
+
983
+ Uses ``AgentBudgetConfig`` from the environment config (or defaults).
984
+ Scales ``base_iterations`` by a multiplier derived from ``complexity_score``:
985
+ score 1 -> 1x, 2 -> 1.5x, 3 -> 2x, 4 -> 2.5x, 5 -> 3x.
986
+
987
+ When ``complexity_score`` is absent the default is **2** (medium),
988
+ giving a 1.5x multiplier over the base. The result is clamped to
989
+ ``[min_iterations, max_iterations]``.
990
+ """
991
+ from codeframe.core.config import AgentBudgetConfig, load_environment_config
992
+
993
+ # Load config (or use defaults). Guard against YAML ``agent_budget: null``.
994
+ env_config = load_environment_config(self.workspace.repo_path)
995
+ if env_config:
996
+ budget_config = env_config.agent_budget or AgentBudgetConfig()
997
+ else:
998
+ budget_config = AgentBudgetConfig()
999
+
1000
+ base = budget_config.base_iterations
1001
+ # Default to medium complexity (2) when score is absent.
1002
+ score = getattr(context.task, "complexity_score", None) or 2
1003
+
1004
+ # Scale: score 1->1x, 2->1.5x, 3->2x, 4->2.5x, 5->3x
1005
+ multiplier = 1.0 + (score - 1) * 0.5
1006
+ calculated = int(base * multiplier)
1007
+
1008
+ return max(budget_config.min_iterations, min(calculated, budget_config.max_iterations))
1009
+
1010
+ def _run_autofix_on_file(self, rel_path: str) -> None:
1011
+ """Run the appropriate linter autofix on a single file within the workspace.
1012
+
1013
+ Delegates to ``gates.run_autofix_on_file()`` for language-aware fixing.
1014
+ Silently skips if the path is empty, outside the workspace, or missing.
1015
+ """
1016
+ if not rel_path:
1017
+ return
1018
+
1019
+ file_path = (self.workspace.repo_path / rel_path).resolve()
1020
+
1021
+ # Prevent path traversal outside workspace
1022
+ try:
1023
+ file_path.relative_to(self.workspace.repo_path.resolve())
1024
+ except ValueError:
1025
+ return
1026
+
1027
+ if not file_path.exists():
1028
+ return
1029
+
1030
+ check = gates.run_autofix_on_file(file_path, self.workspace.repo_path)
1031
+
1032
+ self._verbose_print(
1033
+ f"[ReactAgent] Autofix {rel_path}: {check.status.value}"
1034
+ )
1035
+ self._emit(EventType.AGENT_AUTOFIX_APPLIED, {
1036
+ "path": rel_path,
1037
+ "status": check.status.value,
1038
+ })
1039
+
1040
+ # ------------------------------------------------------------------
1041
+ # Verbose and debug logging
1042
+ # ------------------------------------------------------------------
1043
+
1044
+ def _verbose_print(self, message: str) -> None:
1045
+ """Print message to stdout (if verbose) and to output log file.
1046
+
1047
+ The output log file is always written to (if logger provided) to enable
1048
+ streaming via ``cf work follow``, even when verbose=False.
1049
+ """
1050
+ if self.verbose:
1051
+ print(message)
1052
+ if self.output_logger:
1053
+ self.output_logger.write(message + "\n")
1054
+
1055
+ def _setup_debug_log(self) -> None:
1056
+ """Set up the debug log file in workspace directory."""
1057
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
1058
+ self._debug_log_path = self.workspace.repo_path / f".codeframe_debug_react_{timestamp}.log"
1059
+
1060
+ with open(self._debug_log_path, "w") as f:
1061
+ f.write("=" * 80 + "\n")
1062
+ f.write("CodeFRAME ReactAgent Debug Log\n")
1063
+ f.write(f"Started: {datetime.now(timezone.utc).isoformat()}\n")
1064
+ f.write(f"Workspace: {self.workspace.id}\n")
1065
+ f.write(f"Repo Path: {self.workspace.repo_path}\n")
1066
+ f.write("=" * 80 + "\n\n")
1067
+
1068
+ def _debug_log(
1069
+ self,
1070
+ message: str,
1071
+ level: str = "INFO",
1072
+ data: Optional[dict] = None,
1073
+ always: bool = False,
1074
+ ) -> None:
1075
+ """Write to the debug log file."""
1076
+ if not self._debug_log_path:
1077
+ return
1078
+
1079
+ if not always and self._failure_count == 0 and level == "DEBUG":
1080
+ return
1081
+
1082
+ timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S.%f")[:-3]
1083
+ line = f"[{timestamp}] [{level}] {message}\n"
1084
+
1085
+ with open(self._debug_log_path, "a") as f:
1086
+ f.write(line)
1087
+ if data:
1088
+ for key, value in data.items():
1089
+ val_str = str(value)
1090
+ if len(val_str) > 500:
1091
+ val_str = val_str[:500] + "... [TRUNCATED]"
1092
+ f.write(f" {key}: {val_str}\n")
1093
+ f.write("\n")
1094
+
1095
+ # ------------------------------------------------------------------
1096
+ # Event emission
1097
+ # ------------------------------------------------------------------
1098
+
1099
+ def _emit(self, event_type: str, payload: dict) -> None:
1100
+ """Emit an event, suppressing failures to keep the agent running."""
1101
+ try:
1102
+ events.emit_for_workspace(
1103
+ self.workspace, event_type, payload, print_event=False,
1104
+ )
1105
+ except Exception:
1106
+ logger.debug("Failed to emit %s event", event_type, exc_info=True)
1107
+ if self.on_event is not None:
1108
+ try:
1109
+ self.on_event(event_type, payload)
1110
+ except Exception:
1111
+ logger.debug("on_event callback failed for %s", event_type, exc_info=True)
1112
+
1113
+ def _emit_progress(
1114
+ self,
1115
+ phase: str,
1116
+ *,
1117
+ step: int = 0,
1118
+ total_steps: int = 0,
1119
+ message: str | None = None,
1120
+ tool_name: str | None = None,
1121
+ file_path: str | None = None,
1122
+ iteration: int | None = None,
1123
+ ) -> None:
1124
+ """Emit a ProgressEvent via the event_publisher, if present."""
1125
+ if self.event_publisher is None:
1126
+ return
1127
+ try:
1128
+ self.event_publisher.publish_sync(
1129
+ self._current_task_id,
1130
+ ProgressEvent(
1131
+ task_id=self._current_task_id,
1132
+ phase=phase,
1133
+ step=step,
1134
+ total_steps=total_steps,
1135
+ message=message,
1136
+ tool_name=tool_name,
1137
+ file_path=file_path,
1138
+ iteration=iteration,
1139
+ ),
1140
+ )
1141
+ except Exception:
1142
+ logger.debug("Failed to emit progress event", exc_info=True)
1143
+
1144
+ def _emit_stream_completion(self, task_id: str) -> None:
1145
+ """Publish CompletionEvent and close the SSE stream for subscribers."""
1146
+ if self.event_publisher is None:
1147
+ return
1148
+ try:
1149
+ self.event_publisher.publish_sync(
1150
+ task_id,
1151
+ CompletionEvent(
1152
+ task_id=task_id,
1153
+ status="completed",
1154
+ duration_seconds=0,
1155
+ ),
1156
+ )
1157
+ except Exception:
1158
+ logger.debug("Failed to emit stream completion", exc_info=True)
1159
+ finally:
1160
+ try:
1161
+ self.event_publisher.complete_task_sync(task_id)
1162
+ except Exception:
1163
+ logger.debug("Failed to close task stream", exc_info=True)
1164
+
1165
+ def _emit_stream_error(self, task_id: str, reason: str) -> None:
1166
+ """Publish ErrorEvent and close the SSE stream for subscribers."""
1167
+ if self.event_publisher is None:
1168
+ return
1169
+ try:
1170
+ self.event_publisher.publish_sync(
1171
+ task_id,
1172
+ ErrorEvent(
1173
+ task_id=task_id,
1174
+ error_type="agent_failed",
1175
+ error=reason,
1176
+ ),
1177
+ )
1178
+ except Exception:
1179
+ logger.debug("Failed to emit stream error", exc_info=True)
1180
+ finally:
1181
+ try:
1182
+ self.event_publisher.complete_task_sync(task_id)
1183
+ except Exception:
1184
+ logger.debug("Failed to close task stream", exc_info=True)
1185
+
1186
+ # ------------------------------------------------------------------
1187
+ # Stall detection
1188
+ # ------------------------------------------------------------------
1189
+
1190
+ def _on_stall(self, event: StallEvent) -> None:
1191
+ """Callback invoked by StallMonitor when inactivity is detected."""
1192
+ self._stall_event = event
1193
+ self._stall_triggered.set()
1194
+ self._verbose_print(
1195
+ f"[ReactAgent] Stall detected: no tool call for {event.elapsed_s:.0f}s "
1196
+ f"(timeout={event.stall_timeout_s}s, iterations={event.iterations_completed})"
1197
+ )
1198
+ self._emit(EventType.AGENT_STALL_DETECTED, {
1199
+ "task_id": event.task_id,
1200
+ "elapsed_s": event.elapsed_s,
1201
+ "stall_timeout_s": event.stall_timeout_s,
1202
+ "iterations_completed": event.iterations_completed,
1203
+ })
1204
+
1205
+ # ------------------------------------------------------------------
1206
+ # Blocker creation helpers
1207
+ # ------------------------------------------------------------------
1208
+
1209
+ def _create_text_blocker(self, text: str, reason: str) -> None:
1210
+ """Create a blocker from LLM text or tool error content.
1211
+
1212
+ Stores the blocker ID on ``self.blocker_id`` so runtime can link
1213
+ the run record to the blocker. If creation fails the exception
1214
+ propagates — callers in ``run()`` catch it and return FAILED.
1215
+ """
1216
+ question = (
1217
+ f"Agent detected a blocker: {reason}\n\n"
1218
+ f"Context:\n{text[:500]}"
1219
+ )
1220
+ origin = "system" if reason == _REASON_STALL_DETECTED else "agent"
1221
+ blocker = blockers.create(
1222
+ workspace=self.workspace,
1223
+ question=question,
1224
+ task_id=self._current_task_id,
1225
+ created_by=origin,
1226
+ )
1227
+ self.blocker_id = blocker.id
1228
+
1229
+ def _create_escalation_blocker(
1230
+ self, error: str, escalation: EscalationDecision
1231
+ ) -> None:
1232
+ """Create a blocker when fix_tracker recommends escalation.
1233
+
1234
+ Stores the blocker ID on ``self.blocker_id`` so runtime can link
1235
+ the run record to the blocker. If creation fails the exception
1236
+ propagates — callers in ``run()`` catch it and return FAILED.
1237
+ """
1238
+ question = build_escalation_question(
1239
+ error, escalation.reason, self.fix_tracker,
1240
+ )
1241
+ blocker = blockers.create(
1242
+ workspace=self.workspace,
1243
+ question=question,
1244
+ task_id=self._current_task_id,
1245
+ created_by="agent",
1246
+ )
1247
+ self.blocker_id = blocker.id
1248
+
1249
+ def _try_quick_fix(self, error_summary: str) -> bool:
1250
+ """Attempt a pattern-based quick fix for the gate error.
1251
+
1252
+ Returns True if a fix was successfully applied.
1253
+ """
1254
+ fix = find_quick_fix(error_summary, repo_path=self.workspace.repo_path)
1255
+ if fix is None:
1256
+ return False
1257
+
1258
+ success, _ = apply_quick_fix(fix, self.workspace.repo_path)
1259
+ return success
1260
+
1261
+ # ------------------------------------------------------------------
1262
+ # Token budget and compaction helpers
1263
+ # ------------------------------------------------------------------
1264
+
1265
+ @staticmethod
1266
+ def _read_compaction_threshold() -> float:
1267
+ """Read compaction threshold from env var, with validation and clamping."""
1268
+ raw = os.environ.get("CODEFRAME_REACT_COMPACT_THRESHOLD")
1269
+ if raw is None:
1270
+ return DEFAULT_COMPACTION_THRESHOLD
1271
+ try:
1272
+ value = float(raw)
1273
+ except (ValueError, TypeError):
1274
+ return DEFAULT_COMPACTION_THRESHOLD
1275
+ # Clamp to valid range
1276
+ return max(0.5, min(0.95, value))
1277
+
1278
+ def _estimate_message_tokens(self, message: dict) -> int:
1279
+ """Estimate token count for a single message using len(str)/4 heuristic."""
1280
+ tokens = 0
1281
+ content = message.get("content", "")
1282
+ if content:
1283
+ tokens += len(content) // 4
1284
+
1285
+ tool_calls = message.get("tool_calls")
1286
+ if tool_calls:
1287
+ tokens += len(json.dumps(tool_calls)) // 4
1288
+
1289
+ tool_results = message.get("tool_results")
1290
+ if tool_results:
1291
+ tokens += len(json.dumps(tool_results)) // 4
1292
+
1293
+ return tokens
1294
+
1295
+ def _estimate_conversation_tokens(self, messages: list[dict]) -> int:
1296
+ """Estimate total tokens across all messages. Updates _total_tokens_used."""
1297
+ total = sum(self._estimate_message_tokens(m) for m in messages)
1298
+ self._total_tokens_used = total
1299
+ return total
1300
+
1301
+ def _should_compact(self, messages: list[dict]) -> bool:
1302
+ """Return True if estimated token usage >= threshold ratio of context window."""
1303
+ tokens = self._estimate_conversation_tokens(messages)
1304
+ ratio = tokens / self._context_window_size if self._context_window_size > 0 else 0
1305
+ return ratio >= self._compaction_threshold
1306
+
1307
+ # ------------------------------------------------------------------
1308
+ # 3-tier compaction
1309
+ # ------------------------------------------------------------------
1310
+
1311
+ @staticmethod
1312
+ def _extract_tool_name_from_pair(assistant_msg: dict) -> str:
1313
+ """Extract tool name from an assistant message's tool_calls."""
1314
+ tool_calls = assistant_msg.get("tool_calls", [])
1315
+ if tool_calls:
1316
+ return tool_calls[0].get("name", "")
1317
+ return ""
1318
+
1319
+ def _compact_tool_results(
1320
+ self, messages: list[dict]
1321
+ ) -> tuple[list[dict], int]:
1322
+ """Tier 1: Replace verbose tool result content with short summaries.
1323
+
1324
+ Iterates through older messages (outside PRESERVE_RECENT_PAIRS zone)
1325
+ and replaces verbose tool result content with a short summary.
1326
+ Error results (is_error=True) are preserved intact.
1327
+
1328
+ Returns (modified messages, tokens_saved).
1329
+ """
1330
+ preserve_count = PRESERVE_RECENT_PAIRS * 2
1331
+ if len(messages) <= preserve_count:
1332
+ return messages, 0
1333
+
1334
+ saved = 0
1335
+ cutoff = len(messages) - preserve_count
1336
+
1337
+ for i in range(cutoff):
1338
+ msg = messages[i]
1339
+ tool_results = msg.get("tool_results")
1340
+ if not tool_results:
1341
+ continue
1342
+
1343
+ # Build tool_call_id → name mapping from preceding assistant message
1344
+ tool_name_by_id: dict[str, str] = {}
1345
+ if i > 0:
1346
+ for tc in messages[i - 1].get("tool_calls", []):
1347
+ tc_id = tc.get("id", "")
1348
+ if tc_id:
1349
+ tool_name_by_id[tc_id] = tc.get("name", "")
1350
+
1351
+ new_results = []
1352
+ for tr in tool_results:
1353
+ if tr.get("is_error"):
1354
+ new_results.append(tr)
1355
+ continue
1356
+
1357
+ old_content = tr.get("content", "")
1358
+ if not old_content:
1359
+ new_results.append(tr)
1360
+ continue
1361
+
1362
+ tool_name = tool_name_by_id.get(tr.get("tool_call_id", ""), "")
1363
+ first_line = old_content.split("\n")[0][:80]
1364
+ summary = f"[Compacted] {tool_name}: {first_line}..."
1365
+ old_tokens = len(old_content) // 4
1366
+ new_tokens = len(summary) // 4
1367
+ saved += max(0, old_tokens - new_tokens)
1368
+ new_results.append({
1369
+ "tool_call_id": tr["tool_call_id"],
1370
+ "content": summary,
1371
+ "is_error": False,
1372
+ })
1373
+
1374
+ messages[i] = {**msg, "tool_results": new_results}
1375
+
1376
+ return messages, saved
1377
+
1378
+ def _remove_intermediate_steps(
1379
+ self, messages: list[dict]
1380
+ ) -> tuple[list[dict], int]:
1381
+ """Tier 2: Remove redundant assistant+user pairs.
1382
+
1383
+ Removes pairs where:
1384
+ - The same file was read again later without an intervening edit
1385
+ - Test output shows all tests passed
1386
+
1387
+ Preserves the last PRESERVE_RECENT_PAIRS*2 messages.
1388
+ Returns (modified messages, tokens_saved).
1389
+ """
1390
+ preserve_count = PRESERVE_RECENT_PAIRS * 2
1391
+ if len(messages) <= preserve_count:
1392
+ return messages, 0
1393
+
1394
+ cutoff = len(messages) - preserve_count
1395
+ indices_to_remove: set[int] = set()
1396
+
1397
+ # Build a map of file reads and edits in the compactable zone
1398
+ # Process pairs: assistant at even index, user at odd index
1399
+ for i in range(0, cutoff - 1, 2):
1400
+ assistant = messages[i]
1401
+ user = messages[i + 1]
1402
+
1403
+ # Validate expected role pairing before processing
1404
+ if assistant.get("role") != "assistant" or user.get("role") != "user":
1405
+ continue
1406
+
1407
+ tool_calls = assistant.get("tool_calls", [])
1408
+ if not tool_calls:
1409
+ continue
1410
+
1411
+ tc = tool_calls[0]
1412
+ tool_name = tc.get("name", "")
1413
+ file_path = tc.get("input", {}).get("path", "")
1414
+
1415
+ # Check for redundant file reads
1416
+ if tool_name == "read_file" and file_path:
1417
+ # Look for a later read of the same file without an edit in between
1418
+ has_edit_between = False
1419
+ has_later_read = False
1420
+ for j in range(i + 2, len(messages) - 1, 2):
1421
+ later_assistant = messages[j]
1422
+ later_tcs = later_assistant.get("tool_calls", [])
1423
+ if not later_tcs:
1424
+ continue
1425
+ later_name = later_tcs[0].get("name", "")
1426
+ later_path = later_tcs[0].get("input", {}).get("path", "")
1427
+ if later_name in ("edit_file", "create_file") and later_path == file_path:
1428
+ has_edit_between = True
1429
+ break
1430
+ if later_name == "read_file" and later_path == file_path:
1431
+ has_later_read = True
1432
+ break
1433
+
1434
+ if has_later_read and not has_edit_between:
1435
+ indices_to_remove.add(i)
1436
+ indices_to_remove.add(i + 1)
1437
+
1438
+ # Check for passed test results (only remove if fully passing)
1439
+ if tool_name in ("run_tests", "run_command"):
1440
+ tool_results = user.get("tool_results", [])
1441
+ for tr in tool_results:
1442
+ content = tr.get("content", "")
1443
+ content_lower = content.lower()
1444
+ if (
1445
+ not tr.get("is_error")
1446
+ and "passed" in content_lower
1447
+ and "failed" not in content_lower
1448
+ and "error" not in content_lower
1449
+ ):
1450
+ indices_to_remove.add(i)
1451
+ indices_to_remove.add(i + 1)
1452
+ break
1453
+
1454
+ if not indices_to_remove:
1455
+ return messages, 0
1456
+
1457
+ saved = sum(
1458
+ self._estimate_message_tokens(messages[i]) for i in indices_to_remove
1459
+ )
1460
+ result = [m for idx, m in enumerate(messages) if idx not in indices_to_remove]
1461
+ return result, saved
1462
+
1463
+ def _summarize_old_messages(
1464
+ self, messages: list[dict], target_tokens: int
1465
+ ) -> tuple[list[dict], int]:
1466
+ """Tier 3: Replace oldest messages with a single summary message.
1467
+
1468
+ Extracts file paths, error messages, and architectural keywords
1469
+ from old messages and creates a compact summary.
1470
+
1471
+ Preserves the last PRESERVE_RECENT_PAIRS*2 messages.
1472
+ Returns (modified messages, tokens_saved).
1473
+ """
1474
+ preserve_count = PRESERVE_RECENT_PAIRS * 2
1475
+ if len(messages) <= preserve_count:
1476
+ return messages, 0
1477
+
1478
+ cutoff = len(messages) - preserve_count
1479
+ old_messages = messages[:cutoff]
1480
+ recent_messages = messages[cutoff:]
1481
+
1482
+ # Preserve existing [Summary] messages from prior compaction rounds
1483
+ prior_summaries: list[str] = []
1484
+ non_summary_old: list[dict] = []
1485
+ for msg in old_messages:
1486
+ content = msg.get("content", "")
1487
+ if content.startswith("[Summary]"):
1488
+ prior_summaries.append(content)
1489
+ else:
1490
+ non_summary_old.append(msg)
1491
+ old_messages = non_summary_old
1492
+
1493
+ # Extract preserved information from old messages
1494
+ file_paths: list[str] = []
1495
+ errors: list[str] = []
1496
+ decisions: list[str] = []
1497
+
1498
+ for msg in old_messages:
1499
+ # Extract file paths from tool calls
1500
+ for tc in msg.get("tool_calls", []):
1501
+ path = tc.get("input", {}).get("path", "")
1502
+ if path and path not in file_paths:
1503
+ file_paths.append(path)
1504
+
1505
+ # Extract errors from tool results
1506
+ for tr in msg.get("tool_results", []):
1507
+ if tr.get("is_error"):
1508
+ errors.append(tr.get("content", "")[:100])
1509
+
1510
+ # Extract architectural keywords from assistant content
1511
+ content = msg.get("content", "")
1512
+ if msg.get("role") == "assistant" and content:
1513
+ for keyword in ("architecture", "design", "pattern", "decision"):
1514
+ if keyword in content.lower():
1515
+ # Extract a snippet around the keyword
1516
+ idx = content.lower().index(keyword)
1517
+ snippet = content[max(0, idx - 20):idx + 50].strip()
1518
+ if snippet not in decisions:
1519
+ decisions.append(snippet)
1520
+ break
1521
+
1522
+ # Build summary
1523
+ parts = ["[Summary] Previous context:"]
1524
+ if prior_summaries:
1525
+ parts.append(f"prior summaries: {' | '.join(prior_summaries[:3])}")
1526
+ if file_paths:
1527
+ parts.append(f"analyzed {len(file_paths)} files ({', '.join(file_paths[:10])})")
1528
+ if errors:
1529
+ parts.append(f"{len(errors)} errors encountered ({'; '.join(errors[:3])})")
1530
+ if decisions:
1531
+ parts.append(f"architectural decisions: {'; '.join(decisions[:3])}")
1532
+
1533
+ summary_content = ", ".join(parts) if len(parts) > 1 else parts[0] + " (no details extracted)"
1534
+ summary_msg = {"role": "user", "content": summary_content}
1535
+
1536
+ # Count tokens from both old messages and absorbed prior summaries
1537
+ saved = sum(self._estimate_message_tokens(m) for m in old_messages)
1538
+ for ps in prior_summaries:
1539
+ saved += len(ps) // 4
1540
+ saved -= self._estimate_message_tokens(summary_msg)
1541
+ saved = max(0, saved)
1542
+
1543
+ return [summary_msg] + recent_messages, saved
1544
+
1545
+ def compact_conversation(
1546
+ self, messages: list[dict]
1547
+ ) -> tuple[list[dict], dict]:
1548
+ """Orchestrate 3-tier compaction when conversation exceeds token budget.
1549
+
1550
+ Runs tiers in order (1 -> 2 -> 3), stopping when under threshold.
1551
+ Returns (messages, stats_dict).
1552
+ """
1553
+ if not self._should_compact(messages):
1554
+ return messages, {"compacted": False}
1555
+
1556
+ # Defensive copy — avoid mutating the caller's list
1557
+ messages = list(messages)
1558
+
1559
+ tokens_before = self._estimate_conversation_tokens(messages)
1560
+ tiers_used: list[str] = []
1561
+
1562
+ # Tier 1: Compact tool results
1563
+ messages, saved = self._compact_tool_results(messages)
1564
+ if saved > 0:
1565
+ tiers_used.append("tier1_tool_results")
1566
+ if not self._should_compact(messages):
1567
+ return self._finalize_compaction(
1568
+ messages, tokens_before, tiers_used
1569
+ )
1570
+
1571
+ # Tier 2: Remove intermediate steps
1572
+ messages, saved = self._remove_intermediate_steps(messages)
1573
+ if saved > 0:
1574
+ tiers_used.append("tier2_intermediate")
1575
+ if not self._should_compact(messages):
1576
+ return self._finalize_compaction(
1577
+ messages, tokens_before, tiers_used
1578
+ )
1579
+
1580
+ # Tier 3: Summarize old messages
1581
+ target = int(self._context_window_size * self._compaction_threshold)
1582
+ messages, saved = self._summarize_old_messages(messages, target)
1583
+ if saved > 0:
1584
+ tiers_used.append("tier3_summary")
1585
+
1586
+ return self._finalize_compaction(messages, tokens_before, tiers_used)
1587
+
1588
+ def _finalize_compaction(
1589
+ self,
1590
+ messages: list[dict],
1591
+ tokens_before: int,
1592
+ tiers_used: list[str],
1593
+ ) -> tuple[list[dict], dict]:
1594
+ """Build stats dict and increment counter after compaction."""
1595
+ self._compaction_count += 1
1596
+ tokens_after = self._estimate_conversation_tokens(messages)
1597
+ stats = {
1598
+ "compacted": True,
1599
+ "tokens_before": tokens_before,
1600
+ "tokens_after": tokens_after,
1601
+ "tokens_saved": tokens_before - tokens_after,
1602
+ "tiers_used": tiers_used,
1603
+ "compaction_number": self._compaction_count,
1604
+ }
1605
+ logger.info(
1606
+ "Compaction #%d: %d -> %d tokens (saved %d, tiers: %s)",
1607
+ stats["compaction_number"],
1608
+ stats["tokens_before"],
1609
+ stats["tokens_after"],
1610
+ stats["tokens_saved"],
1611
+ stats["tiers_used"],
1612
+ )
1613
+ return messages, stats
1614
+
1615
+ def get_token_stats(self, messages: list[dict]) -> dict:
1616
+ """Return current token usage statistics."""
1617
+ total = self._estimate_conversation_tokens(messages)
1618
+ return {
1619
+ "total_tokens": total,
1620
+ "percentage_used": total / self._context_window_size if self._context_window_size > 0 else 0,
1621
+ "compaction_count": self._compaction_count,
1622
+ "context_window_size": self._context_window_size,
1623
+ }
1624
+
1625
+ # ------------------------------------------------------------------
1626
+ # Message history management (deprecated — use compact_conversation)
1627
+ # ------------------------------------------------------------------
1628
+
1629
+ @staticmethod
1630
+ def _trim_messages(messages: list[dict]) -> list[dict]:
1631
+ """Drop oldest assistant+user pairs when history exceeds the token budget.
1632
+
1633
+ Preserves the first complete pair (messages[0:2]) and the most recent
1634
+ pair, removing whole assistant+user pairs from index 2 onward so no
1635
+ assistant message is ever left without its corresponding user/result.
1636
+ """
1637
+ total = sum(len(str(m)) for m in messages)
1638
+ if total <= _MAX_HISTORY_CHARS:
1639
+ return messages
1640
+
1641
+ # Keep first pair (0,1) + at least one trailing pair → need > 4
1642
+ while len(messages) > 4 and total > _MAX_HISTORY_CHARS:
1643
+ removed = messages.pop(2)
1644
+ total -= len(str(removed))
1645
+ # Remove the following user message to keep the pair intact
1646
+ if len(messages) > 2 and messages[2].get("role") == "user":
1647
+ removed = messages.pop(2)
1648
+ total -= len(str(removed))
1649
+
1650
+ return messages