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:
parent
1a9ec7c717
commit
5ac47f412f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue