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
tools/reflection.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/reflection.py
|
|
3
|
+
-------------------
|
|
4
|
+
Periodic self-reflection over recent failed cycles.
|
|
5
|
+
|
|
6
|
+
Runs every N iterations (config.REFLECTION_INTERVAL). Reads recent
|
|
7
|
+
iteration logs, clusters failures by normalized reason, and asks an LLM
|
|
8
|
+
to extract generalizable observations. Output observations are written
|
|
9
|
+
to lessons/observations/ with _auto_generated: true.
|
|
10
|
+
|
|
11
|
+
The observations then flow through the existing lifecycle:
|
|
12
|
+
observations → SQLite (via bridge) → A-Score ranking
|
|
13
|
+
→ eventually promoted by human or by sustained helpful count.
|
|
14
|
+
|
|
15
|
+
Never raises. Never blocks the pipeline. Worst case, it produces zero
|
|
16
|
+
observations and the pipeline continues.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
import time
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
import config
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _reflection_interval() -> int:
|
|
35
|
+
return int(getattr(config, "REFLECTION_INTERVAL", 5))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reflection_model() -> str:
|
|
39
|
+
# Reflection shapes future behavior; use the top-tier model unless
|
|
40
|
+
# explicitly overridden.
|
|
41
|
+
explicit = getattr(config, "REFLECTION_MODEL", None)
|
|
42
|
+
if explicit:
|
|
43
|
+
return explicit
|
|
44
|
+
try:
|
|
45
|
+
from tools.llm_selector import MODEL_TIERS
|
|
46
|
+
high = MODEL_TIERS.get("high") or []
|
|
47
|
+
if high:
|
|
48
|
+
return high[0]
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
return "anthropic/claude-sonnet-4.5"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _max_observations_per_run() -> int:
|
|
55
|
+
return int(getattr(config, "REFLECTION_MAX_OBSERVATIONS", 3))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _min_pattern_occurrences() -> int:
|
|
59
|
+
return int(getattr(config, "REFLECTION_MIN_OCCURRENCES", 2))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ReflectionResult:
|
|
64
|
+
success: bool
|
|
65
|
+
observations_written: List[str] = field(default_factory=list)
|
|
66
|
+
observations_skipped: List[str] = field(default_factory=list)
|
|
67
|
+
failures_analyzed: int = 0
|
|
68
|
+
patterns_found: int = 0
|
|
69
|
+
error: str = ""
|
|
70
|
+
|
|
71
|
+
def as_dict(self) -> Dict:
|
|
72
|
+
return {
|
|
73
|
+
"success": self.success,
|
|
74
|
+
"observations_written": self.observations_written,
|
|
75
|
+
"observations_skipped": self.observations_skipped,
|
|
76
|
+
"failures_analyzed": self.failures_analyzed,
|
|
77
|
+
"patterns_found": self.patterns_found,
|
|
78
|
+
"error": self.error,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_recent_logs(project_dir: str, limit: int = 30) -> List[Dict[str, Any]]:
|
|
83
|
+
logs_dir = Path(project_dir) / "iteration_logs"
|
|
84
|
+
if not logs_dir.is_dir():
|
|
85
|
+
return []
|
|
86
|
+
files = sorted(
|
|
87
|
+
logs_dir.glob("*.json"),
|
|
88
|
+
key=lambda p: p.stat().st_mtime,
|
|
89
|
+
reverse=True,
|
|
90
|
+
)[:limit]
|
|
91
|
+
out: List[Dict[str, Any]] = []
|
|
92
|
+
for p in files:
|
|
93
|
+
try:
|
|
94
|
+
with p.open("r", encoding="utf-8") as f:
|
|
95
|
+
data = json.load(f)
|
|
96
|
+
if isinstance(data, dict):
|
|
97
|
+
data["_source_file"] = p.name
|
|
98
|
+
out.append(data)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
logger.debug("Skipping unreadable log %s: %s", p, exc)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _extract_failures(logs: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
105
|
+
failures: List[Dict[str, str]] = []
|
|
106
|
+
for log in logs:
|
|
107
|
+
iteration = log.get("iteration_count") or log.get("iteration") or 0
|
|
108
|
+
reason = ""
|
|
109
|
+
target = log.get("active_target_file") or log.get("target_file") or ""
|
|
110
|
+
|
|
111
|
+
review = log.get("review_result") or {}
|
|
112
|
+
if isinstance(review, dict):
|
|
113
|
+
verdict = str(review.get("verdict", "")).upper()
|
|
114
|
+
if verdict == "REJECT":
|
|
115
|
+
reason = review.get("reason") or review.get("feedback") or "rejected"
|
|
116
|
+
|
|
117
|
+
compiler = str(log.get("compiler_output") or "")
|
|
118
|
+
if not reason and "REVIEWER_REJECTED" in compiler:
|
|
119
|
+
match = re.search(r"REVIEWER_REJECTED:\s*(.+)", compiler)
|
|
120
|
+
reason = match.group(1).strip() if match else "rejected"
|
|
121
|
+
|
|
122
|
+
if not reason:
|
|
123
|
+
status = str(log.get("status") or "").lower()
|
|
124
|
+
if status == "failed":
|
|
125
|
+
reason = compiler[:200] or "failed"
|
|
126
|
+
|
|
127
|
+
if reason:
|
|
128
|
+
failures.append({
|
|
129
|
+
"cycle": str(iteration),
|
|
130
|
+
"reason": reason[:400],
|
|
131
|
+
"target_file": target,
|
|
132
|
+
"kind": "reviewer_reject" if "REJECT" in compiler else "cycle_failure",
|
|
133
|
+
})
|
|
134
|
+
return failures
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalize_reason(reason: str) -> str:
|
|
138
|
+
text = (reason or "").lower()
|
|
139
|
+
text = re.sub(r"[^\w\s]", " ", text)
|
|
140
|
+
text = re.sub(
|
|
141
|
+
r"\b(contains?|has|have|found|blocking|across|project|file|files|the|a|an)\b",
|
|
142
|
+
" ",
|
|
143
|
+
text,
|
|
144
|
+
)
|
|
145
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
146
|
+
return " ".join(text.split()[:10])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _cluster_failures(failures: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
|
150
|
+
groups: Dict[str, List[Dict[str, str]]] = defaultdict(list)
|
|
151
|
+
for f in failures:
|
|
152
|
+
key = _normalize_reason(f["reason"])
|
|
153
|
+
if key:
|
|
154
|
+
groups[key].append(f)
|
|
155
|
+
|
|
156
|
+
threshold = _min_pattern_occurrences()
|
|
157
|
+
clusters = []
|
|
158
|
+
for key, members in groups.items():
|
|
159
|
+
if len(members) >= threshold:
|
|
160
|
+
clusters.append({
|
|
161
|
+
"normalized_key": key,
|
|
162
|
+
"count": len(members),
|
|
163
|
+
"sample_reason": members[0]["reason"],
|
|
164
|
+
"sample_targets": list({m["target_file"] for m in members if m["target_file"]}),
|
|
165
|
+
"cycles": [m["cycle"] for m in members],
|
|
166
|
+
})
|
|
167
|
+
clusters.sort(key=lambda c: c["count"], reverse=True)
|
|
168
|
+
return clusters
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _existing_lesson_ids(project_dir: str) -> set:
|
|
172
|
+
ids = set()
|
|
173
|
+
for stage in ("observations", "candidates", "promoted", "deprecated"):
|
|
174
|
+
stage_dir = Path(project_dir) / "lessons" / stage
|
|
175
|
+
if not stage_dir.is_dir():
|
|
176
|
+
continue
|
|
177
|
+
for p in stage_dir.glob("*.json"):
|
|
178
|
+
ids.add(p.stem)
|
|
179
|
+
return ids
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _similar_root_cause_exists(
|
|
183
|
+
candidate_root_cause: str,
|
|
184
|
+
existing_lessons: List[Dict[str, Any]],
|
|
185
|
+
threshold: float = 0.75,
|
|
186
|
+
) -> bool:
|
|
187
|
+
def tokens(s: str) -> set:
|
|
188
|
+
return set(re.findall(r"[a-z]{4,}", (s or "").lower()))
|
|
189
|
+
|
|
190
|
+
new_tokens = tokens(candidate_root_cause)
|
|
191
|
+
if not new_tokens:
|
|
192
|
+
return False
|
|
193
|
+
for lesson in existing_lessons:
|
|
194
|
+
existing = tokens(lesson.get("root_cause", ""))
|
|
195
|
+
if not existing:
|
|
196
|
+
continue
|
|
197
|
+
inter = new_tokens & existing
|
|
198
|
+
union = new_tokens | existing
|
|
199
|
+
if union and len(inter) / len(union) >= threshold:
|
|
200
|
+
return True
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _load_all_existing_lessons(project_dir: str) -> List[Dict[str, Any]]:
|
|
205
|
+
out = []
|
|
206
|
+
for stage in ("observations", "candidates", "promoted"):
|
|
207
|
+
stage_dir = Path(project_dir) / "lessons" / stage
|
|
208
|
+
if not stage_dir.is_dir():
|
|
209
|
+
continue
|
|
210
|
+
for p in stage_dir.glob("*.json"):
|
|
211
|
+
try:
|
|
212
|
+
with p.open("r", encoding="utf-8") as f:
|
|
213
|
+
data = json.load(f)
|
|
214
|
+
if isinstance(data, dict):
|
|
215
|
+
out.append(data)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_REFLECTION_SYSTEM_PROMPT = """You are a self-reflection agent for an AI coding pipeline.
|
|
222
|
+
|
|
223
|
+
You receive a summary of recent FAILED cycles. Your job is to find PATTERNS
|
|
224
|
+
across failures, not to fix individual bugs.
|
|
225
|
+
|
|
226
|
+
Rules:
|
|
227
|
+
1. Only propose an observation if the same underlying pattern appears
|
|
228
|
+
at least twice in the input.
|
|
229
|
+
2. Each observation must be a general, reusable rule. Not a specific fix.
|
|
230
|
+
Bad: "add the class .card-title to styles.css"
|
|
231
|
+
Good: "every HTML class must have a matching CSS rule in the stylesheet"
|
|
232
|
+
3. Output at most {max_obs} observations.
|
|
233
|
+
4. If nothing generalizable can be said, output an empty list. Better to
|
|
234
|
+
output zero observations than one fabricated pattern.
|
|
235
|
+
5. Each observation must have a short snake_case id, a one-sentence
|
|
236
|
+
root_cause, and a one-sentence details.rule.
|
|
237
|
+
|
|
238
|
+
Respond ONLY with a valid JSON object in this exact shape:
|
|
239
|
+
{{
|
|
240
|
+
"observations": [
|
|
241
|
+
{{
|
|
242
|
+
"id": "short_snake_case_id",
|
|
243
|
+
"root_cause": "one sentence",
|
|
244
|
+
"details": {{"rule": "one sentence"}}
|
|
245
|
+
}}
|
|
246
|
+
]
|
|
247
|
+
}}
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _build_reflection_prompt(clusters: List[Dict[str, Any]]) -> str:
|
|
252
|
+
parts = ["Recent failure patterns:\n"]
|
|
253
|
+
for i, c in enumerate(clusters, 1):
|
|
254
|
+
parts.append(f"Pattern {i} (occurred {c['count']} times):")
|
|
255
|
+
parts.append(f" Sample reason: {c['sample_reason']}")
|
|
256
|
+
if c["sample_targets"]:
|
|
257
|
+
parts.append(f" Files involved: {', '.join(c['sample_targets'][:3])}")
|
|
258
|
+
parts.append("")
|
|
259
|
+
return "\n".join(parts)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _call_reflection_llm(prompt: str) -> Optional[Dict[str, Any]]:
|
|
263
|
+
try:
|
|
264
|
+
from tools.llm_client import safe_chat_completion
|
|
265
|
+
except ImportError:
|
|
266
|
+
logger.warning("llm_client not importable; reflection skipped")
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
system_prompt = _REFLECTION_SYSTEM_PROMPT.format(
|
|
270
|
+
max_obs=_max_observations_per_run()
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
response, _ = safe_chat_completion(
|
|
275
|
+
role_or_model="researcher",
|
|
276
|
+
messages=[
|
|
277
|
+
{"role": "system", "content": system_prompt},
|
|
278
|
+
{"role": "user", "content": prompt},
|
|
279
|
+
],
|
|
280
|
+
temperature=0.2,
|
|
281
|
+
max_tokens=1500,
|
|
282
|
+
model=_reflection_model(),
|
|
283
|
+
)
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
logger.warning("Reflection LLM call failed: %s", exc)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
if not response or not getattr(response, "choices", None):
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
raw = (response.choices[0].message.content or "").strip()
|
|
292
|
+
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
|
|
293
|
+
if m:
|
|
294
|
+
raw = m.group(1)
|
|
295
|
+
else:
|
|
296
|
+
start = raw.find("{")
|
|
297
|
+
end = raw.rfind("}")
|
|
298
|
+
if start != -1 and end > start:
|
|
299
|
+
raw = raw[start:end + 1]
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
return json.loads(raw)
|
|
303
|
+
except json.JSONDecodeError:
|
|
304
|
+
logger.warning("Reflection: could not parse JSON from LLM output")
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _slugify(raw: str) -> str:
|
|
309
|
+
s = re.sub(r"[^a-z0-9]+", "_", (raw or "").lower()).strip("_")
|
|
310
|
+
return s[:60] or "auto_observation"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _write_observation(
|
|
314
|
+
project_dir: str,
|
|
315
|
+
observation: Dict[str, Any],
|
|
316
|
+
source_clusters: List[Dict[str, Any]],
|
|
317
|
+
) -> Optional[str]:
|
|
318
|
+
raw_id = observation.get("id") or ""
|
|
319
|
+
obs_id = _slugify(raw_id)
|
|
320
|
+
if not obs_id:
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
root_cause = str(observation.get("root_cause") or "").strip()
|
|
324
|
+
rule = str((observation.get("details") or {}).get("rule") or "").strip()
|
|
325
|
+
if not root_cause or not rule:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
obs_dir = Path(project_dir) / "lessons" / "observations"
|
|
329
|
+
obs_dir.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
|
|
331
|
+
target = obs_dir / f"{obs_id}.json"
|
|
332
|
+
if target.exists():
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
payload = {
|
|
336
|
+
"id": obs_id,
|
|
337
|
+
"root_cause": root_cause,
|
|
338
|
+
"details": {"rule": rule},
|
|
339
|
+
"_auto_generated": True,
|
|
340
|
+
"_source": "reflector",
|
|
341
|
+
"_generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
342
|
+
"_source_patterns": [
|
|
343
|
+
{"count": c["count"], "sample": c["sample_reason"][:200]}
|
|
344
|
+
for c in source_clusters
|
|
345
|
+
],
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
target.write_text(
|
|
350
|
+
json.dumps(payload, indent=4, ensure_ascii=False),
|
|
351
|
+
encoding="utf-8",
|
|
352
|
+
)
|
|
353
|
+
except OSError as exc:
|
|
354
|
+
logger.warning("Failed to write observation %s: %s", obs_id, exc)
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
return obs_id
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def should_reflect(iteration: int) -> bool:
|
|
361
|
+
interval = _reflection_interval()
|
|
362
|
+
return interval > 0 and iteration > 0 and iteration % interval == 0
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def run_reflection(
|
|
366
|
+
state: Dict[str, Any],
|
|
367
|
+
project_dir: str = ".",
|
|
368
|
+
force: bool = False,
|
|
369
|
+
) -> ReflectionResult:
|
|
370
|
+
"""
|
|
371
|
+
Run one reflection cycle. Never raises.
|
|
372
|
+
|
|
373
|
+
force=True ignores the minimum-occurrence threshold. Use for the
|
|
374
|
+
manual `vegex reflect` command.
|
|
375
|
+
"""
|
|
376
|
+
result = ReflectionResult(success=False)
|
|
377
|
+
try:
|
|
378
|
+
logs = _load_recent_logs(project_dir, limit=30)
|
|
379
|
+
failures = _extract_failures(logs)
|
|
380
|
+
result.failures_analyzed = len(failures)
|
|
381
|
+
|
|
382
|
+
if not failures:
|
|
383
|
+
result.success = True
|
|
384
|
+
result.error = "no recent failures to reflect on"
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
clusters = _cluster_failures(failures)
|
|
388
|
+
if not clusters and not force:
|
|
389
|
+
result.success = True
|
|
390
|
+
result.error = "no repeated patterns found"
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
if not clusters:
|
|
394
|
+
seen: Dict[str, Dict[str, Any]] = {}
|
|
395
|
+
for f in failures:
|
|
396
|
+
key = _normalize_reason(f["reason"])
|
|
397
|
+
if key and key not in seen:
|
|
398
|
+
seen[key] = {
|
|
399
|
+
"normalized_key": key,
|
|
400
|
+
"count": 1,
|
|
401
|
+
"sample_reason": f["reason"],
|
|
402
|
+
"sample_targets": [f["target_file"]] if f["target_file"] else [],
|
|
403
|
+
"cycles": [f["cycle"]],
|
|
404
|
+
}
|
|
405
|
+
clusters = list(seen.values())[:_max_observations_per_run() * 2]
|
|
406
|
+
|
|
407
|
+
result.patterns_found = len(clusters)
|
|
408
|
+
|
|
409
|
+
prompt = _build_reflection_prompt(clusters)
|
|
410
|
+
parsed = _call_reflection_llm(prompt)
|
|
411
|
+
if not parsed:
|
|
412
|
+
result.error = "LLM call or parse failed"
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
raw_observations = parsed.get("observations") or []
|
|
416
|
+
if not isinstance(raw_observations, list):
|
|
417
|
+
result.error = "observations field is not a list"
|
|
418
|
+
return result
|
|
419
|
+
|
|
420
|
+
existing_lessons = _load_all_existing_lessons(project_dir)
|
|
421
|
+
existing_ids = _existing_lesson_ids(project_dir)
|
|
422
|
+
|
|
423
|
+
for obs in raw_observations[:_max_observations_per_run()]:
|
|
424
|
+
if not isinstance(obs, dict):
|
|
425
|
+
continue
|
|
426
|
+
obs_id = _slugify(obs.get("id", ""))
|
|
427
|
+
if not obs_id:
|
|
428
|
+
continue
|
|
429
|
+
if obs_id in existing_ids:
|
|
430
|
+
result.observations_skipped.append(obs_id)
|
|
431
|
+
continue
|
|
432
|
+
if _similar_root_cause_exists(obs.get("root_cause", ""), existing_lessons):
|
|
433
|
+
result.observations_skipped.append(obs_id)
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
written = _write_observation(project_dir, obs, clusters)
|
|
437
|
+
if written:
|
|
438
|
+
result.observations_written.append(written)
|
|
439
|
+
existing_ids.add(written)
|
|
440
|
+
else:
|
|
441
|
+
result.observations_skipped.append(obs_id)
|
|
442
|
+
|
|
443
|
+
result.success = True
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
logger.exception("Reflection failed")
|
|
448
|
+
result.error = f"unhandled exception: {exc}"
|
|
449
|
+
return result
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
__all__ = ["ReflectionResult", "run_reflection", "should_reflect"]
|
tools/sandbox_utils.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
VEGEX_FRAMEWORK_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
5
|
+
|
|
6
|
+
VEGEX_FRAMEWORK_CORE_FILES = {
|
|
7
|
+
".env", "config.py", "state.json", "agents_chat.py",
|
|
8
|
+
"agents_chat_2.py", "deduplicate.py", "PROJECT_STRUCTURE.md"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
VEGEX_PROTECTED_DIRS = {"tools", "lessons", "iteration_logs"}
|
|
12
|
+
|
|
13
|
+
def is_vegex_framework_file(target_file: str, project_dir: str = ".") -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Проверяет, принадлежит ли файл самому фреймворку VegeX
|
|
16
|
+
(включая папки tools/, lessons/ и т.д.), защищая их от перезаписи агентами.
|
|
17
|
+
"""
|
|
18
|
+
abs_target = os.path.abspath(os.path.join(project_dir, target_file))
|
|
19
|
+
rel_to_root = os.path.relpath(abs_target, VEGEX_FRAMEWORK_ROOT)
|
|
20
|
+
|
|
21
|
+
if rel_to_root.startswith(".."):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
parts = Path(rel_to_root).parts
|
|
25
|
+
|
|
26
|
+
# 1. Защита файлов в корне фреймворка
|
|
27
|
+
if len(parts) == 1 and parts[0] in VEGEX_FRAMEWORK_CORE_FILES:
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
# 2. Защита служебных директорий (tools/*.py, lessons/*.json и др.)
|
|
31
|
+
if len(parts) >= 2 and parts[0] in VEGEX_PROTECTED_DIRS:
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def validate_sandboxed_path(target_file: str, project_dir: str = ".") -> str:
|
|
37
|
+
"""Проверяет границы песочницы и запрещает модификацию кода самого фреймворка."""
|
|
38
|
+
base_path = os.path.abspath(project_dir)
|
|
39
|
+
target_path = os.path.abspath(os.path.join(base_path, target_file))
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
contained = os.path.commonpath([base_path, target_path]) == base_path
|
|
43
|
+
except ValueError:
|
|
44
|
+
contained = False
|
|
45
|
+
|
|
46
|
+
if not contained:
|
|
47
|
+
raise PermissionError(f"SANDBOX VIOLATION: Path '{target_file}' escapes boundary '{project_dir}'.")
|
|
48
|
+
|
|
49
|
+
if is_vegex_framework_file(target_file, project_dir):
|
|
50
|
+
raise PermissionError(f"PROTECTION VIOLATION: Modification of framework system file '{target_file}' is forbidden.")
|
|
51
|
+
|
|
52
|
+
return target_path
|
|
53
|
+
from tools.project_scanner import get_full_project_context
|
|
54
|
+
|
|
55
|
+
def get_project_context(project_dir: str = ".") -> str:
|
|
56
|
+
return get_full_project_context(project_dir=project_dir)
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/security/behavior_guard.py
|
|
3
|
+
--------------------------------
|
|
4
|
+
Detects runaway agent behavior (context bloat, repeated actions,
|
|
5
|
+
path escapes) before it burns tokens or compromises the sandbox.
|
|
6
|
+
|
|
7
|
+
This is the direct fix for the August 2026 incident where a looping agent
|
|
8
|
+
kept appending files to its context, eventually hallucinating success.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BehaviorGuard:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.recent_attempts = []
|
|
19
|
+
self.context_size_history = []
|
|
20
|
+
|
|
21
|
+
def _get_action_signature(self, state: dict, meta) -> str:
|
|
22
|
+
"""Create a unique fingerprint of what the agent just did."""
|
|
23
|
+
target = str(state.get("target_file") or "")
|
|
24
|
+
# Use the agent's raw output (or the generated code) as the payload hash
|
|
25
|
+
raw = ""
|
|
26
|
+
if hasattr(meta, "raw_response"):
|
|
27
|
+
raw = str(meta.raw_response)
|
|
28
|
+
elif isinstance(state.get("generated_code"), dict):
|
|
29
|
+
raw = str(state["generated_code"].get("code", ""))
|
|
30
|
+
|
|
31
|
+
payload_hash = hashlib.md5(raw.encode("utf-8")).hexdigest()
|
|
32
|
+
return f"{target}:{payload_hash}"
|
|
33
|
+
|
|
34
|
+
def observe_attempt(self, state: dict, meta) -> str | None:
|
|
35
|
+
"""
|
|
36
|
+
Returns a reason string if we should HALT the pipeline, else None.
|
|
37
|
+
"""
|
|
38
|
+
# 1. Context Bloat Detection
|
|
39
|
+
prompt_tokens = getattr(meta, "prompt_token_count", 0) or 0
|
|
40
|
+
self.context_size_history.append(prompt_tokens)
|
|
41
|
+
|
|
42
|
+
if len(self.context_size_history) >= 3:
|
|
43
|
+
last3 = self.context_size_history[-3:]
|
|
44
|
+
is_massive = last3[2] > 25000
|
|
45
|
+
is_exponential = last3[0] < last3[1] < last3[2] and last3[2] > (last3[0] * 2.0)
|
|
46
|
+
|
|
47
|
+
if is_massive or is_exponential:
|
|
48
|
+
return (
|
|
49
|
+
f"CONTEXT_BLOAT: prompt size growing dangerously "
|
|
50
|
+
f"({last3[0]} -> {last3[1]} -> {last3[2]} tokens). "
|
|
51
|
+
f"Agent is likely stuck in a loop adding irrelevant files."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# 2. Repeated Action Detection
|
|
55
|
+
sig = self._get_action_signature(state, meta)
|
|
56
|
+
self.recent_attempts.append({"sig": sig, "ts": time.time()})
|
|
57
|
+
self.recent_attempts = self.recent_attempts[-5:] # keep last 5
|
|
58
|
+
|
|
59
|
+
if len(self.recent_attempts) >= 3:
|
|
60
|
+
last3_sigs = [a["sig"] for a in self.recent_attempts[-3:]]
|
|
61
|
+
if len(set(last3_sigs)) == 1:
|
|
62
|
+
return (
|
|
63
|
+
"REPEATED_ACTION: the exact same patch/code was generated "
|
|
64
|
+
"3 times in a row. The agent is stuck."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# 3. Path Escape Detection
|
|
68
|
+
project_root = state.get("project_root", ".")
|
|
69
|
+
target_file = state.get("target_file")
|
|
70
|
+
if target_file:
|
|
71
|
+
abs_target = os.path.abspath(os.path.join(project_root, target_file))
|
|
72
|
+
abs_root = os.path.abspath(project_root)
|
|
73
|
+
|
|
74
|
+
# Check if target is outside root
|
|
75
|
+
if not abs_target.startswith(abs_root + os.sep) and abs_target != abs_root:
|
|
76
|
+
return f"PATH_ESCAPE: target file '{target_file}' is outside the project root."
|
|
77
|
+
|
|
78
|
+
# Check for credential files
|
|
79
|
+
sensitive_names = (".env", "id_rsa", ".pem", "credentials", "secrets")
|
|
80
|
+
if any(s in os.path.basename(abs_target).lower() for s in sensitive_names):
|
|
81
|
+
return f"CREDENTIAL_ACCESS: agent tried to modify sensitive file '{target_file}'."
|
|
82
|
+
|
|
83
|
+
return None
|