oclaw/runtime/agents/factory.py
oliver e9227bf7a0 统一 specialist 发现与角色绑定入口,收敛到运行时 registry 快照。
移除静态 specialist 列表依赖,并将未知动态 specialist 默认映射为最小权限 expert,降低路由与权限不一致风险。

Made-with: Cursor
2026-04-27 15:43:30 +08:00

437 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""根据存储与配置构建 Agent(不依赖 Streamlit)。"""
from __future__ import annotations
import os
from typing import Any
from oclaw.runtime.agents.agent_scope import resolve_default_agent_id
from oclaw.runtime.agents.network_ops_agent import NetworkOpsAgent
from oclaw.runtime.agents.specialist_agent import SpecialistProfile
from oclaw.runtime.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY,
normalize_specialist_id,
agent_role_ids,
MANAGER_AGENT_ID,
specialist_ids,
default_system_prefix_for_specialist,
default_tool_tags_for_specialist,
dump_agent_profile_bindings,
expert_name_for_specialist,
parse_agent_profile_bindings,
)
from oclaw.runtime.chat.agent import Agent
from oclaw.runtime.orchestration.inventory import inventory_snapshot
from oclaw.runtime.orchestration.memory import upsert_knowledge_chunks
from oclaw.platform.llm.chat_models import GoogleGeminiChatModel, OpenAIChatModel, RuleBasedChatModel, StaticTextChatModel
from oclaw.platform.llm.transports.anthropic_messages import AnthropicMessagesModel
from oclaw.platform.llm.transports.openai_responses import OpenAIResponsesModel
from oclaw.platform.persistence.sqlite_store import (
SqliteStore,
active_llm_profile_setting_key,
agent_profile_bindings_setting_key,
is_administrator_model_pool,
)
from oclaw.prompts import render_prompt
from oclaw.runtime.tools.catalog import default_registry
from oclaw.runtime.tools.plugin_loader import sync_plugin_metadata
def _openai_missing_key_user_message(lang: str) -> str:
prompt_id = "fallback/openai_missing_key_user.en.md" if (lang or "zh").startswith("en") else "fallback/openai_missing_key_user.zh.md"
return render_prompt(prompt_id, strict=True)
DEFAULT_OLLAMA_BASE_URL = (
(os.getenv("OLLAMA_BASE_URL") or os.getenv("OPENAI_BASE_URL_OLLAMA") or "").strip()
or "http://127.0.0.1:11434/v1"
)
DEFAULT_OLLAMA_MODEL = (os.getenv("OLLAMA_MODEL") or "qwen2.5:7b").strip()
_OLLAMA_DUMMY_KEY = "ollama"
def _build_executor_components(
store: SqliteStore,
*,
lang: str = "zh",
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
) -> tuple[
NetworkOpsAgent,
dict[str, SpecialistProfile],
object,
str,
dict[str, object],
dict[str, str],
]:
lang = (lang or "zh").strip().lower()
uid_scoped = str(viewer_user_id or "").strip()
personal = bool(uid_scoped) and not is_administrator_model_pool(viewer_username)
if personal:
active_key = active_llm_profile_setting_key(uid_scoped, viewer_username)
bindings_key = agent_profile_bindings_setting_key(uid_scoped, viewer_username)
list_kw: dict[str, Any] = {"viewer_user_id": uid_scoped, "viewer_username": viewer_username}
tid = str(viewer_tenant_id or "").strip()
if tid:
list_kw["viewer_tenant_id"] = tid
else:
active_key = "active_llm_profile_id"
bindings_key = AGENT_PROFILE_BINDINGS_KEY
list_kw = {}
active_pid = (profile_id or store.get_setting(active_key) or "").strip()
def _normalize_mode(raw: str | None) -> str:
m = (raw or "").strip().lower()
return m if m in ("openai", "openai_responses", "anthropic", "ollama", "rule", "google") else "rule"
def _build_chat_model_for_profile(
target_profile_id: str | None,
*,
allow_runtime_overrides: bool = False,
) -> tuple[object, str]:
pid = (target_profile_id or "").strip()
profile = store.get_llm_profile(pid) if pid else None
mode = _normalize_mode(
(llm_mode if allow_runtime_overrides else None)
or (profile.get("mode") if profile else None)
or os.getenv("AIA_ASSISTANT_MODE")
or "openai"
)
raw_model = (model if allow_runtime_overrides else None) or (profile.get("model") if profile else None) or ""
raw_model = str(raw_model).strip()
if not raw_model:
raw_model = (
(os.getenv("OLLAMA_MODEL") or "").strip()
if mode == "ollama"
else (os.getenv("OPENAI_MODEL") or "").strip()
)
model_name = raw_model or (DEFAULT_OLLAMA_MODEL if mode == "ollama" else "gpt-4o-mini")
bu = (base_url if allow_runtime_overrides else None) or (profile.get("base_url") if profile else None) or os.getenv("OPENAI_BASE_URL") or ""
bu = str(bu).strip()
stored_key = store.get_llm_profile_secret(pid) if pid else None
api_key = (openai_api_key if allow_runtime_overrides else None) or stored_key or os.getenv("OPENAI_API_KEY")
api_key = (api_key or "").strip()
if mode == "openai_responses":
if not api_key:
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
return OpenAIResponsesModel(model=model_name, api_key=api_key, base_url=bu or None), mode
if mode == "anthropic":
akey = (
(openai_api_key if allow_runtime_overrides else None)
or stored_key
or os.getenv("ANTHROPIC_API_KEY")
or os.getenv("OPENAI_API_KEY")
or ""
)
akey = str(akey or "").strip()
if not akey:
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
return AnthropicMessagesModel(model=model_name, api_key=akey, base_url=bu or None), mode
if mode == "google":
gkey = (
(openai_api_key if allow_runtime_overrides else None)
or stored_key
or os.getenv("GOOGLE_API_KEY")
or os.getenv("GEMINI_API_KEY")
or os.getenv("OPENAI_API_KEY")
or ""
)
gkey = str(gkey or "").strip()
if not gkey:
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
return GoogleGeminiChatModel(model=model_name, api_key=gkey, base_url=bu or None), mode
if mode == "rule":
return RuleBasedChatModel(), mode
if mode == "ollama":
ollama_base = (bu or DEFAULT_OLLAMA_BASE_URL).strip() or DEFAULT_OLLAMA_BASE_URL
ollama_key = api_key or _OLLAMA_DUMMY_KEY
return OpenAIChatModel(model=model_name, api_key=ollama_key, base_url=ollama_base), mode
if not api_key:
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
return OpenAIChatModel(model=model_name, api_key=api_key, base_url=bu or None), mode
valid_profile_ids = {p["id"] for p in store.list_llm_profiles(visible_only=True, **list_kw)}
if active_pid and active_pid not in valid_profile_ids:
active_pid = ""
active_model, active_mode = _build_chat_model_for_profile(active_pid, allow_runtime_overrides=True)
raw_bindings = parse_agent_profile_bindings(store.get_setting(bindings_key))
normalized_bindings: dict[str, str] = {}
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):
store.set_setting(bindings_key, dump_agent_profile_bindings(normalized_bindings))
def _pick_model_for_role(role_id: str) -> tuple[object, str]:
bound_pid = (normalized_bindings.get(role_id) or "").strip()
if not bound_pid:
return active_model, active_mode
return _build_chat_model_for_profile(bound_pid, allow_runtime_overrides=False)
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():
m, md = _pick_model_for_role(sid)
specialist_models[sid] = m
specialist_modes[sid] = md
base_agent = NetworkOpsAgent(
store=store,
model=specialist_models.get("ops") or active_model,
lang=lang,
llm_profile_mode=specialist_modes.get("ops") or active_mode,
)
try:
store.set_setting("agent_inventory_snapshot", str(inventory_snapshot()))
except Exception:
pass
try:
sync_plugin_metadata(store)
except Exception:
pass
try:
upsert_knowledge_chunks(
store,
source="builtin:src",
chunks=[
"Use tools for route lookup, path search, config diff, device ping, and log analysis.",
"High-risk actions require explicit confirmation by user before execution.",
"Prefer citing tool outputs and avoid fabricating external facts.",
],
)
except Exception:
pass
specialist_profiles = {
sid: SpecialistProfile(
name=sid,
system_prefix=default_system_prefix_for_specialist(sid, lang),
tool_tags=default_tool_tags_for_specialist(sid),
)
for sid in specialist_ids()
}
return (
base_agent,
specialist_profiles,
manager_model,
manager_mode,
specialist_models,
specialist_modes,
)
def build_ops_agent(
store: SqliteStore,
*,
lang: str = "zh",
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
) -> Any:
del viewer_user_id, viewer_username, viewer_tenant_id
return build_gateway_executor(
store,
lang=lang,
specialist="ops",
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
)
def build_gateway_executor(
store: SqliteStore,
*,
lang: str = "zh",
specialist: str | None = None,
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> Any:
base_agent, specialist_profiles, _, _, specialist_models, specialist_modes = _build_executor_components(
store,
lang=lang,
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
viewer_user_id=viewer_user_id,
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
)
sid = normalize_specialist_id(specialist)
if sid not in specialist_profiles:
sid = "generalist"
prof = specialist_profiles.get(sid) or specialist_profiles["generalist"]
chosen_model = specialist_models.get(prof.name) or base_agent.model
chosen_mode = specialist_modes.get(prof.name) or getattr(base_agent, "llm_profile_mode", None)
if prof.name == "ops":
return NetworkOpsAgent(
store=store,
model=chosen_model,
lang=(lang or "zh").strip().lower(),
llm_profile_mode=chosen_mode,
system_prompt=prof.system_prefix,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
tools = default_registry(
expert=expert_name_for_specialist(prof.name),
specialist=prof.name,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=store,
)
return Agent(
store=store,
tools=tools,
model=chosen_model,
system_prompt=prof.system_prefix,
lang=(lang or "zh").strip().lower(),
llm_profile_mode=chosen_mode,
)
def build_gateway_executors(
store: SqliteStore,
*,
lang: str = "zh",
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> dict[str, Any]:
manager = build_gateway_executor(
store,
lang=lang,
specialist="generalist",
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
viewer_user_id=viewer_user_id,
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
specialists: dict[str, Any] = {}
for sid in specialist_ids():
specialists[sid] = build_gateway_executor(
store,
lang=lang,
specialist=sid,
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
viewer_user_id=viewer_user_id,
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
return {"manager": manager, "specialists": specialists}
def build_ephemeral_executor(
store: SqliteStore,
*,
lang: str = "zh",
system_prompt: str,
tool_policy: dict[str, Any] | None = None,
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> Any:
base_agent, _, _, _, _, _ = _build_executor_components(
store,
lang=lang,
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
viewer_user_id=viewer_user_id,
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
)
declared = tool_policy if isinstance(tool_policy, dict) else {}
allow_tags = [str(x) for x in (declared.get("allow_tags") or []) if str(x or "").strip()]
allow_tools = [str(x) for x in (declared.get("allow_tools") or []) if str(x or "").strip()]
tools = default_registry(
expert="generalist+workspace+productivity",
specialist="generalist",
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=store,
allow_tags=allow_tags,
allow_tools=allow_tools,
)
return Agent(
store=store,
tools=tools,
model=base_agent.model,
system_prompt=str(system_prompt or "").strip(),
lang=(lang or "zh").strip().lower(),
llm_profile_mode=getattr(base_agent, "llm_profile_mode", None),
)
__all__ = [
"DEFAULT_OLLAMA_BASE_URL",
"DEFAULT_OLLAMA_MODEL",
"build_ops_agent",
"build_gateway_executor",
"build_gateway_executors",
"build_ephemeral_executor",
]