fix: deduplicate agent IDs when profiles share the same name

_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]
This commit is contained in:
Flowershangfromthebranches 2026-06-10 09:29:40 +08:00
parent 1a9ec7c717
commit 5ac47f412f
3 changed files with 118 additions and 13 deletions

View File

@ -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

View File

@ -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";

View File

@ -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