From 5ac47f412f1dd85cc300dfb0c0d5185fb247c747 Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:29:40 +0800 Subject: [PATCH] fix: deduplicate agent IDs when profiles share the same name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resolve_agent_id() produced identical slugs for same-named profiles (e.g. two "Baidu" → both "baidu"), causing dropdown selection to always hit the first match. Add _resolve_all_agent_ids() which runs the same base resolution but appends _2, _3, … suffixes on collision, guaranteeing every ID in the returned list is unique. Applied across all call sites: list_agents, get_agent, send_questionnaire, _simulation_round_structured_input, _profiles_summary, and Web Console embedded data. JS template mirrors the same dedup logic client-side. 105 passed, 0 failed. Provider boundary check passed. 🤖 Generated with [Qoder][https://qoder.com] --- IMPLEMENTATION_SUMMARY.md | 2 +- backend/app/agent_engine/runner.py | 61 ++++++++++++++++++---- backend/tests/test_agent_interaction.py | 68 +++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 13 deletions(-) diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index 68c5b709..2bcf16c3 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 -# 98 passed, 690 warnings +# 105 passed, 710 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 c79231fc..49b28364 100644 --- a/backend/app/agent_engine/runner.py +++ b/backend/app/agent_engine/runner.py @@ -83,6 +83,32 @@ def _resolve_agent_id(profile: Dict[str, Any], index: int = 0) -> str: return f"agent_{index + 1}" +def _resolve_all_agent_ids(profiles: list) -> List[str]: + """Resolve agent IDs for a full list of profiles with deduplication. + + Same base logic as _resolve_agent_id, but when two profiles produce + the same ID the later ones get a ``_2``, ``_3``, … suffix so every + ID in the returned list is unique. + """ + resolved: List[str] = [] + seen: Dict[str, int] = {} + for i, profile in enumerate(profiles): + base_id = _resolve_agent_id(profile, i) + if base_id in seen: + seen[base_id] += 1 + unique_id = f"{base_id}_{seen[base_id]}" + # Edge case: the suffixed ID might itself collide + while unique_id in seen: + seen[base_id] += 1 + unique_id = f"{base_id}_{seen[base_id]}" + resolved.append(unique_id) + seen[unique_id] = 1 + else: + resolved.append(base_id) + seen[base_id] = 1 + return resolved + + class PredictionRunService: def __init__( self, @@ -540,9 +566,10 @@ class PredictionRunService: if not profiles_path.exists(): return {"status": "ok", "agents": [], "count": 0} profiles = json.loads(profiles_path.read_text(encoding="utf-8")) + resolved_ids = _resolve_all_agent_ids(profiles) agents = [] for idx, profile in enumerate(profiles): - agent_id = _resolve_agent_id(profile, idx) + agent_id = resolved_ids[idx] agents.append({ "agent_id": agent_id, "name": profile.get("name", ""), @@ -557,8 +584,9 @@ 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")) + resolved_ids = _resolve_all_agent_ids(profiles) for idx, profile in enumerate(profiles): - pid = _resolve_agent_id(profile, idx) + pid = resolved_ids[idx] if pid == agent_id: return {"status": "ok", "agent": { "agent_id": pid, @@ -641,8 +669,9 @@ class PredictionRunService: if not profiles_path.exists(): return {"status": "error", "error": "profiles.json not found; cannot send questionnaire"} profiles = json.loads(profiles_path.read_text(encoding="utf-8")) + resolved_ids = _resolve_all_agent_ids(profiles) agents = [ - {"agent_id": _resolve_agent_id(p, i), "profile": p} + {"agent_id": resolved_ids[i], "profile": p} for i, p in enumerate(profiles) ] questionnaire_id = f"questionnaire_{uuid.uuid4().hex[:8]}" @@ -1475,6 +1504,7 @@ class PredictionRunService: settings = self._ensure_state_simulation_settings(state, store) agent_limit = int(settings.get("agent_count") or min(len(profiles), 5) or 1) selected_profiles = profiles[:agent_limit] + selected_ids = _resolve_all_agent_ids(selected_profiles) round_id = f"round_{round_index}" return { "round_id": round_id, @@ -1482,7 +1512,7 @@ class PredictionRunService: "simulation_settings": settings, "actions": [ { - "agent_id": _resolve_agent_id(profile, index), + "agent_id": selected_ids[index], "action_id": f"{round_id}_action_{index + 1}", "round_id": round_id, } @@ -1604,7 +1634,7 @@ class PredictionRunService: profiles = json.loads(path.read_text(encoding="utf-8")) if path.exists() else [] return { "profile_count": len(profiles), - "agent_ids": [_resolve_agent_id(profile, i) for i, profile in enumerate(profiles)], + "agent_ids": _resolve_all_agent_ids(profiles), } def _config_summary(self, store: RunStore, state) -> Dict[str, Any]: @@ -1873,9 +1903,10 @@ class PredictionRunService: profiles = artifacts.get("profiles.json", []) # Normalize profile IDs before embedding (mirrors JS _resolveId logic) if isinstance(profiles, list): + _resolved = _resolve_all_agent_ids(profiles) for _pi, _pp in enumerate(profiles): - if isinstance(_pp, dict) and not _pp.get("agent_id"): - _pp["agent_id"] = _resolve_agent_id(_pp, _pi) + if isinstance(_pp, dict): + _pp["agent_id"] = _resolved[_pi] sim_config = artifacts.get("simulation_config.json", {}) sim_actions = artifacts.get("simulation_actions.json", []) agent_questions = interactions.get("agent_questions", []) @@ -2211,9 +2242,21 @@ pre { background:var(--surface2); border:1px solid var(--border); border-radius: } function _posFallback(idx) { return "agent_" + ((idx || 0) + 1); } - // Normalize embedded profiles so each has a guaranteed agent_id + // Normalize embedded profiles so each has a guaranteed unique 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); }); + var _seenIds = {}; + _profiles.forEach(function(p, i) { + if (!p.agent_id) p.agent_id = _resolveId(p, i); + if (_seenIds[p.agent_id]) { + _seenIds[p.agent_id]++; + var newId = p.agent_id + "_" + _seenIds[p.agent_id]; + while (_seenIds[newId]) { _seenIds[p.agent_id]++; newId = p.agent_id + "_" + _seenIds[p.agent_id]; } + p.agent_id = newId; + _seenIds[newId] = 1; + } else { + _seenIds[p.agent_id] = 1; + } + }); // ── API Client ───────────────────────────────────────────────────────── let API_BASE = "http://localhost:5001"; diff --git a/backend/tests/test_agent_interaction.py b/backend/tests/test_agent_interaction.py index 0a5521b2..560aea28 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, _resolve_agent_id +from app.agent_engine.runner import PredictionRunService, _resolve_agent_id, _resolve_all_agent_ids from app.agent_engine.schemas import AGENT_TASK_TYPES from app.agent_engine.state import RunStore @@ -712,6 +712,46 @@ class TestResolveAgentId: assert _resolve_agent_id({"agent_id": " x "}, 0) == "x" +class TestResolveAllAgentIds: + def test_no_duplicates_passthrough(self): + profiles = [{"name": "Alpha"}, {"name": "Beta"}] + ids = _resolve_all_agent_ids(profiles) + assert ids == ["alpha", "beta"] + + def test_duplicate_names_get_suffix(self): + profiles = [ + {"name": "Baidu"}, + {"name": "Google China"}, + {"name": "Baidu"}, + {"type": "anon"}, + ] + ids = _resolve_all_agent_ids(profiles) + assert ids[0] == "baidu" + assert ids[1] == "google_china" + assert ids[2] == "baidu_2" + assert ids[3] == "agent_4" + # All unique + assert len(set(ids)) == len(ids) + + def test_triple_duplicate(self): + profiles = [{"name": "X"}, {"name": "X"}, {"name": "X"}] + ids = _resolve_all_agent_ids(profiles) + assert ids == ["x", "x_2", "x_3"] + + def test_explicit_agent_id_collision(self): + profiles = [ + {"agent_id": "agent_1"}, + {"agent_id": "agent_1"}, + ] + ids = _resolve_all_agent_ids(profiles) + assert ids[0] == "agent_1" + assert ids[1] == "agent_1_2" + assert len(set(ids)) == 2 + + def test_empty_list(self): + assert _resolve_all_agent_ids([]) == [] + + 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) @@ -721,10 +761,12 @@ class TestListAgentsFallbackId: 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" + # Third profile also named "Baidu" — deduplicated with suffix + assert ids[2] == "baidu_2" # Fourth profile has no name — positional fallback assert ids[3] == "agent_4" + # All IDs must be unique + assert len(set(ids)) == len(ids), "agent_ids must be unique" # None should be empty or "unknown" assert all(aid for aid in ids), "No agent_id should be empty" assert "unknown" not in ids @@ -736,6 +778,15 @@ class TestListAgentsFallbackId: assert result["agent"]["agent_id"] == "google_china" assert result["agent"]["name"] == "Google China" + def test_get_agent_finds_second_duplicate(self, tmp_path): + run_dir, service = _init_run_no_agent_id(tmp_path) + # First "Baidu" → "baidu", second "Baidu" → "baidu_2" + result = service.get_agent(str(run_dir), "baidu_2") + assert result["status"] == "ok" + assert result["agent"]["agent_id"] == "baidu_2" + assert result["agent"]["name"] == "Baidu" + assert result["agent"]["profile"]["type"] == "ai" + class TestAskAgentFallbackId: def test_ask_agent_with_fallback_id_creates_request(self, tmp_path, monkeypatch): @@ -772,3 +823,14 @@ class TestWebConsoleFallbackId: 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 + + def test_web_console_duplicate_names_get_unique_ids(self, tmp_path): + """Embedded profiles with duplicate names must have unique agent_ids.""" + 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 second "Baidu" profile should get "baidu_2" + assert "baidu_2" in html_content + # The fourth profile (no name) should get "agent_4" + assert "agent_4" in html_content