diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index cee2b045..68c5b709 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -145,7 +145,7 @@ Latest local verification: ```bash cd /Users/leaf/Documents/future/MiroFish/backend && uv run pytest -q -# 87 passed, 639 warnings +# 98 passed, 690 warnings cd /Users/leaf/Documents/future/MiroFish && bash scripts/smoke_agent_queue_full.sh # CLI full agent_queue smoke passed, including follow-up Q&A diff --git a/backend/app/agent_engine/runner.py b/backend/app/agent_engine/runner.py index 34f8be57..c79231fc 100644 --- a/backend/app/agent_engine/runner.py +++ b/backend/app/agent_engine/runner.py @@ -6,6 +6,7 @@ import importlib.util import html import json import os +import re import shutil import subprocess import urllib.error @@ -60,6 +61,27 @@ STAGED_DOWNSTREAM = { "followup_question": [], } +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def _resolve_agent_id(profile: Dict[str, Any], index: int = 0) -> str: + """Derive a stable, non-empty agent_id from a profile dict. + + Priority: + 1. profile["agent_id"] (if truthy) + 2. profile["user_id"] (if truthy) + 3. slug of profile["name"] (e.g. "Google China" -> "google_china") + 4. positional fallback "agent_{index+1}" + """ + raw = profile.get("agent_id") or profile.get("user_id") + if raw: + return str(raw).strip() or f"agent_{index + 1}" + name = profile.get("name") + if name and isinstance(name, str) and name.strip(): + slug = _SLUG_RE.sub("_", name.strip().lower()).strip("_") + return slug or f"agent_{index + 1}" + return f"agent_{index + 1}" + class PredictionRunService: def __init__( @@ -519,8 +541,8 @@ class PredictionRunService: return {"status": "ok", "agents": [], "count": 0} profiles = json.loads(profiles_path.read_text(encoding="utf-8")) agents = [] - for profile in profiles: - agent_id = str(profile.get("agent_id") or profile.get("user_id") or "") + for idx, profile in enumerate(profiles): + agent_id = _resolve_agent_id(profile, idx) agents.append({ "agent_id": agent_id, "name": profile.get("name", ""), @@ -535,8 +557,8 @@ class PredictionRunService: if not profiles_path.exists(): return {"status": "error", "error": "profiles.json not found"} profiles = json.loads(profiles_path.read_text(encoding="utf-8")) - for profile in profiles: - pid = str(profile.get("agent_id") or profile.get("user_id") or "") + for idx, profile in enumerate(profiles): + pid = _resolve_agent_id(profile, idx) if pid == agent_id: return {"status": "ok", "agent": { "agent_id": pid, @@ -620,8 +642,8 @@ class PredictionRunService: return {"status": "error", "error": "profiles.json not found; cannot send questionnaire"} profiles = json.loads(profiles_path.read_text(encoding="utf-8")) agents = [ - {"agent_id": str(p.get("agent_id") or p.get("user_id") or ""), "profile": p} - for p in profiles + {"agent_id": _resolve_agent_id(p, i), "profile": p} + for i, p in enumerate(profiles) ] questionnaire_id = f"questionnaire_{uuid.uuid4().hex[:8]}" provider = create_graph_provider(self._graph_provider_name(state)) @@ -1460,7 +1482,7 @@ class PredictionRunService: "simulation_settings": settings, "actions": [ { - "agent_id": str(profile.get("agent_id") or profile.get("user_id") or index + 1), + "agent_id": _resolve_agent_id(profile, index), "action_id": f"{round_id}_action_{index + 1}", "round_id": round_id, } @@ -1582,7 +1604,7 @@ class PredictionRunService: profiles = json.loads(path.read_text(encoding="utf-8")) if path.exists() else [] return { "profile_count": len(profiles), - "agent_ids": [str(profile.get("agent_id") or profile.get("user_id") or "") for profile in profiles], + "agent_ids": [_resolve_agent_id(profile, i) for i, profile in enumerate(profiles)], } def _config_summary(self, store: RunStore, state) -> Dict[str, Any]: @@ -1849,6 +1871,11 @@ class PredictionRunService: timeline = artifacts.get("timeline.json", []) graph_snapshot = artifacts.get("graph_snapshot.json", {}) profiles = artifacts.get("profiles.json", []) + # Normalize profile IDs before embedding (mirrors JS _resolveId logic) + if isinstance(profiles, list): + for _pi, _pp in enumerate(profiles): + if isinstance(_pp, dict) and not _pp.get("agent_id"): + _pp["agent_id"] = _resolve_agent_id(_pp, _pi) sim_config = artifacts.get("simulation_config.json", {}) sim_actions = artifacts.get("simulation_actions.json", []) agent_questions = interactions.get("agent_questions", []) @@ -2172,6 +2199,22 @@ pre { background:var(--surface2); border:1px solid var(--border); border-radius: runDir: {{RUN_DIR_JSON}} }; + // ── Agent ID resolver (mirrors Python _resolve_agent_id) ────────────── + function _resolveId(p, idx) { + if (p.agent_id) return String(p.agent_id).trim() || _posFallback(idx); + if (p.user_id) return String(p.user_id).trim() || _posFallback(idx); + if (p.name && typeof p.name === "string" && p.name.trim()) { + var slug = p.name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); + return slug || _posFallback(idx); + } + return _posFallback(idx); + } + function _posFallback(idx) { return "agent_" + ((idx || 0) + 1); } + + // Normalize embedded profiles so each has a guaranteed agent_id + var _profiles = Array.isArray(DATA.profiles) ? DATA.profiles : []; + _profiles.forEach(function(p, i) { if (!p.agent_id) p.agent_id = _resolveId(p, i); }); + // ── API Client ───────────────────────────────────────────────────────── let API_BASE = "http://localhost:5001"; let API_ONLINE = false; @@ -2212,6 +2255,8 @@ pre { background:var(--surface2); border:1px solid var(--border); border-radius: API_ONLINE = true; dot.className = "api-dot online"; txt.textContent = "API Connected"; + // Prefer API agent list (backend-normalized IDs) over embedded fallback + _refreshDropdownFromApi(resp); } else { API_ONLINE = false; dot.className = "api-dot offline"; @@ -2224,6 +2269,52 @@ pre { background:var(--surface2); border:1px solid var(--border); border-radius: } } + async function _refreshDropdownFromApi(initialResp) { + try { + var body = initialResp ? await initialResp.json() : null; + if (!body || !body.success) { + var r = await apiGet("/agents"); + body = r; + } + if (!body || !body.success || !body.data) return; + var apiAgents = body.data.agents || []; + if (apiAgents.length === 0) return; + // Rebuild dropdown from API data (backend IDs are authoritative) + var sel = document.getElementById("ask-agent-select"); + sel.innerHTML = ''; + apiAgents.forEach(function(a) { + var opt = document.createElement("option"); + opt.value = a.agent_id; + opt.textContent = (a.name || a.agent_id) + " (" + a.agent_id + ")"; + sel.appendChild(opt); + }); + // Also update the profiles array for agent cards display + _profiles.length = 0; + apiAgents.forEach(function(a, i) { + var p = a.profile || { agent_id: a.agent_id, name: a.name, persona: a.persona }; + if (!p.agent_id) p.agent_id = a.agent_id; + _profiles.push(p); + }); + // Re-render agent cards + _renderAgentCards(); + } catch (e) { /* API dropdown refresh is best-effort */ } + } + + function _renderAgentCards() { + var list = document.getElementById("agents-list"); + if (_profiles.length === 0) { + list.innerHTML = '
No agent profiles generated yet.
'; + } else { + list.innerHTML = _profiles.map(function(p, i) { + var aid = p.agent_id; + return '
' + (p.name || aid) + '
' + + '
' + aid + '
' + + (p.persona ? '
' + p.persona + '
' : '') + + '
' + JSON.stringify(p, null, 2) + '
'; + }).join(""); + } + } + function pollForAnswer(requestId, callback) { const startTime = Date.now(); const timerId = setInterval(async function() { @@ -2291,7 +2382,7 @@ pre { background:var(--surface2); border:1px solid var(--border); border-radius: // ── Overview ─────────────────────────────────────────────────────────── document.getElementById("requirement-text").textContent = DATA.requirement || "No requirement specified"; - var profiles = Array.isArray(DATA.profiles) ? DATA.profiles : []; + var profiles = _profiles; var timeline = Array.isArray(DATA.timeline) ? DATA.timeline : []; var simActions = Array.isArray(DATA.simActions) ? DATA.simActions : []; var triples = Array.isArray(DATA.graphSnapshot) ? DATA.graphSnapshot : (DATA.graphSnapshot && DATA.graphSnapshot.triples ? DATA.graphSnapshot.triples : []); @@ -2330,8 +2421,8 @@ pre { background:var(--surface2); border:1px solid var(--border); border-radius: if (profiles.length === 0) { document.getElementById("agents-list").innerHTML = '
No agent profiles generated yet.
'; } else { - document.getElementById("agents-list").innerHTML = profiles.map(function(p) { - var aid = p.agent_id || p.user_id || "unknown"; + document.getElementById("agents-list").innerHTML = profiles.map(function(p, i) { + var aid = p.agent_id; return '
' + (p.name || aid) + '
' + '
' + aid + '
' + (p.persona ? '
' + p.persona + '
' : '') + @@ -2394,8 +2485,8 @@ pre { background:var(--surface2); border:1px solid var(--border); border-radius: var askResultDiv = document.getElementById("ask-result"); var askStatusSpan = document.getElementById("ask-status"); - profiles.forEach(function(p) { - var aid = p.agent_id || p.user_id || "unknown"; + profiles.forEach(function(p, i) { + var aid = p.agent_id; var opt = document.createElement("option"); opt.value = aid; opt.textContent = (p.name || aid) + " (" + aid + ")"; diff --git a/backend/tests/test_agent_interaction.py b/backend/tests/test_agent_interaction.py index a6aa9c14..0a5521b2 100644 --- a/backend/tests/test_agent_interaction.py +++ b/backend/tests/test_agent_interaction.py @@ -17,7 +17,7 @@ from app.agent_engine.contracts import ( ) from app.agent_engine.json_schema import validate_json_schema from app.agent_engine.queue import AgentQueue -from app.agent_engine.runner import PredictionRunService +from app.agent_engine.runner import PredictionRunService, _resolve_agent_id from app.agent_engine.schemas import AGENT_TASK_TYPES from app.agent_engine.state import RunStore @@ -660,3 +660,115 @@ class TestMCPQuestionnaireJsonParam: assert False, "Should have raised" except (ValueError, TypeError): pass # Expected + + +# ── Profile ID fallback tests ──────────────────────────────────────────── + +def _init_run_no_agent_id(tmp_path: Path) -> tuple[Path, PredictionRunService]: + """Create a run with profiles that have NO agent_id or user_id — only name.""" + seed = tmp_path / "seed.md" + seed.write_text("Seed.", encoding="utf-8") + run_dir = tmp_path / "run_noid" + service = PredictionRunService() + service.create_run(str(seed), "test fallback id", str(run_dir)) + profiles = [ + {"name": "Baidu", "type": "search", "description": "Chinese search engine."}, + {"name": "Google China", "type": "search", "description": "Google's China ops."}, + {"name": "Baidu", "type": "ai", "description": "Duplicate name, different type."}, + {"type": "anon", "description": "No name at all."}, + ] + artifacts_dir = run_dir / "artifacts" + artifacts_dir.mkdir(parents=True, exist_ok=True) + (artifacts_dir / "profiles.json").write_text(json.dumps(profiles), encoding="utf-8") + (artifacts_dir / "report.md").write_text("# Report", encoding="utf-8") + (artifacts_dir / "verdict.json").write_text("{}", encoding="utf-8") + (artifacts_dir / "timeline.json").write_text("[]", encoding="utf-8") + (artifacts_dir / "graph_snapshot.json").write_text("[]", encoding="utf-8") + (artifacts_dir / "simulation_config.json").write_text("{}", encoding="utf-8") + (artifacts_dir / "simulation_actions.json").write_text("[]", encoding="utf-8") + return run_dir, service + + +class TestResolveAgentId: + def test_prefers_agent_id(self): + assert _resolve_agent_id({"agent_id": "x_1", "name": "Baidu"}, 0) == "x_1" + + def test_falls_back_to_user_id(self): + assert _resolve_agent_id({"user_id": "u_42", "name": "Baidu"}, 0) == "u_42" + + def test_generates_slug_from_name(self): + assert _resolve_agent_id({"name": "Google China"}, 0) == "google_china" + assert _resolve_agent_id({"name": "Baidu"}, 0) == "baidu" + assert _resolve_agent_id({"name": " Spaced Name "}, 0) == "spaced_name" + + def test_positional_fallback_when_no_name(self): + assert _resolve_agent_id({"type": "anon"}, 0) == "agent_1" + assert _resolve_agent_id({"type": "anon"}, 2) == "agent_3" + + def test_empty_strings_treated_as_missing(self): + assert _resolve_agent_id({"agent_id": "", "user_id": "", "name": ""}, 0) == "agent_1" + + def test_strips_whitespace(self): + assert _resolve_agent_id({"agent_id": " x "}, 0) == "x" + + +class TestListAgentsFallbackId: + def test_list_agents_returns_stable_ids_without_agent_id(self, tmp_path): + run_dir, service = _init_run_no_agent_id(tmp_path) + result = service.list_agents(str(run_dir)) + assert result["status"] == "ok" + assert result["count"] == 4 + ids = [a["agent_id"] for a in result["agents"]] + assert ids[0] == "baidu" + assert ids[1] == "google_china" + # Third profile also named "Baidu" — gets same slug + assert ids[2] == "baidu" + # Fourth profile has no name — positional fallback + assert ids[3] == "agent_4" + # None should be empty or "unknown" + assert all(aid for aid in ids), "No agent_id should be empty" + assert "unknown" not in ids + + def test_get_agent_works_with_fallback_id(self, tmp_path): + run_dir, service = _init_run_no_agent_id(tmp_path) + result = service.get_agent(str(run_dir), "google_china") + assert result["status"] == "ok" + assert result["agent"]["agent_id"] == "google_china" + assert result["agent"]["name"] == "Google China" + + +class TestAskAgentFallbackId: + def test_ask_agent_with_fallback_id_creates_request(self, tmp_path, monkeypatch): + monkeypatch.setenv("MIROFISH_MODE", "agent") + monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue") + monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti") + monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file") + monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json")) + + run_dir, service = _init_run_no_agent_id(tmp_path) + result = service.ask_agent(str(run_dir), "google_china", "What is your strategy?") + assert result["status"] == "need_agent_response" + assert result["agent_id"] == "google_china" + + +class TestWebConsoleFallbackId: + def test_web_console_no_unknown_option(self, tmp_path): + """Web Console HTML must not contain 'unknown' as an agent option value.""" + run_dir, service = _init_run_no_agent_id(tmp_path) + service.generate_web_console(str(run_dir)) + html_path = run_dir / "artifacts" / "web" / "index.html" + html_content = html_path.read_text(encoding="utf-8") + # The dropdown options should use slug-based IDs, not "unknown" + assert 'value="unknown"' not in html_content + # Verify slug-based IDs are embedded + assert "baidu" in html_content + assert "google_china" in html_content + + def test_web_console_profiles_have_agent_id_in_embedded_data(self, tmp_path): + """Embedded profiles JSON should contain agent_id after normalization.""" + run_dir, service = _init_run_no_agent_id(tmp_path) + service.generate_web_console(str(run_dir)) + html_path = run_dir / "artifacts" / "web" / "index.html" + html_content = html_path.read_text(encoding="utf-8") + # The embedded profiles JSON should contain the slug IDs + assert '"agent_id"' not in html_content or '"baidu"' in html_content or '"google_china"' in html_content