Add per-agent skill workspaces and TraderView management

This commit is contained in:
2026-03-17 13:55:14 +08:00
parent 1f5ee3698e
commit 2daf5717ba
35 changed files with 4774 additions and 331 deletions

View File

@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
from backend.agents.prompt_factory import build_agent_system_prompt
from backend.agents.skills_manager import SkillsManager
from backend.agents.workspace_manager import WorkspaceManager
class _DummyToolkit:
def get_agent_skill_prompt(self):
return ""
def get_activated_notes(self):
return ""
def test_workspace_manager_creates_extended_agent_files(tmp_path):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
assert (asset_dir / "SOUL.md").exists()
assert (asset_dir / "PROFILE.md").exists()
assert (asset_dir / "AGENTS.md").exists()
assert (asset_dir / "MEMORY.md").exists()
assert (asset_dir / "HEARTBEAT.md").exists()
assert (asset_dir / "agent.yaml").exists()
assert (asset_dir / "skills" / "installed").is_dir()
assert (asset_dir / "skills" / "active").is_dir()
assert (asset_dir / "skills" / "disabled").is_dir()
assert (asset_dir / "skills" / "local").is_dir()
def test_agent_workspace_config_controls_prompt_files(tmp_path, monkeypatch):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
(asset_dir / "SOUL.md").write_text("soul-line", encoding="utf-8")
(asset_dir / "PROFILE.md").write_text("profile-line", encoding="utf-8")
(asset_dir / "MEMORY.md").write_text("memory-line", encoding="utf-8")
(asset_dir / "agent.yaml").write_text(
"prompt_files:\n"
" - SOUL.md\n"
" - MEMORY.md\n",
encoding="utf-8",
)
from backend.agents import prompt_factory
monkeypatch.setattr(
prompt_factory,
"SkillsManager",
lambda: SkillsManager(project_root=tmp_path),
)
prompt = build_agent_system_prompt(
agent_id="risk_manager",
config_name="demo",
toolkit=_DummyToolkit(),
)
assert "soul-line" in prompt
assert "memory-line" in prompt
assert "profile-line" not in prompt
def test_skills_manager_applies_agent_level_skill_toggles(tmp_path):
builtin_root = tmp_path / "backend" / "skills" / "builtin"
for skill_name in ("risk_review", "extra_guard"):
skill_dir = builtin_root / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"# {skill_name}\n",
encoding="utf-8",
)
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
(asset_dir / "agent.yaml").write_text(
"enabled_skills:\n"
" - extra_guard\n"
"disabled_skills:\n"
" - risk_review\n",
encoding="utf-8",
)
skills_manager = SkillsManager(project_root=tmp_path)
active_map = skills_manager.prepare_active_skills(
config_name="demo",
agent_defaults={"risk_manager": ["risk_review"]},
)
active_dirs = active_map["risk_manager"]
assert [path.name for path in active_dirs] == ["extra_guard"]
assert (asset_dir / "skills" / "installed" / "extra_guard" / "SKILL.md").exists()
assert (asset_dir / "skills" / "active" / "extra_guard" / "SKILL.md").exists()
assert (asset_dir / "skills" / "disabled" / "risk_review" / "SKILL.md").exists()
assert not (asset_dir / "skills" / "active" / "risk_review").exists()
def test_agent_local_skill_is_activated_from_agent_workspace(tmp_path):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
local_skill = asset_dir / "skills" / "local" / "local_guard"
local_skill.mkdir(parents=True, exist_ok=True)
(local_skill / "SKILL.md").write_text(
"---\nname: 本地风控\ndescription: local skill\nversion: 1.0.0\n---\n",
encoding="utf-8",
)
skills_manager = SkillsManager(project_root=tmp_path)
active_map = skills_manager.prepare_active_skills(
config_name="demo",
agent_defaults={"risk_manager": []},
)
assert [path.name for path in active_map["risk_manager"]] == ["local_guard"]
assert (asset_dir / "skills" / "active" / "local_guard" / "SKILL.md").exists()
def test_prompt_includes_active_skill_metadata_summary(tmp_path, monkeypatch):
builtin_root = tmp_path / "backend" / "skills" / "builtin"
skill_dir = builtin_root / "extra_guard"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: extra_guard\n"
"description: This skill should be used when the user asks to \"run a risk check\".\n"
"version: 1.0.0\n"
"tools:\n"
" - risk_ops\n"
"---\n\n"
"# Extra Guard\n",
encoding="utf-8",
)
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
(asset_dir / "agent.yaml").write_text(
"enabled_skills:\n"
" - extra_guard\n",
encoding="utf-8",
)
skills_manager = SkillsManager(project_root=tmp_path)
skills_manager.prepare_active_skills(
config_name="demo",
agent_defaults={"risk_manager": []},
)
from backend.agents import prompt_factory
monkeypatch.setattr(
prompt_factory,
"SkillsManager",
lambda: SkillsManager(project_root=tmp_path),
)
prompt = build_agent_system_prompt(
agent_id="risk_manager",
config_name="demo",
toolkit=_DummyToolkit(),
)
assert "Active Skill Catalog" in prompt
assert "This skill should be used when the user asks to \"run a risk check\"." in prompt
assert "version: 1.0.0" in prompt
assert "risk_ops" not in prompt

