**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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user