Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion contextual_orchestrator/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
"active_status": "Active",
"agent_pool_title": "Agent Pool",
"register_agent": "Register Agent",
"model_groups_title": "Model groups",
"group_name_label": "Group name",
"group_members_label": "Provider model members",
"save_group": "Save group",
"delete_group": "Delete group",
"no_model_groups": "No model groups yet. Create one to route a logical model across providers.",
"group_saved": "Group saved. Send requests with this group name to use measured routing.",
"group_deleted": "Group deleted. Its provider models remain available.",
"search_agents": "Search agents",
"all_statuses": "All statuses",
"no_agents_match": "No agents match the current filters.",
Expand Down Expand Up @@ -255,6 +263,14 @@
"active_status": "활성",
"agent_pool_title": "에이전트 풀",
"register_agent": "에이전트 등록",
"model_groups_title": "모델 그룹",
"group_name_label": "그룹 이름",
"group_members_label": "공급자 모델 멤버",
"save_group": "그룹 저장",
"delete_group": "그룹 삭제",
"no_model_groups": "모델 그룹이 없습니다. 논리 모델을 여러 공급자로 라우팅하려면 그룹을 만드세요.",
"group_saved": "그룹을 저장했습니다. 측정 기반 라우팅에는 이 그룹 이름으로 요청하세요.",
"group_deleted": "그룹을 삭제했습니다. 공급자 모델은 그대로 사용할 수 있습니다.",
"search_agents": "에이전트 검색",
"all_statuses": "전체 상태",
"no_agents_match": "현재 필터와 일치하는 에이전트가 없습니다.",
Expand Down Expand Up @@ -869,6 +885,18 @@
</thead>
<tbody id="agents"></tbody>
</table>
<form id="modelGroupForm" class="policy-list" aria-labelledby="modelGroupsTitle">
<h2 id="modelGroupsTitle" data-i18n="model_groups_title">Model groups</h2>
<label><span data-i18n="group_name_label">Group name</span>
<input id="modelGroupName" required pattern="[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)+" autocomplete="off">
</label>
<label><span data-i18n="group_members_label">Provider model members</span>
<select id="modelGroupMembers" multiple required size="5"></select>
</label>
<div class="actions"><button class="btn primary" type="submit" data-i18n="save_group">Save group</button></div>
<p id="modelGroupFeedback" role="status" aria-live="polite"></p>
<ul id="modelGroups" class="policy-list"></ul>
</form>
</section>
<section class="panel" id="orchestration-policy" tabindex="-1">
<div class="panel-header">
Expand Down Expand Up @@ -1032,10 +1060,15 @@
viewAudit: document.querySelector("#viewAudit"),
agentSettings: document.querySelector("#agentSettings"),
registerAgent: document.querySelector("#registerAgent"),
modelGroupForm: document.querySelector("#modelGroupForm"),
modelGroupName: document.querySelector("#modelGroupName"),
modelGroupMembers: document.querySelector("#modelGroupMembers"),
modelGroupFeedback: document.querySelector("#modelGroupFeedback"),
modelGroups: document.querySelector("#modelGroups"),
mobileView: document.querySelector("#mobileView"),
language: document.querySelector("#language")
};
let state = {agents: [], last: null, analytics: null, readiness: null, buyerHandoffBundle: null, saleabilityDecision: null, commercialEvidenceExport: null, commercialAcceptanceCheck: null, commercialReleaseCandidate: null, commercialGapRegister: null, commercialProcurementReadiness: null, commercialContractReadiness: null, commercialOnboardingReadiness: null, commercialOperationsReadiness: null, commercialSecurityAttestation: null, commercialValueReadiness: null, commercialCloseReadiness: null, commercialGoToMarketReadiness: null, commercialLaunchReadiness: null, commercialCompletionScorecard: null, commercialBuyerAcceptanceWorkflow: null, commercialDemoScenarios: null, commercialProposalPacket: null, commercialPurchaseApprovalPacket: null, commercialDueDiligenceRoom: null, commercialInvestmentCommitteeMemo: null};
let state = {agents: [], modelGroups: [], last: null, analytics: null, readiness: null, buyerHandoffBundle: null, saleabilityDecision: null, commercialEvidenceExport: null, commercialAcceptanceCheck: null, commercialReleaseCandidate: null, commercialGapRegister: null, commercialProcurementReadiness: null, commercialContractReadiness: null, commercialOnboardingReadiness: null, commercialOperationsReadiness: null, commercialSecurityAttestation: null, commercialValueReadiness: null, commercialCloseReadiness: null, commercialGoToMarketReadiness: null, commercialLaunchReadiness: null, commercialCompletionScorecard: null, commercialBuyerAcceptanceWorkflow: null, commercialDemoScenarios: null, commercialProposalPacket: null, commercialPurchaseApprovalPacket: null, commercialDueDiligenceRoom: null, commercialInvestmentCommitteeMemo: null};
let currentLang = "en";
let activeTraceTab = "timeline";
const datasets = [
Expand Down Expand Up @@ -1077,6 +1110,41 @@
<td>${agent.group_routing ? `${(agent.group_routing.success_posterior_mean * 100).toFixed(1)}%` : "—"}</td>
</tr>`).join("") || `<tr><td colspan="6" class="empty" data-i18n="no_agents_match">${t("no_agents_match")}</td></tr>`;
}
function renderModelGroups() {
const selected = new Set(Array.from(els.modelGroupMembers.selectedOptions).map(option => option.value));
els.modelGroupMembers.innerHTML = state.agents.map(agent =>
`<option value="${escapeHtml(agent.id)}" ${selected.has(agent.id) ? "selected" : ""}>${escapeHtml(agent.id)} — ${escapeHtml(agent.model)} [${escapeHtml((agent.tags || []).join(", "))}]</option>`
).join("");
els.modelGroups.innerHTML = state.modelGroups.map(group => `
<li><strong>${escapeHtml(group.group_name)}</strong> — ${escapeHtml(group.member_agent_ids.join(", "))}<br>
<small>${escapeHtml(Object.entries(group.capability_coverage || {}).map(([name, count]) => `${name}: ${count}`).join(" · "))}</small>
<button class="btn" type="button" data-delete-group="${escapeHtml(group.group_name)}" data-i18n="delete_group">${t("delete_group")}</button>
</li>`).join("") || `<li class="empty">${t("no_model_groups")}</li>`;
}

async function refreshModelGroups() {
const response = await fetch("/api/v1/model_groups");
const payload = await response.json();
if (!response.ok) throw new Error(payload.error?.message || "Could not load model groups");
state.modelGroups = payload.items || [];
renderModelGroups();
}

async function saveModelGroup(event) {
event.preventDefault();
const groupName = els.modelGroupName.value.trim();
const memberIds = Array.from(els.modelGroupMembers.selectedOptions).map(option => option.value);
const exists = state.modelGroups.some(group => group.group_name === groupName.replaceAll("-", "_").toLowerCase());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Client group-name canonicalization matches server

saveModelGroup picks POST vs PATCH by comparing groupName.replaceAll("-","_").toLowerCase() to stored canonical names (admin.py). Because the input pattern forbids whitespace and consecutive separators, this matches the server's canonical_group_name (contextual_orchestrator/model_group.py:53-66) for every pattern-valid input, so the exists check stays reliable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const response = await fetch(exists ? `/api/v1/model_groups/${encodeURIComponent(groupName)}` : "/api/v1/model_groups", {
method: exists ? "PATCH" : "POST",
headers: {"content-type": "application/json"},
body: JSON.stringify(exists ? {member_agent_ids: memberIds} : {group_name: groupName, member_agent_ids: memberIds})
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error?.message || "Could not save model group");
els.modelGroupFeedback.textContent = t("group_saved");
await refreshModelGroups();
}
function renderTrace(result) {
els.traceMode.textContent = result.mode;
const trace = result.trace || [];
Expand Down Expand Up @@ -1471,11 +1539,13 @@
});
localStorage.setItem("admin_lang", lang);
if (state.agents.length) renderAgents();
if (state.agents.length) renderModelGroups();
if (state.policy) renderSecondaryViews();
}
async function load() {
const res = await fetch("/admin/state");
state = await res.json();
await refreshModelGroups();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: load() aborts entirely if model_groups fetch fails

refreshModelGroups() is awaited early in load() with no try/catch (admin.py). If /api/v1/model_groups errors, the whole load path — agents, analytics, readiness, trace — is skipped and the console stays blank. This follows the existing awaited-fetch pattern, but adds another hard dependency up front. A later language switch also calls renderModelGroups() guarded only by state.agents.length (admin.py); if that refresh failed, state.modelGroups is undefined and .map throws.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

await refreshAnalytics();
await refreshReadiness();
els.hintCount.textContent = state.policy.complex_hints.length;
Expand Down Expand Up @@ -1572,6 +1642,20 @@
els.viewAudit.addEventListener("click", () => showView("audit"));
els.agentSettings.addEventListener("click", () => showView("settings"));
els.registerAgent.addEventListener("click", () => showView("integrations"));
els.modelGroupForm.addEventListener("submit", event => saveModelGroup(event).catch(error => {
els.modelGroupFeedback.textContent = error.message;
}));
els.modelGroups.addEventListener("click", event => {
const name = event.target.dataset?.deleteGroup;
if (!name) return;
fetch(`/api/v1/model_groups/${encodeURIComponent(name)}`, {method: "DELETE"})
.then(response => response.ok ? response.json() : Promise.reject(new Error("Could not delete model group")))
.then(() => {
els.modelGroupFeedback.textContent = t("group_deleted");
return refreshModelGroups();
})
.catch(error => { els.modelGroupFeedback.textContent = error.message; });
});
els.language.addEventListener("change", () => applyI18n(els.language.value));
els.mobileView.addEventListener("change", () => showView(els.mobileView.value));
document.querySelector("#copyJson").addEventListener("click", () => {
Expand Down
73 changes: 64 additions & 9 deletions contextual_orchestrator/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
ProviderDestination = tuple[int, tuple[Any, ...]]
MAX_LOCAL_CONCURRENCY = 64
DEFAULT_PROVIDER_PROBE_TIMEOUT = 5.0
MODEL_CAPABILITIES = frozenset(
{"text", "image", "video", "speech", "transcription", "embedding", "rerank", "audio"}
)
MAX_PROVIDER_PROBE_TIMEOUT = 30.0
_SAFE_PROVIDER_PROBE_ERROR_TYPES = frozenset({
"ConnectionError",
Expand Down Expand Up @@ -1585,29 +1588,67 @@ def load_agents(path: str) -> list[ModelAgent]: # pragma: no cover


class _AgentPoolStore:
"""Durable agent-pool (model group) storage: operator changes survive restarts.

ponytail: one sqlite table keyed by agent id, JSON payloads, thread-safe. The
seed agents file stays the bootstrap; stored rows overlay it at startup.
"""
"""Durable, normalized agent and model-group storage."""

def __init__(self, path: str) -> None:
self._lock = threading.Lock()
self._path = path
conn = sqlite3.connect(self._path)
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("CREATE TABLE IF NOT EXISTS agent_pool (agent_id TEXT PRIMARY KEY, payload TEXT NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS model_group (group_name TEXT PRIMARY KEY)")
conn.execute(
"CREATE TABLE IF NOT EXISTS model_group_member ("
"agent_id TEXT PRIMARY KEY REFERENCES agent_pool(agent_id) ON DELETE CASCADE, "
"group_name TEXT NOT NULL REFERENCES model_group(group_name) ON DELETE CASCADE)"
)
self._migrate_legacy_groups(conn)
conn.commit()
finally:
conn.close()

@staticmethod
def _migrate_legacy_groups(conn: sqlite3.Connection) -> None:
"""Move legacy payload group names into the normalized membership relation."""
for agent_id, raw_payload in conn.execute("SELECT agent_id, payload FROM agent_pool"):
payload = json.loads(raw_payload)
group_name = payload.pop("group_name", "")
if not group_name:
continue
group_name = canonical_group_name(group_name)
conn.execute("INSERT OR IGNORE INTO model_group (group_name) VALUES (?)", (group_name,))
conn.execute(
"INSERT OR REPLACE INTO model_group_member (agent_id, group_name) VALUES (?, ?)",
(agent_id, group_name),
)
conn.execute(
"UPDATE agent_pool SET payload = ? WHERE agent_id = ?",
(json.dumps(payload, ensure_ascii=False), agent_id),
)
Comment on lines +1614 to +1628

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Migration mutates agent_pool while scanning it

_migrate_legacy_groups iterates a SELECT ... FROM agent_pool cursor and runs UPDATE agent_pool inside the loop on the same connection (orchestrator.py). The scan is by rowid and only the non-key payload column is updated in place, so no rows move, insert, or delete, and a re-visit would be idempotent since group_name is already popped. Safe in practice; .fetchall() up front would remove the hazard. The single-row migration test does not exercise multi-row scanning.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


def save(self, agent: "ModelAgent") -> None:
with self._lock:
conn = sqlite3.connect(self._path)
try:
conn.execute("PRAGMA foreign_keys = ON")
payload = agent.to_config()
payload.pop("group_name", None)
conn.execute(
"INSERT OR REPLACE INTO agent_pool (agent_id, payload) VALUES (?, ?)",
(agent.id, json.dumps(agent.to_config(), ensure_ascii=False)),
"INSERT INTO agent_pool (agent_id, payload) VALUES (?, ?) "
"ON CONFLICT(agent_id) DO UPDATE SET payload = excluded.payload",
(agent.id, json.dumps(payload, ensure_ascii=False)),
)
conn.execute("DELETE FROM model_group_member WHERE agent_id = ?", (agent.id,))
if agent.group_name:
conn.execute("INSERT OR IGNORE INTO model_group (group_name) VALUES (?)", (agent.group_name,))
conn.execute(
"INSERT INTO model_group_member (agent_id, group_name) VALUES (?, ?)",
(agent.id, agent.group_name),
)
conn.execute(
"DELETE FROM model_group WHERE NOT EXISTS ("
"SELECT 1 FROM model_group_member WHERE model_group_member.group_name = model_group.group_name)"
)
conn.commit()
finally:
Expand All @@ -1617,10 +1658,19 @@ def load_all(self) -> list["ModelAgent"]:
with self._lock:
conn = sqlite3.connect(self._path)
try:
rows = conn.execute("SELECT payload FROM agent_pool ORDER BY agent_id").fetchall()
rows = conn.execute(
"SELECT agent_pool.payload, model_group_member.group_name "
"FROM agent_pool LEFT JOIN model_group_member USING (agent_id) "
"ORDER BY agent_pool.agent_id"
).fetchall()
finally:
conn.close()
return [ModelAgent.from_dict(json.loads(row[0])) for row in rows]
values = []
for raw_payload, group_name in rows:
payload = json.loads(raw_payload)
payload["group_name"] = group_name or ""
values.append(ModelAgent.from_dict(payload))
return values

def close(self) -> None:
"""Compatibility no-op: agent-pool operations use short-lived sqlite handles."""
Expand Down Expand Up @@ -2432,6 +2482,11 @@ def get_model_group(self, group_name: str) -> dict[str, Any]:
"group_name": name,
"member_agent_ids": ranked_ids,
"enabled_member_count": sum(1 for agent in members if not agent.disabled),
"capability_coverage": {
capability: sum(capability in agent.tags for agent in members)
for capability in sorted(MODEL_CAPABILITIES)
if any(capability in agent.tags for agent in members)
},
Comment on lines +2485 to +2489

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: capability_coverage relies on discovery tags

capability_coverage counts members whose tags contain each MODEL_CAPABILITIES name (orchestrator.py). It is non-empty only for discovered agents, since agent_from_discovered injects bare capability strings into tags (contextual_orchestrator/model_discovery.py:297-302). Hand-configured agents (coding/reasoning) yield {}, matching the contract test.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

"members": [self._agent_to_admin_payload(self._agent(agent_id)) for agent_id in ranked_ids],
}

Expand Down
19 changes: 18 additions & 1 deletion docs/planning/adrs/0026-model-group-cost-aware-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,24 @@ sequenceDiagram

## Data, web, and operational boundaries

Group membership survives restart in the existing `agent_pool` database payload. Authenticated REST resources provide `GET/POST /api/v1/model_groups` and `GET/PATCH/DELETE /api/v1/model_groups/{group_name}`; deleting a group retains its provider agents. The existing worker-agent create/PATCH API also accepts `group_name`. Admin shows the group, posterior success probability, and EWMA latency instead of fabricated capacity/success figures. The observation ledger intentionally resets on process restart: persisting it without a measurement horizon would let stale provider incidents dominate current routing. Add a normalized, time-windowed observation table when multi-instance aggregation and an explicit retention/decay policy are specified.
Group membership survives restart in normalized `model_group` and `model_group_member` relations; the agent JSON payload no longer duplicates `group_name`. Startup migrates legacy payload membership transactionally without dropping agent configuration. Authenticated REST resources provide `GET/POST /api/v1/model_groups` and `GET/PATCH/DELETE /api/v1/model_groups/{group_name}`; deleting a group retains its provider agents. The existing worker-agent create/PATCH API also accepts `group_name`. Admin provides a keyboard/native-form editor backed by those same resources and shows capability coverage, posterior success probability, and EWMA latency instead of fabricated capacity/success figures. The observation ledger intentionally resets on process restart: persisting it without a measurement horizon would let stale provider incidents dominate current routing. Add a normalized, time-windowed observation table when multi-instance aggregation and an explicit retention/decay policy are specified.

```mermaid
erDiagram
agent_pool ||--o| model_group_member : "belongs through"
model_group ||--o{ model_group_member : contains
agent_pool {
text agent_id PK
text payload
}
model_group {
text group_name PK
}
model_group_member {
text agent_id PK,FK
text group_name FK
}
```

## Verification and gaps

Expand Down
18 changes: 18 additions & 0 deletions tests/test_admin_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ def test_admin_surface_exists_for_enterprise_operations() -> None:
assert 'id="registerAgent"' in ADMIN_HTML
assert "registerAgent: document.querySelector" in ADMIN_HTML
assert 'els.registerAgent.addEventListener("click", () => showView("integrations"))' in ADMIN_HTML
assert 'id="modelGroupForm"' in ADMIN_HTML
assert 'id="modelGroupName" required pattern=' in ADMIN_HTML
assert 'id="modelGroupMembers" multiple required' in ADMIN_HTML
assert 'role="status" aria-live="polite"' in ADMIN_HTML
assert 'fetch("/api/v1/model_groups")' in ADMIN_HTML
assert 'method: exists ? "PATCH" : "POST"' in ADMIN_HTML
assert 'method: "DELETE"' in ADMIN_HTML
for key in (
"model_groups_title",
"group_name_label",
"group_members_label",
"save_group",
"delete_group",
"no_model_groups",
"group_saved",
"group_deleted",
):
assert key in ADMIN_TRANSLATIONS["en"] and key in ADMIN_TRANSLATIONS["ko"]
assert ADMIN_TRANSLATIONS["en"]["no_agents_configured"] == "No agents are configured yet."
assert '|| `<tr><td colspan="3" class="empty" data-i18n="no_agents_configured">${t("no_agents_configured")}</td></tr>`' in ADMIN_HTML
assert ADMIN_TRANSLATIONS["en"]["no_audit_events"] == "No audit events yet."
Expand Down
Loading