Lukas Goldschmidt 3 tygodni temu
rodzic
commit
6f8f625239

+ 3 - 3
src/hermes_mcp/dashboard.py

@@ -944,14 +944,14 @@ def overview():
             const wallet = payload.wallet_state || {};
             const ranking = payload.strategy_fit_ranking || [];
             const top = ranking[0] || {};
-            const current = payload.current_primary_strategy || d.target_strategy || '-';
+            const current = d.target_strategy_label || payload.current_primary_strategy || d.target_strategy || '-';
             const breakout = payload.grid_breakout_pressure || {};
             return `
             <tr class='${hasChanged ? 'recent-change' : ''}'>
               <td class='focus-cell'>${d.concern_id || ''}</td>
               <td><span class='chip ${modeChip(d.mode)}'>${d.mode || ''}</span></td>
               <td><span class='chip ${modeChip(d.action)}'>${d.action || ''}</span></td>
-              <td>${d.target_strategy || '-'}</td>
+              <td>${d.target_strategy_label || d.target_strategy || '-'}</td>
               <td>${d.reason_summary || ''}</td>
               <td>
                 <div class='small'><strong>active now</strong>: ${current}</div>
@@ -1110,7 +1110,7 @@ def changes():
             const concern = concernsById.get(String(cur.concern_id || '')) || {};
             const statePayload = parseState(stateByCycle.get(String(cur.cycle_id || '')) || {});
             const price = priceFromState(statePayload);
-            const targetLabel = strategyLabel(payload, cur.target_strategy || '');
+            const targetLabel = cur.target_strategy_label || strategyLabel(payload, cur.target_strategy || '');
             const breakout = payload.grid_breakout_pressure || {};
             const changed = diffs.map(f => `${f}`).join(', ');
             const baseAvailable = Number(wallet.base_available);

+ 94 - 12
src/hermes_mcp/server.py

@@ -24,7 +24,7 @@ from .narrative_engine import build_narrative
 from .replay import build_replay_input
 from .state_engine import synthesize_state
 from .store import delete_concern, get_decision_profile, get_state, init_db, list_concerns, list_strategy_assignments, list_strategy_groups, latest_cycle, latest_cycles, latest_decisions, latest_narratives, latest_observations, latest_regime_samples, prune_older_than, recent_regime_samples, recent_states_for_concern, sync_concerns_from_strategies, upsert_concern, upsert_cycle, upsert_decision, upsert_decision_profile, upsert_narrative, upsert_observation, upsert_regime_sample, upsert_state, latest_states, upsert_strategy_assignment, upsert_strategy_group
-from .trader_client import apply_control_decision as trader_apply_control_decision, cancel_all_orders as trader_cancel_all_orders, get_strategy as trader_get_strategy, list_strategies
+from .trader_client import apply_control_decision as trader_apply_control_decision, cancel_all_orders as trader_cancel_all_orders, control_strategy as trader_control_strategy, get_strategy as trader_get_strategy, list_strategies
 
 mcp = FastMCP(
     "hermes-mcp",
@@ -123,7 +123,7 @@ async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concer
 
 
 @mcp.tool(description="Return Hermes current state, narrative, uncertainty, and a short self-assessment report.")
-def report() -> dict:
+async def report() -> dict:
     state = get_state()
     cfg = load_config()
     concerns = list_concerns()
@@ -132,10 +132,16 @@ def report() -> dict:
         groups_by_concern.setdefault(str(group.get("concern_id") or ""), []).append(group)
 
     try:
-        accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
+        accounts_by_id, markets_by_symbol, total_values = await _load_exec_enrichment(cfg.exec_url, cfg.crypto_url, concerns)
     except Exception:
         accounts_by_id, markets_by_symbol, total_values = {}, {}, {}
+    try:
+        strategy_inventory = await list_strategies(cfg.trader_url)
+    except Exception:
+        strategy_inventory = []
+    strategies_by_id = {str(s.get("id") or "").strip(): s for s in strategy_inventory if str(s.get("id") or "").strip()}
 
+    latest_decision_by_concern = {str(d.get("concern_id") or ""): d for d in latest_decisions(200)}
     concern_summaries = []
     for concern in concerns:
         concern_id = str(concern.get("id") or "")
@@ -146,37 +152,63 @@ def report() -> dict:
         groups = groups_by_concern.get(concern_id, [])
         active_playbook = next((g for g in groups if str(g.get("status") or "").lower() == "active"), None)
         assignments = list_strategy_assignments(strategy_group_id=str(active_playbook.get("id") or "")) if active_playbook else []
+        latest_decision = latest_decision_by_concern.get(concern_id, {})
+        active_strategy_id = str(latest_decision.get("target_strategy") or "").strip()
+        active_strategy = next((a for a in assignments if str(a.get("strategy_id") or "").strip() == active_strategy_id), None)
+        if not active_strategy and assignments:
+            active_strategy = next((a for a in assignments if str(a.get("role") or "").strip().lower() in {"primary", "active", "buy", "sell", "trend_buy", "trend_sell"}), assignments[0])
+            active_strategy_id = str(active_strategy.get("strategy_id") or "").strip()
+        active_strategy_meta = strategies_by_id.get(active_strategy_id, {}) if active_strategy_id else {}
         concern_summaries.append({
             "concern_id": concern_id,
             "account_id": account_id or None,
             "account": account_info.get("display_name") or account_id or None,
             "market_symbol": str(concern.get("market_symbol") or "") or None,
             "market": market_info.get("name") or str(concern.get("market_symbol") or "") or None,
+            "label": f"{account_info.get('display_name') or account_id or 'account'} · {market_info.get('name') or str(concern.get('market_symbol') or '') or 'market'}",
             "status": str(concern.get("status") or "active"),
             "active_playbook": {
                 "id": str(active_playbook.get("id") or "") or None,
                 "name": str(active_playbook.get("name") or "") or None,
                 "family": str(active_playbook.get("strategy_family") or "") or None,
             } if active_playbook else None,
-            "active_strategies": [
+            "active_strategy": {
+                "strategy_id": active_strategy_id or None,
+                "label": _strategy_display_label(active_strategy_meta) if active_strategy_meta else active_strategy_id or None,
+                "role": str(active_strategy.get("role") or "active") if active_strategy else None,
+                "strategy_type": str(active_strategy.get("strategy_type") or "") if active_strategy else None,
+            } if active_strategy_id else None,
+            "assigned_strategies": [
                 {
                     "strategy_id": str(a.get("strategy_id") or "") or None,
+                    "label": _strategy_display_label(strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {})) if str(a.get("strategy_id") or "").strip() in strategies_by_id else str(a.get("strategy_id") or "") or None,
                     "role": str(a.get("role") or "member") or "member",
+                    "status": str(a.get("status") or "active") or "active",
                     "strategy_type": str(a.get("strategy_type") or "") or None,
                 }
                 for a in assignments
             ],
-            "balances": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
+            "balances": _structured_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
             "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
         })
-    return {
-        "status": state.get("status", "stub"),
-        "thinking": state.get("thinking", "Hermes scaffold is ready."),
+    status = str(state.get("status") or "").strip().lower()
+    if status in {"", "stub", "scaffold"}:
+        status = "ok"
+    thinking = str(state.get("thinking") or "").strip()
+    thinking_l = thinking.lower()
+    if not thinking or "scaffold" in thinking_l or "waiting for integrations" in thinking_l:
+        thinking = "Hermes report is ready."
+    payload = {
+        "status": status,
+        "thinking": thinking,
         "confidence": state.get("confidence", 0.0),
-        "uncertainty": state.get("uncertainty", ["no live adapters wired yet"]),
         "layers": state.get("layers", []),
         "concerns": concern_summaries,
     }
+    uncertainty = state.get("uncertainty")
+    if isinstance(uncertainty, list) and uncertainty:
+        payload["uncertainty"] = uncertainty
+    return payload
 
 
 @asynccontextmanager
@@ -556,6 +588,30 @@ def _compact_balances(payload: object) -> str:
     return " | ".join(parts[:5]) or "-"
 
 
+def _structured_balances(payload: object) -> list[dict[str, Any]]:
+    if not isinstance(payload, list):
+        return []
+    balances: list[dict[str, Any]] = []
+    for item in payload:
+        if not isinstance(item, dict):
+            continue
+        asset = str(item.get("asset_code") or item.get("asset") or "").upper().strip()
+        if not asset:
+            continue
+        entry: dict[str, Any] = {"asset": asset}
+        for key in ("total", "available", "value_usd"):
+            value = item.get(key)
+            if isinstance(value, (int, float)):
+                entry[key] = float(value)
+            else:
+                try:
+                    entry[key] = float(value)
+                except Exception:
+                    entry[key] = None
+        balances.append(entry)
+    return balances
+
+
 def _resolve_regime_symbol(concern: dict) -> str | None:
     base = str(concern.get("base_currency") or "").strip().upper()
     if base:
@@ -691,6 +747,7 @@ def dashboard_data() -> JSONResponse:
         for strategy in strategy_inventory
         if str(strategy.get("account_id") or "").strip() and str(strategy.get("market_symbol") or "").strip()
     }
+    strategies_by_id = {str(strategy.get("id") or "").strip(): strategy for strategy in strategy_inventory if str(strategy.get("id") or "").strip()}
     enriched = []
     concern_lookup: dict[str, dict] = {}
     for concern in concerns:
@@ -725,6 +782,13 @@ def dashboard_data() -> JSONResponse:
             "market_display": concern_meta.get("market_display"),
             "market_symbol": concern_meta.get("market_symbol"),
         }})
+    def _decorate_decision(row: dict[str, Any]) -> dict[str, Any]:
+        target_strategy_id = str(row.get("target_strategy") or "").strip()
+        strategy = strategies_by_id.get(target_strategy_id, {})
+        return {
+            **row,
+            "target_strategy_label": _strategy_display_label(strategy) if strategy else target_strategy_id or None,
+        }
     return JSONResponse({
         "latest_cycle": latest_cycle(),
         "cycles": latest_cycles(10),
@@ -735,8 +799,8 @@ def dashboard_data() -> JSONResponse:
         "state_samples": latest_states(20),
         "state_history": latest_states(100),
         "narrative_samples": latest_narratives(20),
-        "decision_samples": latest_decisions(20),
-        "decision_history": latest_decisions(100),
+        "decision_samples": [_decorate_decision(d) for d in latest_decisions(20)],
+        "decision_history": [_decorate_decision(d) for d in latest_decisions(100)],
     })
 
 
@@ -1258,6 +1322,7 @@ async def dashboard_playbook_assignment_upsert(playbook_id: str, request: Reques
 
 @app.post("/dashboard/concerns/{concern_id}/status")
 async def dashboard_set_concern_status(concern_id: str, request: Request) -> JSONResponse:
+    cfg = load_config()
     concern_id = str(concern_id or "").strip()
     concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
     if not concern:
@@ -1269,11 +1334,28 @@ async def dashboard_set_concern_status(concern_id: str, request: Request) -> JSO
         return JSONResponse({"ok": False, "error": "status must be active or inactive"}, status_code=400)
 
     account_id = str(concern.get("account_id") or "").strip()
+    market_symbol = str(concern.get("market_symbol") or "").strip().lower()
     if status == "inactive" and account_id:
         try:
-            await trader_cancel_all_orders(cfg.trader_url if (cfg := load_config()) else "", account_id)
+            await trader_cancel_all_orders(cfg.trader_url, account_id)
         except Exception:
             pass
+        try:
+            strategies = [
+                s for s in await list_strategies(cfg.trader_url)
+                if str(s.get("account_id") or "").strip() == account_id
+                and str(s.get("market_symbol") or "").strip().lower() == market_symbol
+            ]
+        except Exception:
+            strategies = []
+        for strategy in strategies:
+            instance_id = str(strategy.get("id") or "").strip()
+            if not instance_id:
+                continue
+            try:
+                await trader_control_strategy(cfg.trader_url, instance_id, "stop")
+            except Exception:
+                pass
 
     upsert_concern(
         id=str(concern.get("id") or ""),

+ 5 - 0
src/hermes_mcp/trader_client.py

@@ -71,6 +71,11 @@ async def cancel_all_orders(base_url: str, account_id: str, client_id: str | Non
     return payload if isinstance(payload, dict) else {}
 
 
+async def control_strategy(base_url: str, instance_id: str, action: str) -> dict[str, Any]:
+    payload = await _call_tool(base_url, "control_strategy", {"instance_id": instance_id, "action": action})
+    return payload if isinstance(payload, dict) else {}
+
+
 async def apply_control_decision(base_url: str, payload: dict[str, Any]) -> dict[str, Any]:
     response = await _call_tool(base_url, "apply_control_decision", {"payload": payload})
     return response if isinstance(response, dict) else {}

+ 6 - 2
tests/test_report.py

@@ -1,9 +1,13 @@
+import asyncio
+
 from hermes_mcp.server import report
 
 
 def test_report_stub():
-    payload = report()
-    assert payload["status"] == "stub"
+    payload = asyncio.run(report())
+    assert payload["status"] == "ok"
     assert payload["layers"]
     assert "concerns" in payload
     assert isinstance(payload["concerns"], list)
+    if payload["concerns"]:
+        assert isinstance(payload["concerns"][0].get("balances"), list)