refactor(cleanup): remove legacy CLI and complete EvoAgent migration cleanup

- Delete backend/cli.py and all CLI-specific tests (test_cli.py,
  test_openclaw_cli_service.py, test_skills_cli.py)
- Remove evotraders console script from pyproject.toml
- Update README/CLAUDE.md to reference python backend/main.py instead of CLI
- Add pytest-asyncio to dev dependencies
- Enhance EvoAgent with reload_runtime_assets and backward-compat attrs
- Align tests with updated API semantics and gateway process models

Constraint: CLI is deprecated in favor of split-service runtime model
Confidence: high
Scope-risk: moderate
This commit is contained in:
2026-04-02 02:06:46 +08:00
parent 3334a41e5a
commit 49d704c363
15 changed files with 117 additions and 2151 deletions

View File

@@ -126,6 +126,8 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
self.agent_id = agent_id
self.config_name = config_name
self.workspace_dir = Path(workspace_dir)
self.workspace_id = config_name
self.config = {"config_name": config_name}
self._skills_manager = skills_manager or SkillsManager()
self._env_context = env_context
self._prompt_files = prompt_files
@@ -328,6 +330,17 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
# Call parent (which may be ToolGuardMixin's _reasoning)
return await super()._reasoning(**kwargs)
def reload_runtime_assets(self, active_skill_dirs: Optional[List[Path]] = None) -> None:
"""Reload toolkit and system prompt from current run assets.
Refreshes prompt files from workspace config and rebuilds the toolkit.
"""
# Rebuild system prompt (also refreshes _agent_config and _prompt_files)
self.rebuild_sys_prompt()
# Reload skills/toolkit
self.reload_skills(active_skill_dirs=active_skill_dirs)
def reload_skills(self, active_skill_dirs: Optional[List[Path]] = None) -> None:
"""Reload skills at runtime.
@@ -497,6 +510,10 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
# Reload agent config in case it changed
self._agent_config = self._load_agent_config()
# Refresh prompt_files from updated config
if "prompt_files" in self._agent_config:
self._prompt_files = list(self._agent_config["prompt_files"])
# Rebuild prompt
self._sys_prompt = self._build_system_prompt()

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ def test_agent_service_status_includes_scope_metadata(tmp_path):
payload = response.json()
assert payload["scope"]["design_time_registry"]["root"] == str(tmp_path / "workspaces")
assert payload["scope"]["runtime_assets"]["root"] == str(tmp_path / "runs")
assert "runs/<run_id>" in payload["scope"]["agent_route_note"]
assert "runs/{run_id}" in payload["scope"]["agent_route_note"]
def test_agent_service_read_routes(monkeypatch, tmp_path):

View File

@@ -1,317 +0,0 @@
# -*- coding: utf-8 -*-
from pathlib import Path
from typer.testing import CliRunner
from backend import cli
def test_live_runs_incremental_market_store_update_before_start(monkeypatch, tmp_path):
project_root = tmp_path
(project_root / ".env").write_text("FINNHUB_API_KEY=test\n", encoding="utf-8")
calls = []
monkeypatch.setattr(cli, "get_project_root", lambda: project_root)
monkeypatch.setattr(cli, "handle_history_cleanup", lambda config_name, auto_clean=False: None)
monkeypatch.setattr(cli, "run_data_updater", lambda project_root: calls.append(("run_data_updater", project_root)))
monkeypatch.setattr(
cli,
"auto_update_market_store",
lambda config_name, end_date=None: calls.append(("auto_update_market_store", config_name, end_date)),
)
monkeypatch.setattr(
cli,
"auto_enrich_market_store",
lambda config_name, end_date=None, lookback_days=120, force=False: calls.append(
("auto_enrich_market_store", config_name, end_date, lookback_days, force)
),
)
monkeypatch.setattr(cli.os, "chdir", lambda path: calls.append(("chdir", Path(path))))
def fake_run(cmd, check=True, **kwargs):
calls.append(("subprocess.run", cmd, check))
return 0
monkeypatch.setattr(cli.subprocess, "run", fake_run)
cli.live(
config_name="smoke_fullstack",
host="0.0.0.0",
port=8765,
trigger_time="now",
poll_interval=10,
clean=False,
enable_memory=False,
)
assert any(item[0] == "run_data_updater" for item in calls)
assert any(
item[0] == "auto_update_market_store" and item[1] == "smoke_fullstack"
for item in calls
)
assert any(
item[0] == "auto_enrich_market_store" and item[1] == "smoke_fullstack"
for item in calls
)
run_call = next(item for item in calls if item[0] == "subprocess.run")
assert run_call[1][:6] == [
cli.sys.executable,
"-u",
"-m",
"backend.main",
"--mode",
"live",
]
def test_backtest_runs_full_market_store_prepare_before_start(monkeypatch, tmp_path):
project_root = tmp_path
calls = []
monkeypatch.setattr(cli, "get_project_root", lambda: project_root)
monkeypatch.setattr(cli, "handle_history_cleanup", lambda config_name, auto_clean=False: None)
monkeypatch.setattr(cli, "run_data_updater", lambda project_root: calls.append(("run_data_updater", project_root)))
monkeypatch.setattr(
cli,
"auto_prepare_backtest_market_store",
lambda config_name, start_date, end_date: calls.append(
("auto_prepare_backtest_market_store", config_name, start_date, end_date)
),
)
monkeypatch.setattr(
cli,
"auto_enrich_market_store",
lambda config_name, end_date=None, lookback_days=120, force=False: calls.append(
("auto_enrich_market_store", config_name, end_date, lookback_days, force)
),
)
monkeypatch.setattr(cli.os, "chdir", lambda path: calls.append(("chdir", Path(path))))
def fake_run(cmd, check=True, **kwargs):
calls.append(("subprocess.run", cmd, check))
return 0
monkeypatch.setattr(cli.subprocess, "run", fake_run)
cli.backtest(
start="2026-03-01",
end="2026-03-10",
config_name="smoke_fullstack",
host="0.0.0.0",
port=8765,
poll_interval=10,
clean=False,
enable_memory=False,
)
assert any(item[0] == "run_data_updater" for item in calls)
assert any(
item[0] == "auto_prepare_backtest_market_store"
and item[1:] == ("smoke_fullstack", "2026-03-01", "2026-03-10")
for item in calls
)
assert any(
item[0] == "auto_enrich_market_store"
and item[1] == "smoke_fullstack"
and item[2] == "2026-03-10"
for item in calls
)
run_call = next(item for item in calls if item[0] == "subprocess.run")
assert run_call[1][:6] == [
cli.sys.executable,
"-u",
"-m",
"backend.main",
"--mode",
"backtest",
]
def test_live_cli_defaults_to_generic_run_label(monkeypatch, tmp_path):
project_root = tmp_path
(project_root / ".env").write_text("FINNHUB_API_KEY=test\n", encoding="utf-8")
calls = []
runner = CliRunner()
monkeypatch.setattr(cli, "get_project_root", lambda: project_root)
monkeypatch.setattr(cli, "handle_history_cleanup", lambda config_name, auto_clean=False: None)
monkeypatch.setattr(cli, "run_data_updater", lambda project_root: None)
monkeypatch.setattr(cli, "auto_update_market_store", lambda config_name, end_date=None: None)
monkeypatch.setattr(
cli,
"auto_enrich_market_store",
lambda config_name, end_date=None, lookback_days=120, force=False: None,
)
monkeypatch.setattr(cli.os, "chdir", lambda path: None)
def fake_run(cmd, check=True, **kwargs):
calls.append(cmd)
return 0
monkeypatch.setattr(cli.subprocess, "run", fake_run)
result = runner.invoke(cli.app, ["live", "--trigger-time", "now"])
assert result.exit_code == 0
assert calls
assert "--config-name" in calls[0]
config_index = calls[0].index("--config-name")
assert calls[0][config_index + 1] == "default_live_run"
def test_backtest_cli_defaults_to_generic_run_label(monkeypatch, tmp_path):
project_root = tmp_path
calls = []
runner = CliRunner()
monkeypatch.setattr(cli, "get_project_root", lambda: project_root)
monkeypatch.setattr(cli, "handle_history_cleanup", lambda config_name, auto_clean=False: None)
monkeypatch.setattr(cli, "run_data_updater", lambda project_root: None)
monkeypatch.setattr(
cli,
"auto_prepare_backtest_market_store",
lambda config_name, start_date, end_date: None,
)
monkeypatch.setattr(
cli,
"auto_enrich_market_store",
lambda config_name, end_date=None, lookback_days=120, force=False: None,
)
monkeypatch.setattr(cli.os, "chdir", lambda path: None)
def fake_run(cmd, check=True, **kwargs):
calls.append(cmd)
return 0
monkeypatch.setattr(cli.subprocess, "run", fake_run)
result = runner.invoke(
cli.app,
["backtest", "--start", "2026-03-01", "--end", "2026-03-10"],
)
assert result.exit_code == 0
assert calls
assert "--config-name" in calls[0]
config_index = calls[0].index("--config-name")
assert calls[0][config_index + 1] == "default_backtest_run"
def test_main_parser_defaults_to_generic_run_label():
from backend.main import build_arg_parser
parser = build_arg_parser()
args = parser.parse_args([])
assert args.config_name == "default_run"
def test_ingest_enrich_runs_batch_enrichment(monkeypatch):
calls = []
monkeypatch.setattr(cli, "_resolve_symbols", lambda raw_tickers, config_name=None: ["AAPL", "MSFT"])
class DummyStore:
pass
monkeypatch.setattr(cli, "MarketStore", lambda: DummyStore())
monkeypatch.setattr(
cli,
"enrich_symbols",
lambda store, symbols, start_date=None, end_date=None, limit=200, analysis_source="local", skip_existing=True: calls.append(
("enrich_symbols", symbols, start_date, end_date, limit, analysis_source, skip_existing)
) or [
{
"symbol": symbol,
"news_count": 3,
"queued_count": 3,
"analyzed": 3,
"skipped_existing_count": 0,
"deduped_count": 0,
"llm_count": 0,
"local_count": 3,
}
for symbol in symbols
],
)
cli.ingest_enrich(
tickers=None,
start="2026-03-01",
end="2026-03-10",
limit=150,
force=False,
config_name="smoke_fullstack",
)
assert calls == [
("enrich_symbols", ["AAPL", "MSFT"], "2026-03-01", "2026-03-10", 150, "local", True)
]
def test_ingest_report_reads_market_store_report(monkeypatch):
calls = []
printed = []
monkeypatch.setattr(cli, "_resolve_symbols", lambda raw_tickers, config_name=None: ["AAPL"])
class DummyStore:
def get_enrich_report(self, symbols=None, start_date=None, end_date=None):
calls.append(("get_enrich_report", symbols, start_date, end_date))
return [
{
"symbol": "AAPL",
"raw_news_count": 10,
"analyzed_news_count": 8,
"coverage_pct": 80.0,
"llm_count": 5,
"local_count": 3,
"latest_trade_date": "2026-03-16",
"latest_analysis_at": "2026-03-16T09:00:00",
}
]
monkeypatch.setattr(cli, "MarketStore", lambda: DummyStore())
monkeypatch.setattr(cli, "get_explain_model_info", lambda: {"provider": "DASHSCOPE", "model_name": "qwen-max", "label": "DASHSCOPE:qwen-max"})
monkeypatch.setattr(cli, "llm_enrichment_enabled", lambda: True)
monkeypatch.setattr(cli.console, "print", lambda value: printed.append(value))
cli.ingest_report(
tickers=None,
start="2026-03-01",
end="2026-03-16",
config_name="smoke_fullstack",
only_problematic=False,
)
assert calls == [
("get_enrich_report", ["AAPL"], "2026-03-01", "2026-03-16")
]
assert printed
assert getattr(printed[0], "caption", "") == "Explain LLM: DASHSCOPE:qwen-max"
def test_filter_problematic_report_rows_keeps_low_coverage_and_no_llm():
rows = [
{
"symbol": "AAPL",
"coverage_pct": 100.0,
"llm_count": 2,
},
{
"symbol": "MSFT",
"coverage_pct": 80.0,
"llm_count": 1,
},
{
"symbol": "NVDA",
"coverage_pct": 100.0,
"llm_count": 0,
},
]
filtered = cli._filter_problematic_report_rows(rows)
assert [row["symbol"] for row in filtered] == ["MSFT", "NVDA"]

View File

@@ -198,6 +198,7 @@ def test_evo_agent_reload_runtime_assets_refreshes_prompt_files(monkeypatch, tmp
workspace_dir=workspace_dir,
model=DummyModel(),
formatter=DummyFormatter(),
prompt_files=["SOUL.md"],
skills_manager=type(
"SkillsManagerStub",
(),
@@ -248,11 +249,15 @@ def test_pipeline_create_runtime_analyst_uses_evo_agent_when_enabled(monkeypatch
created = {}
class DummyEvoAgent:
name = "test_analyst"
def __init__(self, **kwargs):
created.update(kwargs)
self.toolkit = None
class DummyAnalystAgent:
name = "test_analyst"
def __init__(self, **kwargs):
created.update(kwargs)
self.toolkit = None
@@ -308,11 +313,15 @@ def test_pipeline_create_runtime_analyst_uses_legacy_when_not_in_evo_ids(monkeyp
created = {}
class DummyEvoAgent:
name = "test_analyst"
def __init__(self, **kwargs):
created.update(kwargs)
self.toolkit = None
class DummyAnalystAgent:
name = "test_analyst"
def __init__(self, **kwargs):
created.update(kwargs)
self.toolkit = None

View File

@@ -115,7 +115,7 @@ class TestPollingPriceManager:
{"c": 100.0, "o": 99.0, "h": 101.0, "l": 98.0, "pc": 99.5, "d": 0.5, "dp": 0.5, "t": 1},
],
):
with caplog.at_level(logging.INFO):
with caplog.at_level(logging.INFO, logger="backend.data.polling_price_manager"):
manager._fetch_prices()
manager._fetch_prices()

View File

@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
"""Tests for the OpenClaw CLI service wrapper."""
from pathlib import Path
import pytest
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
class _Completed:
def __init__(self, *, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def test_openclaw_cli_service_runs_json_command(monkeypatch, tmp_path):
captured = {}
def _fake_run(command, **kwargs):
captured["command"] = command
captured["cwd"] = kwargs["cwd"]
return _Completed(stdout='{"sessions":[{"key":"main/session-1"}]}')
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
payload = service.list_sessions()
assert payload["sessions"][0]["key"] == "main/session-1"
assert captured["command"] == ["openclaw", "sessions", "--json"]
assert captured["cwd"] == tmp_path
def test_openclaw_cli_service_raises_on_failure(monkeypatch, tmp_path):
def _fake_run(command, **kwargs):
return _Completed(returncode=7, stdout="", stderr="boom")
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
with pytest.raises(OpenClawCliError) as exc_info:
service.list_cron_jobs()
assert exc_info.value.exit_code == 7
assert exc_info.value.stderr == "boom"
def test_openclaw_cli_service_can_extract_single_session(monkeypatch, tmp_path):
def _fake_run(command, **kwargs):
return _Completed(stdout='{"sessions":[{"key":"main/session-1","agentId":"main"}]}')
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
session = service.get_session("main/session-1")
assert session["agentId"] == "main"

View File

@@ -43,11 +43,49 @@ class _FakeOpenClawCliService:
"items": [{"role": "assistant", "text": "hello"}],
}
def status_model(self):
from shared.models.openclaw import OpenClawStatus
return OpenClawStatus(runtimeVersion="2026.3.24")
def get_session_model(self, session_key: str):
from shared.models.openclaw import SessionEntry
for session in self.list_sessions()["sessions"]:
if session["key"] == session_key:
return SessionEntry.model_validate(session, strict=False)
raise KeyError(session_key)
def list_sessions_model(self):
from shared.models.openclaw import SessionsList, SessionEntry
sessions = [
SessionEntry.model_validate(s, strict=False)
for s in self.list_sessions()["sessions"]
]
return SessionsList(sessions=sessions)
def get_session_history_model(self, session_key: str, *, limit: int = 20):
from shared.models.openclaw import SessionHistory
raw = self.get_session_history(session_key, limit=limit)
return SessionHistory(
sessionKey=raw["sessionKey"],
session_id=None,
events=raw["items"],
history=raw["items"],
raw_text=None,
)
def list_cron_jobs(self):
return {"jobs": [{"id": "job-1", "name": "Daily sync"}]}
def list_cron_jobs_model(self):
from shared.models.openclaw import CronList
return CronList.from_raw(self.list_cron_jobs())
def list_approvals(self):
return {"approvals": [{"id": "ap-1", "status": "pending"}]}
return {"approvals": [{"approvalId": "ap-1", "toolName": "test_tool", "status": "pending"}]}
def list_approvals_model(self):
from shared.models.openclaw import ApprovalsList
return ApprovalsList.from_raw(self.list_approvals())
def test_openclaw_service_routes_are_exposed():
@@ -85,17 +123,17 @@ def test_openclaw_service_read_routes():
assert status.status_code == 200
assert status.json()["status"] == "operational"
assert openclaw_status.status_code == 200
assert openclaw_status.json()["runtimeVersion"] == "2026.3.24"
assert openclaw_status.json()["runtime_version"] == "2026.3.24"
assert sessions.status_code == 200
assert len(sessions.json()["sessions"]) == 2
assert session.status_code == 200
assert session.json()["session"]["agentId"] == "main"
assert session.json()["session"]["agent_id"] == "main"
assert history.status_code == 200
assert history.json()["limit"] == 5
assert len(history.json()["events"]) == 1
assert cron.status_code == 200
assert cron.json()["jobs"][0]["id"] == "job-1"
assert approvals.status_code == 200
assert approvals.json()["approvals"][0]["id"] == "ap-1"
assert approvals.json()["approvals"][0]["approval_id"] == "ap-1"
def test_openclaw_service_session_404():

View File

@@ -38,8 +38,13 @@ def test_runtime_service_health_and_status(monkeypatch):
assert health_response.json() == {
"status": "healthy",
"service": "runtime-service",
"gateway_running": False,
"gateway_port": 9876,
"gateway": {
"running": False,
"port": 9876,
"pid": None,
"process_status": "not_running",
"returncode": None,
},
}
assert status_response.status_code == 200
assert status_response.json() == {
@@ -48,6 +53,8 @@ def test_runtime_service_health_and_status(monkeypatch):
"runtime": {
"gateway_running": False,
"gateway_port": 9876,
"gateway_pid": None,
"gateway_process_status": "not_running",
"has_runtime_manager": True,
},
}
@@ -365,6 +372,8 @@ def test_runtime_service_start_stop_lifecycle_contract(monkeypatch, tmp_path):
return self.context
class _DummyProcess:
pid = 12345
def poll(self):
return None
@@ -574,6 +583,8 @@ def test_start_runtime_restore_reuses_historical_run_id(monkeypatch, tmp_path):
return self.context
class _DummyProcess:
pid = 12345
def poll(self):
return None

View File

@@ -25,6 +25,8 @@ class _DummyAsyncClient:
async def get(self, path, params=None):
self.calls.append(("get", path, params))
if path == "/sessions/main/session-1":
return _DummyResponse({"session": {"key": "main/session-1", "agentId": "main"}})
return _DummyResponse({"path": path, "params": params})
async def post(self, path, json=None):

View File

@@ -1,119 +0,0 @@
# -*- coding: utf-8 -*-
from backend import cli
from backend.agents.skill_metadata import parse_skill_metadata
from backend.agents.skills_manager import SkillsManager
from backend.agents.team_pipeline_config import (
ensure_team_pipeline_config,
load_team_pipeline_config,
update_active_analysts,
)
def test_parse_skill_metadata_extended_frontmatter(tmp_path):
skill_dir = tmp_path / "demo_skill"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: demo_skill\n"
"description: Demo description\n"
"tools:\n"
" - technical\n"
"---\n\n"
"# Demo Skill\n",
encoding="utf-8",
)
parsed = parse_skill_metadata(skill_dir, source="builtin")
assert parsed.skill_name == "demo_skill"
assert parsed.description == "Demo description"
assert parsed.tools == ["technical"]
def test_update_agent_skill_overrides(tmp_path):
manager = SkillsManager(project_root=tmp_path)
asset_dir = manager.get_agent_asset_dir("demo", "risk_manager")
asset_dir.mkdir(parents=True, exist_ok=True)
(asset_dir / "agent.yaml").write_text(
"enabled_skills:\n"
" - risk_review\n"
"disabled_skills:\n"
" - old_skill\n",
encoding="utf-8",
)
result = manager.update_agent_skill_overrides(
config_name="demo",
agent_id="risk_manager",
enable=["extra_guard"],
disable=["risk_review"],
)
assert result["enabled_skills"] == ["extra_guard"]
assert result["disabled_skills"] == ["old_skill", "risk_review"]
def test_skills_enable_disable_and_list(monkeypatch, tmp_path):
builtin_root = tmp_path / "backend" / "skills" / "builtin"
for name in ("risk_review", "extra_guard"):
skill_dir = builtin_root / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {name} desc\n---\n",
encoding="utf-8",
)
printed = []
monkeypatch.setattr(cli, "get_project_root", lambda: tmp_path)
monkeypatch.setattr(cli.console, "print", lambda value: printed.append(value))
cli.skills_enable(agent_id="risk_manager", skill="extra_guard", config_name="demo")
cli.skills_disable(agent_id="risk_manager", skill="risk_review", config_name="demo")
cli.skills_list(config_name="demo", agent_id="risk_manager")
text_dump = "\n".join(str(item) for item in printed)
assert "Enabled" in text_dump
assert "Disabled" in text_dump
assert any(getattr(item, "title", None) == "Skill Catalog" for item in printed)
def test_install_external_skill_for_agent(tmp_path):
manager = SkillsManager(project_root=tmp_path)
skill_dir = tmp_path / "downloaded" / "new_skill"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: new_skill\n"
"description: external skill\n"
"---\n\n"
"# New Skill\n",
encoding="utf-8",
)
result = manager.install_external_skill_for_agent(
config_name="demo",
agent_id="risk_manager",
source=str(skill_dir),
activate=True,
)
assert result["skill_name"] == "new_skill"
target = manager.get_agent_local_root("demo", "risk_manager") / "new_skill"
assert target.exists()
def test_team_pipeline_active_analyst_updates(tmp_path):
project_root = tmp_path
ensure_team_pipeline_config(
project_root=project_root,
config_name="demo",
default_analysts=["fundamentals_analyst", "technical_analyst"],
)
update_active_analysts(
project_root=project_root,
config_name="demo",
available_analysts=["fundamentals_analyst", "technical_analyst"],
remove=["technical_analyst"],
)
config = load_team_pipeline_config(project_root, "demo")
assert config["discussion"]["active_analysts"] == ["fundamentals_analyst"]