feat: multi-agent architecture — core orchestrator, specialised agents, strategy system (#647)

* feat: multi-agent architecture — core orchestrator, specialised agents, strategy system

Phase 0-7 core agent infrastructure:
- AgentContext/AgentOpinion/StageResult protocols
- run_agent_loop() shared runner extracted from AgentExecutor
- AgentOrchestrator with 4 modes (quick/standard/full/strategy)
- BaseAgent ABC + Technical/Intel/Risk/Decision/Portfolio agents
- StrategyRouter (regime detection), StrategyAggregator (weighted consensus)
- AgentMemory with prediction tracking and confidence calibration
- Backtest summary tools registered as read-only Agent tools
- AGENT_ARCH switch (single/multi), config registry + WebUI entries
- is_agent_available() auto-detection from LITELLM_MODEL
- Config __post_init__ validation for AGENT_ARCH/ORCHESTRATOR_MODE/STRATEGY_ROUTING

* fix: address PR #647 review comments

- base_agent: propagate tool_calls_log in result.meta for orchestrator aggregation
- risk_agent: fix docstring — AGENT_RISK_OVERRIDE is bool, not string
- __init__: remove ResearchAgent from lazy imports (research.py not on this branch)
- router: cast trend_score to float to handle LLM string responses
- config_registry: fix duplicate display_order 65 — bump AGENT_MEMORY_ENABLED to 66 and cascade
- test_system_config_service: add missing assertions to validate test
- agent.py: clarify user_id must include platform prefix in docstring

* feat(multi-agent): strategy mode, data perspective fix, review fixes

- Multi-agent orchestrator: add strategy mode with consensus voting
- Fix data perspective MA N/A: compute trend before agent branch, read
  from trend_result instead of LLM output
- Fix risk override double-apply: make _apply_risk_override idempotent
  so normal runs don't over-downgrade signals
- Fix timeout error suppression: preserve error_message on agent result
  regardless of success flag
- Fix notification float subscript: wrap dashboard values in str()
- Add skills->strategies backward compat on ChatRequest model
- Fix zero-value falsy bug in price_position dict construction
- Add fill_price_position_if_needed post-processing for single-agent
- Add _bias_label helper for computed bias status display
- Increase orchestrator timeout default to 600s
- Web: skills->strategies rename, i18n updates
- Python 3.9 compat: add future annotations to stock_mapping.py

* fix: update pipeline routing test for trend_result param, fix risk docstring

- test_agent_mode_routes_to_agent: assert 8 positional args (trend_result added)
- risk_agent.py: fix docstring to match actual boolean config behavior

* fix typeerror
This commit is contained in:
mumu
2026-03-14 13:45:35 +08:00
committed by GitHub
parent 48163e8693
commit d1ec2c8b5f
48 changed files with 6116 additions and 423 deletions

View File

@@ -28,6 +28,7 @@ except ModuleNotFoundError:
from src.agent.executor import AgentExecutor, AgentResult
from src.agent.llm_adapter import LLMResponse, ToolCall
from src.agent.runner import parse_dashboard_json, serialize_tool_result
from src.agent.tools.registry import ToolRegistry, ToolDefinition, ToolParameter
@@ -317,35 +318,30 @@ class TestAgentExecutor(unittest.TestCase):
# ============================================================
class TestDashboardParsing(unittest.TestCase):
"""Test _parse_dashboard with various input formats."""
def setUp(self):
self.executor = AgentExecutor(
ToolRegistry(), _make_mock_adapter(), max_steps=1
)
"""Test parse_dashboard_json with various input formats."""
def test_parse_markdown_json_block(self):
content = f"Here is my analysis:\n```json\n{json.dumps(SAMPLE_DASHBOARD)}\n```\nDone."
result = self.executor._parse_dashboard(content)
result = parse_dashboard_json(content)
self.assertIsNotNone(result)
self.assertEqual(result["sentiment_score"], 75)
def test_parse_raw_json(self):
content = json.dumps(SAMPLE_DASHBOARD)
result = self.executor._parse_dashboard(content)
result = parse_dashboard_json(content)
self.assertIsNotNone(result)
def test_parse_json_in_text(self):
content = f"Let me present: {json.dumps(SAMPLE_DASHBOARD)} — that's all."
result = self.executor._parse_dashboard(content)
result = parse_dashboard_json(content)
self.assertIsNotNone(result)
def test_parse_empty_content(self):
self.assertIsNone(self.executor._parse_dashboard(""))
self.assertIsNone(self.executor._parse_dashboard(None))
self.assertIsNone(parse_dashboard_json(""))
self.assertIsNone(parse_dashboard_json(None))
def test_parse_no_json(self):
self.assertIsNone(self.executor._parse_dashboard("This is just plain text with no JSON"))
self.assertIsNone(parse_dashboard_json("This is just plain text with no JSON"))
# ============================================================
@@ -353,29 +349,24 @@ class TestDashboardParsing(unittest.TestCase):
# ============================================================
class TestSerializeToolResult(unittest.TestCase):
"""Test _serialize_tool_result for various types."""
def setUp(self):
self.executor = AgentExecutor(
ToolRegistry(), _make_mock_adapter(), max_steps=1
)
"""Test serialize_tool_result for various types."""
def test_serialize_none(self):
result = self.executor._serialize_tool_result(None)
result = serialize_tool_result(None)
self.assertEqual(json.loads(result), {"result": None})
def test_serialize_string(self):
result = self.executor._serialize_tool_result("hello")
result = serialize_tool_result("hello")
self.assertEqual(result, "hello")
def test_serialize_dict(self):
d = {"key": "value", "num": 42}
result = self.executor._serialize_tool_result(d)
result = serialize_tool_result(d)
self.assertEqual(json.loads(result), d)
def test_serialize_list(self):
lst = [1, 2, 3]
result = self.executor._serialize_tool_result(lst)
result = serialize_tool_result(lst)
self.assertEqual(json.loads(result), lst)
def test_serialize_dataclass(self):
@@ -384,7 +375,7 @@ class TestSerializeToolResult(unittest.TestCase):
name: str = "test"
value: int = 42
result = self.executor._serialize_tool_result(Sample())
result = serialize_tool_result(Sample())
parsed = json.loads(result)
self.assertEqual(parsed["name"], "test")
self.assertEqual(parsed["value"], 42)