mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
40
runtime/agents/__init__.py
Normal file
40
runtime/agents/__init__.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .agent_scope import (
|
||||
resolve_agent_id_by_workspace_path,
|
||||
resolve_agent_id_from_session_key,
|
||||
resolve_agent_ids_by_workspace_path,
|
||||
resolve_agent_workspace_dir,
|
||||
resolve_default_agent_id,
|
||||
resolve_session_agent_id,
|
||||
resolve_session_agent_ids,
|
||||
)
|
||||
from .subagent_registry import init_subagent_registry
|
||||
|
||||
__all__ = [
|
||||
"build_gateway_executor",
|
||||
"build_ops_agent",
|
||||
"NetworkOpsAgent",
|
||||
"resolve_default_agent_id",
|
||||
"resolve_agent_workspace_dir",
|
||||
"resolve_agent_id_from_session_key",
|
||||
"resolve_session_agent_id",
|
||||
"resolve_session_agent_ids",
|
||||
"resolve_agent_id_by_workspace_path",
|
||||
"resolve_agent_ids_by_workspace_path",
|
||||
"init_subagent_registry",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in {"build_gateway_executor", "build_ops_agent"}:
|
||||
from .factory import build_gateway_executor, build_ops_agent
|
||||
|
||||
return {"build_gateway_executor": build_gateway_executor, "build_ops_agent": build_ops_agent}[name]
|
||||
if name == "NetworkOpsAgent":
|
||||
from .network_ops_agent import NetworkOpsAgent
|
||||
|
||||
return NetworkOpsAgent
|
||||
raise AttributeError(f"module 'src.agents' has no attribute {name!r}")
|
||||
167
runtime/agents/agent_scope.py
Normal file
167
runtime/agents/agent_scope.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_AGENT_ID = "default"
|
||||
|
||||
|
||||
def _normalize_agent_id(value: str | None) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return DEFAULT_AGENT_ID
|
||||
out = []
|
||||
for ch in text:
|
||||
if ch.isalnum() or ch in {"-", "_"}:
|
||||
out.append(ch)
|
||||
elif ch.isspace():
|
||||
out.append("-")
|
||||
normalized = "".join(out).strip("-_")
|
||||
return normalized or DEFAULT_AGENT_ID
|
||||
|
||||
|
||||
def list_agent_entries(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
agents = (cfg.get("agents") or {}) if isinstance(cfg, dict) else {}
|
||||
entries = agents.get("list")
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [x for x in entries if isinstance(x, dict)]
|
||||
|
||||
|
||||
def list_agent_ids(cfg: dict[str, Any]) -> list[str]:
|
||||
entries = list_agent_entries(cfg)
|
||||
if not entries:
|
||||
return [DEFAULT_AGENT_ID]
|
||||
seen: set[str] = set()
|
||||
ids: list[str] = []
|
||||
for entry in entries:
|
||||
aid = _normalize_agent_id(entry.get("id"))
|
||||
if aid in seen:
|
||||
continue
|
||||
seen.add(aid)
|
||||
ids.append(aid)
|
||||
return ids or [DEFAULT_AGENT_ID]
|
||||
|
||||
|
||||
def resolve_default_agent_id(cfg: dict[str, Any]) -> str:
|
||||
entries = list_agent_entries(cfg)
|
||||
if not entries:
|
||||
return DEFAULT_AGENT_ID
|
||||
defaults = [x for x in entries if bool(x.get("default"))]
|
||||
chosen = (defaults[0] if defaults else entries[0]).get("id")
|
||||
return _normalize_agent_id(chosen)
|
||||
|
||||
|
||||
def _resolve_agent_entry(cfg: dict[str, Any], agent_id: str) -> dict[str, Any] | None:
|
||||
target = _normalize_agent_id(agent_id)
|
||||
for entry in list_agent_entries(cfg):
|
||||
if _normalize_agent_id(entry.get("id")) == target:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def resolve_agent_id_from_session_key(session_key: str | None) -> str:
|
||||
text = str(session_key or "").strip()
|
||||
if not text:
|
||||
return DEFAULT_AGENT_ID
|
||||
prefix = text.split(":", 1)[0].strip()
|
||||
return _normalize_agent_id(prefix)
|
||||
|
||||
|
||||
def resolve_session_agent_ids(
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
cfg = config if isinstance(config, dict) else {}
|
||||
default_agent_id = resolve_default_agent_id(cfg)
|
||||
explicit_agent_id = _normalize_agent_id(agent_id) if str(agent_id or "").strip() else None
|
||||
session_agent_id = explicit_agent_id or resolve_agent_id_from_session_key(session_key) or default_agent_id
|
||||
return {"default_agent_id": default_agent_id, "session_agent_id": session_agent_id}
|
||||
|
||||
|
||||
def resolve_session_agent_id(
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> str:
|
||||
return resolve_session_agent_ids(session_key=session_key, config=config, agent_id=agent_id)["session_agent_id"]
|
||||
|
||||
|
||||
def _normalize_path_for_comparison(input_path: str) -> Path:
|
||||
raw = str(input_path or "").replace("\x00", "").strip() or "."
|
||||
p = Path(raw).expanduser()
|
||||
try:
|
||||
p = p.resolve(strict=False)
|
||||
except Exception:
|
||||
pass
|
||||
norm = str(p)
|
||||
if os.name == "nt":
|
||||
norm = norm.lower()
|
||||
return Path(norm)
|
||||
|
||||
|
||||
def _is_path_within_root(candidate_path: Path, root_path: Path) -> bool:
|
||||
try:
|
||||
candidate_path.relative_to(root_path)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_agent_workspace_dir(cfg: dict[str, Any], agent_id: str) -> str:
|
||||
aid = _normalize_agent_id(agent_id)
|
||||
agents_cfg = (cfg.get("agents") or {}) if isinstance(cfg, dict) else {}
|
||||
defaults = (agents_cfg.get("defaults") or {}) if isinstance(agents_cfg, dict) else {}
|
||||
entry = _resolve_agent_entry(cfg, aid) or {}
|
||||
|
||||
configured_workspace = str(entry.get("workspace") or "").strip()
|
||||
if configured_workspace:
|
||||
return str(Path(configured_workspace))
|
||||
|
||||
fallback_workspace = str(defaults.get("workspace") or "").strip()
|
||||
default_agent_id = resolve_default_agent_id(cfg)
|
||||
if aid == default_agent_id:
|
||||
if fallback_workspace:
|
||||
return str(Path(fallback_workspace))
|
||||
return str(Path("."))
|
||||
|
||||
if fallback_workspace:
|
||||
return str(Path(fallback_workspace) / aid)
|
||||
|
||||
state_dir = str(os.getenv("OCLAW_STATE_DIR") or ".oclaw").strip() or ".oclaw"
|
||||
return str(Path(state_dir) / f"workspace-{aid}")
|
||||
|
||||
|
||||
def resolve_agent_ids_by_workspace_path(cfg: dict[str, Any], workspace_path: str) -> list[str]:
|
||||
target = _normalize_path_for_comparison(workspace_path)
|
||||
matches: list[tuple[str, Path, int]] = []
|
||||
for idx, aid in enumerate(list_agent_ids(cfg)):
|
||||
ws = _normalize_path_for_comparison(resolve_agent_workspace_dir(cfg, aid))
|
||||
if not _is_path_within_root(target, ws):
|
||||
continue
|
||||
matches.append((aid, ws, idx))
|
||||
matches.sort(key=lambda row: (-len(str(row[1])), row[2]))
|
||||
return [x[0] for x in matches]
|
||||
|
||||
|
||||
def resolve_agent_id_by_workspace_path(cfg: dict[str, Any], workspace_path: str) -> str | None:
|
||||
ids = resolve_agent_ids_by_workspace_path(cfg, workspace_path)
|
||||
return ids[0] if ids else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AGENT_ID",
|
||||
"list_agent_entries",
|
||||
"list_agent_ids",
|
||||
"resolve_agent_id_by_workspace_path",
|
||||
"resolve_agent_id_from_session_key",
|
||||
"resolve_agent_ids_by_workspace_path",
|
||||
"resolve_session_agent_id",
|
||||
"resolve_session_agent_ids",
|
||||
"resolve_agent_workspace_dir",
|
||||
"resolve_default_agent_id",
|
||||
]
|
||||
450
runtime/agents/factory.py
Normal file
450
runtime/agents/factory.py
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
"""根据存储与配置构建 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,
|
||||
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 = {
|
||||
"ops": SpecialistProfile(
|
||||
name="ops",
|
||||
system_prefix=default_system_prefix_for_specialist("ops", lang),
|
||||
tool_tags=default_tool_tags_for_specialist("ops"),
|
||||
),
|
||||
"generalist": SpecialistProfile(
|
||||
name="generalist",
|
||||
system_prefix=default_system_prefix_for_specialist("generalist", lang),
|
||||
tool_tags=default_tool_tags_for_specialist("generalist"),
|
||||
),
|
||||
"image": SpecialistProfile(
|
||||
name="image",
|
||||
system_prefix=default_system_prefix_for_specialist("image", lang),
|
||||
tool_tags=default_tool_tags_for_specialist("image"),
|
||||
),
|
||||
"memory_curator": SpecialistProfile(
|
||||
name="memory_curator",
|
||||
system_prefix=default_system_prefix_for_specialist("memory_curator", lang),
|
||||
tool_tags=default_tool_tags_for_specialist("memory_curator"),
|
||||
),
|
||||
}
|
||||
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 = str(specialist or "").strip().lower() or "generalist"
|
||||
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",
|
||||
]
|
||||
45
runtime/agents/network_ops_agent.py
Normal file
45
runtime/agents/network_ops_agent.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.chat.agent import Agent
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.prompts.loader import render_runtime_prompt
|
||||
from oclaw.runtime.tools import default_registry
|
||||
|
||||
NETWORK_SYSTEM_PROMPT_ZH = render_runtime_prompt("roles/specialists/ops/system.md", strict=True)
|
||||
|
||||
|
||||
class NetworkOpsAgent(Agent):
|
||||
"""网络运维专家 Agent:固定专家提示词与专家工具目录。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: SqliteStore,
|
||||
model: Any,
|
||||
lang: str = "zh",
|
||||
llm_profile_mode: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> None:
|
||||
tools = default_registry(
|
||||
expert="network_ops",
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
store=store,
|
||||
)
|
||||
super().__init__(
|
||||
store=store,
|
||||
tools=tools,
|
||||
model=model,
|
||||
system_prompt=(system_prompt or render_runtime_prompt("roles/specialists/ops/system.md", strict=True)),
|
||||
lang=lang,
|
||||
llm_profile_mode=llm_profile_mode,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["NetworkOpsAgent", "NETWORK_SYSTEM_PROMPT_ZH"]
|
||||
516
runtime/agents/specialist_agent.py
Normal file
516
runtime/agents/specialist_agent.py
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import base64
|
||||
import hashlib
|
||||
import httpx
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from oclaw.runtime.chat.agent import Agent
|
||||
from oclaw.runtime.chat.agent import GenerationInterrupted
|
||||
from oclaw.runtime.agents.network_ops_agent import NetworkOpsAgent
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.platform.files.attachment_assets import AttachmentAssetStore, attachment_id_to_data_url
|
||||
from oclaw.platform.llm.image_message_client import send_image_messages
|
||||
from oclaw.runtime.tools import default_registry
|
||||
from oclaw.runtime.agents.specialists import expert_name_for_specialist
|
||||
|
||||
from oclaw.runtime.chat.turn_types import TurnRunOutcome
|
||||
from oclaw.runtime.relay_pointer import build_manifest_from_attachment_refs
|
||||
from oclaw.runtime.types import RelayShareEnvelope
|
||||
from oclaw.runtime.orchestration.protocol import (
|
||||
AgentTask,
|
||||
PlanStep,
|
||||
SpecialistDelivery,
|
||||
SpecialistResult,
|
||||
SpecialistToolTrace,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialistProfile:
|
||||
name: str
|
||||
system_prefix: str
|
||||
tool_tags: frozenset[str] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecialistAgentRunner:
|
||||
store: SqliteStore
|
||||
model: Any
|
||||
llm_profile_mode: str | None
|
||||
lang: str
|
||||
profiles: dict[str, SpecialistProfile] = field(default_factory=dict)
|
||||
model_by_specialist: dict[str, Any] = field(default_factory=dict)
|
||||
llm_mode_by_specialist: dict[str, str | None] = field(default_factory=dict)
|
||||
_agent_cache: dict[tuple, Agent] = field(default_factory=dict, init=False, repr=False)
|
||||
|
||||
@staticmethod
|
||||
def _allowlist_mutation_fingerprint(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> str:
|
||||
t = (path_policy_tenant_id or "").strip() or None
|
||||
u = (path_policy_user_id or "").strip() or None
|
||||
if (not t or not u) and (policy_session_id or "").strip():
|
||||
try:
|
||||
own = store.get_ui_session_owner(session_id=str(policy_session_id).strip()) or {}
|
||||
except Exception:
|
||||
own = {}
|
||||
t = t or (str(own.get("tenant_id") or "").strip() or None)
|
||||
u = u or (str(own.get("user_id") or "").strip() or None)
|
||||
if not t or not u:
|
||||
return "0"
|
||||
try:
|
||||
row = store.get_user_workspace_path_allowlist(tenant_id=t, user_id=u)
|
||||
except Exception:
|
||||
row = None
|
||||
if not row or not isinstance(row, dict):
|
||||
return "0|"
|
||||
er = str(row.get("extra_roots") or "")
|
||||
return f"{1 if int(row.get('allow_any_path') or 0) else 0}|{str(row.get('updated_at') or '')}|{er[:2000]}"
|
||||
|
||||
def _agent_cache_fingerprint(
|
||||
self,
|
||||
specialist: str,
|
||||
prof: SpecialistProfile,
|
||||
*,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> str:
|
||||
tool_names: list[str] = []
|
||||
try:
|
||||
regs = 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=self.store,
|
||||
)
|
||||
tool_names = sorted([str(t.name) for t in regs.list()])
|
||||
except Exception:
|
||||
tool_names = []
|
||||
raw = json.dumps(
|
||||
{
|
||||
"specialist": specialist,
|
||||
"profile_name": prof.name,
|
||||
"system_prefix": prof.system_prefix,
|
||||
"tool_names": tool_names,
|
||||
"tool_tags": sorted(list(prof.tool_tags or frozenset())),
|
||||
"policy_session_tail": (str(policy_session_id or "")[-16:]),
|
||||
"allowlist_fp": self._allowlist_mutation_fingerprint(
|
||||
self.store,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
def _resolve_profile_and_model(self, specialist: str) -> tuple[SpecialistProfile, Any, str | None]:
|
||||
prof = self.profiles.get(specialist) or self.profiles["generalist"]
|
||||
chosen_model = self.model_by_specialist.get(prof.name) or self.model
|
||||
chosen_mode = self.llm_mode_by_specialist.get(prof.name) or self.llm_profile_mode
|
||||
return prof, chosen_model, chosen_mode
|
||||
|
||||
def _build_agent_for(
|
||||
self,
|
||||
specialist: str,
|
||||
*,
|
||||
policy_session_id: str | None = None,
|
||||
use_cache: bool = True,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> Agent:
|
||||
prof, chosen_model, chosen_mode = self._resolve_profile_and_model(specialist)
|
||||
cache_fp = self._agent_cache_fingerprint(
|
||||
specialist,
|
||||
prof,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
alfp = self._allowlist_mutation_fingerprint(
|
||||
self.store,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
cache_key = (prof.name, id(chosen_model), chosen_mode, self.lang, cache_fp, str(policy_session_id or ""), alfp)
|
||||
if use_cache:
|
||||
cached = self._agent_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if prof.name == "ops":
|
||||
agent: Agent = NetworkOpsAgent(
|
||||
store=self.store,
|
||||
model=chosen_model,
|
||||
lang=self.lang,
|
||||
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,
|
||||
)
|
||||
if use_cache:
|
||||
self._agent_cache[cache_key] = agent
|
||||
return agent
|
||||
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=self.store,
|
||||
)
|
||||
agent = Agent(
|
||||
store=self.store,
|
||||
tools=tools,
|
||||
model=chosen_model,
|
||||
system_prompt=prof.system_prefix,
|
||||
lang=self.lang,
|
||||
llm_profile_mode=chosen_mode,
|
||||
)
|
||||
if use_cache:
|
||||
self._agent_cache[cache_key] = agent
|
||||
return agent
|
||||
|
||||
def run_specialist(
|
||||
self,
|
||||
*,
|
||||
parent_task: AgentTask,
|
||||
step: PlanStep,
|
||||
session_id: str | None = None,
|
||||
use_cache: bool = True,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None,
|
||||
should_stop: Optional[Callable[[], bool]] = None,
|
||||
) -> SpecialistResult:
|
||||
started = time.perf_counter()
|
||||
if on_progress:
|
||||
obj = (step.objective or "").strip().replace("\n", " ")
|
||||
if len(obj) > 140:
|
||||
obj = obj[:137] + "..."
|
||||
on_progress(f"[sp.start] {step.step_id} specialist={step.specialist} objective={obj}")
|
||||
created_session_id: str | None = None
|
||||
if not session_id:
|
||||
temp_session = self.store.create_session(f"specialist:{step.specialist}")
|
||||
session_id = temp_session.id
|
||||
created_session_id = session_id
|
||||
# User chat session for workspace/MCP path policy (specialist temp session usually has no ui_session_owner).
|
||||
_raw_policy_sid = str(parent_task.session_id or "").strip() or str(session_id or "").strip()
|
||||
policy_session_id: str | None = _raw_policy_sid if _raw_policy_sid else None
|
||||
_meta: dict[str, Any] = parent_task.metadata if isinstance(getattr(parent_task, "metadata", None), dict) else {}
|
||||
_path_tenant = str(_meta.get("tenant_id") or "").strip() or None
|
||||
_path_user = str(_meta.get("user_id") or "").strip() or None
|
||||
prompt = (
|
||||
f"Specialist: {step.specialist}\n"
|
||||
f"Objective: {step.objective}\n"
|
||||
f"Parent user request: {parent_task.user_text}\n"
|
||||
f"Step input: {step.input_text}\n"
|
||||
"Execution policy: when the user asks to read/open/list/summarize concrete files, URLs, or MCP resources, "
|
||||
"execute with available tools first. Do not return generic optimization plans unless explicitly requested.\n"
|
||||
)
|
||||
image_input_count = 0
|
||||
image_input_kind: list[str] = []
|
||||
image_protocol = ""
|
||||
image_debug_schema = ""
|
||||
image_debug_payload: dict[str, Any] | str = {}
|
||||
specialist_delivery: SpecialistDelivery | None = None
|
||||
try:
|
||||
if step.specialist == "image":
|
||||
image_protocol = "messages.content.image"
|
||||
selected_images: list[str] = []
|
||||
for att in parent_task.attachments or []:
|
||||
if not isinstance(att, dict):
|
||||
continue
|
||||
t = str(att.get("type") or "").strip().lower()
|
||||
if t == "image_ref":
|
||||
aid = str(att.get("attachment_id") or "").strip()
|
||||
if not aid:
|
||||
continue
|
||||
data_url = attachment_id_to_data_url(aid, mime=str(att.get("mime") or ""))
|
||||
if data_url:
|
||||
selected_images.append(data_url)
|
||||
elif t in ("input_image", "image"):
|
||||
raw = str(att.get("image_base64") or att.get("data") or "").strip()
|
||||
if raw:
|
||||
mime = str(att.get("mime") or "image/jpeg")
|
||||
if raw.startswith("data:"):
|
||||
selected_images.append(raw)
|
||||
else:
|
||||
selected_images.append(f"data:{mime};base64,{raw}")
|
||||
elif t == "image_url":
|
||||
u = str(att.get("url") or "").strip()
|
||||
if u:
|
||||
selected_images.append(u)
|
||||
if len(selected_images) >= 3:
|
||||
break
|
||||
image_input_count = len(selected_images)
|
||||
image_input_kind = ["data_url" if s.startswith("data:") else "url" for s in selected_images]
|
||||
if not selected_images:
|
||||
output = "Image specialist received no image input."
|
||||
ok = False
|
||||
else:
|
||||
_, chosen_model, _ = self._resolve_profile_and_model(step.specialist)
|
||||
model_name = str(
|
||||
os.getenv("AIA_IMAGE_MODEL")
|
||||
or getattr(chosen_model, "model", None)
|
||||
or ""
|
||||
).strip() or None
|
||||
api_key = str(getattr(chosen_model, "api_key", "") or "").strip() or None
|
||||
base_url = str(getattr(chosen_model, "base_url", "") or "").strip() or None
|
||||
dashscope_api_key = api_key if base_url and "dashscope.aliyuncs.com" in base_url.lower() else None
|
||||
dashscope_base_http_api_url = None
|
||||
if base_url and "dashscope.aliyuncs.com" in base_url.lower():
|
||||
# Normalize compatible-mode/v1 to native /api/v1 for DashScope SDK.
|
||||
dashscope_base_http_api_url = str(base_url).replace("/compatible-mode/v1", "/api/v1")
|
||||
resp = send_image_messages(
|
||||
images=selected_images,
|
||||
prompt=f"{step.objective}\n\n{step.input_text}\n\n{parent_task.user_text}",
|
||||
model=model_name,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
dashscope_api_key=dashscope_api_key,
|
||||
dashscope_base_http_api_url=dashscope_base_http_api_url,
|
||||
)
|
||||
image_debug_schema = str(resp.get("debug_used_schema") or "").strip()
|
||||
dbg = resp.get("debug_used_debug")
|
||||
if isinstance(dbg, dict):
|
||||
image_debug_payload = dbg
|
||||
elif dbg is not None:
|
||||
image_debug_payload = str(dbg)
|
||||
ok = bool(resp.get("ok"))
|
||||
output = str(resp.get("text") or "").strip()
|
||||
if not ok:
|
||||
err = str(resp.get("error") or "").strip()
|
||||
output = f"Image generation failed: {err or 'unknown error'}"
|
||||
elif not output:
|
||||
output = "Image processed."
|
||||
# persist output images as attachment assets for UI rendering
|
||||
produced_attachments: list[dict[str, Any]] = []
|
||||
if ok:
|
||||
out_images = resp.get("images")
|
||||
if isinstance(out_images, list):
|
||||
store = AttachmentAssetStore()
|
||||
for idx, item in enumerate(out_images[:3], start=1):
|
||||
s = str(item or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
if s.startswith("data:") and ";base64," in s:
|
||||
head, b64 = s.split(";base64,", 1)
|
||||
mime = head.replace("data:", "", 1) or "image/png"
|
||||
try:
|
||||
blob = base64.b64decode(b64.encode("ascii"))
|
||||
except Exception:
|
||||
continue
|
||||
meta = store.save_bytes(
|
||||
blob,
|
||||
filename=f"image-output-{idx}.png",
|
||||
mime=mime,
|
||||
)
|
||||
produced_attachments.append(
|
||||
{
|
||||
"type": "image_ref",
|
||||
"attachment_id": meta.attachment_id,
|
||||
"name": meta.name,
|
||||
"mime": meta.mime,
|
||||
"bytes": meta.bytes,
|
||||
"width": meta.width,
|
||||
"height": meta.height,
|
||||
}
|
||||
)
|
||||
elif s.startswith("http://") or s.startswith("https://"):
|
||||
try:
|
||||
with httpx.Client(timeout=20.0, follow_redirects=True) as client:
|
||||
r = client.get(s)
|
||||
if r.status_code < 400 and r.content:
|
||||
mime = str(r.headers.get("content-type") or "image/png").split(";", 1)[0].strip() or "image/png"
|
||||
ext = ".png"
|
||||
if mime == "image/jpeg":
|
||||
ext = ".jpg"
|
||||
elif mime == "image/webp":
|
||||
ext = ".webp"
|
||||
elif mime == "image/gif":
|
||||
ext = ".gif"
|
||||
meta = store.save_bytes(
|
||||
r.content,
|
||||
filename=f"image-output-{idx}{ext}",
|
||||
mime=mime,
|
||||
)
|
||||
produced_attachments.append(
|
||||
{
|
||||
"type": "image_ref",
|
||||
"attachment_id": meta.attachment_id,
|
||||
"name": meta.name,
|
||||
"mime": meta.mime,
|
||||
"bytes": meta.bytes,
|
||||
"width": meta.width,
|
||||
"height": meta.height,
|
||||
}
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
produced_attachments.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"url": s,
|
||||
"name": f"image-output-{idx}.png",
|
||||
}
|
||||
)
|
||||
# Treat missing image outputs as failure to avoid false "generated" state.
|
||||
if not produced_attachments:
|
||||
ok = False
|
||||
output = (
|
||||
"Image generation failed: response succeeded but no image output was returned."
|
||||
)
|
||||
self.store.add_message(
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
content=output,
|
||||
attachments=produced_attachments or None,
|
||||
)
|
||||
specialist_delivery = SpecialistDelivery(
|
||||
specialist=step.specialist,
|
||||
step_id=step.step_id,
|
||||
answer_text=str(output or ""),
|
||||
tool_traces=(),
|
||||
notes="image_pipeline",
|
||||
)
|
||||
else:
|
||||
agent = self._build_agent_for(
|
||||
step.specialist,
|
||||
policy_session_id=policy_session_id,
|
||||
use_cache=use_cache,
|
||||
path_policy_tenant_id=_path_tenant,
|
||||
path_policy_user_id=_path_user,
|
||||
)
|
||||
from oclaw.runtime.gateway import OclawGateway
|
||||
from oclaw.runtime.types import StandardMessage
|
||||
|
||||
gw = OclawGateway(store=self.store)
|
||||
msg = StandardMessage(
|
||||
session_id=str(session_id),
|
||||
tenant_id=str(_path_tenant or ""),
|
||||
user_id=str(_path_user or ""),
|
||||
role="member",
|
||||
channel="specialist",
|
||||
text=str(prompt or ""),
|
||||
attachments=list(parent_task.attachments or []),
|
||||
metadata={
|
||||
"tenant_id": str(_path_tenant or ""),
|
||||
"user_id": str(_path_user or ""),
|
||||
"channel": f"specialist:{step.specialist}",
|
||||
},
|
||||
)
|
||||
output = gw.handle_turn(
|
||||
msg=msg,
|
||||
lang=str(getattr(agent, "lang", "zh") or "zh"),
|
||||
executor=agent,
|
||||
on_token=on_token,
|
||||
on_progress=on_progress,
|
||||
on_tool_ui=on_tool_ui,
|
||||
should_stop=should_stop,
|
||||
).reply_text
|
||||
ok = bool((output or "").strip())
|
||||
outcome = getattr(agent, "_last_turn_outcome", None)
|
||||
if isinstance(outcome, TurnRunOutcome):
|
||||
traces = tuple(
|
||||
SpecialistToolTrace(
|
||||
name=str(x.get("name") or ""),
|
||||
ok=bool(x.get("ok")),
|
||||
latency_ms=int(x.get("latency_ms") or x.get("duration_ms") or 0),
|
||||
)
|
||||
for x in outcome.tool_traces
|
||||
)
|
||||
specialist_delivery = SpecialistDelivery(
|
||||
specialist=step.specialist,
|
||||
step_id=step.step_id,
|
||||
answer_text=str(output or ""),
|
||||
tool_traces=traces,
|
||||
notes=str(outcome.handoff_note or ""),
|
||||
)
|
||||
except GenerationInterrupted:
|
||||
raise
|
||||
except Exception as e:
|
||||
output = f"{type(e).__name__}: {e}"
|
||||
ok = False
|
||||
finally:
|
||||
produced_attachments: list[dict[str, Any]] = []
|
||||
try:
|
||||
rows = self.store.get_messages(session_id=session_id, limit=40) if session_id else []
|
||||
for m in reversed(rows):
|
||||
if str(m.role) != "assistant":
|
||||
continue
|
||||
if not m.attachments:
|
||||
continue
|
||||
raw = json.loads(m.attachments)
|
||||
if isinstance(raw, list):
|
||||
produced_attachments = [a for a in raw if isinstance(a, dict)]
|
||||
break
|
||||
except Exception:
|
||||
produced_attachments = []
|
||||
if created_session_id:
|
||||
try:
|
||||
parent_sid = str(parent_task.session_id or "").strip()
|
||||
if parent_sid and parent_sid != str(created_session_id):
|
||||
# Preserve tool usage telemetry: tool uses run inside temp specialist sessions.
|
||||
# If we delete temp sessions directly, FK cascade would drop those tool_log rows.
|
||||
self.store.move_tool_logs_to_session(
|
||||
from_session_id=str(created_session_id),
|
||||
to_session_id=parent_sid,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.store.delete_session(created_session_id)
|
||||
latency = int((time.perf_counter() - started) * 1000)
|
||||
if on_progress:
|
||||
on_progress(
|
||||
f"[sp.done] {step.step_id} specialist={step.specialist} ok={ok} latency_ms={latency}"
|
||||
)
|
||||
scope_id = str(session_id or parent_task.session_id or "").strip()
|
||||
manifest = build_manifest_from_attachment_refs(
|
||||
produced_attachments,
|
||||
scope_id=scope_id,
|
||||
source_agent=str(step.specialist or ""),
|
||||
ttl_policy="turn",
|
||||
)
|
||||
relay_env = RelayShareEnvelope(
|
||||
schema_version="v1",
|
||||
trace_id=str((parent_task.metadata or {}).get("trace_id") or ""),
|
||||
run_id=str((parent_task.metadata or {}).get("run_id") or ""),
|
||||
attempt_no=int((parent_task.metadata or {}).get("attempt_no") or 0),
|
||||
attachments=manifest,
|
||||
)
|
||||
return SpecialistResult(
|
||||
step_id=step.step_id,
|
||||
specialist=step.specialist,
|
||||
success=ok,
|
||||
output_text=output,
|
||||
latency_ms=latency,
|
||||
metadata={
|
||||
"objective": step.objective,
|
||||
"attachments": produced_attachments,
|
||||
"relay_share_envelope": relay_env.to_dict(),
|
||||
"image_input_count": image_input_count,
|
||||
"image_input_kind": image_input_kind,
|
||||
"image_protocol": image_protocol,
|
||||
"image_debug_schema": image_debug_schema,
|
||||
"image_debug_payload": image_debug_payload,
|
||||
},
|
||||
delivery=specialist_delivery,
|
||||
)
|
||||
123
runtime/agents/specialists.py
Normal file
123
runtime/agents/specialists.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.agent_context import build_role_system_context
|
||||
|
||||
|
||||
SpecialistId = str
|
||||
AgentRoleId = str
|
||||
MANAGER_AGENT_ID: AgentRoleId = "manager"
|
||||
AGENT_PROFILE_BINDINGS_KEY = "agent_profile_bindings"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialistConfig:
|
||||
specialist_id: SpecialistId
|
||||
expert_name: str
|
||||
default_tool_tags: frozenset[str] | None
|
||||
|
||||
|
||||
SPECIALISTS: dict[SpecialistId, SpecialistConfig] = {
|
||||
"ops": SpecialistConfig(
|
||||
specialist_id="ops",
|
||||
expert_name="network_ops",
|
||||
default_tool_tags=None,
|
||||
),
|
||||
"generalist": SpecialistConfig(
|
||||
specialist_id="generalist",
|
||||
expert_name="generalist+workspace+productivity",
|
||||
default_tool_tags=None,
|
||||
),
|
||||
"image": SpecialistConfig(
|
||||
specialist_id="image",
|
||||
# image specialist currently reuses generalist expert tool registry,
|
||||
# including image_edit tool.
|
||||
expert_name="generalist",
|
||||
default_tool_tags=None,
|
||||
),
|
||||
"memory_curator": SpecialistConfig(
|
||||
specialist_id="memory_curator",
|
||||
expert_name="memory_curator",
|
||||
default_tool_tags=None,
|
||||
),
|
||||
}
|
||||
SPECIALIST_IDS: tuple[SpecialistId, ...] = tuple(SPECIALISTS.keys())
|
||||
AGENT_ROLE_IDS: tuple[AgentRoleId, ...] = (MANAGER_AGENT_ID, *SPECIALIST_IDS)
|
||||
|
||||
|
||||
def expert_name_for_specialist(specialist_id: SpecialistId) -> str:
|
||||
cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"]
|
||||
return cfg.expert_name
|
||||
|
||||
|
||||
def default_tool_tags_for_specialist(specialist_id: SpecialistId) -> frozenset[str] | None:
|
||||
cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"]
|
||||
return cfg.default_tool_tags
|
||||
|
||||
|
||||
def default_system_prefix_for_specialist(specialist_id: SpecialistId, lang: str = "zh") -> str:
|
||||
sid = (specialist_id or "").strip().lower() or "generalist"
|
||||
cfg = SPECIALISTS.get(sid) or SPECIALISTS["generalist"]
|
||||
_ = (lang or "zh").strip().lower()
|
||||
return build_role_system_context(cfg.specialist_id)
|
||||
|
||||
|
||||
def model_role_for_specialist(specialist_id: SpecialistId) -> AgentRoleId:
|
||||
sid = (specialist_id or "").strip().lower()
|
||||
if sid in SPECIALISTS:
|
||||
return sid
|
||||
return "generalist"
|
||||
|
||||
|
||||
def empty_agent_profile_bindings() -> dict[AgentRoleId, str]:
|
||||
return {rid: "" for rid in AGENT_ROLE_IDS}
|
||||
|
||||
|
||||
def parse_agent_profile_bindings(raw: str | None) -> dict[AgentRoleId, str]:
|
||||
out = empty_agent_profile_bindings()
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return out
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except Exception:
|
||||
return out
|
||||
if not isinstance(obj, dict):
|
||||
return out
|
||||
for rid in AGENT_ROLE_IDS:
|
||||
v = obj.get(rid)
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
out[rid] = s
|
||||
return out
|
||||
|
||||
|
||||
def dump_agent_profile_bindings(bindings: dict[AgentRoleId, Any]) -> str:
|
||||
raw = {}
|
||||
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)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AGENT_PROFILE_BINDINGS_KEY",
|
||||
"AGENT_ROLE_IDS",
|
||||
"AgentRoleId",
|
||||
"dump_agent_profile_bindings",
|
||||
"empty_agent_profile_bindings",
|
||||
"MANAGER_AGENT_ID",
|
||||
"SpecialistConfig",
|
||||
"SpecialistId",
|
||||
"SPECIALISTS",
|
||||
"SPECIALIST_IDS",
|
||||
"default_system_prefix_for_specialist",
|
||||
"default_tool_tags_for_specialist",
|
||||
"expert_name_for_specialist",
|
||||
"model_role_for_specialist",
|
||||
"parse_agent_profile_bindings",
|
||||
]
|
||||
37
runtime/agents/subagent_registry.py
Normal file
37
runtime/agents/subagent_registry.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from threading import Lock
|
||||
|
||||
_LOCK = Lock()
|
||||
_INITIALIZED = False
|
||||
|
||||
|
||||
def init_subagent_registry() -> None:
|
||||
"""Initialize subagent registry runtime once.
|
||||
|
||||
Python gateway currently keeps this as a lightweight compatibility seam,
|
||||
so startup code can mirror the Oclaw TypeScript bootstrap flow.
|
||||
"""
|
||||
global _INITIALIZED
|
||||
with _LOCK:
|
||||
if _INITIALIZED:
|
||||
return
|
||||
_INITIALIZED = True
|
||||
|
||||
|
||||
def is_subagent_registry_initialized() -> bool:
|
||||
with _LOCK:
|
||||
return _INITIALIZED
|
||||
|
||||
|
||||
def reset_subagent_registry_for_tests() -> None:
|
||||
global _INITIALIZED
|
||||
with _LOCK:
|
||||
_INITIALIZED = False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"init_subagent_registry",
|
||||
"is_subagent_registry_initialized",
|
||||
"reset_subagent_registry_for_tests",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue