feat: implement auto-mode for iterative task execution with state persistence and git integration

This commit is contained in:
sambella 2026-04-12 23:38:40 +05:30
parent 4eecaf06e4
commit 1cb68ded0b
2 changed files with 253 additions and 1 deletions

13
cli.py
View file

@ -9446,7 +9446,18 @@ class HermesCLI:
if app.is_running:
app.exit()
continue
# ✨ AUTO command — iterative task loop via execute_task()
if isinstance(user_input, str) and user_input.startswith("auto "):
task = user_input[len("auto "):].strip()
if task:
_cprint(f"\n🔄 AUTO mode: {task}")
if self.agent is not None:
self.agent.execute_task(task)
else:
_cprint(" ⚠ Agent not initialized yet — send a normal message first.")
continue
# Expand paste references back to full content
import re as _re
_paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')

View file

@ -504,6 +504,247 @@ class AIAgent:
_context_pressure_last_warned: dict = {}
_CONTEXT_PRESSURE_COOLDOWN = 300 # seconds between re-warning same session
def create_prd(self, task: str) -> list:
"""Break a high-level task into a numbered, actionable step list.
Sends a planning prompt to the LLM and parses the response into a
list of step dicts: ``[{"step": str, "done": bool}, ...]``.
"""
prompt = f"""
Break this task into clear step-by-step actionable items.
Task:
{task}
Return as a numbered list.
"""
response = self.run(prompt)
steps = [s.strip() for s in response.split("\n") if s.strip()]
return [{"step": s, "done": False} for s in steps]
def execute_task(self, task: str, max_iters: int = 15,
state_file: str = "agent_state.json"):
"""Execute *task* via a structured PRD loop with persistence and git.
Workflow:
1. Load saved state from *state_file* if it exists (resume), otherwise
call :meth:`create_prd` to decompose the task.
2. Skip steps already marked ``done`` (resume support).
3. Error-aware retry: appends a correction hint to the prompt when the
previous response contained the word "error".
4. Auto-validates Python files by running them after each response.
5. Saves progress to *state_file* and commits to git after each step.
Returns the PRD list with ``done`` flags updated.
"""
# ── Resume or plan ────────────────────────────────────────────
prd = self.load_state(state_file) or self.create_prd(task)
print("\n\U0001f4cb Task Plan:")
for i, item in enumerate(prd):
status = "\u2705" if item.get("done") else "\u25cb"
print(f" {status} {i + 1}. {item['step']}")
# ── Step loop ─────────────────────────────────────────────────
for i, item in enumerate(prd):
if item.get("done"):
print(f"\n\u23ed\ufe0f Skipping Step {i + 1} (already done): {item['step']}")
continue
print(f"\n\U0001f680 Working on Step {i + 1}/{len(prd)}: {item['step']}")
base_prompt = f"""
You are an autonomous coding agent executing step {i + 1} of {len(prd)}.
Step:
{item['step']}
Rules:
- Use read_file to inspect existing files before writing
- Use write_file to create or overwrite files with complete content
- Use search_files to discover what already exists in the workspace
- Modify files instead of rewriting from scratch when possible
- Build real, working code not pseudocode or placeholders
- Do not restart. Continue progress from previous attempts.
"""
prompt = base_prompt
for attempt in range(max_iters):
print(f" Attempt {attempt + 1}/{max_iters}")
response = self.run(prompt)
print("\n\U0001f916 Response:\n", response)
# Auto-validate: run any Python file mentioned in the response
if "python" in response.lower() or ".py" in response.lower():
import re as _re
py_files = _re.findall(r'[\w/\\.-]+\.py', response)
for py_file in py_files[:1]:
print(f"\n\u26a1 Auto-validating: {py_file}")
output = self.run_python_file(py_file)
print("\u26a1 Execution Output:\n", output)
if self._is_task_complete(response):
item["done"] = True
print(f"\u2705 Step {i + 1} completed")
# Persist progress and commit to git
self.save_state(prd, state_file)
self.git_commit(f"[auto] Step {i + 1}: {item['step'][:72]}")
break
# Error-aware retry: inject correction hint into next prompt
if "error" in response.lower():
prompt = base_prompt + "\nThe previous attempt produced an error. Fix the error and try again."
else:
prompt = base_prompt
else:
print(f"\u26a0\ufe0f Step {i + 1} hit max retries ({max_iters}) — moving on")
self.save_state(prd, state_file) # Save partial progress
completed = sum(1 for item in prd if item.get("done"))
print(f"\n\U0001f389 TASK EXECUTION COMPLETE — {completed}/{len(prd)} steps done")
if completed == len(prd):
# Clean up saved state on full completion
import os as _os
try:
_os.remove(state_file)
except OSError:
pass
return prd
def _is_task_complete(self, response: str) -> bool:
"""Ask the LLM whether the step response counts as done.
Falls back to keyword matching when the LLM call itself fails,
so a broken completion-check never silently kills the loop.
"""
prompt = f"""
Determine if this task step is completed.
Response:
{response}
Answer ONLY: YES or NO
"""
try:
result = self.run(prompt)
return "yes" in result.lower()
except Exception:
# Fallback: keyword heuristic
keywords = ["task completed", "done", "successfully built", "finished", "working solution"]
return any(k in response.lower() for k in keywords)
def _refine_task(self, task: str, response: str) -> str:
return f"""
Original task:
{task}
Previous attempt:
{response}
Fix errors and continue the task.
Do not restart. Continue from where you left off.
"""
def run_python_file(self, path: str, timeout: int = 30) -> str:
"""Execute a Python file in a subprocess and return its output.
Safe wrapper around subprocess.run captures stdout + stderr,
enforces a *timeout* (default 30 s), and never raises; returns the
error description as a string instead so the execution loop can
keep going even when a script crashes.
"""
import subprocess
try:
result = subprocess.run(
["python", path],
capture_output=True,
text=True,
timeout=timeout,
)
output = (result.stdout + result.stderr).strip()
return output or "(no output)"
except subprocess.TimeoutExpired:
return f"Timed out after {timeout}s — script may be running an infinite loop."
except FileNotFoundError:
return f"File not found: {path}"
except Exception as exc:
return str(exc)
# ── Persistence ───────────────────────────────────────────────────────
def save_state(self, prd: list, filename: str = "agent_state.json") -> None:
"""Persist the PRD list to *filename* so the task can be resumed."""
import json as _json
try:
with open(filename, "w", encoding="utf-8") as f:
_json.dump(prd, f, indent=2, ensure_ascii=False)
logger.debug("Agent state saved to %s", filename)
except Exception as exc:
logger.warning("Could not save agent state to %s: %s", filename, exc)
def load_state(self, filename: str = "agent_state.json") -> list | None:
"""Load a previously saved PRD list from *filename*.
Returns ``None`` when the file does not exist or is unreadable,
so callers can fall back to :meth:`create_prd` transparently.
"""
import json as _json
try:
with open(filename, "r", encoding="utf-8") as f:
data = _json.load(f)
if isinstance(data, list):
print(f"\U0001f504 Resuming from saved state: {filename} ({len(data)} steps)")
return data
except (FileNotFoundError, _json.JSONDecodeError):
pass
except Exception as exc:
logger.warning("Could not load agent state from %s: %s", filename, exc)
return None
# ── Git helpers ───────────────────────────────────────────────────────
def git_commit(self, message: str) -> str:
"""Stage all changes and create a git commit with *message*.
Returns a status string (success or error) never raises so the
execution loop continues even in non-git workspaces.
"""
import subprocess as _sp
try:
_sp.run(["git", "add", "."], check=True, capture_output=True, text=True)
result = _sp.run(
["git", "commit", "-m", message],
check=True, capture_output=True, text=True,
)
msg = result.stdout.strip() or "Commit successful"
logger.debug("git commit: %s", msg)
return msg
except _sp.CalledProcessError as exc:
# "nothing to commit" is not a real error
stderr = (exc.stderr or "").lower()
if "nothing to commit" in stderr or "nothing added" in stderr:
return "Nothing to commit."
return f"git commit failed: {exc.stderr or exc}"
except FileNotFoundError:
return "git not found — skipping commit."
except Exception as exc:
return str(exc)
def git_status(self) -> str:
"""Return the current ``git status`` output as a string."""
import subprocess as _sp
try:
result = _sp.run(
["git", "status"],
capture_output=True, text=True, timeout=10,
)
return result.stdout or result.stderr or "(no output)"
except FileNotFoundError:
return "git not found."
except Exception as exc:
return str(exc)
@property
def base_url(self) -> str:
return self._base_url