211 lines
7.8 KiB
Python
211 lines
7.8 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import time
|
||
|
|
import traceback
|
||
|
|
from typing import Any, Dict, List
|
||
|
|
|
||
|
|
from .config import load_config, workspace_path
|
||
|
|
from .memory import append_journal, load_evolution, maybe_update_evolution, tail_journal
|
||
|
|
from .ollama_client import chat
|
||
|
|
from .tools import ToolExecutor, ToolError, parse_model_json
|
||
|
|
|
||
|
|
|
||
|
|
ACTION_SCHEMA = r"""
|
||
|
|
You must reply with ONLY a single raw JSON object (no markdown fences, no commentary) of this form:
|
||
|
|
{
|
||
|
|
"reflection": "short reasoning",
|
||
|
|
"actions": [
|
||
|
|
{"type": "list_dir", "path": "frontline/units"},
|
||
|
|
{"type": "read_file", "path": "relative/path/from/repo/root.mq5"},
|
||
|
|
{"type": "write_file", "path": "self-coding-agent/generated/example.txt", "content": "file contents"},
|
||
|
|
{"type": "fetch_url", "url": "https://..."},
|
||
|
|
{"type": "run_backtest", "strategy": "RSIReversalStrategy", "symbol": "XAUUSD", "start": "2023-01-01", "end": "2024-01-01", "timeframe": "H1"},
|
||
|
|
{"type": "run_python", "script_relative": "backtesting/MT5/test_setup.py", "args": []}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
Rules:
|
||
|
|
- Paths are relative to the repository root and must stay under allowed prefixes.
|
||
|
|
- Prefer reading before writing; keep edits minimal and compile-friendly for MQL5.
|
||
|
|
- Use fetch_url for MQL5 documentation pages when unsure about APIs.
|
||
|
|
- run_backtest uses the repo's Python MT5 harness (MetaTrader 5 terminal must be installed/running).
|
||
|
|
- If you only need to think, use an empty actions list.
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def build_system_message(cfg: Dict[str, Any]) -> str:
|
||
|
|
mission = str(cfg.get("mission") or "").strip()
|
||
|
|
prefixes = cfg.get("allowed_path_prefixes") or []
|
||
|
|
return (
|
||
|
|
mission
|
||
|
|
+ "\n\nAllowed path prefixes (read/write/list):\n"
|
||
|
|
+ "\n".join(f"- {p}" for p in prefixes)
|
||
|
|
+ "\n\n"
|
||
|
|
+ ACTION_SCHEMA
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def build_user_message(
|
||
|
|
iteration: int,
|
||
|
|
last_results: str,
|
||
|
|
journal_tail: str,
|
||
|
|
evolution_hint: str,
|
||
|
|
) -> str:
|
||
|
|
parts = [
|
||
|
|
f"Iteration: {iteration}",
|
||
|
|
"Previous tool results (JSON):\n```json\n"
|
||
|
|
+ last_results
|
||
|
|
+ "\n```",
|
||
|
|
]
|
||
|
|
if journal_tail.strip():
|
||
|
|
parts.append("Recent journal (tail):\n" + journal_tail)
|
||
|
|
if evolution_hint.strip():
|
||
|
|
parts.append("Evolution memory hint:\n" + evolution_hint)
|
||
|
|
parts.append(
|
||
|
|
"Plan the next improvements and output your JSON response. "
|
||
|
|
"If this is iteration 1 and there is no parse error yet, prefer list_dir/read_file only; "
|
||
|
|
"avoid run_backtest until you have read relevant code."
|
||
|
|
)
|
||
|
|
return "\n\n".join(parts)
|
||
|
|
|
||
|
|
|
||
|
|
def run_loop(
|
||
|
|
*,
|
||
|
|
max_iterations: int | None = None,
|
||
|
|
model: str | None = None,
|
||
|
|
sleep_seconds: float | None = None,
|
||
|
|
mt5_backtest_enabled: bool | None = None,
|
||
|
|
) -> None:
|
||
|
|
cfg = load_config()
|
||
|
|
if mt5_backtest_enabled is not None:
|
||
|
|
cfg.setdefault("mt5_backtest", {})["enabled"] = bool(mt5_backtest_enabled)
|
||
|
|
ws = workspace_path(cfg)
|
||
|
|
ex = ToolExecutor(cfg, ws)
|
||
|
|
|
||
|
|
ollama = cfg.get("ollama") or {}
|
||
|
|
base_url = str(ollama.get("base_url", "http://127.0.0.1:11434"))
|
||
|
|
model_name = str(model or ollama.get("model", "llama3.2"))
|
||
|
|
options = ollama.get("options") or {}
|
||
|
|
|
||
|
|
loop_cfg = cfg.get("loop") or {}
|
||
|
|
max_iters = int(max_iterations if max_iterations is not None else loop_cfg.get("max_iterations", 0))
|
||
|
|
sleep_s = float(sleep_seconds if sleep_seconds is not None else loop_cfg.get("sleep_seconds", 2.0))
|
||
|
|
err_limit = int(loop_cfg.get("consecutive_error_limit", 15))
|
||
|
|
|
||
|
|
mem_cfg = cfg.get("memory") or {}
|
||
|
|
journal_max = int(mem_cfg.get("journal_max_lines", 80))
|
||
|
|
|
||
|
|
system = build_system_message(cfg)
|
||
|
|
|
||
|
|
iteration = 0
|
||
|
|
consecutive_errors = 0
|
||
|
|
last_results_json = json.dumps({"info": "No previous tool results yet."})
|
||
|
|
|
||
|
|
while True:
|
||
|
|
iteration += 1
|
||
|
|
if max_iters and iteration > max_iters:
|
||
|
|
print(f"Stopping: reached max_iterations={max_iters}", flush=True)
|
||
|
|
return
|
||
|
|
|
||
|
|
ev = load_evolution(cfg)
|
||
|
|
ev_notes = ev.get("notes") or []
|
||
|
|
evolution_hint = ""
|
||
|
|
if ev_notes:
|
||
|
|
evolution_hint = json.dumps(ev_notes[-3:], ensure_ascii=False)
|
||
|
|
|
||
|
|
journal_tail = tail_journal(cfg, journal_max)
|
||
|
|
user = build_user_message(iteration, last_results_json, journal_tail, evolution_hint)
|
||
|
|
|
||
|
|
messages: List[Dict[str, str]] = [
|
||
|
|
{"role": "system", "content": system},
|
||
|
|
{"role": "user", "content": user},
|
||
|
|
]
|
||
|
|
|
||
|
|
try:
|
||
|
|
raw = chat(base_url, model_name, messages, options=options)
|
||
|
|
try:
|
||
|
|
plan = parse_model_json(raw)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
tail = raw if len(raw) <= 4000 else raw[:4000] + "\n...[truncated]..."
|
||
|
|
print("Model returned non-JSON; raw (truncated):\n" + tail + "\n---", flush=True)
|
||
|
|
raise
|
||
|
|
actions = plan.get("actions") or []
|
||
|
|
if not isinstance(actions, list):
|
||
|
|
raise ValueError("actions must be a list")
|
||
|
|
|
||
|
|
results: List[Dict[str, Any]] = []
|
||
|
|
backtest_blob = ""
|
||
|
|
max_actions = 12
|
||
|
|
for i, action in enumerate(actions[:max_actions]):
|
||
|
|
if not isinstance(action, dict):
|
||
|
|
results.append({"ok": False, "error": "action must be an object"})
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
r = ex.execute(action)
|
||
|
|
results.append({"action": action, "result": r})
|
||
|
|
if action.get("type") == "run_backtest":
|
||
|
|
stdout = str((r or {}).get("stdout") or "")
|
||
|
|
stderr = str((r or {}).get("stderr") or "")
|
||
|
|
backtest_blob = stdout + "\n" + stderr
|
||
|
|
except ToolError as e:
|
||
|
|
results.append({"action": action, "result": {"ok": False, "error": str(e)}})
|
||
|
|
except Exception as e:
|
||
|
|
results.append(
|
||
|
|
{"action": action, "result": {"ok": False, "error": f"{type(e).__name__}: {e}"}}
|
||
|
|
)
|
||
|
|
|
||
|
|
last_results_json = json.dumps(
|
||
|
|
{
|
||
|
|
"reflection": plan.get("reflection"),
|
||
|
|
"parsed_ok": True,
|
||
|
|
"tool_results": results,
|
||
|
|
},
|
||
|
|
ensure_ascii=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
if backtest_blob.strip():
|
||
|
|
maybe_update_evolution(cfg, iteration, backtest_blob)
|
||
|
|
|
||
|
|
append_journal(
|
||
|
|
cfg,
|
||
|
|
{
|
||
|
|
"iteration": iteration,
|
||
|
|
"reflection": plan.get("reflection"),
|
||
|
|
"actions": actions[:max_actions],
|
||
|
|
"ok": True,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
consecutive_errors = 0
|
||
|
|
print(
|
||
|
|
f"[iter {iteration}] ok reflection={str(plan.get('reflection', ''))[:160]!r} "
|
||
|
|
f"actions={len(actions[:max_actions])}",
|
||
|
|
flush=True,
|
||
|
|
)
|
||
|
|
time.sleep(max(0.0, sleep_s))
|
||
|
|
|
||
|
|
except KeyboardInterrupt:
|
||
|
|
print("Interrupted by user; exiting.", flush=True)
|
||
|
|
return
|
||
|
|
except Exception as e:
|
||
|
|
consecutive_errors += 1
|
||
|
|
err_text = f"{type(e).__name__}: {e}\n{traceback.format_exc()[-4000:]}"
|
||
|
|
print(err_text, flush=True)
|
||
|
|
append_journal(
|
||
|
|
cfg,
|
||
|
|
{
|
||
|
|
"iteration": iteration,
|
||
|
|
"ok": False,
|
||
|
|
"error": err_text,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
last_results_json = json.dumps({"parse_or_run_error": err_text}, ensure_ascii=False)
|
||
|
|
backoff = min(300.0, float(2 ** min(consecutive_errors, 8)))
|
||
|
|
if consecutive_errors >= err_limit:
|
||
|
|
print(
|
||
|
|
f"Many consecutive errors ({consecutive_errors}); sleeping {backoff:.1f}s before retry.",
|
||
|
|
flush=True,
|
||
|
|
)
|
||
|
|
time.sleep(backoff)
|