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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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
|