View File

@@ -382,3 +382,341 @@ async def test_refresh_market_store_for_watchlist_emits_system_messages(monkeypa
assert gateway.state_sync.system_messages[0] == "正在同步自选股市场数据: AAPL, MSFT"
assert "自选股市场数据已同步:" in gateway.state_sync.system_messages[1]
assert "AAPL prices=3 news=4" in gateway.state_sync.system_messages[1]
@pytest.mark.asyncio
async def test_handle_get_agent_skills_returns_statuses(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",
)
agent_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
agent_dir.mkdir(parents=True, exist_ok=True)
(agent_dir / "agent.yaml").write_text(
"enabled_skills:\n"
" - extra_guard\n"
"disabled_skills:\n"
" - risk_review\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
await gateway._handle_get_agent_skills(
websocket,
{"agent_id": "risk_manager"},
)
assert websocket.messages[-1]["type"] == "agent_skills_loaded"
statuses = {
row["skill_name"]: row["status"]
for row in websocket.messages[-1]["skills"]
}
assert statuses["extra_guard"] == "enabled"
assert statuses["risk_review"] == "disabled"
@pytest.mark.asyncio
async def test_handle_get_agent_profile_returns_model_and_tool_groups(monkeypatch, tmp_path):
agent_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
agent_dir.mkdir(parents=True, exist_ok=True)
(agent_dir / "agent.yaml").write_text(
"prompt_files:\n"
" - SOUL.md\n"
" - MEMORY.md\n"
"active_tool_groups:\n"
" - risk_ops\n"
"disabled_tool_groups:\n"
" - legacy_group\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
monkeypatch.setattr(
gateway_module,
"load_agent_profiles",
lambda: {"risk_manager": {"skills": ["risk_review"], "active_tool_groups": ["risk_ops", "legacy_group"]}},
)
monkeypatch.setattr(
gateway_module,
"get_agent_model_info",
lambda agent_id: ("gpt-4o-mini", "OPENAI"),
)
class _Bootstrap:
@staticmethod
def agent_override(_agent_id):
return {}
monkeypatch.setattr(
gateway_module,
"get_bootstrap_config_for_run",
lambda project_root, config_name: _Bootstrap(),
)
await gateway._handle_get_agent_profile(
websocket,
{"agent_id": "risk_manager"},
)
assert websocket.messages[-1]["type"] == "agent_profile_loaded"
profile = websocket.messages[-1]["profile"]
assert profile["model_name"] == "gpt-4o-mini"
assert profile["model_provider"] == "OPENAI"
assert profile["prompt_files"] == ["SOUL.md", "MEMORY.md"]
assert profile["active_tool_groups"] == ["risk_ops"]
assert profile["disabled_tool_groups"] == ["legacy_group"]
@pytest.mark.asyncio
async def test_handle_get_skill_detail_returns_markdown_body(tmp_path):
skill_dir = tmp_path / "backend" / "skills" / "builtin" / "risk_review"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: 风险审查\ndescription: 说明\nversion: 1.0.0\n---\n# 风险审查\n\n完整正文\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway._project_root = tmp_path
websocket = DummyWebSocket()
await gateway._handle_get_skill_detail(
websocket,
{"skill_name": "risk_review"},
)
assert websocket.messages[-1]["type"] == "skill_detail_loaded"
assert websocket.messages[-1]["skill"]["name"] == "风险审查"
assert websocket.messages[-1]["skill"]["version"] == "1.0.0"
assert websocket.messages[-1]["skill"]["content"] == "# 风险审查\n\n完整正文"
@pytest.mark.asyncio
async def test_handle_get_skill_detail_prefers_agent_local_skill(tmp_path):
skill_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "skills" / "local" / "local_guard"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: 本地风控\ndescription: 本地说明\nversion: 1.0.0\n---\n# 本地风控\n\n本地正文\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
await gateway._handle_get_skill_detail(
websocket,
{"agent_id": "risk_manager", "skill_name": "local_guard"},
)
assert websocket.messages[-1]["type"] == "skill_detail_loaded"
assert websocket.messages[-1]["agent_id"] == "risk_manager"
assert websocket.messages[-1]["skill"]["source"] == "local"
assert websocket.messages[-1]["skill"]["content"] == "# 本地风控\n\n本地正文"
@pytest.mark.asyncio
async def test_handle_update_agent_skill_persists_and_returns_refresh(monkeypatch, tmp_path):
skill_dir = tmp_path / "backend" / "skills" / "builtin" / "extra_guard"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: extra_guard\ndescription: desc\n---\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_update_agent_skill(
websocket,
{
"agent_id": "risk_manager",
"skill_name": "extra_guard",
"enabled": True,
},
)
assert websocket.messages[0]["type"] == "agent_skill_updated"
assert websocket.messages[-1]["type"] == "agent_skills_loaded"
agent_yaml = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "agent.yaml"
assert "extra_guard" in agent_yaml.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_handle_create_and_update_agent_local_skill(monkeypatch, tmp_path):
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_create_agent_local_skill(
websocket,
{"agent_id": "risk_manager", "skill_name": "local_guard"},
)
assert websocket.messages[0]["type"] == "agent_local_skill_created"
assert websocket.messages[1]["type"] == "agent_skills_loaded"
assert websocket.messages[2]["type"] == "skill_detail_loaded"
target = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "skills" / "local" / "local_guard" / "SKILL.md"
assert target.exists()
websocket.messages.clear()
await gateway._handle_update_agent_local_skill(
websocket,
{
"agent_id": "risk_manager",
"skill_name": "local_guard",
"content": "---\nname: 本地风控\ndescription: 更新后\nversion: 1.0.0\n---\n# 本地风控\n\n更新正文\n",
},
)
assert websocket.messages[0]["type"] == "agent_local_skill_updated"
assert websocket.messages[1]["type"] == "skill_detail_loaded"
assert "更新正文" in target.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_handle_delete_agent_local_skill(monkeypatch, tmp_path):
skill_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "skills" / "local" / "local_guard"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: 本地风控\ndescription: desc\nversion: 1.0.0\n---\n",
encoding="utf-8",
)
agent_yaml = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "agent.yaml"
agent_yaml.parent.mkdir(parents=True, exist_ok=True)
agent_yaml.write_text(
"enabled_skills:\n"
" - local_guard\n"
"disabled_skills:\n"
" - local_guard\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_delete_agent_local_skill(
websocket,
{"agent_id": "risk_manager", "skill_name": "local_guard"},
)
assert websocket.messages[0]["type"] == "agent_local_skill_deleted"
assert websocket.messages[1]["type"] == "agent_skills_loaded"
assert not skill_dir.exists()
assert "local_guard" not in agent_yaml.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_handle_remove_agent_skill_marks_disabled(monkeypatch, tmp_path):
skill_dir = tmp_path / "backend" / "skills" / "builtin" / "risk_review"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: 风险审查\ndescription: desc\nversion: 1.0.0\n---\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_remove_agent_skill(
websocket,
{"agent_id": "risk_manager", "skill_name": "risk_review"},
)
assert websocket.messages[0]["type"] == "agent_skill_removed"
assert websocket.messages[1]["type"] == "agent_skills_loaded"
agent_yaml = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "agent.yaml"
assert "risk_review" in agent_yaml.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_handle_get_agent_workspace_file_returns_content(tmp_path):
file_path = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "SOUL.md"
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text("soul content", encoding="utf-8")
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
await gateway._handle_get_agent_workspace_file(
websocket,
{"agent_id": "risk_manager", "filename": "SOUL.md"},
)
assert websocket.messages[-1] == {
"type": "agent_workspace_file_loaded",
"config_name": "demo",
"agent_id": "risk_manager",
"filename": "SOUL.md",
"content": "soul content",
}
@pytest.mark.asyncio
async def test_handle_update_agent_workspace_file_persists_and_returns_refresh(monkeypatch, tmp_path):
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_update_agent_workspace_file(
websocket,
{
"agent_id": "risk_manager",
"filename": "SOUL.md",
"content": "updated soul",
},
)
assert websocket.messages[0]["type"] == "agent_workspace_file_updated"
assert websocket.messages[-1]["type"] == "agent_workspace_file_loaded"
target = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "SOUL.md"
assert target.read_text(encoding="utf-8") == "updated soul"

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
from backend import cli
from backend.agents.skill_metadata import parse_skill_metadata
from backend.agents.skills_manager import SkillsManager
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)

View File

@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
from backend.agents.skills_manager import SkillsManager
from backend.skills.builtin.valuation_review.scripts.dcf_report import (
build_dcf_report,
)
from backend.skills.builtin.valuation_review.scripts.multiple_valuation_report import (
build_ev_ebitda_report,
build_residual_income_report,
)
from backend.skills.builtin.valuation_review.scripts.owner_earnings_report import (
build_owner_earnings_report,
)
def test_build_dcf_report_renders_assessment():
report = build_dcf_report(
[
{
"ticker": "AAPL",
"current_fcf": 100.0,
"growth_rate": 0.05,
"market_cap": 900.0,
"discount_rate": 0.10,
"terminal_growth": 0.03,
"num_years": 5,
},
],
"2026-03-17",
)
assert "DCF Valuation Analysis (2026-03-17)" in report
assert "AAPL:" in report
assert "Market Cap: $900" in report
assert "Value Gap:" in report
def test_build_owner_earnings_report_handles_errors():
report = build_owner_earnings_report(
[
{
"ticker": "MSFT",
"error": "Negative owner earnings ($-50)",
},
],
"2026-03-17",
)
assert "MSFT: Negative owner earnings ($-50)" in report
def test_multiple_valuation_reports_render_expected_sections():
ev_report = build_ev_ebitda_report(
[
{
"ticker": "NVDA",
"current_multiple": 18.0,
"median_multiple": 20.0,
"current_ebitda": 50.0,
"market_cap": 800.0,
"net_debt": 100.0,
},
],
"2026-03-17",
)
residual_report = build_residual_income_report(
[
{
"ticker": "META",
"book_value": 200.0,
"initial_ri": 30.0,
"market_cap": 300.0,
"cost_of_equity": 0.10,
"bv_growth": 0.03,
"terminal_growth": 0.03,
"num_years": 5,
"margin_of_safety": 0.20,
},
],
"2026-03-17",
)
assert "EV/EBITDA Valuation (2026-03-17)" in ev_report
assert "NVDA:" in ev_report
assert "Residual Income Valuation (2026-03-17)" in residual_report
assert "META:" in residual_report
def test_prepare_active_skills_copies_skill_scripts(tmp_path):
builtin_skill = tmp_path / "backend" / "skills" / "builtin" / "valuation_review"
scripts_dir = builtin_skill / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
(builtin_skill / "SKILL.md").write_text(
"---\nname: 估值分析\ndescription: desc\nversion: 1.0.0\n---\n",
encoding="utf-8",
)
(scripts_dir / "dcf_report.py").write_text("print('ok')\n", encoding="utf-8")
manager = SkillsManager(project_root=tmp_path)
active_map = manager.prepare_active_skills(
config_name="demo",
agent_defaults={"valuation_analyst": ["valuation_review"]},
)
active_dir = active_map["valuation_analyst"][0]
assert (active_dir / "scripts" / "dcf_report.py").exists()