From 1a9ec7c717eb79b6b7108ce56e2b879a5f840741 Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:16:22 +0800 Subject: [PATCH] fix: unify profile ID normalization with fallback chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When profiles.json lacks agent_id/user_id fields, the backend and Web Console would fall back to empty strings or "unknown", breaking agent routing and dropdown rendering. Add _resolve_agent_id() (Python) and _resolveId() (JS) implementing a unified fallback chain: agent_id → user_id → name slug → positional agent_N. Applied consistently across list_agents, get_agent, send_questionnaire, _simulation_round_structured_input, _profiles_summary, and Web Console embedded data normalization. Web Console now refreshes the dropdown from the API when online (authoritative backend IDs), with normalized embedded profiles as offline fallback. Add 12 new tests covering: resolve logic, list_agents/get_agent/ask_agent fallback IDs, and Web Console HTML generation (no "unknown" values, embedded profiles carry agent_id). 98 passed, 0 failed. Provider boundary check passed. 🤖 Generated with [Qoder][https://qoder.com] --- IMPLEMENTATION_SUMMARY.md | 2 +- backend/app/agent_engine/runner.py | 117 +++++++++++++++++++++--- backend/tests/test_agent_interaction.py | 114 ++++++++++++++++++++++- 3 files changed, 218 insertions(+), 15 deletions(-) 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