mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 11:53:16 +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
386
runtime/skills.py
Normal file
386
runtime/skills.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||
from oclaw.platform.config.runtime_paths import runtime_skills_root
|
||||
from oclaw.prompts.frontmatter import parse_frontmatter_dict, split_markdown_frontmatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillSpec:
|
||||
name: str
|
||||
description: str
|
||||
input_schema: dict[str, Any]
|
||||
read_only: bool = False
|
||||
tags: tuple[str, ...] = ()
|
||||
origin: str = "builtin"
|
||||
location: str = ""
|
||||
install_spec_count: int = 0
|
||||
model_invocable: bool = True
|
||||
|
||||
def as_openai_tool(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.input_schema,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillInstallSpec:
|
||||
id: str
|
||||
kind: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillManifest:
|
||||
name: str
|
||||
description: str
|
||||
skill_dir: str
|
||||
skill_file: str
|
||||
user_invocable: bool = True
|
||||
disable_model_invocation: bool = False
|
||||
metadata_oclaw: dict[str, Any] = field(default_factory=dict)
|
||||
runtime: dict[str, Any] = field(default_factory=dict)
|
||||
install: tuple[SkillInstallSpec, ...] = ()
|
||||
body: str = ""
|
||||
|
||||
|
||||
class SkillRegistry:
|
||||
def __init__(self, skills: tuple[SkillSpec, ...] | list[SkillSpec] | None = None):
|
||||
self._skills: dict[str, SkillSpec] = {}
|
||||
if skills:
|
||||
for s in skills:
|
||||
self.register(s)
|
||||
|
||||
def register(self, skill: SkillSpec) -> None:
|
||||
self._skills[str(skill.name)] = skill
|
||||
|
||||
def get(self, name: str) -> SkillSpec | None:
|
||||
return self._skills.get(str(name))
|
||||
|
||||
def list(self) -> tuple[SkillSpec, ...]:
|
||||
return tuple(self._skills.values())
|
||||
|
||||
def as_openai_tools(self) -> list[dict[str, Any]]:
|
||||
return [s.as_openai_tool() for s in self.list()]
|
||||
|
||||
|
||||
def default_skills_root() -> Path:
|
||||
raw = str(os.getenv("AIA_SKILLS_ROOT") or "").strip()
|
||||
if raw:
|
||||
return Path(raw).resolve()
|
||||
preferred = runtime_skills_root()
|
||||
if preferred.exists() and preferred.is_dir():
|
||||
return preferred
|
||||
# Optional hard-disable for legacy fallback lookups.
|
||||
if str(os.getenv("AIA_DISABLE_LEGACY_SKILLS_FALLBACK") or "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
return preferred
|
||||
typo_legacy = (PROJECT_ROOT / "oclaw" / "sills").resolve()
|
||||
if typo_legacy.exists() and typo_legacy.is_dir():
|
||||
return typo_legacy
|
||||
legacy = (PROJECT_ROOT / "oclaw" / "skills").resolve()
|
||||
if legacy.exists() and legacy.is_dir():
|
||||
return legacy
|
||||
return preferred
|
||||
|
||||
|
||||
def _normalize_skill_manifest_frontmatter(fm: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(fm)
|
||||
md = out.get("metadata")
|
||||
if isinstance(md, str) and md.strip():
|
||||
try:
|
||||
out["metadata"] = json.loads(md)
|
||||
except Exception:
|
||||
out["metadata"] = {}
|
||||
elif not isinstance(md, dict):
|
||||
out["metadata"] = {}
|
||||
u = out.get("user-invocable", True)
|
||||
if isinstance(u, bool):
|
||||
out["user-invocable"] = u
|
||||
else:
|
||||
uinv = str(u or "true").strip().lower()
|
||||
out["user-invocable"] = uinv in {"1", "true", "yes", "on"}
|
||||
d = out.get("disable-model-invocation", False)
|
||||
if isinstance(d, bool):
|
||||
out["disable-model-invocation"] = d
|
||||
else:
|
||||
dmi = str(d or "false").strip().lower()
|
||||
out["disable-model-invocation"] = dmi in {"1", "true", "yes", "on"}
|
||||
return out
|
||||
|
||||
|
||||
def _parse_skill_frontmatter_block(frontmatter_text: str) -> dict[str, Any]:
|
||||
raw_fm = str(frontmatter_text or "").strip()
|
||||
if not raw_fm:
|
||||
return {}
|
||||
fm = parse_frontmatter_dict(raw_fm, source="skill")
|
||||
return _normalize_skill_manifest_frontmatter(fm)
|
||||
|
||||
|
||||
def _parse_install_specs(metadata_oclaw: dict[str, Any]) -> tuple[SkillInstallSpec, ...]:
|
||||
raw = metadata_oclaw.get("install")
|
||||
if not isinstance(raw, list):
|
||||
return ()
|
||||
out: list[SkillInstallSpec] = []
|
||||
for idx, it in enumerate(raw):
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
sid = str(it.get("id") or f"install_{idx + 1}").strip() or f"install_{idx + 1}"
|
||||
kind = str(it.get("kind") or "").strip().lower()
|
||||
if not kind:
|
||||
continue
|
||||
payload = dict(it)
|
||||
out.append(SkillInstallSpec(id=sid, kind=kind, payload=payload))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _parse_runtime_spec(metadata_oclaw: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = metadata_oclaw.get("runtime")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
tp = str(raw.get("type") or "").strip().lower()
|
||||
if tp not in {"shell", "python", "node", "hook"}:
|
||||
return {}
|
||||
entry = str(raw.get("entry") or "").strip().replace("\\", "/")
|
||||
if not entry or entry.startswith("/") or ".." in entry.split("/"):
|
||||
return {}
|
||||
out["type"] = tp
|
||||
out["entry"] = entry
|
||||
schema = raw.get("schema")
|
||||
if isinstance(schema, dict):
|
||||
out["schema"] = schema
|
||||
perms = raw.get("permissions")
|
||||
if isinstance(perms, dict):
|
||||
out["permissions"] = perms
|
||||
return out
|
||||
|
||||
|
||||
def load_skill_manifest(skill_dir: str | Path) -> SkillManifest | None:
|
||||
root = Path(skill_dir).resolve()
|
||||
f = root / "SKILL.md"
|
||||
if not f.exists() or not f.is_file():
|
||||
return None
|
||||
raw = f.read_text(encoding="utf-8", errors="ignore")
|
||||
fm_text, body = split_markdown_frontmatter(raw)
|
||||
fm = _parse_skill_frontmatter_block(fm_text)
|
||||
name = str(fm.get("name") or root.name).strip() or root.name
|
||||
desc = str(fm.get("description") or "").strip() or f"{name} skill"
|
||||
md = fm.get("metadata")
|
||||
md = dict(md) if isinstance(md, dict) else {}
|
||||
oc = md.get("oclaw")
|
||||
oc = dict(oc) if isinstance(oc, dict) else {}
|
||||
return SkillManifest(
|
||||
name=name,
|
||||
description=desc,
|
||||
skill_dir=str(root),
|
||||
skill_file=str(f),
|
||||
user_invocable=bool(fm.get("user-invocable", True)),
|
||||
disable_model_invocation=bool(fm.get("disable-model-invocation", False)),
|
||||
metadata_oclaw=oc,
|
||||
runtime=_parse_runtime_spec(oc),
|
||||
install=_parse_install_specs(oc),
|
||||
body=str(body or ""),
|
||||
)
|
||||
|
||||
|
||||
def discover_workspace_skill_manifests(skills_root: str | Path | None = None) -> tuple[SkillManifest, ...]:
|
||||
base = Path(skills_root).resolve() if skills_root else default_skills_root()
|
||||
if not base.exists() or not base.is_dir():
|
||||
return ()
|
||||
out: list[SkillManifest] = []
|
||||
if (base / "SKILL.md").exists():
|
||||
one = load_skill_manifest(base)
|
||||
if one:
|
||||
out.append(one)
|
||||
for d in sorted(base.iterdir(), key=lambda p: p.name.lower()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
m = load_skill_manifest(d)
|
||||
if m:
|
||||
out.append(m)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _tool_origin(tool: "ToolSpec") -> str:
|
||||
nm = str(getattr(tool, "name", "") or "")
|
||||
if nm.startswith("mcp__"):
|
||||
return "mcp"
|
||||
tags = set(getattr(tool, "tags", frozenset()) or frozenset())
|
||||
if "plugin" in tags:
|
||||
return "plugin"
|
||||
return "builtin"
|
||||
|
||||
|
||||
def _allowed_tool_names_after_wire_policy(
|
||||
*,
|
||||
registry: "ToolRegistry",
|
||||
store: Any,
|
||||
base_url: str,
|
||||
) -> tuple[set[str], list[str]]:
|
||||
raw = registry.as_openai_tools()
|
||||
try:
|
||||
from oclaw.platform.llm.tool_wire_policy import prepare_openai_tools_for_llm_api
|
||||
|
||||
prepared = prepare_openai_tools_for_llm_api(
|
||||
raw,
|
||||
base_url=base_url,
|
||||
store=store,
|
||||
max_json_bytes=None,
|
||||
role=None,
|
||||
)
|
||||
except Exception:
|
||||
prepared = raw
|
||||
|
||||
allowed: set[str] = set()
|
||||
for e in prepared or []:
|
||||
if not isinstance(e, dict) or str(e.get("type") or "") != "function":
|
||||
continue
|
||||
fn = e.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
nm = str(fn.get("name") or "").strip()
|
||||
if nm:
|
||||
allowed.add(nm)
|
||||
|
||||
all_names = {str((t.get("function") or {}).get("name") or "") for t in (raw or []) if isinstance(t, dict)}
|
||||
all_names.discard("")
|
||||
hidden = sorted([n for n in all_names if n and n not in allowed])
|
||||
return allowed or all_names, hidden
|
||||
|
||||
|
||||
def build_skill_manifest(
|
||||
*,
|
||||
registry: "ToolRegistry",
|
||||
store: Any,
|
||||
base_url: str = "",
|
||||
) -> tuple[tuple[SkillSpec, ...], dict[str, Any]]:
|
||||
allowed, hidden = _allowed_tool_names_after_wire_policy(registry=registry, store=store, base_url=base_url)
|
||||
disabled_names: set[str] = set()
|
||||
try:
|
||||
raw_disabled = str(store.get_setting("AIA_SKILL_DISABLED_NAMES") or "").strip()
|
||||
if raw_disabled:
|
||||
arr = json.loads(raw_disabled)
|
||||
if isinstance(arr, list):
|
||||
disabled_names = {str(x).strip() for x in arr if str(x).strip()}
|
||||
except Exception:
|
||||
disabled_names = set()
|
||||
|
||||
manifest_by_name = {m.name: m for m in discover_workspace_skill_manifests()}
|
||||
skills: list[SkillSpec] = []
|
||||
for t in sorted((registry.list() or []), key=lambda x: str(getattr(x, "name", "") or "").lower()):
|
||||
name = str(getattr(t, "name", "") or "").strip()
|
||||
if not name or name not in allowed or name in disabled_names:
|
||||
continue
|
||||
desc = str(getattr(t, "description", "") or "")
|
||||
params = getattr(t, "parameters", None)
|
||||
schema = dict(params) if isinstance(params, dict) else {"type": "object", "additionalProperties": True}
|
||||
tags = tuple(sorted({str(x) for x in (getattr(t, "tags", frozenset()) or frozenset()) if str(x)}))
|
||||
m = manifest_by_name.get(name)
|
||||
location = str(m.skill_file) if m is not None else f"tool:{name}"
|
||||
install_spec_count = int(len(m.install)) if m is not None else 0
|
||||
model_invocable = not bool(m.disable_model_invocation) if m is not None else True
|
||||
skills.append(
|
||||
SkillSpec(
|
||||
name=name,
|
||||
description=desc,
|
||||
input_schema=schema,
|
||||
read_only=bool(getattr(t, "read_only", False) or getattr(t, "is_read_only", lambda: False)()),
|
||||
tags=tags,
|
||||
origin=_tool_origin(t),
|
||||
location=location,
|
||||
install_spec_count=install_spec_count,
|
||||
model_invocable=model_invocable,
|
||||
)
|
||||
)
|
||||
|
||||
skills = sorted(skills, key=lambda x: x.name.lower())
|
||||
hidden_mcp = [n for n in hidden if n.startswith("mcp__")]
|
||||
stats = {
|
||||
"skills_total": len(skills),
|
||||
"hidden_total": len(hidden),
|
||||
"hidden_mcp_total": len(hidden_mcp),
|
||||
"hidden_mcp_preview": hidden_mcp[:20],
|
||||
"disabled_total": len(disabled_names),
|
||||
"visible_names_preview": [s.name for s in skills[:30]],
|
||||
}
|
||||
try:
|
||||
json.dumps(stats, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
stats = {"skills_total": len(skills)}
|
||||
return tuple(skills), stats
|
||||
|
||||
|
||||
def build_skill_registry(
|
||||
*,
|
||||
registry: ToolRegistry,
|
||||
store: Any,
|
||||
base_url: str = "",
|
||||
) -> tuple[SkillRegistry, dict[str, Any]]:
|
||||
skills, stats = build_skill_manifest(registry=registry, store=store, base_url=base_url)
|
||||
return SkillRegistry(skills), stats
|
||||
|
||||
|
||||
def materialize_skills_from_tool_specs(
|
||||
tools: list["ToolSpec"],
|
||||
) -> tuple[SkillSpec, ...]:
|
||||
out: list[SkillSpec] = []
|
||||
for t in tools or []:
|
||||
name = str(getattr(t, "name", "") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
desc = str(getattr(t, "description", "") or "")
|
||||
params = getattr(t, "parameters", None)
|
||||
schema = dict(params) if isinstance(params, dict) else {"type": "object", "additionalProperties": True}
|
||||
tags = tuple(sorted({str(x) for x in (getattr(t, "tags", frozenset()) or frozenset()) if str(x)}))
|
||||
out.append(
|
||||
SkillSpec(
|
||||
name=name,
|
||||
description=desc,
|
||||
input_schema=schema,
|
||||
read_only=bool(getattr(t, "read_only", False) or getattr(t, "is_read_only", lambda: False)()),
|
||||
tags=tags,
|
||||
origin=_tool_origin(t),
|
||||
)
|
||||
)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def skill_runtime_diagnostics() -> dict[str, Any]:
|
||||
root = default_skills_root()
|
||||
manifests = discover_workspace_skill_manifests(root)
|
||||
return {
|
||||
"skills_root": str(root),
|
||||
"skills_total": len(manifests),
|
||||
"skills_names": [m.name for m in manifests],
|
||||
"skills_files": [m.skill_file for m in manifests],
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SkillSpec",
|
||||
"SkillInstallSpec",
|
||||
"SkillManifest",
|
||||
"SkillRegistry",
|
||||
"build_skill_manifest",
|
||||
"build_skill_registry",
|
||||
"default_skills_root",
|
||||
"discover_workspace_skill_manifests",
|
||||
"load_skill_manifest",
|
||||
"materialize_skills_from_tool_specs",
|
||||
"skill_runtime_diagnostics",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue