differix 0.1.0a1__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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
main.py
ADDED
|
@@ -0,0 +1,2796 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VegeX orchestrator entry point.
|
|
3
|
+
|
|
4
|
+
State machine: research → edit → review → (research | done).
|
|
5
|
+
|
|
6
|
+
Every write to a plan step's target file marks that step completed
|
|
7
|
+
immediately; the reviewer's verdict decides whether to keep the write or
|
|
8
|
+
revert it. Coherence across files runs only when no plan step remains
|
|
9
|
+
pending — mid-plan, a missing file is expected, not a failure.
|
|
10
|
+
|
|
11
|
+
Files created by the pipeline (as opposed to files that existed before
|
|
12
|
+
the pipeline first touched them) are tracked in `pipeline_created_files`.
|
|
13
|
+
On rejection, a pipeline-created file is deleted rather than restored —
|
|
14
|
+
this prevents the accumulation of partial writes across cycles.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import select
|
|
20
|
+
import sys
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Union
|
|
25
|
+
from dotenv import load_dotenv
|
|
26
|
+
|
|
27
|
+
from tools.security.behavior_guard import BehaviorGuard
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.panel import Panel
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
import config
|
|
35
|
+
|
|
36
|
+
from tools.file_manager import FolderSandbox
|
|
37
|
+
from tools.compiler import run_universal_build
|
|
38
|
+
from tools.backup_manager import (
|
|
39
|
+
get_repo,
|
|
40
|
+
make_auto_commit,
|
|
41
|
+
safe_rollback,
|
|
42
|
+
show_all_commits,
|
|
43
|
+
create_success_tag,
|
|
44
|
+
)
|
|
45
|
+
from tools.visual_reviewer import review_page as visual_review_page
|
|
46
|
+
from tools.agents_chat import handle_msg_command
|
|
47
|
+
from tools.changelog_engine import ChangelogEngine
|
|
48
|
+
from tools.project_structure import generate_or_update_structure
|
|
49
|
+
from tools.security_filter import find_dangerous_code
|
|
50
|
+
from tools.reasoning_logger import log_reasoning
|
|
51
|
+
from tools.project_scanner import get_full_project_context as get_project_context
|
|
52
|
+
from tools.drift_guard import DriftGuard
|
|
53
|
+
from tools.coherence_checker import check_coherence, render_report as render_coherence_report
|
|
54
|
+
from tools.project_context_gatherer import (
|
|
55
|
+
gather_related_context,
|
|
56
|
+
render_related_context,
|
|
57
|
+
)
|
|
58
|
+
from tools.cost_predictor import predict_cost
|
|
59
|
+
from tools.plugin_manager import PluginManager
|
|
60
|
+
|
|
61
|
+
from agents.researcher import run_researcher
|
|
62
|
+
from agents.editor import run_editor
|
|
63
|
+
from agents.reviewer import run_reviewer
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from memory.manager import MemoryManager
|
|
67
|
+
except ImportError:
|
|
68
|
+
MemoryManager = None
|
|
69
|
+
|
|
70
|
+
from tools.lesson_manager import (
|
|
71
|
+
LESSONS_DIRS,
|
|
72
|
+
move_to_candidates,
|
|
73
|
+
move_to_promoted,
|
|
74
|
+
move_to_deprecated,
|
|
75
|
+
load_promoted_lessons,
|
|
76
|
+
analyze_lesson_outcome,
|
|
77
|
+
record_lesson_trigger,
|
|
78
|
+
record_lesson_helpful,
|
|
79
|
+
record_lesson_violation,
|
|
80
|
+
lesson_memory_stats,
|
|
81
|
+
)
|
|
82
|
+
from tools.atomic_changeset import ChangeSet
|
|
83
|
+
from tools.contract_validator import ContractValidator
|
|
84
|
+
from tools.session_adr import SessionADR, build_entry as build_adr_entry
|
|
85
|
+
from tools.handshake import build_session_context
|
|
86
|
+
|
|
87
|
+
from cli.model_cli import handle_llm_command, sync_active_models_specs
|
|
88
|
+
|
|
89
|
+
from tools.state_manager import (
|
|
90
|
+
AppState,
|
|
91
|
+
apply_researcher_plan,
|
|
92
|
+
build_state_template,
|
|
93
|
+
load_state,
|
|
94
|
+
save_state,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
load_dotenv()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Framework paths
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
_FRAMEWORK_ROOT = os.path.abspath(os.path.dirname(__file__))
|
|
104
|
+
_VEGEX_DIR = os.path.join(_FRAMEWORK_ROOT, ".vegex")
|
|
105
|
+
|
|
106
|
+
REGISTRY_FILE = os.path.join(_VEGEX_DIR, "projects_registry.json")
|
|
107
|
+
LEGACY_REGISTRY_FILE = os.path.join(os.getcwd(), "projects_registry.json")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# stdin handling
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
def _drain_stdin() -> None:
|
|
114
|
+
try:
|
|
115
|
+
if not sys.stdin.isatty():
|
|
116
|
+
return
|
|
117
|
+
while select.select([sys.stdin], [], [], 0.0)[0]:
|
|
118
|
+
sys.stdin.readline()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_PASTE_ARTIFACT_PREFIXES = ("#", "-", ">", "|", "```", " ", " ")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _looks_like_paste_artifact(line: str) -> bool:
|
|
127
|
+
stripped = line.strip()
|
|
128
|
+
if not stripped:
|
|
129
|
+
return False
|
|
130
|
+
return stripped.startswith(_PASTE_ARTIFACT_PREFIXES)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Registry
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
def load_registry() -> dict:
|
|
137
|
+
if os.path.exists(REGISTRY_FILE):
|
|
138
|
+
try:
|
|
139
|
+
with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
|
|
140
|
+
data = json.load(f)
|
|
141
|
+
if isinstance(data, dict) and data:
|
|
142
|
+
return data
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
os.path.exists(LEGACY_REGISTRY_FILE)
|
|
148
|
+
and os.path.abspath(LEGACY_REGISTRY_FILE) != os.path.abspath(REGISTRY_FILE)
|
|
149
|
+
):
|
|
150
|
+
try:
|
|
151
|
+
with open(LEGACY_REGISTRY_FILE, "r", encoding="utf-8") as f:
|
|
152
|
+
legacy = json.load(f)
|
|
153
|
+
if isinstance(legacy, dict) and legacy:
|
|
154
|
+
_write_registry(legacy)
|
|
155
|
+
print(
|
|
156
|
+
f"[Registry] Migrated from legacy {LEGACY_REGISTRY_FILE} "
|
|
157
|
+
f"to {REGISTRY_FILE}"
|
|
158
|
+
)
|
|
159
|
+
return legacy
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
cwd = os.getcwd()
|
|
164
|
+
return {
|
|
165
|
+
"1": {
|
|
166
|
+
"name": os.path.basename(cwd) or "Current Workspace",
|
|
167
|
+
"path": cwd,
|
|
168
|
+
"lang": ".py",
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def save_registry(registry: dict) -> None:
|
|
174
|
+
_write_registry(registry)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _write_registry(registry: dict) -> None:
|
|
178
|
+
os.makedirs(os.path.dirname(REGISTRY_FILE), exist_ok=True)
|
|
179
|
+
tmp = REGISTRY_FILE + ".tmp"
|
|
180
|
+
try:
|
|
181
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
182
|
+
json.dump(registry, f, indent=2, ensure_ascii=False)
|
|
183
|
+
os.replace(tmp, REGISTRY_FILE)
|
|
184
|
+
except Exception:
|
|
185
|
+
try:
|
|
186
|
+
os.unlink(tmp)
|
|
187
|
+
except OSError:
|
|
188
|
+
pass
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Text helpers
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
def extract_json_payload(raw_text: str) -> Union[dict, list]:
|
|
196
|
+
if not isinstance(raw_text, str):
|
|
197
|
+
return raw_text
|
|
198
|
+
|
|
199
|
+
match = re.search(
|
|
200
|
+
r"```(?:json)?\s*([\[\{].*?[\]\}])\s*```", raw_text, re.DOTALL
|
|
201
|
+
)
|
|
202
|
+
candidate = match.group(1) if match else raw_text.strip()
|
|
203
|
+
|
|
204
|
+
start_obj = candidate.find("{")
|
|
205
|
+
start_arr = candidate.find("[")
|
|
206
|
+
|
|
207
|
+
if start_arr != -1 and (start_obj == -1 or start_arr < start_obj):
|
|
208
|
+
end_arr = candidate.rfind("]")
|
|
209
|
+
if end_arr != -1:
|
|
210
|
+
candidate = candidate[start_arr: end_arr + 1]
|
|
211
|
+
elif start_obj != -1:
|
|
212
|
+
end_obj = candidate.rfind("}")
|
|
213
|
+
if end_obj != -1:
|
|
214
|
+
candidate = candidate[start_obj: end_obj + 1]
|
|
215
|
+
|
|
216
|
+
return json.loads(candidate)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def sanitize_generated_code(code_str: str, allow_patches: bool = False) -> str:
|
|
220
|
+
if not isinstance(code_str, str):
|
|
221
|
+
raise ValueError(
|
|
222
|
+
"Code input must be a string, got: " + type(code_str).__name__
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
fence_match = re.search(
|
|
226
|
+
r"```(?:\w+)?\n(.*?)```", code_str.strip(), re.DOTALL
|
|
227
|
+
)
|
|
228
|
+
if fence_match:
|
|
229
|
+
code_str = fence_match.group(1)
|
|
230
|
+
|
|
231
|
+
cleaned_lines = []
|
|
232
|
+
for line in code_str.splitlines():
|
|
233
|
+
stripped = line.strip()
|
|
234
|
+
|
|
235
|
+
if re.match(r"^(<{4,}|={4,}|>{4,})", stripped):
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if stripped.startswith("```") and not allow_patches:
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
cleaned_lines.append(line)
|
|
242
|
+
|
|
243
|
+
result = "\n".join(cleaned_lines).strip()
|
|
244
|
+
|
|
245
|
+
if not result:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
"Code payload is empty after sanitization. "
|
|
248
|
+
"The LLM response may have contained only conflict markers or code fences."
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def validate_required_state_fields(state: dict) -> tuple:
|
|
255
|
+
if not isinstance(state, dict):
|
|
256
|
+
return False, ["state (must be dict)"]
|
|
257
|
+
|
|
258
|
+
if not state.get("task_prompt") and state.get("goal"):
|
|
259
|
+
state["task_prompt"] = state["goal"]
|
|
260
|
+
elif not state.get("goal") and state.get("task_prompt"):
|
|
261
|
+
state["goal"] = state["task_prompt"]
|
|
262
|
+
|
|
263
|
+
required_fields = ["task_prompt", "current_step", "iteration_count"]
|
|
264
|
+
missing = [f for f in required_fields if f not in state or state[f] is None]
|
|
265
|
+
|
|
266
|
+
if "task_prompt" in state and not str(state.get("task_prompt", "")).strip():
|
|
267
|
+
if "task_prompt (cannot be empty)" not in missing:
|
|
268
|
+
missing.append("task_prompt (cannot be empty)")
|
|
269
|
+
|
|
270
|
+
return len(missing) == 0, missing
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Orchestrator
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
class OrchestratorConsole:
|
|
277
|
+
VEGEX_FRAMEWORK_ROOT = _FRAMEWORK_ROOT
|
|
278
|
+
|
|
279
|
+
def __init__(self):
|
|
280
|
+
self.total_cost = 0.0
|
|
281
|
+
self.should_pause = False
|
|
282
|
+
self.should_stop = False
|
|
283
|
+
self.registry = load_registry()
|
|
284
|
+
|
|
285
|
+
self.proj = self.registry.get("1")
|
|
286
|
+
if self.proj and os.path.exists(self.proj["path"]):
|
|
287
|
+
self.sandbox = FolderSandbox(self.proj["path"])
|
|
288
|
+
self.logger = ChangelogEngine(project_root=self.proj["path"])
|
|
289
|
+
self.memory = (
|
|
290
|
+
MemoryManager(db_path=os.path.join(self.proj["path"], "memory.db"))
|
|
291
|
+
if MemoryManager
|
|
292
|
+
else None
|
|
293
|
+
)
|
|
294
|
+
self.adr = SessionADR(self.proj["path"])
|
|
295
|
+
self.validator = ContractValidator(self.proj["path"])
|
|
296
|
+
self._guard = DriftGuard(self.proj["path"])
|
|
297
|
+
self.plugin_mgr = PluginManager(self.proj["path"])
|
|
298
|
+
else:
|
|
299
|
+
self.sandbox = None
|
|
300
|
+
self.logger = None
|
|
301
|
+
self.memory = MemoryManager() if MemoryManager else None
|
|
302
|
+
self.adr = None
|
|
303
|
+
self.validator = None
|
|
304
|
+
self._guard = None
|
|
305
|
+
self.plugin_mgr = None
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------ #
|
|
308
|
+
# State I/O
|
|
309
|
+
# ------------------------------------------------------------------ #
|
|
310
|
+
def get_state_path(self, project_dir: str = None) -> str:
|
|
311
|
+
target_dir = project_dir or (self.proj["path"] if self.proj else None)
|
|
312
|
+
if target_dir and os.path.isdir(target_dir):
|
|
313
|
+
return os.path.join(target_dir, "state.json")
|
|
314
|
+
return os.path.join(self.VEGEX_FRAMEWORK_ROOT, "state.json")
|
|
315
|
+
|
|
316
|
+
def load_state(self, project_dir: str = None) -> dict:
|
|
317
|
+
state_file = self.get_state_path(project_dir)
|
|
318
|
+
|
|
319
|
+
if self._guard is not None:
|
|
320
|
+
if not self._guard.check_state("last-save", state_file):
|
|
321
|
+
console.print(
|
|
322
|
+
"[dim]state.json changed externally — reloading from disk.[/dim]"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
app_state = load_state(state_file)
|
|
326
|
+
|
|
327
|
+
if hasattr(app_state, "model_dump"):
|
|
328
|
+
data = app_state.model_dump()
|
|
329
|
+
elif isinstance(app_state, dict):
|
|
330
|
+
data = app_state
|
|
331
|
+
else:
|
|
332
|
+
data = {}
|
|
333
|
+
|
|
334
|
+
exec_ctrl = data.get("execution_control", {}) or {}
|
|
335
|
+
task_spec = data.get("task_spec", {}) or {}
|
|
336
|
+
|
|
337
|
+
data["current_step"] = exec_ctrl.get("current_step", "init")
|
|
338
|
+
data["status"] = exec_ctrl.get("status", "idle")
|
|
339
|
+
data["active_agent"] = exec_ctrl.get("active_agent", "researcher")
|
|
340
|
+
data["iteration_count"] = exec_ctrl.get("iteration_count", 0)
|
|
341
|
+
|
|
342
|
+
data["task_prompt"] = task_spec.get("raw_user_prompt", "")
|
|
343
|
+
data["goal"] = (
|
|
344
|
+
task_spec.get("raw_user_prompt", "")
|
|
345
|
+
or task_spec.get("decomposed_goal", "")
|
|
346
|
+
)
|
|
347
|
+
return data
|
|
348
|
+
|
|
349
|
+
def save_state(self, state: Union[dict, AppState], project_dir: str = None):
|
|
350
|
+
state_file = self.get_state_path(project_dir)
|
|
351
|
+
|
|
352
|
+
if isinstance(state, AppState):
|
|
353
|
+
state = state.model_dump()
|
|
354
|
+
|
|
355
|
+
if isinstance(state, dict):
|
|
356
|
+
if "execution_control" not in state or not isinstance(
|
|
357
|
+
state["execution_control"], dict
|
|
358
|
+
):
|
|
359
|
+
state["execution_control"] = {}
|
|
360
|
+
if "task_spec" not in state or not isinstance(
|
|
361
|
+
state["task_spec"], dict
|
|
362
|
+
):
|
|
363
|
+
state["task_spec"] = {}
|
|
364
|
+
|
|
365
|
+
if "current_step" in state:
|
|
366
|
+
state["execution_control"]["current_step"] = state["current_step"]
|
|
367
|
+
if "status" in state:
|
|
368
|
+
state["execution_control"]["status"] = state["status"]
|
|
369
|
+
if "active_agent" in state:
|
|
370
|
+
state["execution_control"]["active_agent"] = state["active_agent"]
|
|
371
|
+
if "iteration_count" in state:
|
|
372
|
+
state["execution_control"]["iteration_count"] = state["iteration_count"]
|
|
373
|
+
|
|
374
|
+
if "task_prompt" in state:
|
|
375
|
+
state["task_spec"]["raw_user_prompt"] = state["task_prompt"]
|
|
376
|
+
if "goal" in state and not state["task_spec"].get("raw_user_prompt"):
|
|
377
|
+
state["task_spec"]["raw_user_prompt"] = state["goal"]
|
|
378
|
+
|
|
379
|
+
app_state = AppState.model_validate(state)
|
|
380
|
+
else:
|
|
381
|
+
app_state = state
|
|
382
|
+
|
|
383
|
+
save_state(app_state, state_file)
|
|
384
|
+
|
|
385
|
+
if self._guard is not None:
|
|
386
|
+
self._guard.pin_state("last-save", state_file)
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------ #
|
|
389
|
+
# Plan step helpers
|
|
390
|
+
# ------------------------------------------------------------------ #
|
|
391
|
+
def _mark_step_completed(self, state: dict, target_file: str) -> bool:
|
|
392
|
+
if not target_file:
|
|
393
|
+
return False
|
|
394
|
+
plan = state.get("plan", [])
|
|
395
|
+
if not isinstance(plan, list):
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
for step in plan:
|
|
399
|
+
if not isinstance(step, dict):
|
|
400
|
+
continue
|
|
401
|
+
if (
|
|
402
|
+
step.get("target_file") == target_file
|
|
403
|
+
and step.get("status") in ("pending", "in_progress")
|
|
404
|
+
):
|
|
405
|
+
step["status"] = "completed"
|
|
406
|
+
state["plan"] = plan
|
|
407
|
+
return True
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
def _mark_step_pending(self, state: dict, target_file: str) -> bool:
|
|
411
|
+
if not target_file:
|
|
412
|
+
return False
|
|
413
|
+
plan = state.get("plan", [])
|
|
414
|
+
if not isinstance(plan, list):
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
for step in plan:
|
|
418
|
+
if not isinstance(step, dict):
|
|
419
|
+
continue
|
|
420
|
+
if (
|
|
421
|
+
step.get("target_file") == target_file
|
|
422
|
+
and step.get("status") == "completed"
|
|
423
|
+
):
|
|
424
|
+
step["status"] = "pending"
|
|
425
|
+
state["plan"] = plan
|
|
426
|
+
return True
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
def _coherence_should_run(self, state: dict) -> bool:
|
|
430
|
+
plan = state.get("plan", [])
|
|
431
|
+
if not plan:
|
|
432
|
+
return False
|
|
433
|
+
pending = [
|
|
434
|
+
s for s in plan
|
|
435
|
+
if isinstance(s, dict) and s.get("status") == "pending"
|
|
436
|
+
]
|
|
437
|
+
return len(pending) == 0
|
|
438
|
+
|
|
439
|
+
def _check_rejection_loop(self, state: dict, reason: str) -> bool:
|
|
440
|
+
"""
|
|
441
|
+
Return True if the same rejection reason has appeared at least 3
|
|
442
|
+
times in a row.
|
|
443
|
+
"""
|
|
444
|
+
import re as _re
|
|
445
|
+
|
|
446
|
+
def _normalize(text: str) -> str:
|
|
447
|
+
t = (text or "").lower()
|
|
448
|
+
t = _re.sub(r"[^\w\s]", " ", t)
|
|
449
|
+
t = _re.sub(r"\b(contains?|has|have|found|blocking|across|project|files?)\b", " ", t)
|
|
450
|
+
t = _re.sub(r"\s+", " ", t).strip()
|
|
451
|
+
return " ".join(t.split()[:8])
|
|
452
|
+
|
|
453
|
+
key = _normalize(reason)
|
|
454
|
+
history = state.get("recent_rejections", [])
|
|
455
|
+
if not isinstance(history, list):
|
|
456
|
+
history = []
|
|
457
|
+
|
|
458
|
+
history.append(key)
|
|
459
|
+
history = history[-5:]
|
|
460
|
+
state["recent_rejections"] = history
|
|
461
|
+
|
|
462
|
+
if len(history) >= 3 and len(set(history[-3:])) == 1:
|
|
463
|
+
return True
|
|
464
|
+
return False
|
|
465
|
+
|
|
466
|
+
# ------------------------------------------------------------------ #
|
|
467
|
+
# Pipeline-created file tracking
|
|
468
|
+
# ------------------------------------------------------------------ #
|
|
469
|
+
def _track_pipeline_file(self, state: dict, rel_path: str) -> None:
|
|
470
|
+
if not rel_path:
|
|
471
|
+
return
|
|
472
|
+
created = state.get("pipeline_created_files", [])
|
|
473
|
+
if not isinstance(created, list):
|
|
474
|
+
created = []
|
|
475
|
+
if rel_path not in created:
|
|
476
|
+
created.append(rel_path)
|
|
477
|
+
state["pipeline_created_files"] = created
|
|
478
|
+
|
|
479
|
+
def _untrack_pipeline_file(self, state: dict, rel_path: str) -> None:
|
|
480
|
+
created = state.get("pipeline_created_files", [])
|
|
481
|
+
if isinstance(created, list) and rel_path in created:
|
|
482
|
+
created.remove(rel_path)
|
|
483
|
+
state["pipeline_created_files"] = created
|
|
484
|
+
|
|
485
|
+
def _is_pipeline_file(self, state: dict, rel_path: str) -> bool:
|
|
486
|
+
created = state.get("pipeline_created_files", [])
|
|
487
|
+
return isinstance(created, list) and rel_path in created
|
|
488
|
+
|
|
489
|
+
def _revert_file(self, state: dict, rel_path: str, backup: str) -> None:
|
|
490
|
+
if not rel_path:
|
|
491
|
+
return
|
|
492
|
+
abs_path = os.path.join(self.proj["path"], rel_path)
|
|
493
|
+
|
|
494
|
+
if backup:
|
|
495
|
+
self.sandbox.safe_write_code(rel_path, backup)
|
|
496
|
+
self._untrack_pipeline_file(state, rel_path)
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
if os.path.exists(abs_path):
|
|
501
|
+
os.remove(abs_path)
|
|
502
|
+
console.print(f"[dim]Removed pipeline-created file: {rel_path}[/dim]")
|
|
503
|
+
except OSError as exc:
|
|
504
|
+
console.print(
|
|
505
|
+
f"[dim yellow]Could not remove {rel_path}: {exc}[/dim yellow]"
|
|
506
|
+
)
|
|
507
|
+
self._untrack_pipeline_file(state, rel_path)
|
|
508
|
+
|
|
509
|
+
def _check_critical_files_intact(self) -> list:
|
|
510
|
+
critical_files = (
|
|
511
|
+
"main.py",
|
|
512
|
+
"config.py",
|
|
513
|
+
"tools/state_manager.py",
|
|
514
|
+
"tools/file_manager.py",
|
|
515
|
+
"tools/atomic_changeset.py",
|
|
516
|
+
"tools/security_filter.py",
|
|
517
|
+
"memory/schemas.py",
|
|
518
|
+
"memory/storage.py",
|
|
519
|
+
"agents/researcher.py",
|
|
520
|
+
"agents/editor.py",
|
|
521
|
+
"agents/reviewer.py",
|
|
522
|
+
)
|
|
523
|
+
missing = []
|
|
524
|
+
for rel in critical_files:
|
|
525
|
+
path = os.path.join(self.proj["path"], rel)
|
|
526
|
+
if not os.path.exists(path):
|
|
527
|
+
missing.append(rel)
|
|
528
|
+
return missing
|
|
529
|
+
|
|
530
|
+
# ------------------------------------------------------------------ #
|
|
531
|
+
# Context builders
|
|
532
|
+
# ------------------------------------------------------------------ #
|
|
533
|
+
def _build_session_context(
|
|
534
|
+
self, state: dict, role: str, record_trigger: bool = False
|
|
535
|
+
) -> str:
|
|
536
|
+
try:
|
|
537
|
+
result = build_session_context(
|
|
538
|
+
project_root=self.proj["path"],
|
|
539
|
+
state=state,
|
|
540
|
+
role=role,
|
|
541
|
+
)
|
|
542
|
+
state["active_lesson_node_ids"] = list(result.lesson_node_ids or [])
|
|
543
|
+
if record_trigger and result.lesson_node_ids:
|
|
544
|
+
try:
|
|
545
|
+
record_lesson_trigger(result.lesson_node_ids)
|
|
546
|
+
except Exception as trig_exc:
|
|
547
|
+
console.print(
|
|
548
|
+
f"[dim yellow]Record trigger failed: {trig_exc}[/dim yellow]"
|
|
549
|
+
)
|
|
550
|
+
return result.text
|
|
551
|
+
except Exception as exc:
|
|
552
|
+
console.print(f"[dim yellow]Handshake build failed: {exc}[/dim yellow]")
|
|
553
|
+
return ""
|
|
554
|
+
|
|
555
|
+
def _build_related_context(self, state: dict, target_file: str) -> str:
|
|
556
|
+
try:
|
|
557
|
+
ctx = gather_related_context(
|
|
558
|
+
project_root=self.proj["path"],
|
|
559
|
+
target_file=target_file,
|
|
560
|
+
plan=state.get("plan", []),
|
|
561
|
+
budget_tokens=getattr(config, "EDITOR_RELATED_BUDGET", 2000),
|
|
562
|
+
)
|
|
563
|
+
rendered = render_related_context(ctx)
|
|
564
|
+
if rendered:
|
|
565
|
+
console.print(
|
|
566
|
+
f"[dim]Related context: {len(ctx.files)} file(s), "
|
|
567
|
+
f"{ctx.total_chars} chars[/dim]"
|
|
568
|
+
)
|
|
569
|
+
return rendered
|
|
570
|
+
except Exception as exc:
|
|
571
|
+
console.print(f"[dim yellow]Related context failed: {exc}[/dim yellow]")
|
|
572
|
+
return ""
|
|
573
|
+
|
|
574
|
+
def _build_coherence_report(self, state: dict) -> str:
|
|
575
|
+
try:
|
|
576
|
+
pipeline_files = state.get("pipeline_created_files") or []
|
|
577
|
+
if not pipeline_files:
|
|
578
|
+
return ""
|
|
579
|
+
|
|
580
|
+
report = check_coherence(self.proj["path"], files=pipeline_files)
|
|
581
|
+
rendered = render_coherence_report(report)
|
|
582
|
+
if report.errors:
|
|
583
|
+
console.print(
|
|
584
|
+
f"[yellow]Coherence: {len(report.errors)} error(s), "
|
|
585
|
+
f"{len(report.warnings)} warning(s) "
|
|
586
|
+
f"(scoped to {len(pipeline_files)} pipeline files)[/yellow]"
|
|
587
|
+
)
|
|
588
|
+
return rendered
|
|
589
|
+
except Exception as exc:
|
|
590
|
+
console.print(f"[dim yellow]Coherence check failed: {exc}[/dim yellow]")
|
|
591
|
+
return ""
|
|
592
|
+
|
|
593
|
+
# ------------------------------------------------------------------ #
|
|
594
|
+
# UI helpers
|
|
595
|
+
# ------------------------------------------------------------------ #
|
|
596
|
+
def print_projects_table(self):
|
|
597
|
+
table = Table(
|
|
598
|
+
title="Remembered Projects",
|
|
599
|
+
show_header=True,
|
|
600
|
+
header_style="bold green",
|
|
601
|
+
)
|
|
602
|
+
table.add_column("Key", justify="center", style="bold yellow")
|
|
603
|
+
table.add_column("Project Name", style="white")
|
|
604
|
+
table.add_column("Path", style="cyan")
|
|
605
|
+
table.add_column("Lang", style="magenta")
|
|
606
|
+
|
|
607
|
+
for k, v in self.registry.items():
|
|
608
|
+
is_active = (
|
|
609
|
+
self.proj
|
|
610
|
+
and os.path.abspath(v["path"]) == os.path.abspath(self.proj["path"])
|
|
611
|
+
)
|
|
612
|
+
active_marker = (
|
|
613
|
+
" [bold green](active)[/bold green]" if is_active else ""
|
|
614
|
+
)
|
|
615
|
+
table.add_row(
|
|
616
|
+
k, v["name"] + active_marker, v["path"], v.get("lang", ".py")
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
console.print(table)
|
|
620
|
+
console.print(f"[dim]Registry: {REGISTRY_FILE}[/dim]")
|
|
621
|
+
|
|
622
|
+
def show_status(self):
|
|
623
|
+
state = self.load_state()
|
|
624
|
+
table = Table(
|
|
625
|
+
title="System Status", show_header=True, header_style="bold cyan"
|
|
626
|
+
)
|
|
627
|
+
table.add_column("Property", style="yellow")
|
|
628
|
+
table.add_column("Value", style="white")
|
|
629
|
+
|
|
630
|
+
table.add_row("Active Project", self.proj["name"] if self.proj else "None")
|
|
631
|
+
table.add_row("Workspace Path", self.proj["path"] if self.proj else "None")
|
|
632
|
+
table.add_row("Current Step", str(state.get("current_step", "N/A")))
|
|
633
|
+
table.add_row("Iteration Count", str(state.get("iteration_count", 0)))
|
|
634
|
+
table.add_row("Session Cost", f"${self.total_cost:.5f}")
|
|
635
|
+
|
|
636
|
+
compiler_log = str(state.get("compiler_output") or "None")
|
|
637
|
+
table.add_row(
|
|
638
|
+
"Last Compiler Output",
|
|
639
|
+
compiler_log[:100] + "..." if len(compiler_log) > 100 else compiler_log,
|
|
640
|
+
)
|
|
641
|
+
console.print(table)
|
|
642
|
+
|
|
643
|
+
def show_changelog(self):
|
|
644
|
+
log_dir = (
|
|
645
|
+
os.path.join(self.proj["path"], "iteration_logs")
|
|
646
|
+
if self.proj
|
|
647
|
+
else os.path.join(self.VEGEX_FRAMEWORK_ROOT, "iteration_logs")
|
|
648
|
+
)
|
|
649
|
+
if not os.path.exists(log_dir):
|
|
650
|
+
console.print("[yellow]No changelogs recorded yet.[/yellow]")
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
logs = sorted(
|
|
654
|
+
[f for f in os.listdir(log_dir) if f.endswith(".json")], reverse=True
|
|
655
|
+
)[:5]
|
|
656
|
+
if not logs:
|
|
657
|
+
console.print("[yellow]No iteration logs found.[/yellow]")
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
console.print(
|
|
661
|
+
Panel("[bold cyan]Recent Iteration Logs[/bold cyan]", border_style="cyan")
|
|
662
|
+
)
|
|
663
|
+
for log_file in logs:
|
|
664
|
+
filepath = os.path.join(log_dir, log_file)
|
|
665
|
+
try:
|
|
666
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
667
|
+
entry = json.load(f)
|
|
668
|
+
step_display = entry.get("current_step")
|
|
669
|
+
if step_display is None:
|
|
670
|
+
iterations = entry.get("iterations") or []
|
|
671
|
+
if iterations:
|
|
672
|
+
step_display = iterations[-1].get("step")
|
|
673
|
+
console.print(
|
|
674
|
+
f"[bold yellow]{log_file}[/bold yellow] | "
|
|
675
|
+
f"Step: [green]{step_display or 'N/A'}[/green]"
|
|
676
|
+
)
|
|
677
|
+
except Exception as e:
|
|
678
|
+
console.print(f"[red]Error reading {log_file}: {e}[/red]")
|
|
679
|
+
|
|
680
|
+
def update_costs(self, meta, agent_name: str = None):
|
|
681
|
+
provider = getattr(meta, "provider", "openrouter")
|
|
682
|
+
model_id = getattr(meta, "model_id", None)
|
|
683
|
+
|
|
684
|
+
if provider == "ollama_fallback":
|
|
685
|
+
price_in, price_out = 0.0, 0.0
|
|
686
|
+
elif agent_name:
|
|
687
|
+
price_in, price_out, _ctx = config.get_current_price(agent_name)
|
|
688
|
+
else:
|
|
689
|
+
price_in = getattr(config, "PRICE_INPUT_1M", 0.14)
|
|
690
|
+
price_out = getattr(config, "PRICE_OUTPUT_1M", 0.28)
|
|
691
|
+
|
|
692
|
+
prompt_tokens = getattr(meta, "prompt_token_count", 0) or 0
|
|
693
|
+
completion_tokens = getattr(meta, "candidates_token_count", 0) or 0
|
|
694
|
+
|
|
695
|
+
cost = (prompt_tokens * (price_in / 1_000_000)) + (
|
|
696
|
+
completion_tokens * (price_out / 1_000_000)
|
|
697
|
+
)
|
|
698
|
+
self.total_cost += cost
|
|
699
|
+
|
|
700
|
+
table = Table(show_header=True, header_style="bold blue", box=None)
|
|
701
|
+
table.add_column("Model", justify="left", style="cyan")
|
|
702
|
+
table.add_column("Input Tokens", justify="right")
|
|
703
|
+
table.add_column("Output Tokens", justify="right")
|
|
704
|
+
table.add_column("Step Cost", justify="right", style="green")
|
|
705
|
+
table.add_column("Session Total", justify="right", style="bold green")
|
|
706
|
+
|
|
707
|
+
model_label = model_id or "unknown"
|
|
708
|
+
if provider == "ollama_fallback":
|
|
709
|
+
model_label += " (local fallback)"
|
|
710
|
+
|
|
711
|
+
table.add_row(
|
|
712
|
+
model_label,
|
|
713
|
+
f"{prompt_tokens:,}",
|
|
714
|
+
f"{completion_tokens:,}",
|
|
715
|
+
f"${cost:.5f}",
|
|
716
|
+
f"${self.total_cost:.5f}",
|
|
717
|
+
)
|
|
718
|
+
console.print(table)
|
|
719
|
+
|
|
720
|
+
def print_help(self):
|
|
721
|
+
help_text = """
|
|
722
|
+
[bold cyan]Available Commands:[/bold cyan]
|
|
723
|
+
[yellow]task / t <prompt>[/yellow] - Bootstrap project task & plan from CLI prompt
|
|
724
|
+
[yellow]run / start [prompt][/yellow] - Execute multi-agent iteration pipeline
|
|
725
|
+
[yellow]cd <path|key>[/yellow] - Navigate to path or switch project by key index
|
|
726
|
+
[yellow]projects / ls[/yellow] - Display remembered project registry
|
|
727
|
+
[yellow]llm <cmd>[/yellow] - Model configuration and provider selection
|
|
728
|
+
[yellow]msg[/yellow] - Chat directly with agents for active workspace
|
|
729
|
+
[yellow]handshake [role][/yellow] - Preview the session context for a role (debugging)
|
|
730
|
+
[yellow]coherence[/yellow] - Run cross-file coherence check on active project
|
|
731
|
+
[yellow]status[/yellow] - Display current execution metrics and active step
|
|
732
|
+
[yellow]pause[/yellow] - Pause execution after current step completes
|
|
733
|
+
[yellow]stop[/yellow] - Halt current agent loop
|
|
734
|
+
[yellow]log / changelog[/yellow] - Render session changelog summaries
|
|
735
|
+
[yellow]history[/yellow] - View Git commit log for current project
|
|
736
|
+
[yellow]rollback <sha>[/yellow] - Revert workspace to specific commit SHA
|
|
737
|
+
[yellow]lesson <cmd>[/yellow] - Lesson management (list | candidate | promote | deprecate)
|
|
738
|
+
[yellow]help[/yellow] - Show command directory
|
|
739
|
+
[yellow]reflect[/yellow] - Run reflection manually on recent failures
|
|
740
|
+
[yellow]upd-tiers[/yellow] - Update LLM tiers in llm_selector
|
|
741
|
+
[yellow]exit / quit[/yellow] - Exit orchestrator
|
|
742
|
+
[yellow]llm routing on / off[/yellow] - Enable or disable automatic model assignment
|
|
743
|
+
"""
|
|
744
|
+
console.print(Panel(help_text.strip(), title="System Help", border_style="cyan"))
|
|
745
|
+
|
|
746
|
+
# ------------------------------------------------------------------ #
|
|
747
|
+
# Directory navigation
|
|
748
|
+
# ------------------------------------------------------------------ #
|
|
749
|
+
def change_directory(self, target: str):
|
|
750
|
+
target = target.strip()
|
|
751
|
+
if not target:
|
|
752
|
+
self.print_projects_table()
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
if target.isdigit():
|
|
756
|
+
if target in self.registry:
|
|
757
|
+
proj_data = self.registry[target]
|
|
758
|
+
if not os.path.isdir(proj_data["path"]):
|
|
759
|
+
console.print(
|
|
760
|
+
f"[bold red]Error:[/bold red] Directory "
|
|
761
|
+
f"'{proj_data['path']}' does not exist."
|
|
762
|
+
)
|
|
763
|
+
return
|
|
764
|
+
self.proj = proj_data
|
|
765
|
+
self.sandbox = FolderSandbox(self.proj["path"])
|
|
766
|
+
self.logger = ChangelogEngine(project_root=self.proj["path"])
|
|
767
|
+
if MemoryManager:
|
|
768
|
+
self.memory = MemoryManager(
|
|
769
|
+
db_path=os.path.join(self.proj["path"], "memory.db")
|
|
770
|
+
)
|
|
771
|
+
self.adr = SessionADR(self.proj["path"])
|
|
772
|
+
self.validator = ContractValidator(self.proj["path"])
|
|
773
|
+
self._guard = DriftGuard(self.proj["path"])
|
|
774
|
+
self.should_pause = False
|
|
775
|
+
self.should_stop = False
|
|
776
|
+
console.print(
|
|
777
|
+
f"[bold green]Switched to registered project [{target}]:[/bold green] "
|
|
778
|
+
f"{self.proj['name']} ([cyan]{self.proj['path']}[/cyan])"
|
|
779
|
+
)
|
|
780
|
+
return
|
|
781
|
+
else:
|
|
782
|
+
console.print(
|
|
783
|
+
f"[bold red]Error:[/bold red] Project key [{target}] not found."
|
|
784
|
+
)
|
|
785
|
+
return
|
|
786
|
+
|
|
787
|
+
expanded = os.path.expanduser(target)
|
|
788
|
+
if os.path.isabs(expanded):
|
|
789
|
+
abs_path = os.path.realpath(expanded)
|
|
790
|
+
else:
|
|
791
|
+
base_dir = (
|
|
792
|
+
self.proj["path"]
|
|
793
|
+
if (self.proj and os.path.isdir(self.proj["path"]))
|
|
794
|
+
else os.getcwd()
|
|
795
|
+
)
|
|
796
|
+
abs_path = os.path.realpath(os.path.join(base_dir, expanded))
|
|
797
|
+
|
|
798
|
+
if not os.path.isdir(abs_path):
|
|
799
|
+
console.print(
|
|
800
|
+
f"[bold red]Error:[/bold red] Directory '{abs_path}' does not exist."
|
|
801
|
+
)
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
for k, v in self.registry.items():
|
|
805
|
+
if os.path.abspath(v["path"]) == abs_path:
|
|
806
|
+
self.proj = v
|
|
807
|
+
self.sandbox = FolderSandbox(self.proj["path"])
|
|
808
|
+
self.logger = ChangelogEngine(project_root=self.proj["path"])
|
|
809
|
+
if MemoryManager:
|
|
810
|
+
self.memory = MemoryManager(
|
|
811
|
+
db_path=os.path.join(self.proj["path"], "memory.db")
|
|
812
|
+
)
|
|
813
|
+
self.adr = SessionADR(self.proj["path"])
|
|
814
|
+
self.validator = ContractValidator(self.proj["path"])
|
|
815
|
+
self._guard = DriftGuard(self.proj["path"])
|
|
816
|
+
self.should_pause = False
|
|
817
|
+
self.should_stop = False
|
|
818
|
+
console.print(
|
|
819
|
+
f"[bold green]Switched to registered project [{k}]:[/bold green] "
|
|
820
|
+
f"{v['name']}"
|
|
821
|
+
)
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
lang = ".py"
|
|
825
|
+
ignored_dirs = {
|
|
826
|
+
".git",
|
|
827
|
+
"node_modules",
|
|
828
|
+
"__pycache__",
|
|
829
|
+
".venv",
|
|
830
|
+
"venv",
|
|
831
|
+
"bin",
|
|
832
|
+
"obj",
|
|
833
|
+
"build",
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
for root, dirs, files in os.walk(abs_path):
|
|
837
|
+
dirs[:] = [d for d in dirs if d not in ignored_dirs]
|
|
838
|
+
for file in files:
|
|
839
|
+
if file.endswith(".cs"):
|
|
840
|
+
lang = ".cs"
|
|
841
|
+
break
|
|
842
|
+
elif file.endswith(".luau") or file.endswith(".lua"):
|
|
843
|
+
lang = ".luau"
|
|
844
|
+
break
|
|
845
|
+
if lang != ".py":
|
|
846
|
+
break
|
|
847
|
+
|
|
848
|
+
new_key = str(len(self.registry) + 1)
|
|
849
|
+
folder_name = os.path.basename(abs_path) or "Project"
|
|
850
|
+
new_proj = {"name": folder_name, "path": abs_path, "lang": lang}
|
|
851
|
+
|
|
852
|
+
self.registry[new_key] = new_proj
|
|
853
|
+
save_registry(self.registry)
|
|
854
|
+
|
|
855
|
+
self.proj = new_proj
|
|
856
|
+
self.sandbox = FolderSandbox(self.proj["path"])
|
|
857
|
+
self.logger = ChangelogEngine(project_root=self.proj["path"])
|
|
858
|
+
if MemoryManager:
|
|
859
|
+
self.memory = MemoryManager(
|
|
860
|
+
db_path=os.path.join(self.proj["path"], "memory.db")
|
|
861
|
+
)
|
|
862
|
+
self.adr = SessionADR(self.proj["path"])
|
|
863
|
+
self.validator = ContractValidator(self.proj["path"])
|
|
864
|
+
self._guard = DriftGuard(self.proj["path"])
|
|
865
|
+
self.should_pause = False
|
|
866
|
+
self.should_stop = False
|
|
867
|
+
console.print(
|
|
868
|
+
f"[bold green]Registered and switched to new project [{new_key}]:[/bold green] "
|
|
869
|
+
f"{folder_name} ([cyan]{abs_path}[/cyan])"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# ------------------------------------------------------------------ #
|
|
873
|
+
# Task command
|
|
874
|
+
# ------------------------------------------------------------------ #
|
|
875
|
+
def handle_task_command(self, prompt: str):
|
|
876
|
+
prompt = prompt.strip()
|
|
877
|
+
|
|
878
|
+
if prompt.startswith("-f "):
|
|
879
|
+
filepath = prompt[3:].strip()
|
|
880
|
+
try:
|
|
881
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
882
|
+
prompt = f.read().strip()
|
|
883
|
+
console.print(f"[green]Prompt loaded from {filepath} ({len(prompt)} chars)[/green]")
|
|
884
|
+
except Exception as e:
|
|
885
|
+
console.print(f"[bold red]Failed to read {filepath}: {e}[/bold red]")
|
|
886
|
+
return
|
|
887
|
+
|
|
888
|
+
if not prompt:
|
|
889
|
+
console.print("[bold yellow]Enter a multi-line prompt. Type 'EOF' on a new line to finish.[/bold yellow]")
|
|
890
|
+
lines = []
|
|
891
|
+
while True:
|
|
892
|
+
try:
|
|
893
|
+
line = console.input()
|
|
894
|
+
except (KeyboardInterrupt, EOFError):
|
|
895
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
896
|
+
return
|
|
897
|
+
if line.strip() == "EOF":
|
|
898
|
+
break
|
|
899
|
+
lines.append(line)
|
|
900
|
+
prompt = "\n".join(lines).strip()
|
|
901
|
+
|
|
902
|
+
if not prompt:
|
|
903
|
+
console.print("[bold red]Prompt cannot be empty. Cancelled.[/bold red]")
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
if not self.proj or not os.path.isdir(self.proj["path"]):
|
|
907
|
+
console.print(
|
|
908
|
+
"[bold red]Error:[/bold red] No valid workspace selected. "
|
|
909
|
+
"Use 'cd <path>' first."
|
|
910
|
+
)
|
|
911
|
+
return
|
|
912
|
+
|
|
913
|
+
app_state = build_state_template(user_prompt=prompt)
|
|
914
|
+
self.save_state(app_state)
|
|
915
|
+
state = self.load_state()
|
|
916
|
+
|
|
917
|
+
live_project_tree = get_project_context(self.proj["path"])
|
|
918
|
+
state["project_files"] = live_project_tree
|
|
919
|
+
|
|
920
|
+
console.print(
|
|
921
|
+
Panel(
|
|
922
|
+
f"[bold cyan]Bootstrapping Task:[/bold cyan]\n{prompt}",
|
|
923
|
+
border_style="cyan",
|
|
924
|
+
)
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
session_ctx = self._build_session_context(
|
|
928
|
+
state, role="researcher", record_trigger=True
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
with console.status(
|
|
932
|
+
"[cyan]Researcher agent structuring plan and requirements...",
|
|
933
|
+
spinner="dots",
|
|
934
|
+
):
|
|
935
|
+
state, meta = run_researcher(
|
|
936
|
+
state,
|
|
937
|
+
self.proj["name"],
|
|
938
|
+
project_dir=self.proj["path"],
|
|
939
|
+
project_files=live_project_tree,
|
|
940
|
+
session_context=session_ctx,
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
self.update_costs(meta, agent_name="researcher")
|
|
944
|
+
|
|
945
|
+
research_data = state.get("research_data", {})
|
|
946
|
+
res_payload = (
|
|
947
|
+
research_data
|
|
948
|
+
if isinstance(research_data, dict)
|
|
949
|
+
else {"raw_response": research_data}
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
app_state = AppState.model_validate(state)
|
|
953
|
+
app_state = apply_researcher_plan(app_state, res_payload)
|
|
954
|
+
|
|
955
|
+
# Predict costs for the plan
|
|
956
|
+
try:
|
|
957
|
+
cost_prediction = predict_cost(list(app_state.plan), task_prompt=prompt)
|
|
958
|
+
if not hasattr(app_state, "metrics") or app_state.metrics is None:
|
|
959
|
+
app_state.metrics = {}
|
|
960
|
+
if isinstance(app_state.metrics, dict):
|
|
961
|
+
app_state.metrics["predicted_cost"] = cost_prediction
|
|
962
|
+
else:
|
|
963
|
+
setattr(app_state.metrics, "predicted_cost", cost_prediction)
|
|
964
|
+
|
|
965
|
+
# Rich Table для вывода прогноза
|
|
966
|
+
cost_table = Table(title="Predicted Cost", show_header=True, header_style="bold green")
|
|
967
|
+
cost_table.add_column("Step", style="cyan")
|
|
968
|
+
cost_table.add_column("Model", style="yellow")
|
|
969
|
+
cost_table.add_column("Complexity", style="magenta")
|
|
970
|
+
cost_table.add_column("~USD", justify="right", style="green")
|
|
971
|
+
|
|
972
|
+
for step in cost_prediction.get("by_step", []):
|
|
973
|
+
cost_table.add_row(
|
|
974
|
+
str(step.get("title", "Unknown"))[:40],
|
|
975
|
+
str(step.get("model", "unknown")),
|
|
976
|
+
str(step.get("complexity", "unknown")),
|
|
977
|
+
f"${step.get('cost_usd', 0.0):.4f}"
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
cost_table.add_row(
|
|
981
|
+
"TOTAL (with 30% overhead)", "", "",
|
|
982
|
+
f"${cost_prediction.get('total_usd', 0.0):.4f}",
|
|
983
|
+
style="bold green"
|
|
984
|
+
)
|
|
985
|
+
console.print(cost_table)
|
|
986
|
+
except Exception as cost_exc:
|
|
987
|
+
console.print(f"[dim yellow]Cost prediction failed: {cost_exc}[/dim yellow]")
|
|
988
|
+
|
|
989
|
+
self.save_state(app_state)
|
|
990
|
+
state = self.load_state()
|
|
991
|
+
|
|
992
|
+
log_reasoning(
|
|
993
|
+
"researcher",
|
|
994
|
+
"research",
|
|
995
|
+
{
|
|
996
|
+
"analysis": state.get("task_spec", {}).get("decomposed_goal", ""),
|
|
997
|
+
"plan": state.get("plan", []),
|
|
998
|
+
},
|
|
999
|
+
project_root=self.proj["path"],
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
plan = state.get("plan", [])
|
|
1003
|
+
if not plan:
|
|
1004
|
+
console.print(
|
|
1005
|
+
Panel(
|
|
1006
|
+
f"[bold red]Researcher produced no plan.[/bold red] "
|
|
1007
|
+
f"{state.get('research_error', '')}\n"
|
|
1008
|
+
"Check model/token limits ('llm' command) and retry.",
|
|
1009
|
+
border_style="red",
|
|
1010
|
+
)
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
target_file = state.get("target_file") or (
|
|
1014
|
+
research_data.get("target_file")
|
|
1015
|
+
if isinstance(research_data, dict)
|
|
1016
|
+
else None
|
|
1017
|
+
)
|
|
1018
|
+
active_subtask = state.get("active_subtask") or (
|
|
1019
|
+
plan[0].get("title") if plan and isinstance(plan[0], dict) else None
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
console.print(
|
|
1023
|
+
Panel(
|
|
1024
|
+
f"[bold green]Task Bootstrapped Successfully![/bold green]\n"
|
|
1025
|
+
f"Target File: [cyan]{target_file or 'Not specified / Needs investigation'}[/cyan]\n"
|
|
1026
|
+
f"Active Subtask: [yellow]{active_subtask or 'None'}[/yellow]\n"
|
|
1027
|
+
f"Plan Steps: [white]{len(plan)} step(s) identified[/white]\n\n"
|
|
1028
|
+
f"Type [bold cyan]run[/bold cyan] to start autonomous execution.",
|
|
1029
|
+
border_style="green",
|
|
1030
|
+
)
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
# ------------------------------------------------------------------ #
|
|
1034
|
+
# Lesson command
|
|
1035
|
+
# ------------------------------------------------------------------ #
|
|
1036
|
+
def handle_lesson_command(self, args: str):
|
|
1037
|
+
parts = args.strip().split()
|
|
1038
|
+
if not parts:
|
|
1039
|
+
console.print(
|
|
1040
|
+
"[bold yellow]Usage:[/bold yellow] lesson "
|
|
1041
|
+
"[list | candidate | promote | deprecate] <filename>"
|
|
1042
|
+
)
|
|
1043
|
+
return
|
|
1044
|
+
|
|
1045
|
+
action = parts[0].lower()
|
|
1046
|
+
|
|
1047
|
+
if action == "list":
|
|
1048
|
+
for cat, path in LESSONS_DIRS.items():
|
|
1049
|
+
files = (
|
|
1050
|
+
[f for f in os.listdir(path) if f.endswith(".json")]
|
|
1051
|
+
if os.path.exists(path)
|
|
1052
|
+
else []
|
|
1053
|
+
)
|
|
1054
|
+
console.print(
|
|
1055
|
+
f"[bold cyan]{cat.upper()}:[/bold cyan] {len(files)} files"
|
|
1056
|
+
)
|
|
1057
|
+
for f in files:
|
|
1058
|
+
console.print(f" - {f}")
|
|
1059
|
+
return
|
|
1060
|
+
|
|
1061
|
+
if action in ("candidate", "promote", "deprecate"):
|
|
1062
|
+
if len(parts) < 2:
|
|
1063
|
+
console.print(
|
|
1064
|
+
f"[bold red]Error:[/bold red] Missing target filename "
|
|
1065
|
+
f"for 'lesson {action}'."
|
|
1066
|
+
)
|
|
1067
|
+
return
|
|
1068
|
+
filename = parts[1]
|
|
1069
|
+
try:
|
|
1070
|
+
if action == "candidate":
|
|
1071
|
+
success = move_to_candidates(
|
|
1072
|
+
filename, source_category="observations"
|
|
1073
|
+
)
|
|
1074
|
+
elif action == "promote":
|
|
1075
|
+
success = move_to_promoted(
|
|
1076
|
+
filename, source_category="candidates"
|
|
1077
|
+
)
|
|
1078
|
+
else:
|
|
1079
|
+
success = move_to_deprecated(
|
|
1080
|
+
filename, source_category="promoted"
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
if success:
|
|
1084
|
+
console.print(
|
|
1085
|
+
f"[bold green]Success:[/bold green] Lesson '{filename}' updated."
|
|
1086
|
+
)
|
|
1087
|
+
else:
|
|
1088
|
+
console.print(
|
|
1089
|
+
f"[bold red]Error:[/bold red] File '{filename}' not found."
|
|
1090
|
+
)
|
|
1091
|
+
except Exception as e:
|
|
1092
|
+
console.print(f"[bold red]Failed to update lesson:[/bold red] {e}")
|
|
1093
|
+
return
|
|
1094
|
+
|
|
1095
|
+
console.print(f"[bold red]Unknown lesson action:[/bold red] '{action}'")
|
|
1096
|
+
|
|
1097
|
+
# ------------------------------------------------------------------ #
|
|
1098
|
+
# Handshake command
|
|
1099
|
+
# ------------------------------------------------------------------ #
|
|
1100
|
+
def handle_handshake_command(self, args: str):
|
|
1101
|
+
role = args.strip().lower() or "researcher"
|
|
1102
|
+
if not self.proj or not os.path.isdir(self.proj["path"]):
|
|
1103
|
+
console.print(
|
|
1104
|
+
"[bold red]Error:[/bold red] No valid workspace selected."
|
|
1105
|
+
)
|
|
1106
|
+
return
|
|
1107
|
+
|
|
1108
|
+
state = self.load_state()
|
|
1109
|
+
try:
|
|
1110
|
+
result = build_session_context(
|
|
1111
|
+
project_root=self.proj["path"],
|
|
1112
|
+
state=state,
|
|
1113
|
+
role=role,
|
|
1114
|
+
)
|
|
1115
|
+
except Exception as e:
|
|
1116
|
+
console.print(f"[bold red]Handshake failed:[/bold red] {e}")
|
|
1117
|
+
return
|
|
1118
|
+
|
|
1119
|
+
console.print(
|
|
1120
|
+
Panel(
|
|
1121
|
+
result.text or "(empty)",
|
|
1122
|
+
title=f"Handshake preview — role={role}",
|
|
1123
|
+
border_style="cyan",
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
console.print(
|
|
1127
|
+
f"[dim]Sections: {', '.join(result.sections_present) or 'none'} | "
|
|
1128
|
+
f"~{result.estimated_tokens} tokens | "
|
|
1129
|
+
f"lessons: {len(result.lesson_node_ids)} nodes[/dim]"
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
# ------------------------------------------------------------------ #
|
|
1133
|
+
# Coherence command
|
|
1134
|
+
# ------------------------------------------------------------------ #
|
|
1135
|
+
def handle_coherence_command(self, args: str):
|
|
1136
|
+
if not self.proj or not os.path.isdir(self.proj["path"]):
|
|
1137
|
+
console.print(
|
|
1138
|
+
"[bold red]Error:[/bold red] No valid workspace selected."
|
|
1139
|
+
)
|
|
1140
|
+
return
|
|
1141
|
+
|
|
1142
|
+
try:
|
|
1143
|
+
report = check_coherence(self.proj["path"])
|
|
1144
|
+
except Exception as exc:
|
|
1145
|
+
console.print(f"[bold red]Coherence check failed:[/bold red] {exc}")
|
|
1146
|
+
return
|
|
1147
|
+
|
|
1148
|
+
border = "green" if report.ok else "yellow"
|
|
1149
|
+
console.print(
|
|
1150
|
+
Panel(
|
|
1151
|
+
render_coherence_report(report),
|
|
1152
|
+
title="Coherence Report",
|
|
1153
|
+
border_style=border,
|
|
1154
|
+
)
|
|
1155
|
+
)
|
|
1156
|
+
console.print(
|
|
1157
|
+
f"[dim]Files checked: {len(report.files_checked)} | "
|
|
1158
|
+
f"HTML classes: {len(report.html_classes)} | "
|
|
1159
|
+
f"CSS selectors: {len(report.css_selectors)} | "
|
|
1160
|
+
f"errors: {len(report.errors)} | warnings: {len(report.warnings)}[/dim]"
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
# ------------------------------------------------------------------ #
|
|
1164
|
+
# Plugin command
|
|
1165
|
+
# ------------------------------------------------------------------ #
|
|
1166
|
+
def handle_plugin_command(self, args):
|
|
1167
|
+
from rich.markup import escape
|
|
1168
|
+
|
|
1169
|
+
usage = (
|
|
1170
|
+
"[bold yellow]Usage:[/bold yellow] "
|
|
1171
|
+
"plugin list | plugin install <path> | plugin remove <id>"
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
if self.plugin_mgr is None and self.proj and os.path.isdir(self.proj["path"]):
|
|
1175
|
+
try:
|
|
1176
|
+
self.plugin_mgr = PluginManager(self.proj["path"])
|
|
1177
|
+
except Exception:
|
|
1178
|
+
self.plugin_mgr = None
|
|
1179
|
+
|
|
1180
|
+
if self.plugin_mgr is None:
|
|
1181
|
+
console.print(
|
|
1182
|
+
Panel(
|
|
1183
|
+
"[bold red]No active project — plugin manager is unavailable.[/bold red]\n"
|
|
1184
|
+
"Use 'cd <path>' to select a workspace first.",
|
|
1185
|
+
title="Plugins",
|
|
1186
|
+
border_style="red",
|
|
1187
|
+
)
|
|
1188
|
+
)
|
|
1189
|
+
return
|
|
1190
|
+
|
|
1191
|
+
if isinstance(args, (list, tuple)):
|
|
1192
|
+
tokens = [str(a) for a in args]
|
|
1193
|
+
else:
|
|
1194
|
+
tokens = str(args or "").strip().split(maxsplit=1)
|
|
1195
|
+
|
|
1196
|
+
if not tokens:
|
|
1197
|
+
console.print(usage)
|
|
1198
|
+
return
|
|
1199
|
+
|
|
1200
|
+
action = tokens[0].lower()
|
|
1201
|
+
rest = ""
|
|
1202
|
+
if len(tokens) > 1:
|
|
1203
|
+
rest = " ".join(tokens[1:]).strip() if isinstance(args, (list, tuple)) else tokens[1].strip()
|
|
1204
|
+
|
|
1205
|
+
if action == "list":
|
|
1206
|
+
try:
|
|
1207
|
+
plugins = self.plugin_mgr.list_plugins() or []
|
|
1208
|
+
except Exception as exc:
|
|
1209
|
+
console.print(
|
|
1210
|
+
Panel(
|
|
1211
|
+
f"[bold red]Failed to list plugins:[/bold red] {escape(str(exc))}",
|
|
1212
|
+
title="Plugins",
|
|
1213
|
+
border_style="red",
|
|
1214
|
+
)
|
|
1215
|
+
)
|
|
1216
|
+
return
|
|
1217
|
+
|
|
1218
|
+
if not plugins:
|
|
1219
|
+
console.print("[dim]No plugins installed[/dim]")
|
|
1220
|
+
return
|
|
1221
|
+
|
|
1222
|
+
table = Table(
|
|
1223
|
+
title="Installed Plugins",
|
|
1224
|
+
show_header=True,
|
|
1225
|
+
header_style="bold cyan",
|
|
1226
|
+
)
|
|
1227
|
+
table.add_column("ID", style="bold yellow")
|
|
1228
|
+
table.add_column("Name", style="white")
|
|
1229
|
+
table.add_column("Version", style="magenta")
|
|
1230
|
+
table.add_column("Author", style="green")
|
|
1231
|
+
|
|
1232
|
+
def _field(item, key):
|
|
1233
|
+
if isinstance(item, dict):
|
|
1234
|
+
value = item.get(key)
|
|
1235
|
+
else:
|
|
1236
|
+
value = getattr(item, key, None)
|
|
1237
|
+
if value is None or value == "":
|
|
1238
|
+
return "-"
|
|
1239
|
+
return escape(str(value))
|
|
1240
|
+
|
|
1241
|
+
for item in plugins:
|
|
1242
|
+
if isinstance(item, str):
|
|
1243
|
+
table.add_row(escape(item), "-", "-", "-")
|
|
1244
|
+
continue
|
|
1245
|
+
table.add_row(
|
|
1246
|
+
_field(item, "id"),
|
|
1247
|
+
_field(item, "name"),
|
|
1248
|
+
_field(item, "version"),
|
|
1249
|
+
_field(item, "author"),
|
|
1250
|
+
)
|
|
1251
|
+
console.print(table)
|
|
1252
|
+
return
|
|
1253
|
+
|
|
1254
|
+
if action == "install":
|
|
1255
|
+
if not rest:
|
|
1256
|
+
console.print("[bold yellow]Usage:[/bold yellow] plugin install <path>")
|
|
1257
|
+
return
|
|
1258
|
+
plugin_path = os.path.expanduser(rest.strip().strip('"').strip("'"))
|
|
1259
|
+
try:
|
|
1260
|
+
result = self.plugin_mgr.install(plugin_path)
|
|
1261
|
+
except Exception as exc:
|
|
1262
|
+
console.print(
|
|
1263
|
+
Panel(
|
|
1264
|
+
f"[bold red]Plugin install failed:[/bold red] {escape(str(exc))}",
|
|
1265
|
+
title="Plugins",
|
|
1266
|
+
border_style="red",
|
|
1267
|
+
)
|
|
1268
|
+
)
|
|
1269
|
+
return
|
|
1270
|
+
|
|
1271
|
+
ok = False
|
|
1272
|
+
message = ""
|
|
1273
|
+
if isinstance(result, tuple):
|
|
1274
|
+
ok = bool(result[0]) if result else False
|
|
1275
|
+
message = str(result[1]) if len(result) > 1 and result[1] is not None else ""
|
|
1276
|
+
elif isinstance(result, dict):
|
|
1277
|
+
ok = bool(result.get("ok", result.get("success", False)))
|
|
1278
|
+
message = str(
|
|
1279
|
+
result.get("message") or result.get("error") or result.get("id") or ""
|
|
1280
|
+
)
|
|
1281
|
+
else:
|
|
1282
|
+
ok = bool(result)
|
|
1283
|
+
message = result if isinstance(result, str) else ""
|
|
1284
|
+
|
|
1285
|
+
if ok:
|
|
1286
|
+
console.print(
|
|
1287
|
+
Panel(
|
|
1288
|
+
f"[bold green]Plugin installed from {escape(plugin_path)}[/bold green]"
|
|
1289
|
+
+ (f"\n{escape(message)}" if message else ""),
|
|
1290
|
+
title="Plugins",
|
|
1291
|
+
border_style="green",
|
|
1292
|
+
)
|
|
1293
|
+
)
|
|
1294
|
+
else:
|
|
1295
|
+
console.print(
|
|
1296
|
+
Panel(
|
|
1297
|
+
f"[bold red]Plugin install failed for {escape(plugin_path)}[/bold red]"
|
|
1298
|
+
+ (f"\n{escape(message)}" if message else ""),
|
|
1299
|
+
title="Plugins",
|
|
1300
|
+
border_style="red",
|
|
1301
|
+
)
|
|
1302
|
+
)
|
|
1303
|
+
return
|
|
1304
|
+
|
|
1305
|
+
if action == "remove":
|
|
1306
|
+
if not rest:
|
|
1307
|
+
console.print("[bold yellow]Usage:[/bold yellow] plugin remove <id>")
|
|
1308
|
+
return
|
|
1309
|
+
plugin_id = rest.strip()
|
|
1310
|
+
try:
|
|
1311
|
+
removed = self.plugin_mgr.remove(plugin_id)
|
|
1312
|
+
except Exception as exc:
|
|
1313
|
+
console.print(
|
|
1314
|
+
Panel(
|
|
1315
|
+
f"[bold red]Plugin removal failed:[/bold red] {escape(str(exc))}",
|
|
1316
|
+
title="Plugins",
|
|
1317
|
+
border_style="red",
|
|
1318
|
+
)
|
|
1319
|
+
)
|
|
1320
|
+
return
|
|
1321
|
+
|
|
1322
|
+
if removed:
|
|
1323
|
+
console.print(
|
|
1324
|
+
Panel(
|
|
1325
|
+
f"[bold green]Plugin '{escape(plugin_id)}' removed.[/bold green]",
|
|
1326
|
+
title="Plugins",
|
|
1327
|
+
border_style="green",
|
|
1328
|
+
)
|
|
1329
|
+
)
|
|
1330
|
+
else:
|
|
1331
|
+
console.print(
|
|
1332
|
+
Panel(
|
|
1333
|
+
f"[bold red]Plugin '{escape(plugin_id)}' not found or could not be removed.[/bold red]",
|
|
1334
|
+
title="Plugins",
|
|
1335
|
+
border_style="red",
|
|
1336
|
+
)
|
|
1337
|
+
)
|
|
1338
|
+
return
|
|
1339
|
+
|
|
1340
|
+
console.print(f"[bold red]Unknown plugin action:[/bold red] '{escape(action)}'")
|
|
1341
|
+
console.print(usage)
|
|
1342
|
+
|
|
1343
|
+
# ------------------------------------------------------------------ #
|
|
1344
|
+
# Task bootstrap
|
|
1345
|
+
# ------------------------------------------------------------------ #
|
|
1346
|
+
def ensure_active_task(self, prompt_arg: str = "") -> bool:
|
|
1347
|
+
prompt_arg = prompt_arg.strip()
|
|
1348
|
+
|
|
1349
|
+
if prompt_arg:
|
|
1350
|
+
app_state = build_state_template(user_prompt=prompt_arg)
|
|
1351
|
+
self.save_state(app_state)
|
|
1352
|
+
console.print(f"[bold green]Принята новая задача:[/bold green] {prompt_arg}\n")
|
|
1353
|
+
return True
|
|
1354
|
+
|
|
1355
|
+
state = self.load_state() or {}
|
|
1356
|
+
raw_prompt = (
|
|
1357
|
+
state.get("task_prompt")
|
|
1358
|
+
or state.get("user_prompt")
|
|
1359
|
+
or state.get("task_spec", {}).get("raw_user_prompt")
|
|
1360
|
+
or ""
|
|
1361
|
+
).strip()
|
|
1362
|
+
|
|
1363
|
+
status = str(state.get("status", "")).lower()
|
|
1364
|
+
current_step = str(state.get("current_step", "")).lower()
|
|
1365
|
+
is_finished = status in ("completed", "failed", "done") or current_step in (
|
|
1366
|
+
"completed",
|
|
1367
|
+
"failed",
|
|
1368
|
+
"done",
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
if not raw_prompt or is_finished:
|
|
1372
|
+
if is_finished:
|
|
1373
|
+
console.print(
|
|
1374
|
+
"[bold yellow]Предыдущая задача уже завершена.[/bold yellow]"
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
console.print("[bold yellow]Enter a multi-line prompt. Type 'EOF' on a new line to finish.[/bold yellow]")
|
|
1378
|
+
lines = []
|
|
1379
|
+
while True:
|
|
1380
|
+
try:
|
|
1381
|
+
line = console.input()
|
|
1382
|
+
except (KeyboardInterrupt, EOFError):
|
|
1383
|
+
console.print("\n[yellow]Ввод отменен.[/yellow]")
|
|
1384
|
+
_drain_stdin()
|
|
1385
|
+
return False
|
|
1386
|
+
if line.strip() == "EOF":
|
|
1387
|
+
break
|
|
1388
|
+
lines.append(line)
|
|
1389
|
+
|
|
1390
|
+
user_input = "\n".join(lines).strip()
|
|
1391
|
+
|
|
1392
|
+
if not user_input:
|
|
1393
|
+
console.print(
|
|
1394
|
+
"[bold red]Промпт не может быть пустым. Запуск отменен.[/bold red]"
|
|
1395
|
+
)
|
|
1396
|
+
return False
|
|
1397
|
+
|
|
1398
|
+
app_state = build_state_template(user_prompt=user_input)
|
|
1399
|
+
self.save_state(app_state)
|
|
1400
|
+
console.print(
|
|
1401
|
+
"[green]Состояние инициализировано с новой задачей.[/green]\n"
|
|
1402
|
+
)
|
|
1403
|
+
return True
|
|
1404
|
+
|
|
1405
|
+
if current_step in (None, "", "idle", "init", "none"):
|
|
1406
|
+
state["current_step"] = "research"
|
|
1407
|
+
state["status"] = "in_progress"
|
|
1408
|
+
self.save_state(state)
|
|
1409
|
+
|
|
1410
|
+
return True
|
|
1411
|
+
|
|
1412
|
+
# ------------------------------------------------------------------ #
|
|
1413
|
+
# Pipeline execution
|
|
1414
|
+
# ------------------------------------------------------------------ #
|
|
1415
|
+
def run_agent_cycle(self, prompt_arg: str = ""):
|
|
1416
|
+
if not self.proj or not os.path.isdir(self.proj["path"]):
|
|
1417
|
+
console.print(
|
|
1418
|
+
"[bold red]Error:[/bold red] No valid workspace selected. "
|
|
1419
|
+
"Use 'cd <path>' first."
|
|
1420
|
+
)
|
|
1421
|
+
return
|
|
1422
|
+
|
|
1423
|
+
if not self.sandbox or not self.logger:
|
|
1424
|
+
self.sandbox = FolderSandbox(self.proj["path"])
|
|
1425
|
+
self.logger = ChangelogEngine(project_root=self.proj["path"])
|
|
1426
|
+
|
|
1427
|
+
if self._guard is None:
|
|
1428
|
+
self._guard = DriftGuard(self.proj["path"])
|
|
1429
|
+
|
|
1430
|
+
if not self.ensure_active_task(prompt_arg):
|
|
1431
|
+
return
|
|
1432
|
+
|
|
1433
|
+
try:
|
|
1434
|
+
sync_active_models_specs()
|
|
1435
|
+
except Exception as e:
|
|
1436
|
+
console.print(
|
|
1437
|
+
f"[dim yellow]Notice: Model specs sync skipped ({e})[/dim yellow]"
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
console.print(
|
|
1441
|
+
Panel(
|
|
1442
|
+
f"[bold green]Starting execution pipeline...[/bold green]\n"
|
|
1443
|
+
f"Target: [white]{self.proj['name']}[/white] "
|
|
1444
|
+
f"([cyan]{self.proj['path']}[/cyan])",
|
|
1445
|
+
border_style="green",
|
|
1446
|
+
)
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
self.should_pause = False
|
|
1450
|
+
self.should_stop = False
|
|
1451
|
+
|
|
1452
|
+
hitl_interval = getattr(config, "HITL_EVERY_N_CYCLES", 3)
|
|
1453
|
+
|
|
1454
|
+
with console.status(
|
|
1455
|
+
"[bold blue]Updating project structure tree...", spinner="dots"
|
|
1456
|
+
):
|
|
1457
|
+
struct_res = generate_or_update_structure(self.proj["path"])
|
|
1458
|
+
console.print(
|
|
1459
|
+
f"[green]Project structure synchronized.[/green] "
|
|
1460
|
+
f"Tracked files: [yellow]{struct_res['file_count']}[/yellow]"
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
res_file = None
|
|
1464
|
+
current_diff = None
|
|
1465
|
+
old_code_backup = ""
|
|
1466
|
+
|
|
1467
|
+
while True:
|
|
1468
|
+
if self.should_stop:
|
|
1469
|
+
console.print("[bold red]Execution stopped.[/bold red]")
|
|
1470
|
+
self.should_stop = False
|
|
1471
|
+
break
|
|
1472
|
+
|
|
1473
|
+
if self.should_pause:
|
|
1474
|
+
console.print("[bold yellow]Execution paused.[/bold yellow]")
|
|
1475
|
+
self.should_pause = False
|
|
1476
|
+
break
|
|
1477
|
+
|
|
1478
|
+
state = self.load_state()
|
|
1479
|
+
iteration = state.get("iteration_count", 0)
|
|
1480
|
+
|
|
1481
|
+
live_project_tree = get_project_context(self.proj["path"])
|
|
1482
|
+
state["project_files"] = live_project_tree
|
|
1483
|
+
|
|
1484
|
+
# ---- HITL checkpoint ----
|
|
1485
|
+
if iteration > 0 and iteration % hitl_interval == 0:
|
|
1486
|
+
console.print(
|
|
1487
|
+
Panel(
|
|
1488
|
+
f"[bold yellow]HITL Checkpoint Reached "
|
|
1489
|
+
f"({iteration} cycles completed)[/bold yellow]\n"
|
|
1490
|
+
"Options: [C]ontinue | [F]eedback | [S]top",
|
|
1491
|
+
border_style="yellow",
|
|
1492
|
+
)
|
|
1493
|
+
)
|
|
1494
|
+
try:
|
|
1495
|
+
cmd = console.input(
|
|
1496
|
+
"[yellow]Select action ([C]/[F]/[S]): [/yellow]"
|
|
1497
|
+
).strip().lower()
|
|
1498
|
+
except (KeyboardInterrupt, EOFError):
|
|
1499
|
+
console.print(
|
|
1500
|
+
"\n[yellow]HITL input cancelled. Pausing execution...[/yellow]"
|
|
1501
|
+
)
|
|
1502
|
+
_drain_stdin()
|
|
1503
|
+
break
|
|
1504
|
+
finally:
|
|
1505
|
+
_drain_stdin()
|
|
1506
|
+
|
|
1507
|
+
if cmd == "f":
|
|
1508
|
+
try:
|
|
1509
|
+
feedback_text = console.input(
|
|
1510
|
+
"[bold cyan]Enter feedback for agents: [/bold cyan]"
|
|
1511
|
+
).strip()
|
|
1512
|
+
state["human_feedback"] = feedback_text
|
|
1513
|
+
state["current_step"] = "research"
|
|
1514
|
+
self.save_state(state)
|
|
1515
|
+
console.print(
|
|
1516
|
+
f"[bold green]Feedback logged:[/bold green] {feedback_text}"
|
|
1517
|
+
)
|
|
1518
|
+
except (KeyboardInterrupt, EOFError):
|
|
1519
|
+
_drain_stdin()
|
|
1520
|
+
break
|
|
1521
|
+
finally:
|
|
1522
|
+
_drain_stdin()
|
|
1523
|
+
elif cmd != "c":
|
|
1524
|
+
break
|
|
1525
|
+
|
|
1526
|
+
# ---- Resource limits ----
|
|
1527
|
+
if (
|
|
1528
|
+
iteration >= config.MAX_ITERATIONS
|
|
1529
|
+
or self.total_cost >= config.MAX_BUDGET_USD
|
|
1530
|
+
):
|
|
1531
|
+
console.print(
|
|
1532
|
+
Panel(
|
|
1533
|
+
"[bold red]Resource Limit Reached![/bold red]",
|
|
1534
|
+
border_style="red",
|
|
1535
|
+
)
|
|
1536
|
+
)
|
|
1537
|
+
try:
|
|
1538
|
+
cmd = console.input(
|
|
1539
|
+
"[yellow]Select Action ([C]ontinue / [S]top): [/yellow]"
|
|
1540
|
+
).strip().lower()
|
|
1541
|
+
except (KeyboardInterrupt, EOFError):
|
|
1542
|
+
_drain_stdin()
|
|
1543
|
+
break
|
|
1544
|
+
finally:
|
|
1545
|
+
_drain_stdin()
|
|
1546
|
+
if cmd == "c":
|
|
1547
|
+
state["iteration_count"] = 0
|
|
1548
|
+
self.save_state(state)
|
|
1549
|
+
continue
|
|
1550
|
+
else:
|
|
1551
|
+
break
|
|
1552
|
+
# Periodic self-reflection: every N iterations, insert a reflect pass
|
|
1553
|
+
# before the normal step dispatch.
|
|
1554
|
+
step = state.get("current_step")
|
|
1555
|
+
|
|
1556
|
+
# Prevent re-triggering reflection on the same iteration
|
|
1557
|
+
last_reflect = state.get("metrics", {}).get("last_reflection", {}).get("iteration", -1)
|
|
1558
|
+
if (
|
|
1559
|
+
step not in ("reflect", "done", "completed")
|
|
1560
|
+
and iteration > 0
|
|
1561
|
+
and iteration % getattr(config, "REFLECTION_INTERVAL", 5) == 0
|
|
1562
|
+
and iteration != last_reflect
|
|
1563
|
+
):
|
|
1564
|
+
state["_pre_reflect_step"] = step
|
|
1565
|
+
state["current_step"] = "reflect"
|
|
1566
|
+
self.save_state(state)
|
|
1567
|
+
continue
|
|
1568
|
+
|
|
1569
|
+
# ============================================================
|
|
1570
|
+
# STEP 1: RESEARCH & PLANNING
|
|
1571
|
+
# ============================================================
|
|
1572
|
+
if step == "research":
|
|
1573
|
+
console.rule("[bold cyan]Step 1: Research and Planning[/bold cyan]")
|
|
1574
|
+
|
|
1575
|
+
state["hardware_constraints"] = state.get("system_constraints", [])
|
|
1576
|
+
|
|
1577
|
+
session_ctx = self._build_session_context(
|
|
1578
|
+
state, role="researcher", record_trigger=True
|
|
1579
|
+
)
|
|
1580
|
+
|
|
1581
|
+
with console.status(
|
|
1582
|
+
"[cyan]Researcher agent decomposing prompt and building plan...",
|
|
1583
|
+
spinner="dots",
|
|
1584
|
+
):
|
|
1585
|
+
state_updated, meta = run_researcher(
|
|
1586
|
+
state,
|
|
1587
|
+
self.proj["name"],
|
|
1588
|
+
project_dir=self.proj["path"],
|
|
1589
|
+
project_files=live_project_tree,
|
|
1590
|
+
session_context=session_ctx,
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
self.update_costs(meta, agent_name="researcher")
|
|
1594
|
+
|
|
1595
|
+
research_data = state_updated.get("research_data", {})
|
|
1596
|
+
res_payload = (
|
|
1597
|
+
research_data
|
|
1598
|
+
if isinstance(research_data, dict)
|
|
1599
|
+
else {"raw_response": research_data}
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
app_state = AppState.model_validate(state_updated)
|
|
1603
|
+
app_state = apply_researcher_plan(app_state, res_payload)
|
|
1604
|
+
|
|
1605
|
+
final_plan = list(app_state.plan)
|
|
1606
|
+
pending_count = sum(1 for s in final_plan if s.status == "pending")
|
|
1607
|
+
|
|
1608
|
+
state_to_save = app_state.model_dump()
|
|
1609
|
+
state_to_save["research_error"] = state_updated.get("research_error")
|
|
1610
|
+
state_to_save["research_analysis"] = state_updated.get(
|
|
1611
|
+
"research_analysis"
|
|
1612
|
+
)
|
|
1613
|
+
state_to_save["active_lesson_node_ids"] = state_updated.get(
|
|
1614
|
+
"active_lesson_node_ids"
|
|
1615
|
+
) or []
|
|
1616
|
+
|
|
1617
|
+
log_reasoning(
|
|
1618
|
+
"researcher",
|
|
1619
|
+
"research",
|
|
1620
|
+
{
|
|
1621
|
+
"analysis": app_state.task_spec.decomposed_goal
|
|
1622
|
+
or state_updated.get("research_analysis", ""),
|
|
1623
|
+
"plan": [s.model_dump() for s in final_plan],
|
|
1624
|
+
},
|
|
1625
|
+
project_root=self.proj["path"],
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
if pending_count == 0:
|
|
1629
|
+
reason = (
|
|
1630
|
+
state_updated.get("research_error") or "no pending steps"
|
|
1631
|
+
)
|
|
1632
|
+
console.print(
|
|
1633
|
+
Panel(
|
|
1634
|
+
f"[bold red]Researcher returned no usable plan:[/bold red] "
|
|
1635
|
+
f"{reason}\n"
|
|
1636
|
+
"Retrying research (counts toward iteration limit).",
|
|
1637
|
+
border_style="red",
|
|
1638
|
+
)
|
|
1639
|
+
)
|
|
1640
|
+
state_to_save["current_step"] = "research"
|
|
1641
|
+
state_to_save["iteration_count"] = (
|
|
1642
|
+
state.get("iteration_count", 0) + 1
|
|
1643
|
+
)
|
|
1644
|
+
self.save_state(state_to_save)
|
|
1645
|
+
continue
|
|
1646
|
+
|
|
1647
|
+
state_to_save["current_step"] = "edit"
|
|
1648
|
+
self.save_state(state_to_save)
|
|
1649
|
+
|
|
1650
|
+
console.print(
|
|
1651
|
+
f"[bold green]Plan generated with {pending_count} pending "
|
|
1652
|
+
f"subtask(s) ({len(final_plan)} total).[/bold green]"
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
# ============================================================
|
|
1656
|
+
# STEP 2: EDIT (code generation)
|
|
1657
|
+
# ============================================================
|
|
1658
|
+
elif step == "edit":
|
|
1659
|
+
console.rule("[bold yellow]Step 2: Code Generation[/bold yellow]")
|
|
1660
|
+
|
|
1661
|
+
pending_steps = [
|
|
1662
|
+
s
|
|
1663
|
+
for s in state.get("plan", [])
|
|
1664
|
+
if isinstance(s, dict) and s.get("status") == "pending"
|
|
1665
|
+
]
|
|
1666
|
+
if not pending_steps:
|
|
1667
|
+
# SAFETY CHECK: Did the agent actually finish a web task?
|
|
1668
|
+
is_web_task = (
|
|
1669
|
+
os.path.exists(os.path.join(self.proj["path"], "index.html")) or
|
|
1670
|
+
"index.html" in str(state.get("pipeline_created_files", []))
|
|
1671
|
+
)
|
|
1672
|
+
|
|
1673
|
+
if is_web_task:
|
|
1674
|
+
missing_files = []
|
|
1675
|
+
if not os.path.exists(os.path.join(self.proj["path"], "styles.css")):
|
|
1676
|
+
missing_files.append("styles.css")
|
|
1677
|
+
if not os.path.exists(os.path.join(self.proj["path"], "script.js")):
|
|
1678
|
+
missing_files.append("script.js")
|
|
1679
|
+
if not os.path.exists(os.path.join(self.proj["path"], "llms.txt")):
|
|
1680
|
+
missing_files.append("llms.txt")
|
|
1681
|
+
|
|
1682
|
+
if missing_files:
|
|
1683
|
+
console.print(
|
|
1684
|
+
f"[bold yellow]WARNING: Plan complete, but web files are missing: "
|
|
1685
|
+
f"{', '.join(missing_files)}. Forcing Research phase to generate them.[/bold yellow]"
|
|
1686
|
+
)
|
|
1687
|
+
state["editor_feedback"] = (
|
|
1688
|
+
f"CRITICAL: You marked the plan as complete, but the following mandatory "
|
|
1689
|
+
f"web files are missing: {', '.join(missing_files)}. "
|
|
1690
|
+
f"You must create a new plan to generate these files."
|
|
1691
|
+
)
|
|
1692
|
+
state["current_step"] = "research"
|
|
1693
|
+
self.save_state(state)
|
|
1694
|
+
continue
|
|
1695
|
+
|
|
1696
|
+
console.print(
|
|
1697
|
+
"[green]No pending steps remaining. Task is complete.[/green]"
|
|
1698
|
+
)
|
|
1699
|
+
state["current_step"] = "done"
|
|
1700
|
+
state["status"] = "completed"
|
|
1701
|
+
self.save_state(state)
|
|
1702
|
+
continue
|
|
1703
|
+
|
|
1704
|
+
current_subtask = pending_steps[0]
|
|
1705
|
+
state["active_subtask"] = current_subtask.get("title")
|
|
1706
|
+
state["target_file"] = current_subtask.get(
|
|
1707
|
+
"target_file", state.get("target_file")
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
session_ctx = self._build_session_context(
|
|
1711
|
+
state, role="editor", record_trigger=True
|
|
1712
|
+
)
|
|
1713
|
+
|
|
1714
|
+
resolved_target = state.get("target_file") or ""
|
|
1715
|
+
related_ctx = (
|
|
1716
|
+
self._build_related_context(state, resolved_target)
|
|
1717
|
+
if resolved_target
|
|
1718
|
+
else ""
|
|
1719
|
+
)
|
|
1720
|
+
|
|
1721
|
+
try:
|
|
1722
|
+
with console.status(
|
|
1723
|
+
"[yellow]Editor agent generating code changes...",
|
|
1724
|
+
spinner="dots",
|
|
1725
|
+
):
|
|
1726
|
+
state, meta = run_editor(
|
|
1727
|
+
state,
|
|
1728
|
+
project_files=live_project_tree,
|
|
1729
|
+
project_dir=self.proj["path"],
|
|
1730
|
+
session_context=session_ctx,
|
|
1731
|
+
related_context=related_ctx,
|
|
1732
|
+
)
|
|
1733
|
+
self.update_costs(meta, agent_name="editor")
|
|
1734
|
+
# === BEHAVIOR GUARD CHECK ===
|
|
1735
|
+
# Pass project_root to state so guard can check paths
|
|
1736
|
+
state["project_root"] = self.proj["path"]
|
|
1737
|
+
halt_reason = self.behavior_guard.observe_attempt(state, meta)
|
|
1738
|
+
|
|
1739
|
+
if halt_reason:
|
|
1740
|
+
console.print(
|
|
1741
|
+
Panel(
|
|
1742
|
+
f"[bold red]CIRCUIT BREAKER (Behavior Guard)[/bold red]\n\n"
|
|
1743
|
+
f"{halt_reason}\n\n"
|
|
1744
|
+
f"The pipeline has been halted to prevent token waste "
|
|
1745
|
+
f"and potential sandbox escape.",
|
|
1746
|
+
border_style="bold red",
|
|
1747
|
+
)
|
|
1748
|
+
)
|
|
1749
|
+
state["status"] = "failed"
|
|
1750
|
+
state["current_step"] = "done"
|
|
1751
|
+
self.save_state(state)
|
|
1752
|
+
break # Breaks the while loop
|
|
1753
|
+
except Exception as e:
|
|
1754
|
+
err_msg = str(e)
|
|
1755
|
+
console.print(f"[bold red]Editor Error:[/bold red] {err_msg}")
|
|
1756
|
+
|
|
1757
|
+
# Если ответ обрезался по лимиту токенов — даем явную инструкцию
|
|
1758
|
+
if "truncated due to max_tokens limit" in err_msg:
|
|
1759
|
+
state["editor_feedback"] = (
|
|
1760
|
+
"CRITICAL ERROR: Your previous response was too long and got cut off. "
|
|
1761
|
+
"You MUST output the code in smaller, manageable chunks (e.g., max 150 lines at a time). "
|
|
1762
|
+
"If you are doing a full rewrite, stop and wait for the next cycle to append more."
|
|
1763
|
+
)
|
|
1764
|
+
state["edit_retries"] = state.get("edit_retries", 0) + 1
|
|
1765
|
+
if state["edit_retries"] >= 3:
|
|
1766
|
+
console.print("[bold red]Editor failed 3 times due to truncation. Reverting to Research.[/bold red]")
|
|
1767
|
+
state["current_step"] = "research"
|
|
1768
|
+
state["edit_retries"] = 0
|
|
1769
|
+
state["research_error"] = "Generated code was too large. You must split the plan into smaller files or parts."
|
|
1770
|
+
else:
|
|
1771
|
+
state["current_step"] = "edit"
|
|
1772
|
+
else:
|
|
1773
|
+
state["iteration_count"] = state.get("iteration_count", 0) + 1
|
|
1774
|
+
|
|
1775
|
+
self.save_state(state)
|
|
1776
|
+
continue
|
|
1777
|
+
|
|
1778
|
+
# ---- Patch application failure ----
|
|
1779
|
+
raw_generated = state.get("generated_code", {})
|
|
1780
|
+
apply_res = (
|
|
1781
|
+
raw_generated.get("apply_result", {})
|
|
1782
|
+
if isinstance(raw_generated, dict)
|
|
1783
|
+
else {}
|
|
1784
|
+
)
|
|
1785
|
+
|
|
1786
|
+
if apply_res and not apply_res.get("ok", True):
|
|
1787
|
+
failed_details = "\n".join(
|
|
1788
|
+
[
|
|
1789
|
+
f"- Block {f.get('block_index', '?')}: {f.get('reason')}\n"
|
|
1790
|
+
f" SEARCH preview:\n```\n{f.get('search_preview')}\n```"
|
|
1791
|
+
for f in apply_res.get("failed", [])
|
|
1792
|
+
]
|
|
1793
|
+
)
|
|
1794
|
+
msg = (
|
|
1795
|
+
f"PATCH APPLICATION REJECTED (Matched: "
|
|
1796
|
+
f"{apply_res.get('matched', 0)}):\n{failed_details}"
|
|
1797
|
+
)
|
|
1798
|
+
console.print(
|
|
1799
|
+
Panel(
|
|
1800
|
+
msg,
|
|
1801
|
+
title="Patch Application Failed",
|
|
1802
|
+
border_style="bold red",
|
|
1803
|
+
)
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
target_path = os.path.join(
|
|
1807
|
+
self.proj["path"], resolved_target or ""
|
|
1808
|
+
)
|
|
1809
|
+
target_exists = bool(resolved_target) and os.path.exists(target_path)
|
|
1810
|
+
target_lines = 0
|
|
1811
|
+
if target_exists:
|
|
1812
|
+
try:
|
|
1813
|
+
with open(target_path, "r", encoding="utf-8") as tf:
|
|
1814
|
+
target_lines = sum(1 for _ in tf)
|
|
1815
|
+
except Exception:
|
|
1816
|
+
pass
|
|
1817
|
+
|
|
1818
|
+
state["editor_feedback"] = (
|
|
1819
|
+
f"CRITICAL PATCH ERROR: Your response could not be parsed.\n\n"
|
|
1820
|
+
f"What we found:\n"
|
|
1821
|
+
f" - Target file: {resolved_target} "
|
|
1822
|
+
f"({'exists' if target_exists else 'does not exist'}, "
|
|
1823
|
+
f"{target_lines} lines)\n"
|
|
1824
|
+
f" - Expected format: either SEARCH/REPLACE blocks, "
|
|
1825
|
+
f"OR a single full-file code block (>=30 lines) if the "
|
|
1826
|
+
f"whole file must be rewritten.\n\n"
|
|
1827
|
+
f"If your SEARCH block was marked AMBIGUOUS_MATCH, "
|
|
1828
|
+
f"include at least 3-5 surrounding lines so the anchor "
|
|
1829
|
+
f"is unique. A single-token anchor like `/*` will match "
|
|
1830
|
+
f"every comment in the file.\n\n"
|
|
1831
|
+
f"Preferred format for an existing file:\n"
|
|
1832
|
+
f"<<<<<<< SEARCH\n"
|
|
1833
|
+
f"(exact lines to find, copied verbatim from the file)\n"
|
|
1834
|
+
f"=======\n"
|
|
1835
|
+
f"(replacement lines)\n"
|
|
1836
|
+
f">>>>>>> REPLACE\n\n"
|
|
1837
|
+
f"Fallback format for a full rewrite:\n"
|
|
1838
|
+
f"```<language>\n(complete file content)\n```"
|
|
1839
|
+
)
|
|
1840
|
+
|
|
1841
|
+
state["edit_retries"] = state.get("edit_retries", 0) + 1
|
|
1842
|
+
# Increment iteration_count so MAX_ITERATIONS actually fires
|
|
1843
|
+
state["iteration_count"] = (
|
|
1844
|
+
state.get("iteration_count", 0) + 1
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
if state["edit_retries"] >= 3:
|
|
1848
|
+
console.print(
|
|
1849
|
+
"[bold red]Max Edit retries reached (3). "
|
|
1850
|
+
"Reverting step to Research.[/bold red]"
|
|
1851
|
+
)
|
|
1852
|
+
# Clean up partial writes to this file if the
|
|
1853
|
+
# pipeline created it. This prevents the researcher
|
|
1854
|
+
# from seeing a broken file on the next cycle.
|
|
1855
|
+
if resolved_target and self._is_pipeline_file(
|
|
1856
|
+
state, resolved_target
|
|
1857
|
+
):
|
|
1858
|
+
abs_target = os.path.join(
|
|
1859
|
+
self.proj["path"], resolved_target
|
|
1860
|
+
)
|
|
1861
|
+
try:
|
|
1862
|
+
if os.path.exists(abs_target):
|
|
1863
|
+
os.remove(abs_target)
|
|
1864
|
+
console.print(
|
|
1865
|
+
f"[dim]Removed broken pipeline-created "
|
|
1866
|
+
f"file: {resolved_target}[/dim]"
|
|
1867
|
+
)
|
|
1868
|
+
except OSError:
|
|
1869
|
+
pass
|
|
1870
|
+
self._untrack_pipeline_file(state, resolved_target)
|
|
1871
|
+
|
|
1872
|
+
state["current_step"] = "research"
|
|
1873
|
+
state["edit_retries"] = 0
|
|
1874
|
+
else:
|
|
1875
|
+
state["current_step"] = "edit"
|
|
1876
|
+
|
|
1877
|
+
self.save_state(state)
|
|
1878
|
+
continue
|
|
1879
|
+
|
|
1880
|
+
# ---- Extract the written code ----
|
|
1881
|
+
state["edit_retries"] = 0
|
|
1882
|
+
|
|
1883
|
+
research_data = state.get("research_data", {})
|
|
1884
|
+
res_file = (
|
|
1885
|
+
state.get("target_file")
|
|
1886
|
+
or research_data.get("target_file")
|
|
1887
|
+
or state.get("active_target_file")
|
|
1888
|
+
)
|
|
1889
|
+
|
|
1890
|
+
res_code = ""
|
|
1891
|
+
expected_target = state.get("target_file")
|
|
1892
|
+
|
|
1893
|
+
if isinstance(raw_generated, dict):
|
|
1894
|
+
res_code = (
|
|
1895
|
+
raw_generated.get("code")
|
|
1896
|
+
or raw_generated.get("content")
|
|
1897
|
+
or raw_generated.get("source")
|
|
1898
|
+
or ""
|
|
1899
|
+
)
|
|
1900
|
+
editor_target = raw_generated.get("target_file")
|
|
1901
|
+
if editor_target:
|
|
1902
|
+
if expected_target and editor_target != expected_target:
|
|
1903
|
+
console.print(
|
|
1904
|
+
f"[bold yellow]WARNING: Editor tried to write '{editor_target}' "
|
|
1905
|
+
f"instead of plan target '{expected_target}'. Forcing plan target.[/bold yellow]"
|
|
1906
|
+
)
|
|
1907
|
+
else:
|
|
1908
|
+
res_file = editor_target
|
|
1909
|
+
elif isinstance(raw_generated, str):
|
|
1910
|
+
try:
|
|
1911
|
+
parsed_json = extract_json_payload(raw_generated)
|
|
1912
|
+
if isinstance(parsed_json, dict):
|
|
1913
|
+
res_code = (
|
|
1914
|
+
parsed_json.get("code")
|
|
1915
|
+
or parsed_json.get("content")
|
|
1916
|
+
or parsed_json.get("source")
|
|
1917
|
+
or ""
|
|
1918
|
+
)
|
|
1919
|
+
editor_target = parsed_json.get("target_file")
|
|
1920
|
+
if editor_target:
|
|
1921
|
+
if expected_target and editor_target != expected_target:
|
|
1922
|
+
console.print(
|
|
1923
|
+
f"[bold yellow]WARNING: Editor tried to write '{editor_target}' "
|
|
1924
|
+
f"instead of plan target '{expected_target}'. Forcing plan target.[/bold yellow]"
|
|
1925
|
+
)
|
|
1926
|
+
else:
|
|
1927
|
+
res_file = editor_target
|
|
1928
|
+
else:
|
|
1929
|
+
res_code = raw_generated
|
|
1930
|
+
except Exception:
|
|
1931
|
+
fence_match = re.search(
|
|
1932
|
+
r"```(?:\w+)?\n(.*?)```", raw_generated, re.DOTALL
|
|
1933
|
+
)
|
|
1934
|
+
res_code = (
|
|
1935
|
+
fence_match.group(1) if fence_match else raw_generated
|
|
1936
|
+
)
|
|
1937
|
+
|
|
1938
|
+
if not res_file:
|
|
1939
|
+
console.print(
|
|
1940
|
+
"[bold red]Editor Error:[/bold red] No target file specified."
|
|
1941
|
+
)
|
|
1942
|
+
state["current_step"] = "edit"
|
|
1943
|
+
self.save_state(state)
|
|
1944
|
+
continue
|
|
1945
|
+
|
|
1946
|
+
try:
|
|
1947
|
+
res_code = sanitize_generated_code(res_code, allow_patches=True)
|
|
1948
|
+
except ValueError as e:
|
|
1949
|
+
console.print(f"[bold red]Editor Error: {str(e)}[/bold red]")
|
|
1950
|
+
state["editor_feedback"] = f"ERROR: {str(e)}"
|
|
1951
|
+
state["current_step"] = "edit"
|
|
1952
|
+
self.save_state(state)
|
|
1953
|
+
continue
|
|
1954
|
+
|
|
1955
|
+
# ---- Security Check ----
|
|
1956
|
+
allow_cleanup = getattr(config, "RSI_MODE", False)
|
|
1957
|
+
danger_hits = find_dangerous_code(res_code, allow_cleanup=allow_cleanup)
|
|
1958
|
+
if danger_hits:
|
|
1959
|
+
msg = f"Forbidden patterns detected: {', '.join(danger_hits)}"
|
|
1960
|
+
console.print(
|
|
1961
|
+
Panel(
|
|
1962
|
+
msg,
|
|
1963
|
+
title="Security Warning",
|
|
1964
|
+
border_style="bold red",
|
|
1965
|
+
)
|
|
1966
|
+
)
|
|
1967
|
+
state["compiler_output"] = f"SECURITY_ERROR: Edit rejected. {msg}"
|
|
1968
|
+
state["current_step"] = "edit"
|
|
1969
|
+
self.save_state(state)
|
|
1970
|
+
continue
|
|
1971
|
+
|
|
1972
|
+
# ---- Backup old content ----
|
|
1973
|
+
old_code = self.sandbox.safe_read_file(res_file)
|
|
1974
|
+
old_code_backup = (
|
|
1975
|
+
""
|
|
1976
|
+
if (
|
|
1977
|
+
isinstance(old_code, str)
|
|
1978
|
+
and old_code.startswith("Error")
|
|
1979
|
+
)
|
|
1980
|
+
else str(old_code)
|
|
1981
|
+
)
|
|
1982
|
+
state["old_code_backup"] = old_code_backup
|
|
1983
|
+
|
|
1984
|
+
# ---- Contract validation ----
|
|
1985
|
+
if self.validator is not None:
|
|
1986
|
+
change_set = ChangeSet()
|
|
1987
|
+
change_set.add_write(res_file, res_code)
|
|
1988
|
+
with console.status(
|
|
1989
|
+
"[cyan]Validating against structure index...",
|
|
1990
|
+
spinner="dots",
|
|
1991
|
+
):
|
|
1992
|
+
report = self.validator.validate_change_set(change_set)
|
|
1993
|
+
|
|
1994
|
+
if report.internal_error:
|
|
1995
|
+
console.print(
|
|
1996
|
+
f"[dim yellow]Validator internal error (failing open): "
|
|
1997
|
+
f"{report.internal_error}[/dim yellow]"
|
|
1998
|
+
)
|
|
1999
|
+
elif report.errors:
|
|
2000
|
+
failed_lines = "\n".join(
|
|
2001
|
+
f" {e.path}:{e.line}: [{e.code}] {e.message}"
|
|
2002
|
+
for e in report.errors
|
|
2003
|
+
)
|
|
2004
|
+
console.print(
|
|
2005
|
+
Panel(
|
|
2006
|
+
f"[bold red]CONTRACT VALIDATION FAILED[/bold red]\n"
|
|
2007
|
+
f"{failed_lines}",
|
|
2008
|
+
border_style="bold red",
|
|
2009
|
+
)
|
|
2010
|
+
)
|
|
2011
|
+
state["editor_feedback"] = (
|
|
2012
|
+
"CONTRACT VALIDATION FAILED — your proposed code calls "
|
|
2013
|
+
"names that do not exist in the current codebase:\n"
|
|
2014
|
+
f"{failed_lines}\n\n"
|
|
2015
|
+
"INSTRUCTION: Provide corrections that only reference "
|
|
2016
|
+
"modules, classes, and functions that actually exist. "
|
|
2017
|
+
"If the target symbol must be created first, add it in "
|
|
2018
|
+
"the same patch."
|
|
2019
|
+
)
|
|
2020
|
+
state["edit_retries"] = state.get("edit_retries", 0) + 1
|
|
2021
|
+
state["iteration_count"] = (
|
|
2022
|
+
state.get("iteration_count", 0) + 1
|
|
2023
|
+
)
|
|
2024
|
+
if state["edit_retries"] >= 3:
|
|
2025
|
+
console.print(
|
|
2026
|
+
"[bold red]Max Edit retries reached (3). "
|
|
2027
|
+
"Reverting step to Research.[/bold red]"
|
|
2028
|
+
)
|
|
2029
|
+
state["current_step"] = "research"
|
|
2030
|
+
state["edit_retries"] = 0
|
|
2031
|
+
else:
|
|
2032
|
+
state["current_step"] = "edit"
|
|
2033
|
+
self.save_state(state)
|
|
2034
|
+
continue
|
|
2035
|
+
|
|
2036
|
+
# --- Pre-flight syntax check ---
|
|
2037
|
+
if res_file.endswith(".py"):
|
|
2038
|
+
try:
|
|
2039
|
+
compile(res_code, res_file, "exec")
|
|
2040
|
+
except SyntaxError as syn_err:
|
|
2041
|
+
console.print(f"[bold red]SYNTAX ERROR in generated code: {syn_err}[/bold red]")
|
|
2042
|
+
state["editor_feedback"] = f"SYNTAX ERROR: {syn_err}. Please fix the syntax."
|
|
2043
|
+
state["current_step"] = "edit"
|
|
2044
|
+
state["iteration_count"] = state.get("iteration_count", 0) + 1
|
|
2045
|
+
self.save_state(state)
|
|
2046
|
+
continue
|
|
2047
|
+
|
|
2048
|
+
# ---- Atomic apply ----
|
|
2049
|
+
change_set = ChangeSet()
|
|
2050
|
+
change_set.add_write(res_file, res_code)
|
|
2051
|
+
# VERIFICATION FIREWALL: verify the file actually changed on disk
|
|
2052
|
+
from tools.security.verification_firewall import (
|
|
2053
|
+
fs_hash, verify_file_changed,
|
|
2054
|
+
)
|
|
2055
|
+
|
|
2056
|
+
abs_target = os.path.join(self.proj["path"], res_file)
|
|
2057
|
+
pre_apply_hash = fs_hash(abs_target)
|
|
2058
|
+
# --- RSI Critical File Gate (Phase 1.3) ---
|
|
2059
|
+
is_rsi = getattr(config, "RSI_MODE", False)
|
|
2060
|
+
is_critical = res_file in getattr(config, "RSI_CRITICAL_FILES", set())
|
|
2061
|
+
|
|
2062
|
+
if is_rsi and is_critical:
|
|
2063
|
+
pending_dir = os.path.join(self.proj["path"], ".vegex", "pending_critical_changes")
|
|
2064
|
+
os.makedirs(pending_dir, exist_ok=True)
|
|
2065
|
+
safe_filename = res_file.replace("/", "_").replace("\\", "_")
|
|
2066
|
+
pending_file = os.path.join(pending_dir, f"{safe_filename}_{int(time.time())}.patch")
|
|
2067
|
+
|
|
2068
|
+
# Save the proposed change to disk for review
|
|
2069
|
+
with open(pending_file, "w", encoding="utf-8") as f:
|
|
2070
|
+
f.write(res_code)
|
|
2071
|
+
|
|
2072
|
+
console.print(Panel(
|
|
2073
|
+
f"[bold yellow]RSI CRITICAL FILE GATE TRIGGERED[/bold yellow]\n\n"
|
|
2074
|
+
f"Agent attempted to modify core framework file: [cyan]{res_file}[/cyan]\n"
|
|
2075
|
+
f"Change saved to: [dim]{pending_file}[/dim]\n\n"
|
|
2076
|
+
f"Manual review required. The pipeline will skip applying this file\n"
|
|
2077
|
+
f"and move to the next step.",
|
|
2078
|
+
border_style="yellow",
|
|
2079
|
+
))
|
|
2080
|
+
|
|
2081
|
+
state["editor_feedback"] = (
|
|
2082
|
+
f"CHANGE GATED: Modifications to '{res_file}' require human approval. "
|
|
2083
|
+
f"Your code was saved to pending review, but not applied to the live file. "
|
|
2084
|
+
f"Proceed with your next subtask or finish."
|
|
2085
|
+
)
|
|
2086
|
+
|
|
2087
|
+
# Mark step completed so the pipeline doesn't loop endlessly
|
|
2088
|
+
self._mark_step_completed(state, res_file)
|
|
2089
|
+
|
|
2090
|
+
# Check if there are more pending steps
|
|
2091
|
+
pending = [s for s in state.get("plan", []) if s.get("status") == "pending"]
|
|
2092
|
+
if pending:
|
|
2093
|
+
state["current_step"] = "research"
|
|
2094
|
+
else:
|
|
2095
|
+
state["current_step"] = "done"
|
|
2096
|
+
state["status"] = "completed"
|
|
2097
|
+
|
|
2098
|
+
self.save_state(state)
|
|
2099
|
+
continue # Skip actual application
|
|
2100
|
+
# ------------------------------------------
|
|
2101
|
+
|
|
2102
|
+
# ---- Atomic apply ----
|
|
2103
|
+
change_set = ChangeSet()
|
|
2104
|
+
change_set.add_write(res_file, res_code)
|
|
2105
|
+
apply_report = change_set.apply(self.proj["path"])
|
|
2106
|
+
|
|
2107
|
+
if apply_report.ok:
|
|
2108
|
+
changed, post_hash = verify_file_changed(abs_target, pre_apply_hash)
|
|
2109
|
+
if not changed:
|
|
2110
|
+
console.print(
|
|
2111
|
+
f"[bold red]⚠ SECURITY: apply_report.ok=True but file "
|
|
2112
|
+
f"did not change on disk. Trusting fs over agent.[/bold red]"
|
|
2113
|
+
)
|
|
2114
|
+
state["editor_feedback"] = (
|
|
2115
|
+
"Filesystem verification failed: the file did not "
|
|
2116
|
+
"change after apply. The atomic changeset may be corrupted."
|
|
2117
|
+
)
|
|
2118
|
+
state["current_step"] = "edit"
|
|
2119
|
+
self.save_state(state)
|
|
2120
|
+
continue
|
|
2121
|
+
|
|
2122
|
+
if not apply_report.ok:
|
|
2123
|
+
console.print(
|
|
2124
|
+
Panel(
|
|
2125
|
+
f"[bold red]ATOMIC APPLY FAILED[/bold red]\n"
|
|
2126
|
+
f"{apply_report.error}",
|
|
2127
|
+
border_style="bold red",
|
|
2128
|
+
)
|
|
2129
|
+
)
|
|
2130
|
+
state["editor_feedback"] = (
|
|
2131
|
+
f"Filesystem apply failed: {apply_report.error}"
|
|
2132
|
+
)
|
|
2133
|
+
state["current_step"] = "edit"
|
|
2134
|
+
self.save_state(state)
|
|
2135
|
+
continue
|
|
2136
|
+
|
|
2137
|
+
current_diff = self.logger.generate_diff(
|
|
2138
|
+
old_code_backup, res_code, res_file
|
|
2139
|
+
)
|
|
2140
|
+
console.print(
|
|
2141
|
+
f"[green]Applied {len(apply_report.applied)} file(s) atomically "
|
|
2142
|
+
f"in {apply_report.elapsed_ms:.1f}ms[/green]"
|
|
2143
|
+
)
|
|
2144
|
+
|
|
2145
|
+
is_framework_root = os.path.abspath(self.proj["path"]) == os.path.abspath(self.VEGEX_FRAMEWORK_ROOT)
|
|
2146
|
+
if config.RSI_MODE and is_framework_root:
|
|
2147
|
+
missing = self._check_critical_files_intact()
|
|
2148
|
+
if missing:
|
|
2149
|
+
console.print(Panel(
|
|
2150
|
+
f"[bold red]CRITICAL FILES MISSING AFTER APPLY[/bold red]\n"
|
|
2151
|
+
f"{chr(10).join(missing)}\n\n"
|
|
2152
|
+
f"Rolling back the change set.",
|
|
2153
|
+
border_style="bold red",
|
|
2154
|
+
))
|
|
2155
|
+
# Revert the file we just wrote
|
|
2156
|
+
if not old_code_backup.strip():
|
|
2157
|
+
abs_path = os.path.join(self.proj["path"], res_file)
|
|
2158
|
+
try:
|
|
2159
|
+
os.remove(abs_path)
|
|
2160
|
+
except OSError:
|
|
2161
|
+
pass
|
|
2162
|
+
else:
|
|
2163
|
+
self.sandbox.safe_write_code(res_file, old_code_backup)
|
|
2164
|
+
|
|
2165
|
+
state["editor_feedback"] = (
|
|
2166
|
+
f"REJECTED: your change would have deleted critical framework "
|
|
2167
|
+
f"files: {', '.join(missing)}. Rollback applied. Do not touch "
|
|
2168
|
+
f"files outside the target module."
|
|
2169
|
+
)
|
|
2170
|
+
state["current_step"] = "edit"
|
|
2171
|
+
self.save_state(state)
|
|
2172
|
+
continue
|
|
2173
|
+
|
|
2174
|
+
# Track pipeline-created files: if backup is empty, the file
|
|
2175
|
+
# did not exist before this write.
|
|
2176
|
+
if not old_code_backup.strip():
|
|
2177
|
+
self._track_pipeline_file(state, res_file)
|
|
2178
|
+
|
|
2179
|
+
if self._mark_step_completed(state, res_file):
|
|
2180
|
+
console.print(
|
|
2181
|
+
f"[dim]Marked plan step for '{res_file}' as completed.[/dim]"
|
|
2182
|
+
)
|
|
2183
|
+
|
|
2184
|
+
state["active_target_file"] = res_file
|
|
2185
|
+
state["target_file"] = res_file
|
|
2186
|
+
state["current_step"] = "review"
|
|
2187
|
+
self.save_state(state)
|
|
2188
|
+
|
|
2189
|
+
# ============================================================
|
|
2190
|
+
# STEP 3: VERIFICATION & REVIEW
|
|
2191
|
+
# ============================================================
|
|
2192
|
+
elif step == "review":
|
|
2193
|
+
console.rule(
|
|
2194
|
+
"[bold magenta]Step 3: Verification and Review[/bold magenta]"
|
|
2195
|
+
)
|
|
2196
|
+
if not res_file:
|
|
2197
|
+
res_file = state.get("active_target_file")
|
|
2198
|
+
|
|
2199
|
+
backup_code = state.get("old_code_backup", old_code_backup)
|
|
2200
|
+
|
|
2201
|
+
with console.status(
|
|
2202
|
+
"[magenta]Running compilation/test checks...", spinner="dots"
|
|
2203
|
+
):
|
|
2204
|
+
build_success, log = run_universal_build(
|
|
2205
|
+
self.proj["path"],
|
|
2206
|
+
self.proj.get("lang", "python"),
|
|
2207
|
+
target_file=res_file,
|
|
2208
|
+
)
|
|
2209
|
+
|
|
2210
|
+
# VERIFICATION FIREWALL: build_success from run_universal_build
|
|
2211
|
+
# is the ONLY authoritative source. Do not trust state for this.
|
|
2212
|
+
from tools.security.verification_firewall import verify_build_result
|
|
2213
|
+
|
|
2214
|
+
claimed_success = bool(state.get("build_success_claimed"))
|
|
2215
|
+
verification = verify_build_result(
|
|
2216
|
+
claimed_success=claimed_success,
|
|
2217
|
+
actual_success=build_success,
|
|
2218
|
+
actual_log=log,
|
|
2219
|
+
)
|
|
2220
|
+
|
|
2221
|
+
if not verification["consistent"]:
|
|
2222
|
+
for warning in verification["warnings"]:
|
|
2223
|
+
console.print(f"[bold red]⚠ SECURITY: {warning}[/bold red]")
|
|
2224
|
+
# Inconsistency is suspicious but not fatal — we use trusted_success
|
|
2225
|
+
# (the compiler's answer) and log the discrepancy.
|
|
2226
|
+
|
|
2227
|
+
state["compiler_output"] = log # always compiler, never agent
|
|
2228
|
+
state["build_success_claimed"] = None # clear after verify
|
|
2229
|
+
|
|
2230
|
+
# ---- Coherence gating ----
|
|
2231
|
+
if self._coherence_should_run(state):
|
|
2232
|
+
coherence_ctx = self._build_coherence_report(state)
|
|
2233
|
+
else:
|
|
2234
|
+
pending_remaining = [
|
|
2235
|
+
s.get("target_file")
|
|
2236
|
+
for s in state.get("plan", [])
|
|
2237
|
+
if isinstance(s, dict) and s.get("status") == "pending"
|
|
2238
|
+
]
|
|
2239
|
+
coherence_ctx = ""
|
|
2240
|
+
console.print(
|
|
2241
|
+
f"[dim]Coherence check deferred — "
|
|
2242
|
+
f"{len(pending_remaining)} step(s) still pending: "
|
|
2243
|
+
f"{', '.join(str(p) for p in pending_remaining[:3])}"
|
|
2244
|
+
f"{'...' if len(pending_remaining) > 3 else ''}[/dim]"
|
|
2245
|
+
)
|
|
2246
|
+
|
|
2247
|
+
# Coherence gate: if the report has errors, do not hand the diff to
|
|
2248
|
+
# the reviewer.
|
|
2249
|
+
if coherence_ctx and "error" in coherence_ctx.lower():
|
|
2250
|
+
rerouted = False
|
|
2251
|
+
for step in reversed(state.get("plan", [])):
|
|
2252
|
+
if (
|
|
2253
|
+
isinstance(step, dict)
|
|
2254
|
+
and step.get("target_file", "").endswith((".css", ".html"))
|
|
2255
|
+
and step.get("status") == "completed"
|
|
2256
|
+
):
|
|
2257
|
+
step["status"] = "pending"
|
|
2258
|
+
state["target_file"] = step["target_file"]
|
|
2259
|
+
state["active_subtask"] = step.get("title")
|
|
2260
|
+
state["current_step"] = "edit"
|
|
2261
|
+
state["editor_feedback"] = (
|
|
2262
|
+
"COHERENCE / SYNTAX FAILURE — the pipeline found "
|
|
2263
|
+
"errors in your code. Fix ONLY what is mentioned below.\n\n"
|
|
2264
|
+
f"--- COMPILER LOG ---\n{log}\n\n"
|
|
2265
|
+
f"--- COHERENCE REPORT ---\n{coherence_ctx}"
|
|
2266
|
+
)
|
|
2267
|
+
console.print(
|
|
2268
|
+
f"[yellow]Coherence failed — rerouting to "
|
|
2269
|
+
f"{step.get('target_file')} to fix consistency[/yellow]"
|
|
2270
|
+
)
|
|
2271
|
+
rerouted = True
|
|
2272
|
+
break
|
|
2273
|
+
|
|
2274
|
+
if rerouted:
|
|
2275
|
+
if res_file:
|
|
2276
|
+
self._revert_file(state, res_file, backup_code)
|
|
2277
|
+
self._mark_step_pending(state, res_file)
|
|
2278
|
+
|
|
2279
|
+
state["active_lesson_node_ids"] = []
|
|
2280
|
+
state.pop("active_target_file", None)
|
|
2281
|
+
state.pop("generated_code", None)
|
|
2282
|
+
state.pop("old_code_backup", None)
|
|
2283
|
+
self.save_state(state)
|
|
2284
|
+
continue
|
|
2285
|
+
|
|
2286
|
+
# --- Visual Review Integration ---
|
|
2287
|
+
visual_ctx = ""
|
|
2288
|
+
if res_file and res_file.endswith(".html"):
|
|
2289
|
+
ref_path = os.path.join(self.proj["path"], "reference.png")
|
|
2290
|
+
target_abs = os.path.join(self.proj["path"], res_file)
|
|
2291
|
+
|
|
2292
|
+
if os.path.exists(ref_path) and os.path.exists(target_abs):
|
|
2293
|
+
with console.status("[magenta]Running visual diff against reference...", spinner="dots"):
|
|
2294
|
+
try:
|
|
2295
|
+
vis_res = visual_review_page(
|
|
2296
|
+
html_path=target_abs,
|
|
2297
|
+
reference_png=ref_path,
|
|
2298
|
+
output_dir=os.path.join(self.proj["path"], ".vegex", "diffs")
|
|
2299
|
+
)
|
|
2300
|
+
if vis_res and not vis_res.get("ok", True):
|
|
2301
|
+
visual_ctx = vis_res.get("feedback", "")
|
|
2302
|
+
console.print(f"[bold yellow]Visual Diff failed (SSIM: {vis_res.get('ssim', 0):.2f})[/bold yellow]")
|
|
2303
|
+
elif vis_res and vis_res.get("ssim"):
|
|
2304
|
+
console.print(f"[dim green]Visual Diff passed (SSIM: {vis_res['ssim']:.2f})[/dim green]")
|
|
2305
|
+
except Exception as vis_exc:
|
|
2306
|
+
console.print(f"[dim yellow]Visual review skipped: {vis_exc}[/dim yellow]")
|
|
2307
|
+
# ---------------------------------
|
|
2308
|
+
|
|
2309
|
+
# ---- Reviewer ----
|
|
2310
|
+
session_ctx = self._build_session_context(
|
|
2311
|
+
state, role="reviewer", record_trigger=True
|
|
2312
|
+
)
|
|
2313
|
+
|
|
2314
|
+
with console.status(
|
|
2315
|
+
"[magenta]Reviewer auditing code diff...", spinner="dots"
|
|
2316
|
+
):
|
|
2317
|
+
state, meta = run_reviewer(
|
|
2318
|
+
state,
|
|
2319
|
+
project_dir=self.proj["path"],
|
|
2320
|
+
session_context=session_ctx,
|
|
2321
|
+
coherence_report=coherence_ctx,
|
|
2322
|
+
visual_context=visual_ctx
|
|
2323
|
+
)
|
|
2324
|
+
self.update_costs(meta, agent_name="reviewer")
|
|
2325
|
+
|
|
2326
|
+
rev_res = state.get("review_result", {})
|
|
2327
|
+
verdict = str(rev_res.get("verdict", "REJECT")).upper()
|
|
2328
|
+
reason = (
|
|
2329
|
+
rev_res.get("reason")
|
|
2330
|
+
or rev_res.get("feedback")
|
|
2331
|
+
or "No detailed reason provided."
|
|
2332
|
+
)
|
|
2333
|
+
|
|
2334
|
+
# ---- Lesson feedback ----
|
|
2335
|
+
injected_ids = state.get("active_lesson_node_ids") or []
|
|
2336
|
+
helpful_ids: list = []
|
|
2337
|
+
violated_ids: list = []
|
|
2338
|
+
if injected_ids:
|
|
2339
|
+
try:
|
|
2340
|
+
helpful_ids, violated_ids = analyze_lesson_outcome(
|
|
2341
|
+
build_success=build_success,
|
|
2342
|
+
build_log=log,
|
|
2343
|
+
injected_node_ids=injected_ids,
|
|
2344
|
+
)
|
|
2345
|
+
except Exception as fb_exc:
|
|
2346
|
+
console.print(
|
|
2347
|
+
f"[dim yellow]Lesson analysis failed: {fb_exc}[/dim yellow]"
|
|
2348
|
+
)
|
|
2349
|
+
|
|
2350
|
+
# ====================================================
|
|
2351
|
+
# ACCEPT
|
|
2352
|
+
# ====================================================
|
|
2353
|
+
if build_success and verdict == "ACCEPT":
|
|
2354
|
+
console.print(
|
|
2355
|
+
Panel(
|
|
2356
|
+
"Build passed and Reviewer accepted changes.",
|
|
2357
|
+
border_style="bold green",
|
|
2358
|
+
)
|
|
2359
|
+
)
|
|
2360
|
+
|
|
2361
|
+
if helpful_ids:
|
|
2362
|
+
try:
|
|
2363
|
+
n = record_lesson_helpful(helpful_ids)
|
|
2364
|
+
console.print(
|
|
2365
|
+
f"[dim]Lesson feedback: {n} lesson(s) marked "
|
|
2366
|
+
f"helpful (H+1)[/dim]"
|
|
2367
|
+
)
|
|
2368
|
+
except Exception as fb_exc:
|
|
2369
|
+
console.print(
|
|
2370
|
+
f"[dim yellow]record_helpful failed: "
|
|
2371
|
+
f"{fb_exc}[/dim yellow]"
|
|
2372
|
+
)
|
|
2373
|
+
state["active_lesson_node_ids"] = []
|
|
2374
|
+
|
|
2375
|
+
if self.adr is not None:
|
|
2376
|
+
try:
|
|
2377
|
+
sid = (
|
|
2378
|
+
(state.get("meta") or {}).get("session_id")
|
|
2379
|
+
or "unknown"
|
|
2380
|
+
)
|
|
2381
|
+
entry = build_adr_entry(
|
|
2382
|
+
session_id=sid,
|
|
2383
|
+
cycle=state.get("iteration_count", 0),
|
|
2384
|
+
action="edit",
|
|
2385
|
+
outcome="accepted",
|
|
2386
|
+
files_touched=[res_file] if res_file else [],
|
|
2387
|
+
decisions=[reason[:200]] if reason else [],
|
|
2388
|
+
)
|
|
2389
|
+
self.adr.append(entry)
|
|
2390
|
+
except Exception as adr_exc:
|
|
2391
|
+
console.print(
|
|
2392
|
+
f"[dim yellow]ADR append failed: "
|
|
2393
|
+
f"{adr_exc}[/dim yellow]"
|
|
2394
|
+
)
|
|
2395
|
+
|
|
2396
|
+
plan = state.get("plan", [])
|
|
2397
|
+
pending_steps = [
|
|
2398
|
+
s
|
|
2399
|
+
for s in plan
|
|
2400
|
+
if isinstance(s, dict) and s.get("status") == "pending"
|
|
2401
|
+
]
|
|
2402
|
+
|
|
2403
|
+
if pending_steps:
|
|
2404
|
+
next_step = pending_steps[0]
|
|
2405
|
+
state["active_subtask"] = next_step.get("title")
|
|
2406
|
+
state["target_file"] = next_step.get("target_file")
|
|
2407
|
+
state["current_step"] = "research"
|
|
2408
|
+
console.print(
|
|
2409
|
+
f"[green]Advancing to next step:[/green] "
|
|
2410
|
+
f"{next_step.get('title')}"
|
|
2411
|
+
)
|
|
2412
|
+
else:
|
|
2413
|
+
state["active_subtask"] = None
|
|
2414
|
+
state["target_file"] = None
|
|
2415
|
+
state["current_step"] = "done"
|
|
2416
|
+
state["status"] = "completed"
|
|
2417
|
+
console.print(
|
|
2418
|
+
"[bold green]All plan steps completed.[/bold green]"
|
|
2419
|
+
)
|
|
2420
|
+
|
|
2421
|
+
state.pop("active_target_file", None)
|
|
2422
|
+
state.pop("generated_code", None)
|
|
2423
|
+
state.pop("old_code_backup", None)
|
|
2424
|
+
state["recent_rejections"] = []
|
|
2425
|
+
|
|
2426
|
+
state["iteration_count"] = state.get("iteration_count", 0) + 1
|
|
2427
|
+
self.save_state(state)
|
|
2428
|
+
self.logger.record_iteration(state, diff_data=current_diff)
|
|
2429
|
+
|
|
2430
|
+
# ====================================================
|
|
2431
|
+
# REJECT
|
|
2432
|
+
# ====================================================
|
|
2433
|
+
else:
|
|
2434
|
+
console.print(
|
|
2435
|
+
Panel(
|
|
2436
|
+
f"[bold red]REJECTED[/bold red]\n"
|
|
2437
|
+
f"[yellow]Reason:[/yellow] {reason}",
|
|
2438
|
+
border_style="bold red",
|
|
2439
|
+
)
|
|
2440
|
+
)
|
|
2441
|
+
|
|
2442
|
+
if self._check_rejection_loop(state, reason):
|
|
2443
|
+
console.print(
|
|
2444
|
+
Panel(
|
|
2445
|
+
"[bold red]CIRCUIT BREAKER TRIGGERED[/bold red]\n"
|
|
2446
|
+
"The same rejection reason has appeared 3 times "
|
|
2447
|
+
"in a row. The pipeline is stuck in a loop.\n\n"
|
|
2448
|
+
"Pausing execution. Inspect state.json, fix any "
|
|
2449
|
+
"underlying issue, then run again.",
|
|
2450
|
+
border_style="bold red",
|
|
2451
|
+
)
|
|
2452
|
+
)
|
|
2453
|
+
state["current_step"] = "done"
|
|
2454
|
+
state["status"] = "failed"
|
|
2455
|
+
self.save_state(state)
|
|
2456
|
+
break
|
|
2457
|
+
|
|
2458
|
+
if violated_ids:
|
|
2459
|
+
try:
|
|
2460
|
+
n = record_lesson_violation(violated_ids)
|
|
2461
|
+
console.print(
|
|
2462
|
+
f"[dim yellow]Lesson feedback: {n} lesson(s) "
|
|
2463
|
+
f"marked as violated (V+1) — the injected rules "
|
|
2464
|
+
f"did not prevent the failure[/dim yellow]"
|
|
2465
|
+
)
|
|
2466
|
+
except Exception as fb_exc:
|
|
2467
|
+
console.print(
|
|
2468
|
+
f"[dim yellow]record_violation failed: "
|
|
2469
|
+
f"{fb_exc}[/dim yellow]"
|
|
2470
|
+
)
|
|
2471
|
+
|
|
2472
|
+
if self.adr is not None:
|
|
2473
|
+
try:
|
|
2474
|
+
sid = (
|
|
2475
|
+
(state.get("meta") or {}).get("session_id")
|
|
2476
|
+
or "unknown"
|
|
2477
|
+
)
|
|
2478
|
+
entry = build_adr_entry(
|
|
2479
|
+
session_id=sid,
|
|
2480
|
+
cycle=state.get("iteration_count", 0),
|
|
2481
|
+
action="edit",
|
|
2482
|
+
outcome="rejected",
|
|
2483
|
+
files_touched=[res_file] if res_file else [],
|
|
2484
|
+
decisions=[reason[:200]] if reason else [],
|
|
2485
|
+
)
|
|
2486
|
+
self.adr.append(entry)
|
|
2487
|
+
except Exception as adr_exc:
|
|
2488
|
+
console.print(
|
|
2489
|
+
f"[dim yellow]ADR append failed: "
|
|
2490
|
+
f"{adr_exc}[/dim yellow]"
|
|
2491
|
+
)
|
|
2492
|
+
|
|
2493
|
+
# Revert via helper (handles empty-backup case by deleting)
|
|
2494
|
+
if res_file:
|
|
2495
|
+
self._revert_file(state, res_file, backup_code)
|
|
2496
|
+
self._mark_step_pending(state, res_file)
|
|
2497
|
+
|
|
2498
|
+
state["compiler_output"] = (
|
|
2499
|
+
f"REVIEWER_REJECTED: {reason}\nBuild output: {log[:300]}"
|
|
2500
|
+
)
|
|
2501
|
+
state["editor_feedback"] = (
|
|
2502
|
+
f"REJECTED BY REVIEWER:\nReason: {reason}"
|
|
2503
|
+
)
|
|
2504
|
+
state["current_step"] = "edit"
|
|
2505
|
+
|
|
2506
|
+
state.pop("generated_code", None)
|
|
2507
|
+
state.pop("active_target_file", None)
|
|
2508
|
+
state.pop("old_code_backup", None)
|
|
2509
|
+
res_file = None
|
|
2510
|
+
|
|
2511
|
+
state["iteration_count"] = state.get("iteration_count", 0) + 1
|
|
2512
|
+
self.save_state(state)
|
|
2513
|
+
self.logger.record_iteration(state, diff_data=current_diff)
|
|
2514
|
+
elif step == "reflect":
|
|
2515
|
+
console.rule("[bold blue]Step 0: Self-Reflection[/bold blue]")
|
|
2516
|
+
|
|
2517
|
+
from tools.reflection import run_reflection
|
|
2518
|
+
|
|
2519
|
+
with console.status(
|
|
2520
|
+
"[blue]Analyzing recent failures for patterns...", spinner="dots"
|
|
2521
|
+
):
|
|
2522
|
+
reflect_result = run_reflection(state, project_dir=self.proj["path"])
|
|
2523
|
+
|
|
2524
|
+
if reflect_result.observations_written:
|
|
2525
|
+
console.print(Panel(
|
|
2526
|
+
f"[bold green]Reflection produced "
|
|
2527
|
+
f"{len(reflect_result.observations_written)} new observation(s):[/bold green]\n"
|
|
2528
|
+
+ "\n".join(f" • {oid}" for oid in reflect_result.observations_written)
|
|
2529
|
+
+ (
|
|
2530
|
+
f"\n\n[dim]Skipped (already exist): "
|
|
2531
|
+
f"{len(reflect_result.observations_skipped)}[/dim]"
|
|
2532
|
+
if reflect_result.observations_skipped else ""
|
|
2533
|
+
),
|
|
2534
|
+
border_style="blue",
|
|
2535
|
+
title="Self-Reflection",
|
|
2536
|
+
))
|
|
2537
|
+
else:
|
|
2538
|
+
console.print(
|
|
2539
|
+
f"[dim]Reflection: no new observations "
|
|
2540
|
+
f"({reflect_result.failures_analyzed} failures analyzed, "
|
|
2541
|
+
f"{reflect_result.patterns_found} patterns found)[/dim]"
|
|
2542
|
+
)
|
|
2543
|
+
|
|
2544
|
+
# Track that reflection ran
|
|
2545
|
+
state["metrics"] = state.get("metrics", {})
|
|
2546
|
+
state["metrics"]["last_reflection"] = {
|
|
2547
|
+
"iteration": iteration,
|
|
2548
|
+
"observations_written": reflect_result.observations_written,
|
|
2549
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
# Re-sync lessons into SQLite so new observations become rankable
|
|
2553
|
+
try:
|
|
2554
|
+
from tools.lesson_memory_bridge import LessonMemoryBridge
|
|
2555
|
+
bridge = LessonMemoryBridge(
|
|
2556
|
+
lessons_root=os.path.join(self.proj["path"], "lessons")
|
|
2557
|
+
)
|
|
2558
|
+
bridge.sync_all()
|
|
2559
|
+
except Exception as exc:
|
|
2560
|
+
console.print(
|
|
2561
|
+
f"[dim yellow]Bridge sync after reflection failed: {exc}[/dim yellow]"
|
|
2562
|
+
)
|
|
2563
|
+
|
|
2564
|
+
# Return to whatever step we came from
|
|
2565
|
+
previous = state.pop("_pre_reflect_step", "research")
|
|
2566
|
+
state["current_step"] = previous
|
|
2567
|
+
self.save_state(state)
|
|
2568
|
+
continue
|
|
2569
|
+
# ============================================================
|
|
2570
|
+
# STEP 4: DONE
|
|
2571
|
+
# ============================================================
|
|
2572
|
+
elif step in ("done", "completed"):
|
|
2573
|
+
console.print(
|
|
2574
|
+
Panel(
|
|
2575
|
+
"[bold green]All tasks completed![/bold green]",
|
|
2576
|
+
border_style="green",
|
|
2577
|
+
)
|
|
2578
|
+
)
|
|
2579
|
+
break
|
|
2580
|
+
|
|
2581
|
+
else:
|
|
2582
|
+
console.print(
|
|
2583
|
+
f"[bold red]Unknown step '{step}'. Defaulting to "
|
|
2584
|
+
f"'research'...[/bold red]"
|
|
2585
|
+
)
|
|
2586
|
+
state["current_step"] = "research"
|
|
2587
|
+
self.save_state(state)
|
|
2588
|
+
time.sleep(1)
|
|
2589
|
+
|
|
2590
|
+
# ------------------------------------------------------------------ #
|
|
2591
|
+
# Main loop
|
|
2592
|
+
# ------------------------------------------------------------------ #
|
|
2593
|
+
def start(self):
|
|
2594
|
+
console.clear()
|
|
2595
|
+
console.print(
|
|
2596
|
+
Panel(
|
|
2597
|
+
"[bold cyan]VegeX Autonomous Multi-Agent Orchestrator[/bold cyan]\n"
|
|
2598
|
+
"[dim]Interactive Terminal Interface[/dim]",
|
|
2599
|
+
border_style="cyan",
|
|
2600
|
+
expand=False,
|
|
2601
|
+
)
|
|
2602
|
+
)
|
|
2603
|
+
|
|
2604
|
+
if self.proj:
|
|
2605
|
+
console.print(
|
|
2606
|
+
f"[bold green]Active Workspace:[/bold green] {self.proj['name']} "
|
|
2607
|
+
f"([cyan]{self.proj['path']}[/cyan])\n"
|
|
2608
|
+
)
|
|
2609
|
+
console.print(f"[dim]Framework root: {self.VEGEX_FRAMEWORK_ROOT}[/dim]")
|
|
2610
|
+
console.print(f"[dim]Registry: {REGISTRY_FILE}[/dim]\n")
|
|
2611
|
+
else:
|
|
2612
|
+
console.print(
|
|
2613
|
+
"[bold yellow]No active workspace selected. "
|
|
2614
|
+
"Use 'cd <path>' or 'projects' to pick one.[/bold yellow]\n"
|
|
2615
|
+
)
|
|
2616
|
+
|
|
2617
|
+
while True:
|
|
2618
|
+
try:
|
|
2619
|
+
cmd_line = console.input("[bold cyan]VegeX > [/bold cyan]").strip()
|
|
2620
|
+
|
|
2621
|
+
if _looks_like_paste_artifact(cmd_line):
|
|
2622
|
+
continue
|
|
2623
|
+
|
|
2624
|
+
if not cmd_line:
|
|
2625
|
+
continue
|
|
2626
|
+
|
|
2627
|
+
parts = cmd_line.split(maxsplit=1)
|
|
2628
|
+
head = parts[0].lower()
|
|
2629
|
+
sub_args = parts[1] if len(parts) > 1 else ""
|
|
2630
|
+
|
|
2631
|
+
if head in ("task", "t"):
|
|
2632
|
+
self.handle_task_command(sub_args)
|
|
2633
|
+
|
|
2634
|
+
elif head in ("run", "start"):
|
|
2635
|
+
self.run_agent_cycle(prompt_arg=sub_args)
|
|
2636
|
+
|
|
2637
|
+
elif head == "cd":
|
|
2638
|
+
self.change_directory(sub_args)
|
|
2639
|
+
|
|
2640
|
+
elif head in ("projects", "ls"):
|
|
2641
|
+
self.print_projects_table()
|
|
2642
|
+
|
|
2643
|
+
elif head == "llm":
|
|
2644
|
+
if sub_args.startswith("routing "):
|
|
2645
|
+
from tools.llm_selector import set_dynamic_routing_enabled
|
|
2646
|
+
mode = sub_args.split(maxsplit=1)[1].strip().lower()
|
|
2647
|
+
if mode == "on":
|
|
2648
|
+
set_dynamic_routing_enabled(True)
|
|
2649
|
+
console.print("[bold green]Dynamic LLM routing ENABLED. Models will be picked by complexity.[/bold green]")
|
|
2650
|
+
elif mode == "off":
|
|
2651
|
+
set_dynamic_routing_enabled(False)
|
|
2652
|
+
console.print("[bold yellow]Dynamic LLM routing DISABLED. Manual bindings will be used.[/bold yellow]")
|
|
2653
|
+
else:
|
|
2654
|
+
console.print("[bold red]Usage: llm routing [on|off][/bold red]")
|
|
2655
|
+
else:
|
|
2656
|
+
handle_llm_command(sub_args)
|
|
2657
|
+
|
|
2658
|
+
elif head == "msg":
|
|
2659
|
+
project_dir = self.proj["path"] if self.proj else "."
|
|
2660
|
+
handle_msg_command(
|
|
2661
|
+
project_dir=project_dir, state=self.load_state()
|
|
2662
|
+
)
|
|
2663
|
+
|
|
2664
|
+
elif head == "handshake":
|
|
2665
|
+
self.handle_handshake_command(sub_args)
|
|
2666
|
+
|
|
2667
|
+
elif head == "coherence":
|
|
2668
|
+
self.handle_coherence_command(sub_args)
|
|
2669
|
+
|
|
2670
|
+
elif head == "plugin":
|
|
2671
|
+
self.handle_plugin_command(sub_args)
|
|
2672
|
+
|
|
2673
|
+
elif head == "reflect":
|
|
2674
|
+
if not self.proj or not os.path.isdir(self.proj["path"]):
|
|
2675
|
+
console.print("[bold red]Error:[/bold red] No valid workspace selected.")
|
|
2676
|
+
continue
|
|
2677
|
+
state = self.load_state()
|
|
2678
|
+
from tools.reflection import run_reflection
|
|
2679
|
+
with console.status("[blue]Analyzing recent failures...", spinner="dots"):
|
|
2680
|
+
res = run_reflection(state, project_dir=self.proj["path"], force=True)
|
|
2681
|
+
console.print(Panel(
|
|
2682
|
+
json.dumps(res.as_dict(), indent=2),
|
|
2683
|
+
title="Reflection Result",
|
|
2684
|
+
border_style="blue",
|
|
2685
|
+
))
|
|
2686
|
+
|
|
2687
|
+
elif head == "status":
|
|
2688
|
+
self.show_status()
|
|
2689
|
+
|
|
2690
|
+
elif head in ("log", "changelog"):
|
|
2691
|
+
self.show_changelog()
|
|
2692
|
+
|
|
2693
|
+
elif head == "lesson":
|
|
2694
|
+
self.handle_lesson_command(sub_args)
|
|
2695
|
+
|
|
2696
|
+
elif head == "pause":
|
|
2697
|
+
self.should_pause = True
|
|
2698
|
+
console.print("[yellow]Pause flag set.[/yellow]")
|
|
2699
|
+
|
|
2700
|
+
elif head == "stop":
|
|
2701
|
+
self.should_stop = True
|
|
2702
|
+
console.print("[red]Stop flag set.[/red]")
|
|
2703
|
+
|
|
2704
|
+
elif head == "history":
|
|
2705
|
+
if self.proj:
|
|
2706
|
+
show_all_commits(self.proj["path"])
|
|
2707
|
+
else:
|
|
2708
|
+
console.print(
|
|
2709
|
+
"[bold red]No active project selected.[/bold red]"
|
|
2710
|
+
)
|
|
2711
|
+
|
|
2712
|
+
elif head == "rollback":
|
|
2713
|
+
if not sub_args:
|
|
2714
|
+
console.print(
|
|
2715
|
+
"[bold yellow]Usage:[/bold yellow] rollback <commit_sha>"
|
|
2716
|
+
)
|
|
2717
|
+
elif self.proj:
|
|
2718
|
+
safe_rollback(self.proj["path"], sub_args)
|
|
2719
|
+
|
|
2720
|
+
elif head == "help":
|
|
2721
|
+
self.print_help()
|
|
2722
|
+
|
|
2723
|
+
elif head == "upd-tiers":
|
|
2724
|
+
from tools.model_ranker import auto_update_tiers
|
|
2725
|
+
auto_update_tiers()
|
|
2726
|
+
|
|
2727
|
+
elif head in ("exit", "quit"):
|
|
2728
|
+
console.print("[bold yellow]Exiting VegeX...[/bold yellow]")
|
|
2729
|
+
sys.exit(0)
|
|
2730
|
+
|
|
2731
|
+
else:
|
|
2732
|
+
console.print(
|
|
2733
|
+
f"[bold red]Unknown command:[/bold red] '{head}'. "
|
|
2734
|
+
f"Type [yellow]help[/yellow] for options."
|
|
2735
|
+
)
|
|
2736
|
+
|
|
2737
|
+
except (KeyboardInterrupt, EOFError):
|
|
2738
|
+
console.print("\nExiting VegeX...")
|
|
2739
|
+
break
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
# ---------------------------------------------------------------------------
|
|
2743
|
+
# Console-script entry point
|
|
2744
|
+
# ---------------------------------------------------------------------------
|
|
2745
|
+
import sys
|
|
2746
|
+
|
|
2747
|
+
def run_cli() -> None:
|
|
2748
|
+
# Если аргументов нет, просто запускаем интерактивную консоль
|
|
2749
|
+
if len(sys.argv) <= 1:
|
|
2750
|
+
app = OrchestratorConsole()
|
|
2751
|
+
# Инициализируем guard прямо у экземпляра класса, если это необходимо
|
|
2752
|
+
app.behavior_guard = BehaviorGuard()
|
|
2753
|
+
app.start()
|
|
2754
|
+
return
|
|
2755
|
+
|
|
2756
|
+
cmd = sys.argv[1].lower()
|
|
2757
|
+
args = " ".join(sys.argv[2:])
|
|
2758
|
+
|
|
2759
|
+
# Карта соответствия команд и вызываемых функций/методов
|
|
2760
|
+
# Для методов создаем один экземпляр OrchestratorConsole()
|
|
2761
|
+
console = OrchestratorConsole()
|
|
2762
|
+
|
|
2763
|
+
commands = {
|
|
2764
|
+
("--help", "-h", "help"): lambda: console.print_help(),
|
|
2765
|
+
("task", "t", "--task", "-t"): lambda: console.handle_task_command(args),
|
|
2766
|
+
("run", "start", "--run"): lambda: console.run_agent_cycle(prompt_arg=args),
|
|
2767
|
+
("status", "--status"): lambda: console.show_status(),
|
|
2768
|
+
("projects", "ls", "--projects"): lambda: console.print_projects_table(),
|
|
2769
|
+
("log", "changelog", "--log"): lambda: console.show_changelog(),
|
|
2770
|
+
("lesson",): lambda: console.handle_lesson_command(args),
|
|
2771
|
+
("coherence",): lambda: console.handle_coherence_command(args),
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
# Поиск и выполнение обычной команды
|
|
2775
|
+
for keys, func in commands.items():
|
|
2776
|
+
if cmd in keys:
|
|
2777
|
+
func()
|
|
2778
|
+
return
|
|
2779
|
+
|
|
2780
|
+
# Отдельная обработка для функций, требующих кастомной логики
|
|
2781
|
+
if cmd == "llm":
|
|
2782
|
+
handle_llm_command(args)
|
|
2783
|
+
return
|
|
2784
|
+
|
|
2785
|
+
if cmd == "msg":
|
|
2786
|
+
handle_msg_command(
|
|
2787
|
+
project_dir=console.proj["path"] if console.proj else ".",
|
|
2788
|
+
state=console.load_state(),
|
|
2789
|
+
)
|
|
2790
|
+
return
|
|
2791
|
+
|
|
2792
|
+
# Если команда не распознана
|
|
2793
|
+
print(f"Неизвестная команда: {cmd}. Используйте --help для справки.")
|
|
2794
|
+
|
|
2795
|
+
if __name__ == "__main__":
|
|
2796
|
+
run_cli()
|