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.
Files changed (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. 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