**feat: refactor agent loop to state machine with dynamic trajectory checks**

- Replace hard-coded `MAX_REPL_STEPS` death-limit with a dynamic Checkpoint Interval.
- Introduce 4-phase state machine: `PLANNING`, `EXECUTING`, `TRAJECTORY_CHECK`, and `PIVOTING`.
- Add Trajectory Checks allowing the agent to self-assess progress against defined success criteria to extend its execution budget.
- Implement Hard (wipe strategy/kernel) and Soft (retry step) Pivoting logic.
- Establish persistent notebook-style kernel (flushed only on Hard Pivots).
- Upgrade sandbox safety with strict import whitelisting, dunder blocking, and taint tracking.

**bugfix:

- logging_config.py: Add markdown_it to suppressed logger list
- utils.py: Rewrite save_agent_trace for delta-based logging
- edge_rlm.py: Add last_trace_msg_count tracking and pass deltas at all 3 call sites
This commit is contained in:
2026-07-18 20:47:44 +01:00
parent 834183807a
commit f70c9b2054
6 changed files with 591 additions and 135 deletions
+84 -2
View File
@@ -10,6 +10,13 @@ from rich.panel import Panel
logger = logging.getLogger(__name__)
console = Console()
WHITELISTED_IMPORTS = {
"json", "math", "datetime", "re", "collections",
"statistics", "random", "difflib", "string",
}
DANGEROUS_BUILTINS = {"eval", "exec", "compile"}
def init_trace_file(log_dir="logs"):
if not os.path.exists(log_dir):
@@ -20,12 +27,15 @@ def init_trace_file(log_dir="logs"):
logger.debug("Trace logging initialized: %s", filename)
return filename
def save_agent_trace(filepath, messages, step=0):
def save_agent_trace(filepath, new_messages, step=0, state=None, plan_step=0, total_messages=0):
try:
data_to_save = {
"timestamp": time.time(),
"step": step,
"context_window": messages
"state": state,
"plan_step": plan_step,
"total_messages": total_messages,
"new_messages": new_messages,
}
with open(filepath, 'a', encoding='utf-8') as f:
@@ -35,6 +45,15 @@ def save_agent_trace(filepath, messages, step=0):
logger.error("Failed to save trace file: %s", e)
def _check_dunder_access(node):
if isinstance(node, ast.Attribute) and node.attr.startswith("__") and node.attr.endswith("__"):
return True
if isinstance(node, ast.Subscript):
if isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str):
if node.slice.value.startswith("__") and node.slice.value.endswith("__"):
return True
return False
def _analyze_code_safety(code_str):
try:
tree = ast.parse(code_str)
@@ -45,6 +64,23 @@ def _analyze_code_safety(code_str):
has_print = False
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name not in WHITELISTED_IMPORTS:
return False, f"Import Violation: '{alias.name}' is not whitelisted. Allowed: {', '.join(sorted(WHITELISTED_IMPORTS))}.", node.lineno
if isinstance(node, ast.ImportFrom):
if node.module not in WHITELISTED_IMPORTS:
return False, f"Import Violation: '{node.module}' is not whitelisted. Allowed: {', '.join(sorted(WHITELISTED_IMPORTS))}.", node.lineno
if _check_dunder_access(node):
name = None
if isinstance(node, ast.Attribute):
name = node.attr
elif isinstance(node, ast.Subscript):
name = getattr(node.slice, 'value', getattr(node.slice, 's', None))
return False, f"Dunder Access Violation: Accessing '{name}' is forbidden.", getattr(node, 'lineno', None)
if isinstance(node, ast.Assign):
if isinstance(node.value, ast.Name) and node.value.id in tainted_vars:
for target in node.targets:
@@ -52,6 +88,10 @@ def _analyze_code_safety(code_str):
tainted_vars.add(target.id)
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name):
if node.func.id in DANGEROUS_BUILTINS:
return False, f"Builtin Violation: '{node.func.id}()' is forbidden.", node.lineno
if isinstance(node.func, ast.Name) and node.func.id == 'print':
has_print = True
for arg in node.args:
@@ -230,6 +270,48 @@ Write the final response in natural language (Markdown).
{"role": "user", "content": user_prompt}
])
def validate_plan_object(obj):
if not isinstance(obj, dict):
return False, "PlanObject must be a JSON object."
for key in ("goal", "strategy", "success_criteria"):
if key not in obj:
return False, f"PlanObject missing required key '{key}'."
if not isinstance(obj["goal"], str) or not obj["goal"].strip():
return False, "PlanObject 'goal' must be a non-empty string."
if not isinstance(obj["strategy"], list) or len(obj["strategy"]) == 0:
return False, "PlanObject 'strategy' must be a non-empty list of strings."
if not isinstance(obj["success_criteria"], list) or len(obj["success_criteria"]) == 0:
return False, "PlanObject 'success_criteria' must be a non-empty list of strings."
if len(obj["strategy"]) != len(obj["success_criteria"]):
return False, "PlanObject 'strategy' and 'success_criteria' must have the same length."
for i, s in enumerate(obj["strategy"]):
if not isinstance(s, str) or not s.strip():
return False, f"PlanObject 'strategy[{i}]' must be a non-empty string."
for i, s in enumerate(obj["success_criteria"]):
if not isinstance(s, str) or not s.strip():
return False, f"PlanObject 'success_criteria[{i}]' must be a non-empty string."
return True, None
def validate_pivot_object(obj):
if not isinstance(obj, dict):
return False, "PivotObject must be a JSON object."
if "diagnosis" not in obj:
return False, "PivotObject missing required key 'diagnosis'."
if not isinstance(obj["diagnosis"], str) or not obj["diagnosis"].strip():
return False, "PivotObject 'diagnosis' must be a non-empty string."
if "new_strategy" in obj or "new_success_criteria" in obj:
if "new_strategy" not in obj or "new_success_criteria" not in obj:
return False, "PivotObject: 'new_strategy' and 'new_success_criteria' must both be present or both omitted."
if not isinstance(obj["new_strategy"], list) or len(obj["new_strategy"]) == 0:
return False, "PivotObject 'new_strategy' must be a non-empty list of strings."
if not isinstance(obj["new_success_criteria"], list) or len(obj["new_success_criteria"]) == 0:
return False, "PivotObject 'new_success_criteria' must be a non-empty list of strings."
if len(obj["new_strategy"]) != len(obj["new_success_criteria"]):
return False, "PivotObject 'new_strategy' and 'new_success_criteria' must have the same length."
return True, None
def load_file(filepath):
try:
with open(filepath, 'r', encoding='utf-8') as f: