From e9227bf7a04efb7bc170620dc158847b020aa8e6 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 27 Apr 2026 15:43:30 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20specialist=20=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E4=B8=8E=E8=A7=92=E8=89=B2=E7=BB=91=E5=AE=9A=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E6=94=B6=E6=95=9B=E5=88=B0=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=20registry=20=E5=BF=AB=E7=85=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除静态 specialist 列表依赖,并将未知动态 specialist 默认映射为最小权限 expert,降低路由与权限不一致风险。 Made-with: Cursor --- interfaces/admin/chat_api.py | 13 +++++++------ interfaces/admin/models_api.py | 6 +++--- runtime/agents/factory.py | 12 ++++++------ runtime/agents/specialists.py | 30 +++++++++++++++++++----------- tests/test_specialists_registry.py | 15 +++++++++++++++ 5 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 tests/test_specialists_registry.py diff --git a/interfaces/admin/chat_api.py b/interfaces/admin/chat_api.py index 312c5948..0d314b56 100644 --- a/interfaces/admin/chat_api.py +++ b/interfaces/admin/chat_api.py @@ -15,7 +15,7 @@ from fastapi import APIRouter, Body, File, Header, HTTPException, Query, UploadF from fastapi.responses import Response, StreamingResponse from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os -from oclaw.runtime.agents.specialists import SPECIALIST_IDS +from oclaw.runtime.agents.specialists import specialist_ids from oclaw.runtime.agents.factory import build_gateway_executor from oclaw.runtime.chat.agent import GenerationInterrupted from oclaw.platform.config.paths import db_path @@ -101,7 +101,8 @@ _DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = { } _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON" _SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON" -_CHAT_SPECIALIST_IDS: tuple[str, ...] = tuple(str(x) for x in SPECIALIST_IDS if str(x).strip()) +def _chat_specialist_ids() -> tuple[str, ...]: + return tuple(str(x) for x in specialist_ids() if str(x).strip()) DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000 DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 5_000 DEFAULT_TEXT_INLINE_MAX_CHARS = 12_000 @@ -326,7 +327,7 @@ def _normalize_memory_mode(payload: dict[str, Any] | None) -> str: def _specialist_flags_with_overrides(store: SqliteStore) -> dict[str, bool]: - flags: dict[str, bool] = {sid: True for sid in _CHAT_SPECIALIST_IDS} + flags: dict[str, bool] = {sid: True for sid in _chat_specialist_ids()} raw = str(store.get_setting(_SPECIALIST_FLAGS_SETTING_KEY) or "").strip() if not raw: return flags @@ -336,7 +337,7 @@ def _specialist_flags_with_overrides(store: SqliteStore) -> dict[str, bool]: return flags if not isinstance(obj, dict): return flags - for sid in _CHAT_SPECIALIST_IDS: + for sid in _chat_specialist_ids(): if sid in obj: flags[sid] = bool(obj.get(sid)) # keep generalist always on for safety @@ -1180,7 +1181,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor ctx = resolve_auth(store, authorization) _require_administrator_chat_viewer(ctx) flags = _specialist_flags_with_overrides(store) - available = [sid for sid in _CHAT_SPECIALIST_IDS if bool(flags.get(sid, True))] + available = [sid for sid in _chat_specialist_ids() if bool(flags.get(sid, True))] return { "ok": True, "setting_key": _SPECIALIST_FLAGS_SETTING_KEY, @@ -1205,7 +1206,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor if not isinstance(raw_flags, dict): raise HTTPException(status_code=400, detail="invalid_flags") clean: dict[str, bool] = {} - for sid in _CHAT_SPECIALIST_IDS: + for sid in _chat_specialist_ids(): if sid in raw_flags: clean[sid] = bool(raw_flags.get(sid)) clean["generalist"] = True diff --git a/interfaces/admin/models_api.py b/interfaces/admin/models_api.py index c78d6d48..7dfb0632 100644 --- a/interfaces/admin/models_api.py +++ b/interfaces/admin/models_api.py @@ -15,7 +15,7 @@ from fastapi.responses import Response from oclaw.runtime.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL from oclaw.runtime.agents.specialists import ( AGENT_PROFILE_BINDINGS_KEY, - AGENT_ROLE_IDS, + agent_role_ids, dump_agent_profile_bindings, parse_agent_profile_bindings, ) @@ -173,7 +173,7 @@ def include_model_mgmt_routes( "ui_lang": ui_lang, "builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID, "has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()), - "role_ids": list(AGENT_ROLE_IDS), + "role_ids": list(agent_role_ids()), "profile_secret": secret, "can_manage_llm_grants": _can_manage_llm_grants(ctx), # 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」 @@ -214,7 +214,7 @@ def include_model_mgmt_routes( if not isinstance(raw, dict): raise HTTPException(status_code=400, detail="bindings_object_required") cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx))) - for rid in AGENT_ROLE_IDS: + for rid in agent_role_ids(): v = raw.get(rid) if v is None: continue diff --git a/runtime/agents/factory.py b/runtime/agents/factory.py index 67ce2180..5b4a51c9 100644 --- a/runtime/agents/factory.py +++ b/runtime/agents/factory.py @@ -11,9 +11,9 @@ from oclaw.runtime.agents.specialist_agent import SpecialistProfile from oclaw.runtime.agents.specialists import ( AGENT_PROFILE_BINDINGS_KEY, normalize_specialist_id, - AGENT_ROLE_IDS, + agent_role_ids, MANAGER_AGENT_ID, - SPECIALIST_IDS, + specialist_ids, default_system_prefix_for_specialist, default_tool_tags_for_specialist, dump_agent_profile_bindings, @@ -167,7 +167,7 @@ def _build_executor_components( raw_bindings = parse_agent_profile_bindings(store.get_setting(bindings_key)) normalized_bindings: dict[str, str] = {} - for rid in AGENT_ROLE_IDS: + for rid in agent_role_ids(): pid = (raw_bindings.get(rid) or "").strip() normalized_bindings[rid] = pid if pid in valid_profile_ids else "" if dump_agent_profile_bindings(normalized_bindings) != dump_agent_profile_bindings(raw_bindings): @@ -182,7 +182,7 @@ def _build_executor_components( manager_model, manager_mode = _pick_model_for_role(MANAGER_AGENT_ID) specialist_models: dict[str, object] = {} specialist_modes: dict[str, str] = {} - for sid in SPECIALIST_IDS: + for sid in specialist_ids(): m, md = _pick_model_for_role(sid) specialist_models[sid] = m specialist_modes[sid] = md @@ -219,7 +219,7 @@ def _build_executor_components( system_prefix=default_system_prefix_for_specialist(sid, lang), tool_tags=default_tool_tags_for_specialist(sid), ) - for sid in SPECIALIST_IDS + for sid in specialist_ids() } return ( base_agent, @@ -354,7 +354,7 @@ def build_gateway_executors( path_policy_user_id=path_policy_user_id, ) specialists: dict[str, Any] = {} - for sid in SPECIALIST_IDS: + for sid in specialist_ids(): specialists[sid] = build_gateway_executor( store, lang=lang, diff --git a/runtime/agents/specialists.py b/runtime/agents/specialists.py index 39fbe660..344853f2 100644 --- a/runtime/agents/specialists.py +++ b/runtime/agents/specialists.py @@ -5,7 +5,7 @@ import json from typing import Any from oclaw.runtime.agent_context import build_role_system_context -from oclaw.runtime.workspaces.experts import discover_specialist_ids_from_workspaces +from oclaw.runtime.workspaces.experts import specialist_registry_snapshot SpecialistId = str @@ -47,16 +47,24 @@ SPECIALISTS: dict[SpecialistId, SpecialistConfig] = { } def discover_specialist_ids() -> tuple[SpecialistId, ...]: - return discover_specialist_ids_from_workspaces(base_order=("generalist", "ops", "image", "memory")) + rows = specialist_registry_snapshot(base_order=("generalist", "ops", "image", "memory")) + return tuple(str(x.get("id") or "").strip().lower() for x in rows if str(x.get("id") or "").strip()) -SPECIALIST_IDS: tuple[SpecialistId, ...] = discover_specialist_ids() -AGENT_ROLE_IDS: tuple[AgentRoleId, ...] = (MANAGER_AGENT_ID, *SPECIALIST_IDS) +def specialist_ids() -> tuple[SpecialistId, ...]: + return discover_specialist_ids() + + +def agent_role_ids() -> tuple[AgentRoleId, ...]: + return (MANAGER_AGENT_ID, *specialist_ids()) def expert_name_for_specialist(specialist_id: SpecialistId) -> str: sid = normalize_specialist_id(specialist_id) - cfg = SPECIALISTS.get(sid) or SPECIALISTS["generalist"] + cfg = SPECIALISTS.get(sid) + if cfg is None: + # Unknown dynamic specialists default to least-privilege tools. + return "generalist" return cfg.expert_name @@ -74,7 +82,7 @@ def default_system_prefix_for_specialist(specialist_id: SpecialistId, lang: str def model_role_for_specialist(specialist_id: SpecialistId) -> AgentRoleId: sid = normalize_specialist_id(specialist_id) - if sid in SPECIALIST_IDS: + if sid in specialist_ids(): return sid return "generalist" @@ -89,7 +97,7 @@ def normalize_specialist_id(specialist_id: SpecialistId | None) -> SpecialistId: def empty_agent_profile_bindings() -> dict[AgentRoleId, str]: - return {rid: "" for rid in AGENT_ROLE_IDS} + return {rid: "" for rid in agent_role_ids()} def parse_agent_profile_bindings(raw: str | None) -> dict[AgentRoleId, str]: @@ -103,7 +111,7 @@ def parse_agent_profile_bindings(raw: str | None) -> dict[AgentRoleId, str]: return out if not isinstance(obj, dict): return out - for rid in AGENT_ROLE_IDS: + for rid in agent_role_ids(): v = obj.get(rid) if v is None: continue @@ -114,7 +122,7 @@ def parse_agent_profile_bindings(raw: str | None) -> dict[AgentRoleId, str]: def dump_agent_profile_bindings(bindings: dict[AgentRoleId, Any]) -> str: raw = {} - for rid in AGENT_ROLE_IDS: + for rid in agent_role_ids(): v = bindings.get(rid) if isinstance(bindings, dict) else None raw[rid] = str(v).strip() if v is not None else "" return json.dumps(raw, ensure_ascii=False) @@ -122,15 +130,15 @@ def dump_agent_profile_bindings(bindings: dict[AgentRoleId, Any]) -> str: __all__ = [ "AGENT_PROFILE_BINDINGS_KEY", - "AGENT_ROLE_IDS", "AgentRoleId", + "agent_role_ids", "dump_agent_profile_bindings", "empty_agent_profile_bindings", "MANAGER_AGENT_ID", "SpecialistConfig", "SpecialistId", "SPECIALISTS", - "SPECIALIST_IDS", + "specialist_ids", "default_system_prefix_for_specialist", "default_tool_tags_for_specialist", "discover_specialist_ids", diff --git a/tests/test_specialists_registry.py b/tests/test_specialists_registry.py new file mode 100644 index 00000000..6993790f --- /dev/null +++ b/tests/test_specialists_registry.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from oclaw.runtime.agents import specialists as specialists_mod + + +def test_unknown_dynamic_specialist_defaults_to_minimum_expert_permissions(monkeypatch) -> None: + monkeypatch.setattr(specialists_mod, "discover_specialist_ids", lambda: ("generalist", "qa")) + assert specialists_mod.expert_name_for_specialist("qa") == "generalist" + + +def test_agent_role_ids_uses_runtime_discovery(monkeypatch) -> None: + monkeypatch.setattr(specialists_mod, "discover_specialist_ids", lambda: ("generalist", "ops", "qa")) + got = specialists_mod.agent_role_ids() + assert got[0] == specialists_mod.MANAGER_AGENT_ID + assert "qa" in set(got)