fix: unify profile ID normalization with fallback chain

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]
This commit is contained in:
Flowershangfromthebranches 2026-06-10 09:16:22 +08:00
parent 37efbe3473
commit 1a9ec7c717
3 changed files with 218 additions and 15 deletions

View File

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

View File

@ -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 = '<option value="">-- Select an agent --</option>';
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 = '<div class="empty-state">No agent profiles generated yet.</div>';
} else {
list.innerHTML = _profiles.map(function(p, i) {
var aid = p.agent_id;
return '<div class="agent-card"><div class="agent-name">' + (p.name || aid) + '</div>' +
'<div class="agent-id">' + aid + '</div>' +
(p.persona ? '<div class="agent-persona">' + p.persona + '</div>' : '') +
'<pre style="margin-top:8px;">' + JSON.stringify(p, null, 2) + '</pre></div>';
}).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 = '<div class="empty-state">No agent profiles generated yet.</div>';
} 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 '<div class="agent-card"><div class="agent-name">' + (p.name || aid) + '</div>' +
'<div class="agent-id">' + aid + '</div>' +
(p.persona ? '<div class="agent-persona">' + p.persona + '</div>' : '') +
@ -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 + ")";

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