mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-15 11:00:48 +08:00
统一 specialist 发现与角色绑定入口,收敛到运行时 registry 快照。
移除静态 specialist 列表依赖,并将未知动态 specialist 默认映射为最小权限 expert,降低路由与权限不一致风险。 Made-with: Cursor
This commit is contained in:
parent
87ff00aa06
commit
e9227bf7a0
5 changed files with 50 additions and 26 deletions
|
|
@ -15,7 +15,7 @@ from fastapi import APIRouter, Body, File, Header, HTTPException, Query, UploadF
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
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.agents.factory import build_gateway_executor
|
||||||
from oclaw.runtime.chat.agent import GenerationInterrupted
|
from oclaw.runtime.chat.agent import GenerationInterrupted
|
||||||
from oclaw.platform.config.paths import db_path
|
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"
|
_DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
||||||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_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_SQL_TIMEOUT_MS = 8_000
|
||||||
DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 5_000
|
DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 5_000
|
||||||
DEFAULT_TEXT_INLINE_MAX_CHARS = 12_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]:
|
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()
|
raw = str(store.get_setting(_SPECIALIST_FLAGS_SETTING_KEY) or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return flags
|
return flags
|
||||||
|
|
@ -336,7 +337,7 @@ def _specialist_flags_with_overrides(store: SqliteStore) -> dict[str, bool]:
|
||||||
return flags
|
return flags
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
return flags
|
return flags
|
||||||
for sid in _CHAT_SPECIALIST_IDS:
|
for sid in _chat_specialist_ids():
|
||||||
if sid in obj:
|
if sid in obj:
|
||||||
flags[sid] = bool(obj.get(sid))
|
flags[sid] = bool(obj.get(sid))
|
||||||
# keep generalist always on for safety
|
# 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)
|
ctx = resolve_auth(store, authorization)
|
||||||
_require_administrator_chat_viewer(ctx)
|
_require_administrator_chat_viewer(ctx)
|
||||||
flags = _specialist_flags_with_overrides(store)
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"setting_key": _SPECIALIST_FLAGS_SETTING_KEY,
|
"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):
|
if not isinstance(raw_flags, dict):
|
||||||
raise HTTPException(status_code=400, detail="invalid_flags")
|
raise HTTPException(status_code=400, detail="invalid_flags")
|
||||||
clean: dict[str, bool] = {}
|
clean: dict[str, bool] = {}
|
||||||
for sid in _CHAT_SPECIALIST_IDS:
|
for sid in _chat_specialist_ids():
|
||||||
if sid in raw_flags:
|
if sid in raw_flags:
|
||||||
clean[sid] = bool(raw_flags.get(sid))
|
clean[sid] = bool(raw_flags.get(sid))
|
||||||
clean["generalist"] = True
|
clean["generalist"] = True
|
||||||
|
|
|
||||||
|
|
@ -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.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
|
||||||
from oclaw.runtime.agents.specialists import (
|
from oclaw.runtime.agents.specialists import (
|
||||||
AGENT_PROFILE_BINDINGS_KEY,
|
AGENT_PROFILE_BINDINGS_KEY,
|
||||||
AGENT_ROLE_IDS,
|
agent_role_ids,
|
||||||
dump_agent_profile_bindings,
|
dump_agent_profile_bindings,
|
||||||
parse_agent_profile_bindings,
|
parse_agent_profile_bindings,
|
||||||
)
|
)
|
||||||
|
|
@ -173,7 +173,7 @@ def include_model_mgmt_routes(
|
||||||
"ui_lang": ui_lang,
|
"ui_lang": ui_lang,
|
||||||
"builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID,
|
"builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID,
|
||||||
"has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()),
|
"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,
|
"profile_secret": secret,
|
||||||
"can_manage_llm_grants": _can_manage_llm_grants(ctx),
|
"can_manage_llm_grants": _can_manage_llm_grants(ctx),
|
||||||
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
|
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
|
||||||
|
|
@ -214,7 +214,7 @@ def include_model_mgmt_routes(
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise HTTPException(status_code=400, detail="bindings_object_required")
|
raise HTTPException(status_code=400, detail="bindings_object_required")
|
||||||
cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
|
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)
|
v = raw.get(rid)
|
||||||
if v is None:
|
if v is None:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ from oclaw.runtime.agents.specialist_agent import SpecialistProfile
|
||||||
from oclaw.runtime.agents.specialists import (
|
from oclaw.runtime.agents.specialists import (
|
||||||
AGENT_PROFILE_BINDINGS_KEY,
|
AGENT_PROFILE_BINDINGS_KEY,
|
||||||
normalize_specialist_id,
|
normalize_specialist_id,
|
||||||
AGENT_ROLE_IDS,
|
agent_role_ids,
|
||||||
MANAGER_AGENT_ID,
|
MANAGER_AGENT_ID,
|
||||||
SPECIALIST_IDS,
|
specialist_ids,
|
||||||
default_system_prefix_for_specialist,
|
default_system_prefix_for_specialist,
|
||||||
default_tool_tags_for_specialist,
|
default_tool_tags_for_specialist,
|
||||||
dump_agent_profile_bindings,
|
dump_agent_profile_bindings,
|
||||||
|
|
@ -167,7 +167,7 @@ def _build_executor_components(
|
||||||
|
|
||||||
raw_bindings = parse_agent_profile_bindings(store.get_setting(bindings_key))
|
raw_bindings = parse_agent_profile_bindings(store.get_setting(bindings_key))
|
||||||
normalized_bindings: dict[str, str] = {}
|
normalized_bindings: dict[str, str] = {}
|
||||||
for rid in AGENT_ROLE_IDS:
|
for rid in agent_role_ids():
|
||||||
pid = (raw_bindings.get(rid) or "").strip()
|
pid = (raw_bindings.get(rid) or "").strip()
|
||||||
normalized_bindings[rid] = pid if pid in valid_profile_ids else ""
|
normalized_bindings[rid] = pid if pid in valid_profile_ids else ""
|
||||||
if dump_agent_profile_bindings(normalized_bindings) != dump_agent_profile_bindings(raw_bindings):
|
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)
|
manager_model, manager_mode = _pick_model_for_role(MANAGER_AGENT_ID)
|
||||||
specialist_models: dict[str, object] = {}
|
specialist_models: dict[str, object] = {}
|
||||||
specialist_modes: dict[str, str] = {}
|
specialist_modes: dict[str, str] = {}
|
||||||
for sid in SPECIALIST_IDS:
|
for sid in specialist_ids():
|
||||||
m, md = _pick_model_for_role(sid)
|
m, md = _pick_model_for_role(sid)
|
||||||
specialist_models[sid] = m
|
specialist_models[sid] = m
|
||||||
specialist_modes[sid] = md
|
specialist_modes[sid] = md
|
||||||
|
|
@ -219,7 +219,7 @@ def _build_executor_components(
|
||||||
system_prefix=default_system_prefix_for_specialist(sid, lang),
|
system_prefix=default_system_prefix_for_specialist(sid, lang),
|
||||||
tool_tags=default_tool_tags_for_specialist(sid),
|
tool_tags=default_tool_tags_for_specialist(sid),
|
||||||
)
|
)
|
||||||
for sid in SPECIALIST_IDS
|
for sid in specialist_ids()
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
base_agent,
|
base_agent,
|
||||||
|
|
@ -354,7 +354,7 @@ def build_gateway_executors(
|
||||||
path_policy_user_id=path_policy_user_id,
|
path_policy_user_id=path_policy_user_id,
|
||||||
)
|
)
|
||||||
specialists: dict[str, Any] = {}
|
specialists: dict[str, Any] = {}
|
||||||
for sid in SPECIALIST_IDS:
|
for sid in specialist_ids():
|
||||||
specialists[sid] = build_gateway_executor(
|
specialists[sid] = build_gateway_executor(
|
||||||
store,
|
store,
|
||||||
lang=lang,
|
lang=lang,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from oclaw.runtime.agent_context import build_role_system_context
|
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
|
SpecialistId = str
|
||||||
|
|
@ -47,16 +47,24 @@ SPECIALISTS: dict[SpecialistId, SpecialistConfig] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
def discover_specialist_ids() -> tuple[SpecialistId, ...]:
|
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()
|
def specialist_ids() -> tuple[SpecialistId, ...]:
|
||||||
AGENT_ROLE_IDS: tuple[AgentRoleId, ...] = (MANAGER_AGENT_ID, *SPECIALIST_IDS)
|
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:
|
def expert_name_for_specialist(specialist_id: SpecialistId) -> str:
|
||||||
sid = normalize_specialist_id(specialist_id)
|
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
|
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:
|
def model_role_for_specialist(specialist_id: SpecialistId) -> AgentRoleId:
|
||||||
sid = normalize_specialist_id(specialist_id)
|
sid = normalize_specialist_id(specialist_id)
|
||||||
if sid in SPECIALIST_IDS:
|
if sid in specialist_ids():
|
||||||
return sid
|
return sid
|
||||||
return "generalist"
|
return "generalist"
|
||||||
|
|
||||||
|
|
@ -89,7 +97,7 @@ def normalize_specialist_id(specialist_id: SpecialistId | None) -> SpecialistId:
|
||||||
|
|
||||||
|
|
||||||
def empty_agent_profile_bindings() -> dict[AgentRoleId, str]:
|
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]:
|
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
|
return out
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
return out
|
return out
|
||||||
for rid in AGENT_ROLE_IDS:
|
for rid in agent_role_ids():
|
||||||
v = obj.get(rid)
|
v = obj.get(rid)
|
||||||
if v is None:
|
if v is None:
|
||||||
continue
|
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:
|
def dump_agent_profile_bindings(bindings: dict[AgentRoleId, Any]) -> str:
|
||||||
raw = {}
|
raw = {}
|
||||||
for rid in AGENT_ROLE_IDS:
|
for rid in agent_role_ids():
|
||||||
v = bindings.get(rid) if isinstance(bindings, dict) else None
|
v = bindings.get(rid) if isinstance(bindings, dict) else None
|
||||||
raw[rid] = str(v).strip() if v is not None else ""
|
raw[rid] = str(v).strip() if v is not None else ""
|
||||||
return json.dumps(raw, ensure_ascii=False)
|
return json.dumps(raw, ensure_ascii=False)
|
||||||
|
|
@ -122,15 +130,15 @@ def dump_agent_profile_bindings(bindings: dict[AgentRoleId, Any]) -> str:
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AGENT_PROFILE_BINDINGS_KEY",
|
"AGENT_PROFILE_BINDINGS_KEY",
|
||||||
"AGENT_ROLE_IDS",
|
|
||||||
"AgentRoleId",
|
"AgentRoleId",
|
||||||
|
"agent_role_ids",
|
||||||
"dump_agent_profile_bindings",
|
"dump_agent_profile_bindings",
|
||||||
"empty_agent_profile_bindings",
|
"empty_agent_profile_bindings",
|
||||||
"MANAGER_AGENT_ID",
|
"MANAGER_AGENT_ID",
|
||||||
"SpecialistConfig",
|
"SpecialistConfig",
|
||||||
"SpecialistId",
|
"SpecialistId",
|
||||||
"SPECIALISTS",
|
"SPECIALISTS",
|
||||||
"SPECIALIST_IDS",
|
"specialist_ids",
|
||||||
"default_system_prefix_for_specialist",
|
"default_system_prefix_for_specialist",
|
||||||
"default_tool_tags_for_specialist",
|
"default_tool_tags_for_specialist",
|
||||||
"discover_specialist_ids",
|
"discover_specialist_ids",
|
||||||
|
|
|
||||||
15
tests/test_specialists_registry.py
Normal file
15
tests/test_specialists_registry.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue