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