重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。

本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-25 01:24:23 +08:00
parent ba3836f00f
commit 4a23b715a2
498 changed files with 2760 additions and 2200 deletions

17
runtime/tools/__init__.py Normal file
View file

@ -0,0 +1,17 @@
"""工具模块:包含 JSON Schema 与处理函数,并通过 :func:`default_registry` 注册。"""
from __future__ import annotations
from oclaw.runtime.tools.catalog import (
TOOL_FACTORIES,
default_registry,
materialize_tool_specs,
tool_inventory,
)
__all__ = [
"TOOL_FACTORIES",
"default_registry",
"materialize_tool_specs",
"tool_inventory",
]

80
runtime/tools/base.py Normal file
View file

@ -0,0 +1,80 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
ToolHandler = Callable[[dict[str, Any]], Any]
@dataclass(frozen=True)
class ToolRateLimit:
"""Best-effort per-tool rate limiting metadata (enforced by runtime when implemented)."""
# tokens per window (simple leaky bucket style); None means unlimited.
limit: int | None = None
window_s: int = 60
@dataclass(frozen=True)
class ToolSpec:
name: str
description: str
parameters: dict[str, Any]
handler: ToolHandler
tags: frozenset[str] = field(default_factory=frozenset)
# Contract metadata (non-OpenAI; used by orchestrator/runtime)
version: str = "v1"
risk_level: str = "low" # low|high (extendable)
timeout_s: float | None = None
rate_limit: ToolRateLimit | None = None
required_permissions: frozenset[str] = field(default_factory=frozenset)
execution_mode: str = "in_process" # in_process|subprocess (best-effort)
#: If true, may run in parallel with other consecutive read-only tools (cc-mini-style batching).
read_only: bool = False
def is_read_only(self) -> bool:
"""Compatibility helper mirroring cc-mini Tool.is_read_only()."""
return bool(self.read_only)
def as_openai_tool(self) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
class ToolRegistry:
def __init__(self, tools: list[ToolSpec] | None = None):
self._tools: dict[str, ToolSpec] = {}
self._openai_tools_cache: list[dict[str, Any]] | None = None
if tools:
for t in tools:
self.register(t)
def register(self, tool: ToolSpec) -> None:
self._tools[tool.name] = tool
self._openai_tools_cache = None
def get(self, name: str) -> ToolSpec | None:
return self._tools.get(name)
def list(self) -> list[ToolSpec]:
return list(self._tools.values())
def as_openai_tools(self) -> list[dict[str, Any]]:
if self._openai_tools_cache is None:
self._openai_tools_cache = [t.as_openai_tool() for t in self.list()]
return list(self._openai_tools_cache)
__all__ = [
"ToolHandler",
"ToolRateLimit",
"ToolSpec",
"ToolRegistry",
]

308
runtime/tools/catalog.py Normal file
View file

@ -0,0 +1,308 @@
"""工具目录聚合与默认注册入口。"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
from oclaw.interfaces.gateway.python_extensions_loader import build_python_extensions_registry
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
from oclaw.runtime.tools.expert_registry import materialize_tools_for_expert
from oclaw.runtime.tools.mcp.adapter import materialize_mcp_tools_for_specialist
from oclaw.runtime.tools.public_registry import materialize_public_tools
from oclaw.runtime.tools.skills_runtime.materialize_skill_tools import materialize_executable_skill_tools
from oclaw.runtime.skills import SkillSpec, materialize_skills_from_tool_specs
logger = logging.getLogger(__name__)
# Legacy export: some modules still import TOOL_FACTORIES. Tools are now intentionally
# restricted to a single safe builtin (`system_time`), so this is left empty.
TOOL_FACTORIES: tuple[object, ...] = ()
def _is_truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
def _apply_declared_tool_policy(
tools: list[ToolSpec],
*,
allow_tags: list[str] | tuple[str, ...] | None = None,
allow_tools: list[str] | tuple[str, ...] | None = None,
) -> list[ToolSpec]:
tags = {str(x or "").strip().lower() for x in (allow_tags or []) if str(x or "").strip()}
names = {str(x or "").strip() for x in (allow_tools or []) if str(x or "").strip()}
if not tags and not names:
return tools
out: list[ToolSpec] = []
for t in tools:
tname = str(t.name or "")
ttags = {str(x or "").strip().lower() for x in set(t.tags or frozenset())}
by_name = tname in names
by_tag = bool(tags.intersection(ttags))
if by_name or by_tag:
out.append(t)
return out
def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
def _create_skill_handler(args: dict[str, Any]) -> dict[str, Any]:
out = create_skill_from_template(
store=store,
name=str(args.get("name") or "").strip(),
description=str(args.get("description") or "").strip(),
body_markdown=str(args.get("body_markdown") or "").strip(),
metadata_oclaw=dict(args.get("metadata_oclaw") or {}) if isinstance(args.get("metadata_oclaw"), dict) else {},
overwrite=bool(args.get("overwrite")),
)
return {"ok": bool(out.ok), "name": out.name, "target_dir": out.target_dir, "detail": out.detail}
def _auto_install_skill_handler(args: dict[str, Any]) -> dict[str, Any]:
payload = {
"name": str(args.get("name") or "").strip(),
"description": str(args.get("description") or "").strip(),
"body_markdown": str(args.get("body_markdown") or "").strip(),
"metadata_oclaw": dict(args.get("metadata_oclaw") or {}) if isinstance(args.get("metadata_oclaw"), dict) else {},
}
out = auto_install_skill_from_payload(store=store, payload=payload)
return {"ok": bool(out.ok), "name": out.name, "target_dir": out.target_dir, "detail": out.detail}
def _list_skills_handler(args: dict[str, Any]) -> dict[str, Any]:
del args
return {"ok": True, "items": list_skills_with_status(store=store)}
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"body_markdown": {"type": "string"},
"metadata_oclaw": {"type": "object"},
"overwrite": {"type": "boolean"},
},
"required": ["name", "description"],
}
return [
ToolSpec(
name="skill_create",
description="Create a local oclaw skill package from template.",
parameters=schema,
handler=_create_skill_handler,
tags=frozenset({"skill", "oclaw", "builder"}),
risk_level="high",
timeout_s=20.0,
),
ToolSpec(
name="skill_auto_install",
description="Auto install a generated oclaw skill package.",
parameters=schema,
handler=_auto_install_skill_handler,
tags=frozenset({"skill", "oclaw", "installer"}),
risk_level="high",
timeout_s=20.0,
),
ToolSpec(
name="skill_list",
description="List installed skills and status.",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=_list_skills_handler,
tags=frozenset({"skill", "oclaw", "read"}),
read_only=True,
timeout_s=10.0,
),
]
def materialize_tool_specs(
factories: tuple[ToolFactory, ...] | None = None,
*,
expert: str | None = None,
specialist: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
store: SqliteStore | None = None,
) -> list[ToolSpec]:
"""Materialize ToolSpec list.
Base tools are loaded from self-registered expert tool directories (role-aware by `expert`),
plus public shared tools available to all roles.
"""
_ = factories
tools: list[ToolSpec] = []
def _risk_allowed(spec: ToolSpec) -> bool:
# Optional safety gate for public tools.
# Default: only allow low risk public tools to be visible to all roles.
# Override via env: AIA_PUBLIC_TOOLS_ALLOW_HIGH=1 to allow high risk public tools.
allow_high = _is_truthy(os.getenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", "0"))
if allow_high:
return True
return str(getattr(spec, "risk_level", "") or "low").strip().lower() != "high"
# Load public shared tools first (available to all roles).
try:
for spec in list(materialize_public_tools()):
if not isinstance(spec, ToolSpec):
continue
if not _risk_allowed(spec):
logger.warning("public tool blocked by risk gate: %s", str(spec.name or ""))
continue
tools.append(spec)
except Exception as exc:
logger.warning("public tool load skipped: %s", exc)
# Load role-scoped self-registered internal tools.
# `expert` can be composite (e.g. "generalist+workspace+productivity"), which
# is already supported by `materialize_tools_for_expert`.
try:
for spec in materialize_tools_for_expert(str(expert or "").strip() or None):
if not isinstance(spec, ToolSpec):
continue
if any(str(t.name or "") == str(spec.name or "") for t in tools):
continue
tools.append(spec)
except Exception as exc:
logger.warning("expert tool load skipped: %s", exc)
# Load executable skills (declared via SKILL.md metadata.oclaw.runtime).
try:
for spec in materialize_executable_skill_tools(store=store):
if not isinstance(spec, ToolSpec):
continue
if any(str(t.name or "") == str(spec.name or "") for t in tools):
continue
tools.append(spec)
except Exception as exc:
logger.warning("skill runtime tool load skipped: %s", exc)
# MCP tools are role-bound and should be materialized before model injection.
# Fine-grained penalty/visibility is still applied by wire policy in direct_loop.
mcp_enabled = True
try:
if store is not None:
raw = str(store.get_setting("AIA_ENABLE_MCP_TOOLS") or "").strip().lower()
if raw:
mcp_enabled = raw in {"1", "true", "yes", "on"}
except Exception:
mcp_enabled = True
if mcp_enabled and _is_truthy(os.getenv("AIA_ENABLE_MCP_TOOLS", "1")):
try:
tools.extend(
materialize_mcp_tools_for_specialist(
store=store,
specialist=str(specialist or "").strip().lower() or None,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
)
except Exception as exc:
logger.warning("mcp tool load skipped: %s", exc)
if not _is_truthy(os.getenv("AIA_PLUGIN_TOOLS_ENABLED", "1")):
return tools
try:
only_ids_raw = str(os.getenv("AIA_PLUGIN_TOOL_IDS") or "").strip()
only_ids = [x.strip() for x in only_ids_raw.split(",") if x.strip()] if only_ids_raw else []
ws_dir = Path(PROJECT_ROOT).resolve()
app_cfg: dict[str, Any] = {}
if store is not None:
try:
app_cfg = dict(store.load_oclaw_config() or {})
except Exception:
app_cfg = {}
plugin_registry = build_python_extensions_registry(
app_config=app_cfg,
workspace_dir=str(ws_dir),
only_plugin_ids=only_ids,
)
for row in list(plugin_registry.get("tools") or []):
if not isinstance(row, dict):
continue
name = str(row.get("name") or "").strip()
handler = row.get("handler")
params = row.get("parameters")
if not name or not callable(handler) or not isinstance(params, dict):
continue
tags_raw = row.get("tags")
tags = frozenset(str(x).strip() for x in (tags_raw or []) if str(x).strip())
tools.append(
ToolSpec(
name=name,
description=str(row.get("description") or ""),
parameters=params,
handler=handler,
tags=tags,
risk_level=str(row.get("risk_level") or "low"),
timeout_s=float(row.get("timeout_s")) if row.get("timeout_s") is not None else None,
read_only=bool(row.get("read_only", False)),
)
)
except Exception as exc:
logger.warning("plugin tool load skipped: %s", exc)
return tools
def default_registry(
*,
expert: str | None = None,
specialist: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
store: SqliteStore | None = None,
allow_tags: list[str] | tuple[str, ...] | None = None,
allow_tools: list[str] | tuple[str, ...] | None = None,
) -> ToolRegistry:
tools = materialize_tool_specs(
expert=expert,
specialist=specialist,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=store,
)
tools = _apply_declared_tool_policy(tools, allow_tags=allow_tags, allow_tools=allow_tools)
return ToolRegistry(tools)
def materialize_skills(
*,
expert: str | None = None,
specialist: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
store: SqliteStore | None = None,
) -> tuple[SkillSpec, ...]:
tools = materialize_tool_specs(
expert=expert,
specialist=specialist,
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 materialize_skills_from_tool_specs(tools)
def tool_inventory() -> list[dict[str, Any]]:
"""返回每个已注册工具的 ``name`` 与 ``tags``(用于文档、测试或后续管理界面)。"""
rows: list[dict[str, Any]] = []
for spec in materialize_tool_specs():
rows.append({"name": spec.name, "tags": sorted(spec.tags)})
return rows
__all__ = [
"_apply_declared_tool_policy",
"default_registry",
"materialize_skills",
"materialize_tool_specs",
"tool_inventory",
]

View file

View file

@ -0,0 +1,105 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.runtime.application.gateway import process_inbound_payload_usecase
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
@dataclass(frozen=True)
class Case:
case_id: str
kind: str
payload: dict[str, Any]
assert_contains: list[str]
assert_not_contains: list[str]
def _load_cases(path: str) -> list[Case]:
p = Path(path)
if not p.exists():
raise FileNotFoundError(path)
out: list[Case] = []
for idx, line in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
raw = line.strip()
if not raw:
continue
row = json.loads(raw)
cid = str(row.get("id") or f"line-{idx}")
kind = str(row.get("kind") or "gateway")
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
ac = row.get("assert_contains") or []
anc = row.get("assert_not_contains") or []
out.append(
Case(
case_id=cid,
kind=kind,
payload=payload,
assert_contains=[str(x) for x in ac if str(x).strip()],
assert_not_contains=[str(x) for x in anc if str(x).strip()],
)
)
return out
def _extract_reply_text(resp: dict[str, Any]) -> str:
try:
replies = resp.get("replies")
if isinstance(replies, list) and replies:
first = replies[0]
if isinstance(first, dict):
return str(first.get("text") or "")
except Exception:
pass
return ""
def run_gateway_eval(dataset_path: str) -> dict[str, Any]:
store = SqliteStore(db_path())
# Seed a tenant + bind code for tests
tenants = store.list_tenants(limit=1)
if tenants:
tenant_id = tenants[0]["id"]
else:
tenant_id = store.create_tenant("Eval")["id"]
code = "EVALCODE"
try:
store.create_bind_code(tenant_id=tenant_id, role="member", code=code)
except Exception:
pass
# Binding creates the user; we will use external ids in payloads.
cases = _load_cases(dataset_path)
results = []
passed = 0
for c in cases:
payload = dict(c.payload)
# inject tenant/code shortcuts
payload.setdefault("channel", "wecom")
payload.setdefault("chat_id", "room_eval")
payload.setdefault("user_id", "wxid_eval_u1")
payload.setdefault("is_group", True)
payload["text"] = str(payload.get("text") or "").replace("EVALCODE", code)
resp = process_inbound_payload_usecase(payload)
text = _extract_reply_text(resp)
failures = []
for must in c.assert_contains:
if must not in text:
failures.append(f"missing:{must}")
for bad in c.assert_not_contains:
if bad in text:
failures.append(f"unexpected:{bad}")
ok = not failures
passed += 1 if ok else 0
results.append({"id": c.case_id, "ok": ok, "text": text, "failures": failures})
return {"total": len(results), "passed": passed, "pass_rate": (passed / len(results)) if results else 0.0, "results": results}
if __name__ == "__main__":
rep = run_gateway_eval("data/eval/assistant_gateway.jsonl")
print(json.dumps({k: v for k, v in rep.items() if k != "results"}, ensure_ascii=False, indent=2))

View file

@ -0,0 +1,125 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from dataclasses import dataclass
from typing import Any
from oclaw.runtime.agents.factory import build_gateway_executor
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.orchestration.evaluation import eval_summary
from oclaw.platform.config.paths import db_path
from oclaw.runtime.gateway import OclawGateway
from oclaw.runtime.types import StandardMessage
@dataclass(frozen=True)
class EvalCase:
case_id: str
input_text: str
assert_contains: list[str]
assert_not_contains: list[str]
@dataclass(frozen=True)
class EvalCaseResult:
case_id: str
ok: bool
latency_ms: int
failures: list[str]
def _load_dataset(dataset_path: str) -> list[EvalCase]:
ds = Path(dataset_path)
if not ds.exists():
raise FileNotFoundError(dataset_path)
cases: list[EvalCase] = []
with ds.open("r", encoding="utf-8") as f:
for idx, line in enumerate(f, start=1):
raw = line.strip()
if not raw:
continue
row = json.loads(raw)
input_text = str(row.get("input") or "").strip()
if not input_text:
continue
case_id = str(row.get("id") or row.get("case_id") or f"line-{idx}").strip()
ac = row.get("assert_contains") or []
anc = row.get("assert_not_contains") or []
assert_contains = [str(x) for x in ac if str(x).strip()]
assert_not_contains = [str(x) for x in anc if str(x).strip()]
cases.append(
EvalCase(
case_id=case_id,
input_text=input_text,
assert_contains=assert_contains,
assert_not_contains=assert_not_contains,
)
)
return cases
def run_eval(
dataset_path: str,
*,
report_path: str | None = None,
limit: int | None = None,
) -> dict[str, Any]:
"""Run a simple offline regression eval.
Dataset format: JSONL, each line:
{"id": "...", "input": "...", "assert_contains": ["..."], "assert_not_contains": ["..."]}
"""
store = SqliteStore(db_path())
agent = build_gateway_executor(store)
session = store.create_session("offline-eval")
gw = OclawGateway(store=store)
cases = _load_dataset(dataset_path)
if limit is not None:
cases = cases[: max(0, int(limit))]
results: list[EvalCaseResult] = []
for c in cases:
t0 = time.perf_counter()
msg = StandardMessage(
session_id=str(session.id),
tenant_id="",
user_id="",
role="owner",
channel="eval",
text=str(c.input_text or ""),
attachments=[],
metadata={"channel": "eval"},
)
out = str(gw.handle_turn(msg=msg, lang="zh", executor=agent).reply_text or "")
latency_ms = int((time.perf_counter() - t0) * 1000)
failures: list[str] = []
for must in c.assert_contains:
if must not in out:
failures.append(f"missing_substring:{must}")
for bad in c.assert_not_contains:
if bad in out:
failures.append(f"unexpected_substring:{bad}")
results.append(EvalCaseResult(case_id=c.case_id, ok=not failures, latency_ms=latency_ms, failures=failures))
passed = sum(1 for r in results if r.ok)
report = {
"dataset": str(dataset_path),
"total": len(results),
"passed": passed,
"pass_rate": round((passed / len(results)) if results else 0.0, 4),
"results": [
{"id": r.case_id, "ok": r.ok, "latency_ms": r.latency_ms, "failures": r.failures} for r in results
],
"agent_metrics": eval_summary(store, limit=5000),
}
if report_path:
Path(report_path).parent.mkdir(parents=True, exist_ok=True)
Path(report_path).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return report
if __name__ == "__main__":
result = run_eval("data/eval/mvp_tasks.jsonl", report_path="data/eval/report.json")
print(json.dumps({k: v for k, v in result.items() if k != "results"}, ensure_ascii=False, indent=2))

View file

@ -0,0 +1,207 @@
from __future__ import annotations
import importlib.util
import inspect
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
logger = logging.getLogger(__name__)
ToolFactory = Callable[[], ToolSpec]
_EXPERTS_ROOT = Path(__file__).resolve().parent / "experts"
_CACHED_FACTORIES_BY_EXPERT: dict[str, list[ToolFactory]] | None = None
_CACHED_SPECS_BY_EXPERT: dict[str, list[ToolSpec]] | None = None
_DEPRECATED_TOOL_NAMES: set[str] = {
# Deprecated internal tool from legacy src/tools chain.
"get_weather",
}
def _load_module_from_path(module_path: Path, module_name: str) -> Any | None:
try:
spec = importlib.util.spec_from_file_location(module_name, module_path)
if not spec or not spec.loader:
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
except Exception as exc:
logger.warning("skip expert module %s: %s", module_path, exc)
return None
def discover_expert_tool_factories() -> dict[str, list[ToolFactory]]:
"""按专家目录发现工具工厂函数(命名约定:`*_tool`)。"""
global _CACHED_FACTORIES_BY_EXPERT
if _CACHED_FACTORIES_BY_EXPERT is not None:
# 返回浅拷贝,避免调用方误改缓存
return {k: list(v) for k, v in _CACHED_FACTORIES_BY_EXPERT.items()}
result: dict[str, list[ToolFactory]] = {}
if not _EXPERTS_ROOT.exists():
_CACHED_FACTORIES_BY_EXPERT = {}
return {}
for expert_dir in sorted([p for p in _EXPERTS_ROOT.iterdir() if p.is_dir()]):
expert = expert_dir.name
factories: list[ToolFactory] = []
for module_path in sorted(expert_dir.glob("*.py")):
if module_path.name == "__init__.py":
continue
mod_name = f"oclaw.runtime.tools.experts.{expert}.{module_path.stem}"
module = _load_module_from_path(module_path, mod_name)
if module is None:
continue
exported = getattr(module, "__all__", None)
if isinstance(exported, list) and exported:
for name in sorted(exported):
value = getattr(module, name, None)
if callable(value) and name.endswith("_tool"):
factories.append(value)
continue
for name, value in sorted(inspect.getmembers(module)):
if callable(value) and name.endswith("_tool"):
factories.append(value)
if factories:
result[expert] = factories
_CACHED_FACTORIES_BY_EXPERT = {k: list(v) for k, v in result.items()}
return result
def materialize_tools_by_expert() -> dict[str, list[ToolSpec]]:
global _CACHED_SPECS_BY_EXPERT
if _CACHED_SPECS_BY_EXPERT is not None:
return {k: list(v) for k, v in _CACHED_SPECS_BY_EXPERT.items()}
rows: dict[str, list[ToolSpec]] = {}
for expert, factories in discover_expert_tool_factories().items():
specs: list[ToolSpec] = []
for factory in factories:
try:
spec = factory()
if str(spec.name or "").strip() in _DEPRECATED_TOOL_NAMES:
continue
specs.append(spec)
except Exception as exc:
logger.warning("skip expert tool factory %s for %s: %s", factory, expert, exc)
if specs:
rows[expert] = specs
_CACHED_SPECS_BY_EXPERT = {k: list(v) for k, v in rows.items()}
return rows
def materialize_tools_for_expert(expert: str | None) -> list[ToolSpec]:
"""获取某个专家工具,expert 为空时返回所有专家工具合集。"""
if expert:
# Support composition: "generalist+workspace"
if "+" in expert:
parts = [p.strip() for p in str(expert).split("+") if p.strip()]
merged: list[ToolSpec] = []
seen: set[str] = set()
for p in parts:
for spec in materialize_tools_for_expert(p):
if spec.name in seen:
continue
seen.add(spec.name)
merged.append(spec)
return merged
by_expert = materialize_tools_by_expert()
if not expert:
out: list[ToolSpec] = []
for key in sorted(by_expert.keys()):
out.extend(by_expert[key])
return out
return list(by_expert.get(expert, []))
def clear_expert_tool_cache() -> None:
global _CACHED_FACTORIES_BY_EXPERT, _CACHED_SPECS_BY_EXPERT
_CACHED_FACTORIES_BY_EXPERT = None
_CACHED_SPECS_BY_EXPERT = None
def preview_expert_tools(expert: str | None) -> dict[str, Any]:
"""Preview expert tool load outcome, including skip reasons.
This bypasses caches so Admin UI can reflect current filesystem state.
"""
exp = str(expert or "").strip()
# Support composition: "generalist+workspace"
parts = [p.strip() for p in exp.split("+") if p.strip()] if exp and "+" in exp else ([exp] if exp else [])
targets = parts if parts else sorted([p.name for p in _EXPERTS_ROOT.iterdir() if p.is_dir()]) if _EXPERTS_ROOT.exists() else []
out_tools: list[ToolSpec] = []
skipped: list[dict[str, str]] = []
seen: set[str] = set()
for ex in targets:
expert_dir = _EXPERTS_ROOT / ex
if not expert_dir.exists() or not expert_dir.is_dir():
continue
for module_path in sorted(expert_dir.glob("*.py")):
if module_path.name == "__init__.py":
continue
mod_name = f"oclaw.runtime.tools.experts.{ex}.{module_path.stem}"
module = None
try:
module = _load_module_from_path(module_path, mod_name)
except Exception as exc:
skipped.append({"module": str(module_path), "error_code": "module_load_exception", "error": str(exc)})
continue
if module is None:
skipped.append({"module": str(module_path), "error_code": "module_load_failed", "error": "load_failed"})
continue
factories: list[ToolFactory] = []
exported = getattr(module, "__all__", None)
if isinstance(exported, list) and exported:
for name in sorted(exported):
value = getattr(module, name, None)
if callable(value) and name.endswith("_tool"):
factories.append(value)
else:
for name, value in sorted(inspect.getmembers(module)):
if callable(value) and name.endswith("_tool"):
factories.append(value)
for factory in factories:
try:
spec = factory()
except Exception as exc:
skipped.append(
{
"module": str(module_path),
"error_code": "tool_factory_failed",
"error": str(exc),
"factory": str(getattr(factory, "__name__", "") or str(factory)),
}
)
continue
if str(spec.name or "").strip() in _DEPRECATED_TOOL_NAMES:
skipped.append(
{
"module": str(module_path),
"error_code": "tool_deprecated",
"error": f"deprecated_tool:{spec.name}",
"factory": str(getattr(factory, "__name__", "") or str(factory)),
}
)
continue
if spec.name in seen:
continue
seen.add(spec.name)
out_tools.append(spec)
return {"tools": out_tools, "skipped": skipped}
__all__ = [
"ToolFactory",
"clear_expert_tool_cache",
"discover_expert_tool_factories",
"materialize_tools_by_expert",
"materialize_tools_for_expert",
"preview_expert_tools",
]

View file

@ -0,0 +1,2 @@
"""按专家维度组织的工具目录。"""

View file

@ -0,0 +1,29 @@
"""通识专家工具清单。"""
from oclaw.runtime.tools.base import ToolSpec
def system_info_tool() -> ToolSpec:
from .system_info import system_info_tool as factory
return factory()
def geo_info_tool() -> ToolSpec:
from .geo_info import geo_info_tool as factory
return factory()
def weather_tool() -> ToolSpec:
from .weather import weather_tool as factory
return factory()
def web_search_tool() -> ToolSpec:
from .web_search import web_search_tool as factory
return factory()
__all__ = ["system_info_tool", "geo_info_tool", "weather_tool", "web_search_tool"]

View file

@ -0,0 +1,66 @@
"""系统工具共用 HTTP 辅助函数(Nominatim 逆地理编码与 ipapi.co)。"""
from __future__ import annotations
from typing import Any
import httpx
NOMINATIM_REQUEST_HEADERS = {"User-Agent": "OpsAssistant/1.0 (internal tool)"}
DEFAULT_HTTP_TIMEOUT = 10.0
def nominatim_reverse(
client: httpx.Client,
lat: float,
lon: float,
*,
accept_language: str = "en",
) -> dict[str, Any]:
"""调用 Nominatim 逆地理编码并返回解析后的 JSON,失败时返回空字典。"""
try:
r = client.get(
"https://nominatim.openstreetmap.org/reverse",
params={"lat": lat, "lon": lon, "format": "json", "accept-language": accept_language},
headers=NOMINATIM_REQUEST_HEADERS,
)
r.raise_for_status()
data = r.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def ipapi_approximate_location(client: httpx.Client) -> dict[str, Any] | None:
try:
ip_resp = client.get("https://ipapi.co/json/")
ip_resp.raise_for_status()
ip_data = ip_resp.json()
lat = ip_data.get("latitude")
lon = ip_data.get("longitude")
if lat is None or lon is None:
return None
lat_f = float(lat)
lon_f = float(lon)
geo = nominatim_reverse(client, lat_f, lon_f)
display_name = geo.get("display_name") if geo else None
if not display_name or not str(display_name).strip():
parts = [ip_data.get("city"), ip_data.get("region"), ip_data.get("country_name")]
display_name = ", ".join(str(p) for p in parts if p)
if not display_name:
display_name = f"Approximate ({lat_f:.4f}, {lon_f:.4f})"
return {
"latitude": lat_f,
"longitude": lon_f,
"display_name": str(display_name).strip(),
"ip": ip_data.get("ip"),
"city": ip_data.get("city"),
"region": ip_data.get("region"),
"country_name": ip_data.get("country_name"),
"nominatim": geo,
}
except Exception:
return None
__all__ = ["DEFAULT_HTTP_TIMEOUT", "NOMINATIM_REQUEST_HEADERS", "ipapi_approximate_location", "nominatim_reverse"]

View file

@ -0,0 +1,78 @@
from __future__ import annotations
import httpx
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from .geo_http import DEFAULT_HTTP_TIMEOUT, ipapi_approximate_location, nominatim_reverse
def _reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
with httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT) as client:
return nominatim_reverse(client, lat, lon)
def geo_info_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
lat = args.get("latitude")
lon = args.get("longitude")
if lat is None or lon is None:
return {"ok": False, "error": "latitude and longitude are required"}
try:
lat_f = float(lat)
lon_f = float(lon)
except (TypeError, ValueError):
return {"ok": False, "error": "latitude and longitude must be numbers"}
data = _reverse_geocode(lat_f, lon_f)
if not data or "error" in data:
error_msg = data.get("error") if data else "Unknown error"
return {"ok": False, "error": error_msg}
return {"ok": True, "address": data.get("display_name"), "details": data.get("address"), "latitude": lat_f, "longitude": lon_f}
return ToolSpec(
name="reverse_geocode",
description="Reverse geocode: get a human-readable address from latitude and longitude.",
parameters={
"type": "object",
"properties": {
"latitude": {"type": "number", "description": "Latitude in decimal degrees."},
"longitude": {"type": "number", "description": "Longitude in decimal degrees."},
},
"required": ["latitude", "longitude"],
"additionalProperties": False,
},
handler=handler,
)
def system_location_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
with httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT) as client:
loc = ipapi_approximate_location(client)
if not loc:
return {"ok": False, "error": "Could not detect coordinates for this network"}
geo = loc.get("nominatim") or {}
return {
"ok": True,
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"address": loc["display_name"],
"ip": loc.get("ip"),
"city": loc.get("city"),
"region": loc.get("region"),
"country": loc.get("country_name"),
"details": geo.get("address") if geo else None,
}
except Exception as e:
return {"ok": False, "error": f"Failed to detect location: {e}"}
return ToolSpec(
name="get_system_location",
description="Detect this machine's public IP and approximate location (coordinates and address).",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=handler,
)
__all__ = ["geo_info_tool", "system_location_tool"]

View file

@ -0,0 +1,129 @@
from __future__ import annotations
import base64
import io
import os
from typing import Any
from PIL import Image
from oclaw.platform.files.attachment_assets import AttachmentAssetStore
from oclaw.runtime.tools.base import ToolSpec
def image_edit_tool() -> ToolSpec:
"""Edit an uploaded image using OpenAI Images API.
Input image is referenced by attachment_id (disk-backed asset store).
Output is saved back to the asset store and returned as attachment_id.
"""
def handler(args: dict[str, Any]) -> dict[str, Any]:
attachment_id = str(args.get("attachment_id") or "").strip()
instruction = str(args.get("instruction") or "").strip()
model = str(args.get("model") or os.getenv("OPENAI_IMAGE_MODEL") or "gpt-image-1").strip()
if not attachment_id:
return {"ok": False, "error": "attachment_id is required"}
if not instruction:
return {"ok": False, "error": "instruction is required"}
store = AttachmentAssetStore()
blob, meta = store.load_bytes(attachment_id)
if not blob:
return {"ok": False, "error": f"attachment not found: {attachment_id}"}
try:
from openai import OpenAI
except Exception as e:
return {"ok": False, "error": f"openai package is not available: {type(e).__name__}: {e}"}
api_key = (os.getenv("OPENAI_API_KEY") or "").strip()
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip()
if not api_key:
return {"ok": False, "error": "OPENAI_API_KEY is not set"}
client_kwargs: dict[str, Any] = {"api_key": api_key}
if base_url:
client_kwargs["base_url"] = base_url
client = OpenAI(**client_kwargs)
# OpenAI SDK expects a file-like object for edits.
img_file = io.BytesIO(blob)
img_file.name = "input.png" # type: ignore[attr-defined]
b64_out: str | None = None
try:
# Preferred: image edit endpoint (if supported by the gateway/model).
resp = client.images.edit( # type: ignore[attr-defined]
model=model,
image=img_file,
prompt=instruction,
response_format="b64_json",
)
data0 = resp.data[0] if getattr(resp, "data", None) else None
b64_out = getattr(data0, "b64_json", None) if data0 is not None else None
except Exception:
# Fallback: generate a new image from prompt (still returns an image, but not true edit).
try:
resp = client.images.generate( # type: ignore[attr-defined]
model=model,
prompt=instruction,
response_format="b64_json",
)
data0 = resp.data[0] if getattr(resp, "data", None) else None
b64_out = getattr(data0, "b64_json", None) if data0 is not None else None
except Exception as e2:
return {"ok": False, "error": f"image api failed: {type(e2).__name__}: {e2}"}
if not b64_out:
return {"ok": False, "error": "image api returned no b64_json payload"}
try:
out_bytes = base64.b64decode(b64_out.encode("ascii"))
except Exception as e:
return {"ok": False, "error": f"failed to decode image b64: {type(e).__name__}: {e}"}
width = None
height = None
try:
with Image.open(io.BytesIO(out_bytes)) as im:
width, height = im.size
except Exception:
pass
out_meta = store.save_bytes(
out_bytes,
filename=f"edited-{meta.name if meta else 'image'}.png",
mime="image/png",
width=width,
height=height,
)
return {
"ok": True,
"attachment_id": out_meta.attachment_id,
"name": out_meta.name,
"mime": out_meta.mime,
"bytes": out_meta.bytes,
"width": out_meta.width,
"height": out_meta.height,
}
return ToolSpec(
name="image_edit",
description="Edit an uploaded image referenced by attachment_id, returning a new attachment_id.",
parameters={
"type": "object",
"properties": {
"attachment_id": {"type": "string", "description": "Input image attachment id (image_ref)."},
"instruction": {"type": "string", "description": "Edit instruction for the image."},
"model": {"type": "string", "description": "OpenAI image model name (default: gpt-image-1)."},
},
"required": ["attachment_id", "instruction"],
},
handler=handler,
tags=frozenset({"image", "edit"}),
)
__all__ = ["image_edit_tool"]

View file

@ -0,0 +1,33 @@
from __future__ import annotations
import datetime
import time
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def system_info_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
now = datetime.datetime.now()
utc_now = datetime.datetime.now(datetime.timezone.utc)
timezone_name = time.tzname[0] if time.daylight == 0 else time.tzname[1]
timezone_offset = (now - utc_now.replace(tzinfo=None)).total_seconds() / 3600
return {
"ok": True,
"current_time": now.strftime("%Y-%m-%d %H:%M:%S"),
"timezone": timezone_name,
"timezone_offset": f"UTC{'+' if timezone_offset >= 0 else ''}{timezone_offset:g}",
"timestamp": int(time.time()),
}
return ToolSpec(
name="get_system_time",
description="Return the current local time, timezone name, and UTC offset.",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=handler,
read_only=True,
)
__all__ = ["system_info_tool"]

View file

@ -0,0 +1,150 @@
from __future__ import annotations
from typing import Any
from oclaw.platform.files.tabular_attachment_store import (
aggregate_table,
analyze_table_full_scan,
query_table,
run_table_sql,
)
from oclaw.runtime.tools.base import ToolSpec
def query_tabular_attachment_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
if not table_id:
return {"ok": False, "error": "table_id_required"}
raw_cols = args.get("columns")
cols = [str(x) for x in raw_cols] if isinstance(raw_cols, list) else None
sheet = str(args.get("sheet") or "").strip() or None
where_contains = args.get("where_contains") if isinstance(args.get("where_contains"), dict) else None
aggregate = args.get("aggregate") if isinstance(args.get("aggregate"), dict) else None
if aggregate:
return aggregate_table(
table_id=table_id,
metric=str(aggregate.get("metric") or ""),
target_column=str(aggregate.get("target_column") or "").strip() or None,
group_by=str(aggregate.get("group_by") or "").strip() or None,
where_contains=where_contains,
top_n=int(aggregate.get("top_n") or 20),
sheet=sheet,
)
return query_table(
table_id=table_id,
columns=cols,
limit=int(args.get("limit") or 50),
offset=int(args.get("offset") or 0),
where_contains=where_contains, # {"column":"...", "keyword":"..."}
sheet=sheet,
)
return ToolSpec(
name="query_tabular_attachment",
description="Query rows from a large uploaded table by table_id with optional column selection and keyword filter.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"columns": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
"offset": {"type": "integer", "minimum": 0},
"where_contains": {
"type": "object",
"properties": {
"column": {"type": "string"},
"keyword": {"type": "string"},
},
"additionalProperties": False,
},
"aggregate": {
"type": "object",
"properties": {
"metric": {"type": "string", "enum": ["count", "sum", "avg"]},
"target_column": {"type": "string"},
"group_by": {"type": "string"},
"top_n": {"type": "integer", "minimum": 1, "maximum": 200},
},
"required": ["metric"],
"additionalProperties": False,
},
},
"required": ["table_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
def run_tabular_sql_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
sql = str(args.get("sql") or "").strip()
sheet = str(args.get("sheet") or "").strip() or None
if not table_id:
return {"ok": False, "error": "table_id_required"}
return run_table_sql(
table_id=table_id,
sql=sql,
limit=int(args.get("limit") or 200),
sheet=sheet,
)
return ToolSpec(
name="run_tabular_sql",
description="Run a READ-ONLY SQL query against uploaded table by table_id. Only SELECT/WITH allowed.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"sql": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 500},
},
"required": ["table_id", "sql"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
def analyze_tabular_attachment_full_scan_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
if not table_id:
return {"ok": False, "error": "table_id_required"}
raw_cols = args.get("columns")
cols = [str(x) for x in raw_cols] if isinstance(raw_cols, list) else None
sheet = str(args.get("sheet") or "").strip() or None
return analyze_table_full_scan(
table_id=table_id,
columns=cols,
sheet=sheet,
top_values_limit=int(args.get("top_values_limit") or 3),
)
return ToolSpec(
name="analyze_tabular_attachment_full_scan",
description="Run a full-table scan for selected columns and return concise profiling stats with audit evidence.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"columns": {"type": "array", "items": {"type": "string"}},
"top_values_limit": {"type": "integer", "minimum": 0, "maximum": 10},
},
"required": ["table_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
__all__ = ["query_tabular_attachment_tool", "run_tabular_sql_tool", "analyze_tabular_attachment_full_scan_tool"]

View file

@ -0,0 +1,150 @@
from __future__ import annotations
import httpx
import re
import unicodedata
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from .geo_http import NOMINATIM_REQUEST_HEADERS, ipapi_approximate_location, nominatim_reverse
_WEATHER_CODES: dict[int, str] = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
95: "Thunderstorm",
}
_LOCAL_WEATHER_ALIASES: frozenset[str] = frozenset(
{"here", "local", "locally", "nearby", "current", "current location", "my location", "this location", "local area", "unknown", "anywhere", "本地", "当地", "这里", "附近", "当前位置", "当前", "本地天气"}
)
def _normalize_city_token(s: str) -> str:
t = unicodedata.normalize("NFKC", (s or "").strip()).casefold()
t = re.sub(r"\s+", " ", t)
return t
def _is_local_weather_alias(city: str) -> bool:
return _normalize_city_token(city) in _LOCAL_WEATHER_ALIASES
def _coerce_city(raw: Any) -> str | None:
if raw is None:
return None
if not isinstance(raw, str):
raw = str(raw)
s = raw.strip()
return s if s else None
def weather_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
city = _coerce_city(args.get("city"))
lat = args.get("latitude")
lon = args.get("longitude")
if (lat is None) ^ (lon is None):
return {"ok": False, "error": "Provide both latitude and longitude, or neither (for local-IP weather), or use city alone."}
has_coords = lat is not None and lon is not None
try:
with httpx.Client(timeout=12.0) as client:
location_basis: str
resolved_city: str
lat_f: float
lon_f: float
extra: dict[str, Any] = {}
if has_coords:
lat_f = float(lat)
lon_f = float(lon)
location_basis = "explicit_coordinates"
rev = nominatim_reverse(client, lat_f, lon_f)
dn = (rev.get("display_name") or "").strip() if rev else ""
resolved_city = dn or f"Coordinates ({lat_f}, {lon_f})"
elif city and not _is_local_weather_alias(city):
geo_resp = client.get(
"https://nominatim.openstreetmap.org/search",
params={"q": city, "format": "json", "limit": 1},
headers=NOMINATIM_REQUEST_HEADERS,
)
geo_resp.raise_for_status()
geo_data = geo_resp.json()
if not geo_data:
return {"ok": False, "error": f"City not found: {city}"}
first = geo_data[0]
lat_f = float(first["lat"])
lon_f = float(first["lon"])
resolved_city = first.get("display_name", city)
location_basis = "explicit_place"
else:
ip_loc = ipapi_approximate_location(client)
if not ip_loc:
return {"ok": False, "error": "Could not resolve local weather: failed to detect location from this network. Pass a concrete city/region (e.g. 北京) or both latitude and longitude."}
lat_f = ip_loc["latitude"]
lon_f = ip_loc["longitude"]
resolved_city = ip_loc["display_name"]
location_basis = "local_network_ip"
if ip_loc.get("ip") is not None:
extra["approximate_ip"] = ip_loc["ip"]
weather_url = "https://api.open-meteo.com/v1/forecast"
weather_params = {
"latitude": lat_f,
"longitude": lon_f,
"current": ["temperature_2m", "relative_humidity_2m", "apparent_temperature", "is_day", "weather_code", "wind_speed_10m"],
"timezone": "auto",
}
w_resp = client.get(weather_url, params=weather_params)
w_resp.raise_for_status()
current = w_resp.json().get("current", {})
code = int(current.get("weather_code") or 0)
condition = _WEATHER_CODES.get(code, "Unknown")
out: dict[str, Any] = {
"ok": True,
"city": resolved_city,
"temperature": f"{current.get('temperature_2m')}°C",
"feels_like": f"{current.get('apparent_temperature')}°C",
"condition": condition,
"humidity": f"{current.get('relative_humidity_2m')}%",
"wind_speed": f"{current.get('wind_speed_10m')} km/h",
"is_day": bool(current.get("is_day")),
"latitude": lat_f,
"longitude": lon_f,
"location_basis": location_basis,
}
out.update(extra)
if location_basis == "local_network_ip":
out["disclaimer"] = "Weather is for the approximate location of this deployment's public IP (VPN/proxy/corporate NAT may differ from the end user's actual place)."
return out
except Exception as e:
return {"ok": False, "error": f"Failed to fetch weather: {e}"}
return ToolSpec(
name="get_weather",
description="Get current weather (Open-Meteo, no API key). Default: omit city and coordinates — uses this server's outbound public IP for approximate local weather. Override: pass a concrete placename in `city` or both `latitude` and `longitude`.",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "Optional place name."},
"latitude": {"type": "number", "description": "Optional. Must pair with longitude."},
"longitude": {"type": "number", "description": "Optional. Must pair with latitude."},
},
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["weather_tool"]

View file

@ -0,0 +1,136 @@
"""基于 DuckDuckGo(ddgs 包)的公网搜索工具(无需 API Key)。"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
_MAX_SNIPPET = 800
_DDGS_TIMEOUT = 15
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _truncate(s: str, limit: int) -> str:
t = (s or "").strip()
if len(t) <= limit:
return t
return t[: limit - 3] + "..."
def _published_display_and_sort_key(raw: Any) -> tuple[str | None, float]:
if raw is None:
return None, float("-inf")
if isinstance(raw, (int, float)):
try:
ts = float(raw)
dt = datetime.fromtimestamp(ts, timezone.utc)
return dt.isoformat(), ts
except (OSError, OverflowError, ValueError):
return str(raw), float("-inf")
s = str(raw).strip()
if not s:
return None, float("-inf")
try:
s2 = s[:-1] + "+00:00" if s.endswith("Z") else s
dt = datetime.fromisoformat(s2)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
iso = dt.astimezone(timezone.utc).isoformat()
return iso, dt.timestamp()
except Exception:
return s, float("-inf")
def web_search_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("query") or "").strip()
if not q:
return {"ok": False, "error": "query is required"}
raw_max = args.get("max_results")
try:
max_n = int(raw_max) if raw_max is not None else 8
except (TypeError, ValueError):
max_n = 8
max_n = max(1, min(15, max_n))
stype = str(args.get("search_type") or "web").strip().lower()
if stype not in ("web", "news"):
return {"ok": False, "error": "search_type must be 'web' or 'news'"}
timelimit = args.get("time_range")
if timelimit is not None and timelimit != "":
tl = str(timelimit).strip().lower()
allowed = {"d", "w", "m", "y"}
if tl not in allowed:
return {"ok": False, "error": f"time_range must be one of {sorted(allowed)} or omitted"}
timelimit = tl
else:
timelimit = None
try:
from ddgs import DDGS
except ImportError:
return {"ok": False, "error": "Package `ddgs` is not installed. Run: pip install ddgs"}
retrieved_at = _utc_now_iso()
try:
rows: list[dict[str, Any]] = []
with DDGS(timeout=_DDGS_TIMEOUT) as ddgs:
if stype == "web":
for r in ddgs.text(q, max_results=max_n, timelimit=timelimit):
if not isinstance(r, dict):
continue
title = _truncate(str(r.get("title") or ""), 300)
url = str(r.get("href") or r.get("url") or "").strip()
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
if title or url or body:
rows.append({"title": title, "url": url, "snippet": body, "published_time": None})
sort_mode = "relevance"
note = "Web index does not provide reliable per-result publication times; order follows search relevance. Use search_type=news for time-sorted news."
else:
decorated: list[tuple[float, dict[str, Any]]] = []
for r in ddgs.news(q, max_results=max_n, timelimit=timelimit):
if not isinstance(r, dict):
continue
title = _truncate(str(r.get("title") or ""), 300)
url = str(r.get("url") or r.get("href") or "").strip()
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
pub, sk = _published_display_and_sort_key(r.get("date"))
src = str(r.get("source") or "").strip()
item = {"title": title, "url": url, "snippet": body, "published_time": pub}
if src:
item["source"] = src
if title or url or body:
decorated.append((sk, item))
decorated.sort(key=lambda x: x[0], reverse=True)
rows = [x[1] for x in decorated]
sort_mode = "published_time_desc"
note = "News results sorted by published_time (newest first). Snippets are from third-party indexes; verify critical facts."
if not rows:
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": [], "note": "No results (empty or blocked). Try rephrasing the query."}
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": rows, "source": "duckduckgo", "note": note}
except Exception as e:
return {"ok": False, "error": f"Web search failed: {e}"}
return ToolSpec(
name="web_search",
description="Search the public web (DuckDuckGo via ddgs, no API key).",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search keywords or question."},
"max_results": {"type": "integer", "description": "Optional. Number of results (1–15). Default 8."},
"search_type": {"type": "string", "enum": ["web", "news"], "description": "Optional. 'web' or 'news'."},
"time_range": {"type": "string", "enum": ["d", "w", "m", "y"], "description": "Optional time limit."},
},
"required": ["query"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["web_search_tool"]

View file

@ -0,0 +1,15 @@
from .wiki_curator_tools import (
memory_curator_wiki_apply_tool,
memory_curator_wiki_get_tool,
memory_curator_wiki_lint_tool,
memory_curator_wiki_search_tool,
memory_curator_wiki_status_tool,
)
__all__ = [
"memory_curator_wiki_status_tool",
"memory_curator_wiki_get_tool",
"memory_curator_wiki_search_tool",
"memory_curator_wiki_lint_tool",
"memory_curator_wiki_apply_tool",
]

View file

@ -0,0 +1,156 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.runtime.tools.base import ToolSpec
def _plugin_cfg() -> dict[str, Any]:
cfg_path = (PROJECT_ROOT / "oclaw" / "oclaw.json").resolve()
if not cfg_path.exists():
return {}
try:
import json
obj = json.loads(cfg_path.read_text(encoding="utf-8"))
except Exception:
return {}
plugins = obj.get("plugins") if isinstance(obj, dict) else {}
entries = plugins.get("entries") if isinstance(plugins, dict) else {}
entry = entries.get("memory-wiki") if isinstance(entries, dict) else {}
return entry if isinstance(entry, dict) else {}
def _wiki_handlers() -> dict[str, Callable[[dict[str, Any]], dict[str, Any]]]:
api_path = (PROJECT_ROOT / "oclaw" / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve()
spec = importlib.util.spec_from_file_location("memory_curator_wiki_api", str(api_path))
if spec is None or spec.loader is None:
return {}
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[assignment]
fn = getattr(mod, "build_wiki_tool_specs", None)
if not callable(fn):
return {}
specs = fn(SimpleNamespace(plugin_config=_plugin_cfg()))
out: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {}
for spec_item in specs:
if not isinstance(spec_item, dict):
continue
name = str(spec_item.get("name") or "").strip()
handler = spec_item.get("handler")
if name and callable(handler):
out[name] = handler
return out
def _delegate(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
handlers = _wiki_handlers()
h = handlers.get(tool_name)
if not callable(h):
return {"ok": False, "error": f"wiki handler unavailable: {tool_name}"}
try:
return h(dict(args or {}))
except Exception as exc:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
def memory_curator_wiki_status_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_status",
description="Read wiki runtime status for memory curation.",
parameters={"type": "object", "properties": {}, "required": [], "additionalProperties": False},
handler=lambda args: _delegate("wiki_status", args),
tags=frozenset({"memory", "wiki", "curator"}),
read_only=True,
)
def memory_curator_wiki_get_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_get",
description="Read a markdown file from wiki for curation.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"start_line": {"type": "integer"},
"end_line": {"type": "integer"},
},
"required": ["path"],
"additionalProperties": False,
},
handler=lambda args: _delegate("wiki_get", args),
tags=frozenset({"memory", "wiki", "curator"}),
read_only=True,
)
def memory_curator_wiki_search_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_search",
description="Search wiki markdown for memory curation.",
parameters={
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"is_regex": {"type": "boolean"},
"case_sensitive": {"type": "boolean"},
},
"required": ["query"],
"additionalProperties": False,
},
handler=lambda args: _delegate("wiki_search", args),
tags=frozenset({"memory", "wiki", "curator"}),
read_only=True,
)
def memory_curator_wiki_lint_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_lint",
description="Lint wiki markdown structure for curation quality.",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": [],
"additionalProperties": False,
},
handler=lambda args: _delegate("wiki_lint", args),
tags=frozenset({"memory", "wiki", "curator"}),
read_only=True,
)
def memory_curator_wiki_apply_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_apply",
description="Apply curated write/append/delete changes to wiki markdown.",
parameters={
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["write", "append", "delete"]},
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["action", "path"],
"additionalProperties": False,
},
handler=lambda args: _delegate("wiki_apply", args),
tags=frozenset({"memory", "wiki", "curator", "write"}),
risk_level="high",
read_only=False,
)
__all__ = [
"memory_curator_wiki_status_tool",
"memory_curator_wiki_get_tool",
"memory_curator_wiki_search_tool",
"memory_curator_wiki_lint_tool",
"memory_curator_wiki_apply_tool",
]

View file

@ -0,0 +1,40 @@
from __future__ import annotations
import difflib
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def config_diff_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
left_name = str(args.get("left_name") or "left")
right_name = str(args.get("right_name") or "right")
left = str(args.get("left") or "")
right = str(args.get("right") or "")
left_lines = left.splitlines(keepends=False)
right_lines = right.splitlines(keepends=False)
diff_lines = list(
difflib.unified_diff(left_lines, right_lines, fromfile=left_name, tofile=right_name, lineterm="")
)
return {"ok": True, "diff": "\n".join(diff_lines), "changed": left_lines != right_lines}
return ToolSpec(
name="config_diff",
description="Compare two configuration texts and return a unified diff.",
parameters={
"type": "object",
"properties": {
"left_name": {"type": "string", "description": "Optional label for the left side."},
"right_name": {"type": "string", "description": "Optional label for the right side."},
"left": {"type": "string", "description": "Left configuration text."},
"right": {"type": "string", "description": "Right configuration text."},
},
"required": ["left", "right"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["config_diff_tool"]

View file

@ -0,0 +1,78 @@
from __future__ import annotations
import re
import subprocess
import sys
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
_TTL_RE = re.compile(r"\bttl[= ]\d+\b", re.IGNORECASE)
_AVG_WIN_RE = re.compile(r"Average\s*=\s*(\d+)\s*ms", re.IGNORECASE)
_AVG_NIX_RE = re.compile(r"=\s*[\d.]+/([\d.]+)/[\d.]+/[\d.]+\s*ms")
def _ping(host: str, count: int, timeout_ms: int) -> dict[str, Any]:
try:
if sys.platform == "win32":
cmd = ["ping", "-n", str(count), "-w", str(timeout_ms), host]
timeout_s = max(1, (timeout_ms * count) / 1000 + 2)
else:
timeout_s_each = max(1, int(round(timeout_ms / 1000)))
cmd = ["ping", "-c", str(count), "-W", str(timeout_s_each), host]
timeout_s = max(1, timeout_s_each * count + 2)
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s)
except FileNotFoundError:
return {"ok": False, "error": "ping command not found on this system"}
except subprocess.TimeoutExpired:
return {"ok": True, "reachable": False, "output": "ping timed out"}
output = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "")
reachable = proc.returncode == 0 and bool(_TTL_RE.search(output))
avg_ms = None
if sys.platform == "win32":
m = _AVG_WIN_RE.search(output)
if m:
try:
avg_ms = int(m.group(1))
except ValueError:
avg_ms = None
else:
m2 = _AVG_NIX_RE.search(output)
if m2:
try:
avg_ms = int(float(m2.group(1)))
except ValueError:
avg_ms = None
return {"ok": True, "reachable": reachable, "avg_ms": avg_ms, "returncode": proc.returncode, "output": output}
def device_status_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
host = str(args.get("host"))
count = int(args.get("count") or 2)
timeout_ms = int(args.get("timeout_ms") or 1000)
if count < 1 or count > 10:
return {"ok": False, "error": "count must be between 1 and 10"}
if timeout_ms < 200 or timeout_ms > 10000:
return {"ok": False, "error": "timeout_ms must be between 200 and 10000"}
return _ping(host=host, count=count, timeout_ms=timeout_ms)
return ToolSpec(
name="device_status",
description="Check host reachability using ICMP ping (system ping binary).",
parameters={
"type": "object",
"properties": {
"host": {"type": "string", "description": "Hostname or IP address."},
"count": {"type": "integer", "description": "Number of ping probes. Default 2."},
"timeout_ms": {"type": "integer", "description": "Per-packet timeout in milliseconds. Default 1000."},
},
"required": ["host"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["device_status_tool"]

View file

@ -0,0 +1,99 @@
from __future__ import annotations
from collections import deque
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def _default_edges() -> list[tuple[str, str]]:
return [
("R1", "R2"),
("R2", "R3"),
("R3", "R4"),
("R2", "R5"),
("R5", "R4"),
("R1", "SW1"),
("SW1", "FW1"),
("FW1", "R3"),
]
def _build_adj(edges: list[tuple[str, str]]) -> dict[str, set[str]]:
adj: dict[str, set[str]] = {}
for a, b in edges:
adj.setdefault(a, set()).add(b)
adj.setdefault(b, set()).add(a)
return adj
def _bfs_path(adj: dict[str, set[str]], src: str, dst: str) -> list[str] | None:
if src == dst:
return [src]
q: deque[str] = deque([src])
prev: dict[str, str | None] = {src: None}
while q:
cur = q.popleft()
for nxt in sorted(adj.get(cur, set())):
if nxt in prev:
continue
prev[nxt] = cur
if nxt == dst:
q.clear()
break
q.append(nxt)
if dst not in prev:
return None
path: list[str] = []
cur2: str | None = dst
while cur2 is not None:
path.append(cur2)
cur2 = prev[cur2]
path.reverse()
return path
def get_path_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
src = str(args.get("src"))
dst = str(args.get("dst"))
raw_edges = args.get("topology_edges")
edges: list[tuple[str, str]]
if raw_edges is None:
edges = _default_edges()
else:
edges = []
for item in raw_edges:
if not isinstance(item, (list, tuple)) or len(item) != 2:
return {"ok": False, "error": "topology_edges must be an array of pairs; each item must contain two node names."}
edges.append((str(item[0]), str(item[1])))
adj = _build_adj(edges)
path = _bfs_path(adj, src, dst)
if not path:
return {"ok": False, "src": src, "dst": dst, "error": "No reachable path in the given topology."}
return {"ok": True, "src": src, "dst": dst, "hops": path, "hop_count": len(path) - 1}
return ToolSpec(
name="get_path",
description="Compute the shortest path from src to dst (BFS) over an undirected topology. Optional topology_edges overrides the built-in demo graph.",
parameters={
"type": "object",
"properties": {
"src": {"type": "string", "description": "Source node name."},
"dst": {"type": "string", "description": "Destination node name."},
"topology_edges": {
"type": "array",
"description": 'Optional edge list, e.g. [["R1","R2"],["R2","R3"]].',
"items": {"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 2},
},
},
"required": ["src", "dst"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["get_path_tool"]

View file

@ -0,0 +1,53 @@
from __future__ import annotations
import re
from collections import Counter
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
_LEVEL_RE = re.compile(r"\b(ERROR|WARN|WARNING|INFO|DEBUG)\b", re.IGNORECASE)
def log_analysis_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
text = str(args.get("log") or "")
max_lines = int(args.get("max_lines") or 2000)
lines = text.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
levels: Counter[str] = Counter()
samples: dict[str, list[str]] = {"ERROR": [], "WARN": []}
for line in lines:
m = _LEVEL_RE.search(line)
if not m:
continue
level = m.group(1).upper()
if level == "WARNING":
level = "WARN"
if level in ("ERROR", "WARN", "INFO", "DEBUG"):
levels[level] += 1
if level in samples and len(samples[level]) < 5:
samples[level].append(line[:500])
top_lines = [l[:500] for l in lines[-20:]]
return {"ok": True, "line_count": len(lines), "level_count": dict(levels), "samples": samples, "tail": top_lines}
return ToolSpec(
name="log_analysis",
description="Summarize log text: counts of ERROR/WARN/INFO/DEBUG lines, sample lines, and the last lines (tail).",
parameters={
"type": "object",
"properties": {
"log": {"type": "string", "description": "Log text to analyze."},
"max_lines": {"type": "integer", "description": "Maximum number of lines to analyze (uses the tail if exceeded). Default 2000."},
},
"required": ["log"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["log_analysis_tool"]

View file

@ -0,0 +1,259 @@
from __future__ import annotations
import concurrent.futures
import datetime
import socket
import ssl
import subprocess
import sys
import uuid
from typing import Any
import httpx
from oclaw.runtime.tools.base import ToolSpec
def dns_lookup_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
domain = args.get("domain")
if not domain:
return {"ok": False, "error": "domain is required"}
try:
ips = socket.gethostbyname_ex(domain)[2]
return {"ok": True, "domain": domain, "ips": ips, "count": len(ips)}
except Exception as e:
return {"ok": False, "error": f"DNS resolution failed: {e}"}
return ToolSpec(
name="dns_lookup",
description="Resolve a domain name to IPv4 addresses (A records via system resolver).",
parameters={
"type": "object",
"properties": {
"domain": {"type": "string", "description": "Domain name (e.g. example.com)."},
},
"required": ["domain"],
"additionalProperties": False,
},
handler=handler,
)
def ssl_check_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
domain = args.get("domain")
port = int(args.get("port") or 443)
if not domain:
return {"ok": False, "error": "domain is required"}
try:
context = ssl.create_default_context()
with socket.create_connection((domain, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
not_before = datetime.datetime.strptime(cert["notBefore"], "%b %d %H:%M:%S %Y %Z")
not_after = datetime.datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
remaining_days = (not_after - datetime.datetime.utcnow()).days
subject = dict(x[0] for x in cert["subject"])
issuer = dict(x[0] for x in cert["issuer"])
return {
"ok": True,
"domain": domain,
"issuer": issuer.get("commonName"),
"issued_to": subject.get("commonName"),
"valid_from": not_before.strftime("%Y-%m-%d"),
"valid_until": not_after.strftime("%Y-%m-%d"),
"remaining_days": remaining_days,
"is_expired": remaining_days < 0,
}
except Exception as e:
return {"ok": False, "error": f"SSL check failed: {e}"}
return ToolSpec(
name="ssl_cert_check",
description="Inspect the TLS certificate presented by host:port (default 443).",
parameters={
"type": "object",
"properties": {
"domain": {"type": "string", "description": "Server hostname."},
"port": {"type": "integer", "description": "TCP port. Default 443."},
},
"required": ["domain"],
"additionalProperties": False,
},
handler=handler,
)
def port_check_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
host = args.get("host")
port = int(args.get("port"))
protocol = str(args.get("protocol") or "tcp").lower()
timeout = float(args.get("timeout") or 2.0)
if not host or not port:
return {"ok": False, "error": "host and port are required"}
if protocol == "tcp":
try:
with socket.create_connection((host, port), timeout=timeout):
return {"ok": True, "host": host, "port": port, "protocol": "TCP", "status": "open"}
except socket.timeout:
return {"ok": True, "host": host, "port": port, "protocol": "TCP", "status": "timeout"}
except Exception as e:
return {"ok": True, "host": host, "port": port, "protocol": "TCP", "status": "closed", "error": str(e)}
if protocol == "udp":
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
sock.sendto(b"", (host, port))
try:
sock.recvfrom(1024)
return {"ok": True, "host": host, "port": port, "protocol": "UDP", "status": "open", "received": True}
except socket.timeout:
return {"ok": True, "host": host, "port": port, "protocol": "UDP", "status": "open|filtered"}
except Exception as e:
return {"ok": True, "host": host, "port": port, "protocol": "UDP", "status": "closed", "error": str(e)}
finally:
sock.close()
except Exception as e:
return {"ok": False, "error": f"UDP check failed: {e}"}
return {"ok": False, "error": f"Unsupported protocol: {protocol}"}
return ToolSpec(
name="port_check",
description="Test whether a TCP or UDP port appears open on a host.",
parameters={
"type": "object",
"properties": {
"host": {"type": "string", "description": "Hostname or IP address."},
"port": {"type": "integer", "description": "Port number."},
"protocol": {"type": "string", "enum": ["tcp", "udp"], "description": "tcp or udp. Default tcp."},
"timeout": {"type": "number", "description": "Timeout in seconds. Default 2.0."},
},
"required": ["host", "port"],
"additionalProperties": False,
},
handler=handler,
)
def port_scan_tool() -> ToolSpec:
COMMON_PORTS = [21, 22, 23, 25, 53, 80, 110, 143, 443, 445, 1433, 1521, 3306, 3389, 5432, 6379, 8080, 27017]
def scan_port(host: str, port: int, timeout: float) -> int | None:
try:
with socket.create_connection((host, port), timeout=timeout):
return port
except Exception:
return None
def handler(args: dict[str, Any]) -> dict[str, Any]:
host = args.get("host")
start_port = args.get("start_port")
end_port = args.get("end_port")
ports_to_scan = args.get("ports")
timeout = float(args.get("timeout") or 0.5)
max_threads = int(args.get("max_threads") or 20)
if not host:
return {"ok": False, "error": "host is required"}
if ports_to_scan:
ports = [int(p) for p in ports_to_scan]
elif start_port is not None and end_port is not None:
s, e = int(start_port), int(end_port)
if e - s > 1000:
return {"ok": False, "error": "Cannot scan more than 1000 ports in one call"}
ports = list(range(s, e + 1))
else:
ports = COMMON_PORTS
open_ports: list[int] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
future_to_port = {executor.submit(scan_port, host, port, timeout): port for port in ports}
for future in concurrent.futures.as_completed(future_to_port):
result = future.result()
if result is not None:
open_ports.append(result)
open_ports.sort()
return {
"ok": True,
"host": host,
"open_ports": open_ports,
"scanned_count": len(ports),
"open_count": len(open_ports),
"status": "completed",
}
return ToolSpec(
name="port_scan",
description="Scan TCP ports on a host (common ports, a numeric range, or an explicit list).",
parameters={
"type": "object",
"properties": {
"host": {"type": "string", "description": "Hostname or IP address."},
"start_port": {"type": "integer", "description": "Start of port range (inclusive)."},
"end_port": {"type": "integer", "description": "End of port range (inclusive)."},
"ports": {"type": "array", "items": {"type": "integer"}, "description": "Explicit list of ports to scan."},
"timeout": {"type": "number", "description": "Per-port timeout in seconds. Default 0.5."},
"max_threads": {"type": "integer", "description": "Maximum concurrent probes. Default 20."},
},
"required": ["host"],
"additionalProperties": False,
},
handler=handler,
)
def local_net_info_tool() -> ToolSpec:
def get_mac_address() -> str:
return ":".join(["{:02x}".format((uuid.getnode() >> i) & 0xFF) for i in range(0, 8 * 6, 8)][::-1])
def get_public_ip() -> str:
try:
with httpx.Client(timeout=5.0) as client:
resp = client.get("https://api64.ipify.org?format=json")
return str(resp.json().get("ip") or "Unknown")
except Exception:
return "Unknown"
def get_gateway() -> str:
try:
if sys.platform == "win32":
output = subprocess.check_output("route print 0.0.0.0", shell=True).decode("gbk", errors="replace")
for line in output.splitlines():
if "0.0.0.0" in line and "On-link" not in line:
parts = line.split()
if len(parts) >= 3:
return parts[2]
else:
output = subprocess.check_output("ip route show default", shell=True).decode(errors="replace")
return output.split()[2]
except Exception:
return "Unknown"
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
hostname = socket.gethostname()
local_ip = socket.gethostbyname(hostname)
mac = get_mac_address()
gateway = get_gateway()
public_ip = get_public_ip()
return {
"ok": True,
"hostname": hostname,
"local_ip": local_ip,
"public_ip": public_ip,
"mac_address": mac,
"gateway": gateway,
"platform": sys.platform,
}
except Exception as e:
return {"ok": False, "error": f"Failed to read local network info: {e}"}
return ToolSpec(
name="get_local_net_info",
description="Summarize local hostname, IPs, MAC, default gateway, and OS platform (best-effort).",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=handler,
)
__all__ = ["dns_lookup_tool", "ssl_check_tool", "port_check_tool", "port_scan_tool", "local_net_info_tool"]

View file

@ -0,0 +1,77 @@
"""网络运维专家工具清单。"""
from oclaw.runtime.tools.base import ToolSpec
def query_route_tool() -> ToolSpec:
from .query_route import query_route_tool as factory
return factory()
def get_path_tool() -> ToolSpec:
from .get_path import get_path_tool as factory
return factory()
def config_diff_tool() -> ToolSpec:
from .config_diff import config_diff_tool as factory
return factory()
def device_status_tool() -> ToolSpec:
from .device_status import device_status_tool as factory
return factory()
def log_analysis_tool() -> ToolSpec:
from .log_analysis import log_analysis_tool as factory
return factory()
def dns_lookup_tool() -> ToolSpec:
from .network_probe_tools import dns_lookup_tool as factory
return factory()
def ssl_check_tool() -> ToolSpec:
from .network_probe_tools import ssl_check_tool as factory
return factory()
def port_check_tool() -> ToolSpec:
from .network_probe_tools import port_check_tool as factory
return factory()
def port_scan_tool() -> ToolSpec:
from .network_probe_tools import port_scan_tool as factory
return factory()
def local_net_info_tool() -> ToolSpec:
from .network_probe_tools import local_net_info_tool as factory
return factory()
__all__ = [
"query_route_tool",
"get_path_tool",
"config_diff_tool",
"device_status_tool",
"log_analysis_tool",
"dns_lookup_tool",
"ssl_check_tool",
"port_check_tool",
"port_scan_tool",
"local_net_info_tool",
]

View file

@ -0,0 +1,51 @@
from __future__ import annotations
import ipaddress
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def _pick_route(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> dict[str, Any]:
if isinstance(ip, ipaddress.IPv4Address):
if ip in ipaddress.ip_network("10.0.0.0/8"):
return {"prefix": "10.0.0.0/8", "next_hop": "192.168.1.1", "out_if": "GigabitEthernet0/0"}
if ip in ipaddress.ip_network("172.16.0.0/12"):
return {"prefix": "172.16.0.0/12", "next_hop": "192.168.2.1", "out_if": "GigabitEthernet0/1"}
if ip in ipaddress.ip_network("192.168.0.0/16"):
return {"prefix": "192.168.0.0/16", "next_hop": "direct", "out_if": "Vlan10"}
return {"prefix": "0.0.0.0/0", "next_hop": "203.0.113.1", "out_if": "GigabitEthernet1/0"}
if ip in ipaddress.ip_network("fc00::/7"):
return {"prefix": "fc00::/7", "next_hop": "fe80::1", "out_if": "Vlan20"}
return {"prefix": "::/0", "next_hop": "2001:db8::1", "out_if": "GigabitEthernet1/0"}
def query_route_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
destination = str(args.get("destination"))
vrf = args.get("vrf")
try:
ip = ipaddress.ip_address(destination)
except ValueError:
return {"ok": False, "error": f"Invalid IP address: {destination}"}
route = _pick_route(ip)
return {"ok": True, "destination": destination, "vrf": vrf, "route": route}
return ToolSpec(
name="query_route",
description="Look up route egress and next hop for a destination IP (demo data; replace with a real device or controller API).",
parameters={
"type": "object",
"properties": {
"destination": {"type": "string", "description": "Destination IP address (IPv4 or IPv6)."},
"vrf": {"type": "string", "description": "Optional VRF name."},
},
"required": ["destination"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["query_route_tool"]

View file

@ -0,0 +1,10 @@
from __future__ import annotations
from oclaw.runtime.tools.experts.generalist.tabular_query import (
analyze_tabular_attachment_full_scan_tool,
query_tabular_attachment_tool,
run_tabular_sql_tool,
)
__all__ = ["query_tabular_attachment_tool", "run_tabular_sql_tool", "analyze_tabular_attachment_full_scan_tool"]

View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

View file

@ -0,0 +1,96 @@
from __future__ import annotations
import hashlib
from typing import Any
from oclaw.platform.config.paths import db_path
from oclaw.platform.embeddings.embedding_client import build_default_embedding_client
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.base import ToolSpec
def _chunk_id(source: str, text: str) -> str:
raw = f"{source}\n{text}".encode("utf-8", errors="ignore")
return hashlib.sha1(raw).hexdigest()
def kb_add_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = str(args.get("tenant_id") or "").strip()
user_id = str(args.get("user_id") or "").strip()
text = str(args.get("text") or "").strip()
title = str(args.get("title") or "").strip()
if not tenant_id or not user_id or not text:
return {"ok": False, "error": "tenant_id, user_id, text are required"}
source = f"builtin:tenant:{tenant_id}:kb"
if title:
source = f"{source}:{title[:48]}"
cid = _chunk_id(source, text)
store = SqliteStore(db_path())
store.upsert_knowledge_chunk(
chunk_id=cid,
source=source,
content=text,
metadata={"tenant_id": tenant_id, "user_id": user_id, "title": title, "source": source},
)
client = build_default_embedding_client()
emb = client.embed(text[:8000])
store.upsert_knowledge_embedding(chunk_id=cid, model=emb.model, vector=emb.vector)
return {"ok": True, "chunk_id": cid, "source": source, "embedding_model": emb.model}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="kb_add",
description="Add a knowledge snippet for a tenant into the vector knowledge base.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "user_id": {"type": "string"}, "title": {"type": "string", "description": "Optional title/label."}, "text": {"type": "string", "description": "Knowledge content to store."}},
"required": ["tenant_id", "user_id", "text"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "rag", "write"}),
)
def kb_search_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = str(args.get("tenant_id") or "").strip()
query = str(args.get("query") or "").strip()
limit = int(args.get("limit") or 3)
if not tenant_id or not query:
return {"ok": False, "error": "tenant_id and query are required"}
store = SqliteStore(db_path())
from oclaw.runtime.orchestration.memory import retrieve_context
rows = retrieve_context(store, query, limit=max(1, min(limit, 6)))
filtered = [r for r in rows if str(r.get("source") or "").startswith(f"builtin:tenant:{tenant_id}:")]
hits = filtered[: max(1, min(limit, 6))]
if not hits:
like_rows = store.search_knowledge(query=query, limit=max(1, min(limit, 6)))
hits = [r for r in like_rows if str(r.get("source") or "").startswith(f"builtin:tenant:{tenant_id}:")][: max(1, min(limit, 6))]
refs = []
for h in hits:
refs.append({"source": str(h.get("source") or ""), "snippet": str(h.get("content") or "")[:240]})
return {"ok": True, "hits": refs}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="kb_search",
description="Search tenant knowledge base and return citations/snippets.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer", "default": 3}},
"required": ["tenant_id", "query"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "rag"}),
)
__all__ = ["kb_add_tool", "kb_search_tool"]

View file

@ -0,0 +1,146 @@
from __future__ import annotations
from typing import Any
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.base import ToolSpec
def _require(s: str, name: str) -> str:
v = (s or "").strip()
if not v:
raise ValueError(f"{name} is required")
return v
def todo_create_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
owner_user_id = _require(str(args.get("owner_user_id") or ""), "owner_user_id")
title = _require(str(args.get("title") or ""), "title")
due_at = str(args.get("due_at") or "").strip() or None
assignee_user_id = str(args.get("assignee_user_id") or "").strip() or None
store = SqliteStore(db_path())
row = store.todo_create(
tenant_id=tenant_id,
owner_user_id=owner_user_id,
title=title,
due_at=due_at,
assignee_user_id=assignee_user_id,
)
return {"ok": True, "todo": row}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="todo_create",
description="Create a todo item for a tenant/user.",
parameters={
"type": "object",
"properties": {
"tenant_id": {"type": "string"},
"owner_user_id": {"type": "string"},
"title": {"type": "string"},
"due_at": {"type": "string", "description": "Optional ISO timestamp or natural text."},
"assignee_user_id": {"type": "string", "description": "Optional user id to assign."},
},
"required": ["tenant_id", "owner_user_id", "title"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write"}),
)
def todo_list_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
assignee_user_id = str(args.get("assignee_user_id") or "").strip() or None
status = str(args.get("status") or "open").strip() or None
limit = int(args.get("limit") or 50)
store = SqliteStore(db_path())
rows = store.todo_list(
tenant_id=tenant_id,
assignee_user_id=assignee_user_id,
status=status,
limit=limit,
)
return {"ok": True, "items": rows}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="todo_list",
description="List todo items by tenant (optionally by assignee and status).",
parameters={
"type": "object",
"properties": {
"tenant_id": {"type": "string"},
"assignee_user_id": {"type": "string"},
"status": {"type": "string", "default": "open"},
"limit": {"type": "integer", "default": 50},
},
"required": ["tenant_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity"}),
)
def todo_done_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
todo_id = _require(str(args.get("todo_id") or ""), "todo_id")
store = SqliteStore(db_path())
ok = store.todo_set_status(tenant_id=tenant_id, todo_id=todo_id, status="done")
return {"ok": bool(ok), "todo_id": todo_id}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="todo_done",
description="Mark a todo item as done.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "todo_id": {"type": "string"}},
"required": ["tenant_id", "todo_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write"}),
)
def todo_assign_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
todo_id = _require(str(args.get("todo_id") or ""), "todo_id")
assignee_user_id = _require(str(args.get("assignee_user_id") or ""), "assignee_user_id")
store = SqliteStore(db_path())
ok = store.todo_assign(tenant_id=tenant_id, todo_id=todo_id, assignee_user_id=assignee_user_id)
return {"ok": bool(ok), "todo_id": todo_id, "assignee_user_id": assignee_user_id}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="todo_assign",
description="Assign a todo item to a user.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "todo_id": {"type": "string"}, "assignee_user_id": {"type": "string"}},
"required": ["tenant_id", "todo_id", "assignee_user_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write"}),
)
__all__ = ["todo_create_tool", "todo_list_tool", "todo_done_tool", "todo_assign_tool"]

View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

View file

@ -0,0 +1,148 @@
from __future__ import annotations
import hashlib
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path
def read_file_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
offset = int(args.get("offset") or 1)
limit = int(args.get("limit") or 400)
if offset == 0:
offset = 1
if limit <= 0:
limit = 1
p = resolve_workspace_path(path)
if not p.exists() or not p.is_file():
return {"ok": False, "error": "file_not_found", "path": str(p)}
text = p.read_text(encoding="utf-8", errors="replace").splitlines()
# 1-indexed offsets; negative counts from end
if offset < 0:
start = max(0, len(text) + offset)
else:
start = max(0, offset - 1)
end = min(len(text), start + min(limit, 2000))
out_lines = [f"{i+1}|{text[i]}" for i in range(start, end)]
blob = p.read_bytes()
sha = hashlib.sha256(blob).hexdigest()
return {
"ok": True,
"path": str(p),
"start_line": start + 1,
"end_line": end,
"total_lines": len(text),
"sha256": sha,
"content": "\n".join(out_lines),
}
return ToolSpec(
name="read_file",
description="Read a text file from the workspace with line numbers.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, relative to workspace root."},
"offset": {"type": "integer", "description": "1-indexed start line; negative counts from end.", "default": 1},
"limit": {"type": "integer", "description": "Max lines to return (capped).", "default": 400},
},
"required": ["path"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace"}),
read_only=True,
)
def write_file_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
content = str(args.get("content") or "")
mode = str(args.get("mode") or "overwrite").strip().lower()
p = resolve_workspace_path(path)
p.parent.mkdir(parents=True, exist_ok=True)
if mode not in ("overwrite", "append"):
return {"ok": False, "error": "invalid_mode", "allowed": ["overwrite", "append"]}
if mode == "append":
p.write_text(p.read_text(encoding="utf-8", errors="replace") + content, encoding="utf-8")
else:
p.write_text(content, encoding="utf-8")
return {"ok": True, "path": str(p), "bytes": p.stat().st_size}
return ToolSpec(
name="write_file",
description="Write text content to a workspace file (overwrite or append).",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, relative to workspace root."},
"content": {"type": "string", "description": "Full text content to write."},
"mode": {"type": "string", "enum": ["overwrite", "append"], "default": "overwrite"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "write"}),
)
def list_files_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
pattern = str(args.get("pattern") or "**/*").strip() or "**/*"
max_results = int(args.get("max_results") or 200)
root_arg = str(args.get("root") or "").strip()
if not root_arg:
base = resolve_workspace_path(".")
else:
base = resolve_workspace_path(root_arg)
if not base.is_dir():
return {"ok": False, "error": "not_a_directory", "path": str(base)}
out: list[str] = []
for p in base.glob(pattern):
if p.is_dir():
continue
rel = str(p.relative_to(base))
out.append(rel)
if len(out) >= max(1, min(max_results, 2000)):
break
return {
"ok": True,
"root": str(base),
"pattern": pattern,
"count": len(out),
"files": out,
}
return ToolSpec(
name="glob",
description=(
"List files under a directory matching a glob pattern. "
"Default root is the workspace root; set `root` to an absolute path (e.g. D:\\\\download) when the user names a folder outside the repo — "
"this respects gateway workspace path policy. Prefer this over MCP filesystem list_directory when the user path may be outside MCP's configured roots."
),
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Glob pattern relative to root, e.g. '**/*' or '*.pdf'.", "default": "**/*"},
"root": {
"type": "string",
"description": "Optional directory to search under (absolute or workspace-relative). If omitted, uses workspace root.",
},
"max_results": {"type": "integer", "default": 200, "description": "Max number of files to return."},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace"}),
read_only=True,
)
__all__ = ["read_file_tool", "write_file_tool", "list_files_tool"]

View file

@ -0,0 +1,154 @@
from __future__ import annotations
import subprocess
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path, truncate_text, sanitize_git_ref
def _git(command: str, *, cwd: str) -> dict[str, Any]:
workdir = resolve_workspace_path(cwd or ".")
cp = subprocess.run(
f"git {command}",
cwd=str(workdir),
shell=True,
capture_output=True,
text=True,
timeout=60.0,
)
out = (cp.stdout or "") + (("\n" + cp.stderr) if cp.stderr else "")
return {"exit_code": int(cp.returncode), "output": truncate_text(out, limit=20000), "cwd": str(workdir)}
def git_status_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
res = _git("status --porcelain=v1 -b", cwd=cwd)
ok = res["exit_code"] == 0
return {"ok": ok, **res}
return ToolSpec(
name="git_status",
description="Show git status (porcelain).",
parameters={
"type": "object",
"properties": {"cwd": {"type": "string", "default": ".", "description": "Repo directory."}},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git"}),
)
def git_diff_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
ref = sanitize_git_ref(str(args.get("ref") or "").strip()) if args.get("ref") else ""
cmd = "diff" if not ref else f"diff {ref}...HEAD"
res = _git(cmd, cwd=cwd)
ok = res["exit_code"] == 0
return {"ok": ok, **res}
return ToolSpec(
name="git_diff",
description="Show git diff (default: working tree; optional ref...HEAD).",
parameters={
"type": "object",
"properties": {
"cwd": {"type": "string", "default": ".", "description": "Repo directory."},
"ref": {"type": "string", "description": "Optional ref for ref...HEAD diff."},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git"}),
)
def git_log_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
n = int(args.get("n") or 10)
n = max(1, min(n, 50))
res = _git(f"log -{n} --oneline --decorate", cwd=cwd)
ok = res["exit_code"] == 0
return {"ok": ok, **res}
return ToolSpec(
name="git_log",
description="Show recent git commits (oneline).",
parameters={
"type": "object",
"properties": {"cwd": {"type": "string", "default": ".", "description": "Repo directory."}, "n": {"type": "integer", "default": 10}},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git"}),
)
def git_commit_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
message = str(args.get("message") or "").strip()
if not message:
return {"ok": False, "error": "message_required"}
# stage all changes (simple default)
s1 = _git("add -A", cwd=cwd)
if s1["exit_code"] != 0:
return {"ok": False, "error": "git_add_failed", **s1}
msg_esc = message.replace('"', '\\"')
s2 = _git(f'commit -m "{msg_esc}"', cwd=cwd)
ok = s2["exit_code"] == 0
return {"ok": ok, **s2}
return ToolSpec(
name="git_commit",
description="Stage all and create a git commit (requires confirmation by policy).",
parameters={
"type": "object",
"properties": {
"cwd": {"type": "string", "default": ".", "description": "Repo directory."},
"message": {"type": "string", "description": "Commit message."},
},
"required": ["message"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git", "write"}),
)
def git_push_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
remote = str(args.get("remote") or "origin").strip() or "origin"
refspec = str(args.get("refspec") or "HEAD").strip() or "HEAD"
res = _git(f"push {remote} {refspec}", cwd=cwd)
ok = res["exit_code"] == 0
return {"ok": ok, **res}
return ToolSpec(
name="git_push",
description="Push current branch (requires confirmation by policy).",
parameters={
"type": "object",
"properties": {
"cwd": {"type": "string", "default": ".", "description": "Repo directory."},
"remote": {"type": "string", "default": "origin"},
"refspec": {"type": "string", "default": "HEAD"},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git", "write"}),
)
__all__ = ["git_status_tool", "git_diff_tool", "git_log_tool", "git_commit_tool", "git_push_tool"]

View file

@ -0,0 +1,53 @@
from __future__ import annotations
import hashlib
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path
def apply_patch_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
new_content = str(args.get("new_content") or "")
expected_sha256 = str(args.get("expected_sha256") or "").strip()
p = resolve_workspace_path(path)
if p.exists() and p.is_file() and expected_sha256:
cur = hashlib.sha256(p.read_bytes()).hexdigest()
if cur != expected_sha256:
return {
"ok": False,
"error": "sha_mismatch",
"path": str(p),
"expected_sha256": expected_sha256,
"current_sha256": cur,
}
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(new_content, encoding="utf-8")
sha = hashlib.sha256(p.read_bytes()).hexdigest()
return {"ok": True, "path": str(p), "sha256": sha, "bytes": p.stat().st_size}
return ToolSpec(
name="apply_patch",
description="Apply a full-file patch by overwriting a file with new content (optional sha256 precondition).",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, relative to workspace root."},
"new_content": {"type": "string", "description": "New full file content."},
"expected_sha256": {
"type": "string",
"description": "If provided, the current file sha256 must match (precondition).",
},
},
"required": ["path", "new_content"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "write"}),
)
__all__ = ["apply_patch_tool"]

View file

@ -0,0 +1,92 @@
from __future__ import annotations
import re
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path
def grep_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
pattern = str(args.get("pattern") or "").strip()
file_glob = str(args.get("file_glob") or "**/*").strip() or "**/*"
max_matches = int(args.get("max_matches") or 200)
if not pattern:
return {"ok": False, "error": "pattern_required"}
root = resolve_workspace_path(".")
try:
rx = re.compile(pattern)
except re.error as e:
return {"ok": False, "error": "invalid_regex", "detail": str(e)}
matches: list[dict[str, Any]] = []
for p in root.glob(file_glob):
if p.is_dir():
continue
try:
text = p.read_text(encoding="utf-8", errors="replace").splitlines()
except Exception:
continue
for i, line in enumerate(text, start=1):
if rx.search(line):
matches.append({"file": str(p.relative_to(root)), "line": i, "text": line[:400]})
if len(matches) >= max(1, min(max_matches, 5000)):
return {"ok": True, "pattern": pattern, "count": len(matches), "matches": matches}
return {"ok": True, "pattern": pattern, "count": len(matches), "matches": matches}
return ToolSpec(
name="grep",
description="Search files in the workspace for a regex pattern.",
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex pattern."},
"file_glob": {"type": "string", "default": "**/*", "description": "Glob of files to search."},
"max_matches": {"type": "integer", "default": 200, "description": "Max number of matches."},
},
"required": ["pattern"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace"}),
read_only=True,
)
def index_workspace_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
max_files = int(args.get("max_files") or 120)
try:
# Lazy import to avoid heavy deps during tool discovery.
from oclaw.platform.config.paths import db_path
except Exception:
pass
# Indexer uses store passed via closure? ToolSpec doesn't carry store.
# We index using the global SqliteStore path (same as app runtime).
try:
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.platform.config.paths import db_path
from oclaw.runtime.tools.workspace_indexer import index_workspace
store = SqliteStore(db_path())
st = index_workspace(store, max_files=max(1, min(max_files, 800)))
return {"ok": True, "files_seen": st.files_seen, "chunks_upserted": st.chunks_upserted, "embeddings_upserted": st.embeddings_upserted}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="index_workspace",
description="Index workspace files into the vector knowledge base for RAG (may be slow).",
parameters={
"type": "object",
"properties": {"max_files": {"type": "integer", "default": 120, "description": "Max files to index."}},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "rag"}),
)
__all__ = ["grep_tool", "index_workspace_tool"]

View file

@ -0,0 +1,91 @@
from __future__ import annotations
import subprocess
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path, truncate_text
def run_command_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
import os
if str(os.getenv("AIA_ENABLE_RUN_COMMAND") or "").strip().lower() not in ("1", "true", "yes", "on"):
return {
"ok": False,
"error": "disabled",
"hint": "Set AIA_ENABLE_RUN_COMMAND=1 to enable this high-risk tool.",
}
command = str(args.get("command") or "").strip()
cwd = str(args.get("cwd") or "").strip()
timeout_s = float(args.get("timeout_s") or 30.0)
max_output_chars = int(args.get("max_output_chars") or 20000)
if not command:
return {"ok": False, "error": "command_required"}
workdir = resolve_workspace_path(cwd or ".")
try:
run_kwargs: dict[str, Any] = {
"cwd": str(workdir),
"shell": True,
"capture_output": True,
"text": True,
"timeout": max(1.0, min(timeout_s, 600.0)),
}
if os.name == "nt":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
run_kwargs["startupinfo"] = startupinfo
run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
cp = subprocess.run(
command,
**run_kwargs,
)
out = (cp.stdout or "") + (("\n" + cp.stderr) if cp.stderr else "")
out = truncate_text(out, limit=max(1000, min(max_output_chars, 200000)))
return {
"ok": True,
"command": command,
"cwd": str(workdir),
"exit_code": int(cp.returncode),
"output": out,
}
except subprocess.TimeoutExpired as e:
partial = ""
try:
partial = ((e.stdout or "") + ("\n" + (e.stderr or "") if e.stderr else "")).strip()
except Exception:
partial = ""
return {
"ok": False,
"error": "timeout",
"command": command,
"cwd": str(workdir),
"timeout_s": timeout_s,
"output": truncate_text(partial, limit=max_output_chars),
}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}", "command": command, "cwd": str(workdir)}
return ToolSpec(
name="run_command",
description="Run a shell command inside the workspace (captured output, timeout).",
parameters={
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to run."},
"cwd": {"type": "string", "description": "Working directory relative to workspace.", "default": "."},
"timeout_s": {"type": "number", "default": 30.0, "description": "Command timeout in seconds."},
"max_output_chars": {"type": "integer", "default": 20000, "description": "Max characters to return."},
},
"required": ["command"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "exec"}),
)
__all__ = ["run_command_tool"]

View file

@ -0,0 +1,261 @@
from __future__ import annotations
import os
import re
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator
from oclaw.platform.config.paths import PROJECT_ROOT
_TLS = threading.local()
def _env_truthy(name: str) -> bool:
return str(os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on")
def workspace_root() -> Path:
# Allow explicit override (recommended when running as a packaged app)
override = (os.getenv("AIA_WORKSPACE_ROOT") or os.getenv("OPS_WORKSPACE_ROOT") or "").strip()
if override:
p = Path(override).expanduser()
return p.resolve()
return Path(PROJECT_ROOT).resolve()
def _parse_pipe_separated_roots(raw: str) -> list[Path]:
out: list[Path] = []
for part in (raw or "").split("|"):
p = part.strip().strip('"').strip("'")
if not p:
continue
try:
rp = Path(p).expanduser().resolve()
if rp.is_absolute():
out.append(rp)
except Exception:
continue
return out
@dataclass(frozen=True)
class WorkspacePathAccess:
"""Effective path guard for the current tool invocation (env + optional per-user DB)."""
extra_roots: tuple[Path, ...]
allow_any_path: bool
def access_from_env() -> WorkspacePathAccess:
raw_extra = os.getenv("AIA_WORKSPACE_EXTRA_ROOTS") or os.getenv("OPS_WORKSPACE_EXTRA_ROOTS") or ""
extra = _parse_pipe_separated_roots(raw_extra)
allow = _env_truthy("AIA_WORKSPACE_ALLOW_ANY_PATH") or _env_truthy("OPS_WORKSPACE_ALLOW_ANY_PATH")
return WorkspacePathAccess(extra_roots=tuple(extra), allow_any_path=allow)
def _merge_access(a: WorkspacePathAccess, b: WorkspacePathAccess) -> WorkspacePathAccess:
merged: dict[str, Path] = {}
for p in (*a.extra_roots, *b.extra_roots):
try:
k = str(p.resolve())
except Exception:
k = str(p)
merged.setdefault(k, p)
return WorkspacePathAccess(
extra_roots=tuple(merged.values()),
allow_any_path=bool(a.allow_any_path or b.allow_any_path),
)
def build_workspace_path_access(
store: Any,
session_id: str | None,
*,
owner_fallback_session_id: str | None = None,
allowlist_tenant_id: str | None = None,
allowlist_user_id: str | None = None,
) -> WorkspacePathAccess:
"""Resolve per-user ``extra_roots`` / ``allow_any_path`` from ``user_workspace_path_allowlist``.
``session_id`` is usually the chat row messages are written to (may be a specialist temp session
without ``ui_session_owner``). In that case pass ``owner_fallback_session_id`` = the user's
UI-owned session id so DB allowlist still applies.
If ``get_ui_session_owner`` yields nothing, ``allowlist_tenant_id`` + ``allowlist_user_id``
(from the authenticated user / request metadata) can be used to load the same allowlist, so
a missing ``ui_session_owner`` row does not drop per-user extra roots.
"""
base = access_from_env()
if store is None:
return base
picked_owner: dict[str, Any] | None = None
for cand in (str(session_id or "").strip(), str(owner_fallback_session_id or "").strip()):
if not cand:
continue
try:
own = store.get_ui_session_owner(session_id=cand)
except Exception:
own = None
if not own:
continue
tid = str(own.get("tenant_id") or "").strip()
uid = str(own.get("user_id") or "").strip()
if tid and uid:
picked_owner = own
break
if picked_owner:
tid = str(picked_owner.get("tenant_id") or "").strip()
uid = str(picked_owner.get("user_id") or "").strip()
try:
row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid)
except Exception:
row = None
if not row:
return base
db_extras = _parse_pipe_separated_roots(str(row.get("extra_roots") or ""))
db_access = WorkspacePathAccess(
extra_roots=tuple(db_extras),
allow_any_path=bool(row.get("allow_any_path")),
)
return _merge_access(base, db_access)
# Fallback: use explicit tenant / user (e.g. wecom or admin ``metadata``) when session is not
# linked in ``ui_session_owner`` (legacy session or data repair in progress).
t2 = str(allowlist_tenant_id or "").strip()
u2 = str(allowlist_user_id or "").strip()
if not t2 or not u2:
return base
try:
row = store.get_user_workspace_path_allowlist(tenant_id=t2, user_id=u2)
except Exception:
row = None
if not row:
return base
db_extras = _parse_pipe_separated_roots(str(row.get("extra_roots") or ""))
db_access = WorkspacePathAccess(
extra_roots=tuple(db_extras),
allow_any_path=bool(row.get("allow_any_path")),
)
return _merge_access(base, db_access)
@contextmanager
def workspace_path_access_scope(
store: Any,
session_id: str | None,
*,
owner_fallback_session_id: str | None = None,
allowlist_tenant_id: str | None = None,
allowlist_user_id: str | None = None,
) -> Iterator[WorkspacePathAccess]:
acc = build_workspace_path_access(
store,
session_id,
owner_fallback_session_id=owner_fallback_session_id,
allowlist_tenant_id=allowlist_tenant_id,
allowlist_user_id=allowlist_user_id,
)
prev = getattr(_TLS, "access", None)
_TLS.access = acc
try:
yield acc
finally:
if prev is None:
if hasattr(_TLS, "access"):
delattr(_TLS, "access")
else:
_TLS.access = prev
def current_workspace_path_access() -> WorkspacePathAccess:
a = getattr(_TLS, "access", None)
if isinstance(a, WorkspacePathAccess):
return a
return access_from_env()
def clear_workspace_path_access_for_tests() -> None:
if hasattr(_TLS, "access"):
delattr(_TLS, "access")
def _is_subpath(path: Path, root: Path) -> bool:
"""``path`` is under ``root`` (treated as a directory), including the root itself.
On Windows, comparison is case- and path-separator-insensitive; ``resolve`` may
not normalize casing consistently across all drives, so we use normcase.
"""
try:
pr = path.resolve()
rr = root.resolve()
except (OSError, ValueError, RuntimeError):
return False
if os.name == "nt":
np = os.path.normcase(str(pr))
nroot = os.path.normcase(str(rr))
if np == nroot:
return True
sep = os.sep
if not nroot.endswith(sep):
nroot = nroot + sep
return np.startswith(nroot) or (np + sep).startswith(nroot)
try:
pr.relative_to(rr)
return True
except (ValueError, OSError, RuntimeError):
return False
def resolve_workspace_path(user_path: str) -> Path:
p = Path(str(user_path or "").strip().strip('"').strip("'") or "")
if not p:
raise ValueError("path is required")
root = workspace_root()
abs_path = p if p.is_absolute() else (root / p)
abs_path = abs_path.resolve()
access = current_workspace_path_access()
if access.allow_any_path:
return abs_path
roots = (root,) + access.extra_roots
if any(_is_subpath(abs_path, r) for r in roots):
return abs_path
raise ValueError("path escapes workspace root")
def truncate_text(s: str, *, limit: int = 20000) -> str:
s = s or ""
if len(s) <= limit:
return s
return s[: max(0, limit - 12)] + "\n...<truncated>"
# NOTE: put '-' at end or escape it to avoid "bad character range" on Windows Python regex.
_SAFE_GIT_REF_RE = re.compile(r"^[A-Za-z0-9._/\\-]{1,80}$")
def sanitize_git_ref(ref: str) -> str:
r = (ref or "").strip()
if not r:
return ""
if not _SAFE_GIT_REF_RE.match(r):
raise ValueError("invalid git ref")
return r
__all__ = [
"WorkspacePathAccess",
"access_from_env",
"build_workspace_path_access",
"clear_workspace_path_access_for_tests",
"current_workspace_path_access",
"resolve_workspace_path",
"sanitize_git_ref",
"truncate_text",
"workspace_path_access_scope",
"workspace_root",
]

View file

@ -0,0 +1,232 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from oclaw.platform.llm.tool_schema import default_max_openai_tools_json_bytes
from oclaw.platform.llm.tool_wire_policy import (
load_merged_admin_config,
load_role_mode_for_role,
load_tool_policies_dict_for_role,
prepare_openai_tools_for_llm_api,
wire_graduation_effective,
)
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.catalog import _is_truthy
from oclaw.runtime.tools.expert_registry import materialize_tools_for_expert, preview_expert_tools
from oclaw.runtime.tools.mcp.adapter import materialize_mcp_tools_for_specialist
from oclaw.runtime.tools.public_registry import materialize_public_tools, preview_public_tools
@dataclass(frozen=True)
class ToolExposurePlan:
role: str
base_url: str | None
max_json_bytes: int | None
mcp_enabled: bool
role_mode: str
wire_policy_effective: bool
policy_keys: int
public_risk_gate_allow_high: bool
public_blocked_high_risk_tools: list[str]
skipped_public: list[dict[str, Any]]
skipped_expert: list[dict[str, Any]]
tools_raw: list[dict[str, Any]]
tools_wired: list[dict[str, Any]]
removed_names: list[str]
removed_mcp_names: list[str]
changed_names: list[str]
added_names: list[str]
def _tool_names(tools: list[dict[str, Any]]) -> list[str]:
out: list[str] = []
for ent in tools or []:
if not isinstance(ent, dict) or str(ent.get("type") or "") != "function":
continue
fn = ent.get("function")
if not isinstance(fn, dict):
continue
nm = str(fn.get("name") or "").strip()
if nm:
out.append(nm)
return out
def _risk_gate_public_tools(tools: list[ToolSpec]) -> tuple[list[ToolSpec], bool, list[str]]:
allow_high = _is_truthy(os.getenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", "0"))
if allow_high:
return list(tools), True, []
kept: list[ToolSpec] = []
blocked: list[str] = []
for t in tools or []:
rl = str(getattr(t, "risk_level", "") or "low").strip().lower()
if rl == "high":
blocked.append(str(getattr(t, "name", "") or ""))
continue
kept.append(t)
return kept, False, sorted([x for x in blocked if x])
def build_internal_tool_specs(
*,
role: str,
preview: bool,
) -> tuple[list[ToolSpec], dict[str, Any]]:
"""Return internal ToolSpec list (public+expert) + diagnostics.
If preview=True, bypass caches and include skipped reasons.
"""
r = str(role or "").strip().lower()
pub_diag = preview_public_tools() if preview else {"tools": materialize_public_tools(), "skipped": []}
exp_diag = preview_expert_tools(r) if preview else {"tools": materialize_tools_for_expert(r), "skipped": []}
pub_tools = list(pub_diag.get("tools") or [])
pub_tools, allow_high, blocked = _risk_gate_public_tools(pub_tools)
exp_tools = list(exp_diag.get("tools") or [])
source_by_name: dict[str, str] = {}
merged: list[ToolSpec] = []
seen: set[str] = set()
for spec in list(pub_tools):
if not isinstance(spec, ToolSpec):
continue
nm = str(spec.name or "").strip()
if not nm or nm in seen:
continue
seen.add(nm)
merged.append(spec)
source_by_name[nm] = "public"
for spec in list(exp_tools):
if not isinstance(spec, ToolSpec):
continue
nm = str(spec.name or "").strip()
if not nm or nm in seen:
continue
seen.add(nm)
merged.append(spec)
source_by_name[nm] = "expert"
diag = {
"public_count": len(pub_tools),
"expert_count": len(exp_tools),
"merged_count": len(merged),
"public_risk_gate_allow_high": allow_high,
"public_blocked_high_risk_tools": blocked,
"skipped_public": list(pub_diag.get("skipped") or []),
"skipped_expert": list(exp_diag.get("skipped") or []),
"source_by_name": source_by_name,
}
return merged, diag
def build_llm_tools_plan(
*,
store: Any,
role: str,
base_url: str | None,
max_json_bytes: int | None,
include_mcp: bool,
preview_internal: bool,
raw_openai_tools_override: list[dict[str, Any]] | None = None,
) -> ToolExposurePlan:
"""Plan raw and wired OpenAI tools for a role, with consistent policy semantics."""
r = str(role or "").strip().lower()
bu = str(base_url or "").strip() or None
cap = int(max_json_bytes) if isinstance(max_json_bytes, int) else None
if cap is None:
cap = default_max_openai_tools_json_bytes(bu)
if cap is not None and cap <= 0:
cap = None
internal_specs: list[ToolSpec] = []
diag_internal: dict[str, Any] = {
"public_risk_gate_allow_high": bool(_is_truthy(os.getenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", "0"))),
"public_blocked_high_risk_tools": [],
"skipped_public": [],
"skipped_expert": [],
}
if raw_openai_tools_override is None:
internal_specs, diag_internal = build_internal_tool_specs(role=r, preview=preview_internal)
mcp_enabled = bool(include_mcp) and (
_is_truthy(os.getenv("AIA_ENABLE_MCP_TOOLS")) or _is_truthy(os.getenv("OPS_ENABLE_MCP_TOOLS"))
)
mcp_specs = materialize_mcp_tools_for_specialist(store, specialist=r) if mcp_enabled else []
if raw_openai_tools_override is None:
raw_specs = list(internal_specs) + list(mcp_specs)
raw_openai_tools = [t.as_openai_tool() for t in raw_specs]
else:
raw_openai_tools = list(raw_openai_tools_override)
admin_cfg = load_merged_admin_config(store)
role_mode = load_role_mode_for_role(store, role=r)
policies = load_tool_policies_dict_for_role(store, role=r)
wire_effective = wire_graduation_effective(bu, admin_cfg) and role_mode == "restricted"
wired_openai_tools = prepare_openai_tools_for_llm_api(
raw_openai_tools,
base_url=bu,
max_json_bytes=cap,
store=store,
role=r,
)
raw_names = _tool_names(raw_openai_tools)
wired_names = _tool_names(wired_openai_tools)
raw_set = set(raw_names)
wired_set = set(wired_names)
removed = sorted([n for n in raw_set if n and n not in wired_set])
added = sorted([n for n in wired_set if n and n not in raw_set])
removed_mcp = [n for n in removed if n.startswith("mcp__")]
# changed = same tool name but payload differs
def _map_by_name(tools: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
m: dict[str, dict[str, Any]] = {}
for ent in tools or []:
if not isinstance(ent, dict) or str(ent.get("type") or "") != "function":
continue
fn = ent.get("function")
if not isinstance(fn, dict):
continue
nm = str(fn.get("name") or "").strip()
if nm:
m[nm] = fn
return m
raw_map = _map_by_name(raw_openai_tools)
wired_map = _map_by_name(wired_openai_tools)
changed: list[str] = []
for nm in sorted([n for n in wired_set if n in raw_set]):
if raw_map.get(nm) != wired_map.get(nm):
changed.append(nm)
return ToolExposurePlan(
role=r,
base_url=bu,
max_json_bytes=cap,
mcp_enabled=mcp_enabled,
role_mode=str(role_mode or "restricted"),
wire_policy_effective=bool(wire_effective),
policy_keys=len(policies),
public_risk_gate_allow_high=bool(diag_internal.get("public_risk_gate_allow_high")),
public_blocked_high_risk_tools=list(diag_internal.get("public_blocked_high_risk_tools") or []),
skipped_public=list(diag_internal.get("skipped_public") or []),
skipped_expert=list(diag_internal.get("skipped_expert") or []),
tools_raw=list(raw_openai_tools),
tools_wired=list(wired_openai_tools),
removed_names=removed,
removed_mcp_names=sorted(list(removed_mcp)),
changed_names=changed,
added_names=added,
)
__all__ = [
"ToolExposurePlan",
"build_internal_tool_specs",
"build_llm_tools_plan",
]

View file

@ -0,0 +1,16 @@
from .manifest import McpServerManifest
from .installer import McpInstallResult, install_mcp_server
from .runtime import McpProcessRuntime
from .adapter import materialize_mcp_tools
from .registry import McpRegistry
from .market import search_mcp_market
__all__ = [
"McpServerManifest",
"McpInstallResult",
"McpProcessRuntime",
"install_mcp_server",
"materialize_mcp_tools",
"McpRegistry",
"search_mcp_market",
]

View file

@ -0,0 +1,165 @@
from __future__ import annotations
from dataclasses import dataclass
import json
import os
from typing import Any
from oclaw.runtime.operations.mcp_env import mcp_env_allowlist_keys
from oclaw.runtime.skills import SkillSpec, materialize_skills_from_tool_specs
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.mcp.filesystem_argv import build_mcp_process_command
from oclaw.runtime.tools.mcp.runtime import McpProcessRuntime
@dataclass
class _McpBoundTool:
server_id: str
tool_name: str
description: str
parameters: dict[str, Any]
command: list[str]
timeout_s: float = 30.0
required_permissions: frozenset[str] = frozenset()
env_allowlist: list[str] | None = None
def to_spec(self) -> ToolSpec:
rt = McpProcessRuntime(command=self.command, timeout_s=self.timeout_s, env_allowlist=self.env_allowlist)
def _handler(args: dict[str, Any]) -> dict[str, Any]:
res = rt.call_tool(tool_name=self.tool_name, arguments=args or {})
if not isinstance(res, dict):
return {"ok": False, "error_code": "mcp_runtime_invalid_payload", "error": "invalid_response"}
if "ok" not in res:
res["ok"] = False
return res
return ToolSpec(
name=f"mcp__{self.server_id}__{self.tool_name}",
description=self.description,
parameters=self.parameters or {"type": "object", "properties": {}},
handler=_handler,
tags=frozenset({"mcp", "plugin"}),
version="v1",
risk_level="high",
timeout_s=self.timeout_s,
required_permissions=self.required_permissions,
execution_mode="subprocess",
)
def materialize_mcp_tools(store: Any, *, policy_session_id: str | None = None) -> list[ToolSpec]:
return materialize_mcp_tools_for_specialist(
store,
specialist=None,
policy_session_id=policy_session_id,
)
def materialize_mcp_tools_for_specialist(
store: Any,
*,
specialist: str | None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> list[ToolSpec]:
sp = str(specialist or "").strip().lower()
if sp == "manager":
# Manager is a first-class binding role in admin UI/config.
# We keep it separate from generalist instead of aliasing.
sp = "manager"
# Preferred mapping: specialist -> server_ids
binding_server_ids: set[str] | None = None
try:
if store is not None and sp:
raw_binding = str(store.get_setting("mcp_specialist_server_binding") or "").strip()
if raw_binding:
obj = json.loads(raw_binding)
if isinstance(obj, dict):
rows = obj.get(sp)
# 缺键或 null:视为未配置该专家的绑定 → 走下方「仅 coarse allowlist」逻辑(可见全部已启用 MCP)。
# 仅当键存在且为 JSON 数组时,才按白名单过滤(含空数组 = 刻意不给该专家任何 MCP)。
if rows is None:
binding_server_ids = None
elif isinstance(rows, list):
binding_server_ids = {str(x).strip() for x in rows if str(x).strip()}
else:
binding_server_ids = set()
except Exception:
binding_server_ids = None
# Fallback to coarse specialist allowlist if no binding mapping is configured.
raw_allowed = ""
try:
if store is not None:
raw_allowed = str(store.get_setting("mcp_allowed_specialists") or "").strip()
except Exception:
raw_allowed = ""
if not raw_allowed:
raw_allowed = str(os.getenv("AIA_MCP_SPECIALISTS") or "generalist,manager").strip()
allowed = {x.strip().lower() for x in raw_allowed.split(",") if x.strip()}
if binding_server_ids is None and sp and sp not in allowed:
return []
out: list[ToolSpec] = []
rows = store.list_mcp_servers(enabled_only=True) if store else []
env_allowlist = mcp_env_allowlist_keys()
for row in rows:
server_id = str(row.get("server_id") or "").strip()
cmd = str(row.get("entry_command") or "").strip()
if not server_id or not cmd:
continue
if binding_server_ids is not None and sp and server_id not in binding_server_ids:
continue
raw_args = [x for x in (row.get("entry_args") or []) if isinstance(x, str)]
command = build_mcp_process_command(
cmd,
raw_args,
store=store,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
try:
tools = store.list_mcp_server_tools(server_id=server_id)
except Exception:
tools = []
for t in tools:
spec = _McpBoundTool(
server_id=server_id,
tool_name=str(t.get("tool_name") or ""),
description=str(t.get("description") or f"MCP tool {t.get('tool_name') or ''}"),
parameters=t.get("parameters") if isinstance(t.get("parameters"), dict) else {},
command=command,
timeout_s=float(row.get("timeout_s") or 30.0),
required_permissions=frozenset(str(x) for x in (row.get("required_permissions") or [])),
env_allowlist=env_allowlist,
).to_spec()
out.append(spec)
return out
def materialize_mcp_skills_for_specialist(
store: Any,
*,
specialist: str | None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> tuple[SkillSpec, ...]:
tools = materialize_mcp_tools_for_specialist(
store=store,
specialist=specialist,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
return materialize_skills_from_tool_specs(tools)
__all__ = [
"materialize_mcp_tools",
"materialize_mcp_tools_for_specialist",
"materialize_mcp_skills_for_specialist",
]

View file

@ -0,0 +1,230 @@
"""Augment MCP @modelcontextprotocol/server-filesystem argv with extra allowed directories.
The filesystem MCP only exposes directories passed on the command line at process start.
Gateway workspace policy (env + per-user DB) must be mirrored here so list_directory sees the same roots.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
def _split_pipe_paths(raw: str) -> list[str]:
out: list[str] = []
for part in (raw or "").split("|"):
s = part.strip().strip('"').strip("'")
if s:
out.append(s)
return out
def _dedup_key(p: str) -> str:
"""Stable key for deduplicating directory roots (Windows: case-insensitive)."""
ra = _resolved_abs_for_argv(p)
if not ra:
return ""
if os.name == "nt":
return os.path.normcase(ra)
return ra
def _resolved_abs_for_argv(p: str) -> str:
"""Absolute path string suitable for MCP process argv; avoids silent drop on resolve quirks."""
raw = (p or "").strip().strip('"').strip("'")
if not raw:
return ""
try:
exp = Path(raw).expanduser()
s = str(exp.resolve())
if s:
return s
except (OSError, ValueError, RuntimeError):
pass
try:
return os.path.normpath(os.path.abspath(raw))
except Exception:
return ""
def _allowlist_extras_tenant_user(*, store: Any, tenant_id: str, user_id: str) -> list[str]:
t = (tenant_id or "").strip()
u = (user_id or "").strip()
if not t or not u or store is None:
return []
try:
row = store.get_user_workspace_path_allowlist(tenant_id=t, user_id=u)
except Exception:
return []
if not row or not isinstance(row, dict):
return []
out: list[str] = []
for s in _split_pipe_paths(str(row.get("extra_roots") or "")):
if s:
out.append(s)
return out
def _extra_roots_for_policy_session(*, store: Any, policy_session_id: str) -> list[str]:
"""Per-user ``extra_roots`` from DB for the chat session that owns the tool run (not a global union)."""
out: list[str] = []
sid = str(policy_session_id or "").strip()
if not sid:
return out
try:
own = store.get_ui_session_owner(session_id=sid)
except Exception:
own = None
if not own:
return out
tid = str(own.get("tenant_id") or "").strip()
uid = str(own.get("user_id") or "").strip()
if not tid or not uid:
return out
try:
row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid)
except Exception:
row = None
if not row:
return out
for s in _split_pipe_paths(str(row.get("extra_roots") or "")):
if s:
out.append(s)
return out
def collect_filesystem_mcp_extra_roots(
*,
store: Any | None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> list[str]:
"""Paths to append to server-filesystem argv (deduped after resolve).
Per-user roots from SQLite only when ``policy_session_id`` resolves via
``ui_session_owner`` (typically the user's chat session id). Without it, only
env/settings roots are merged (safe for admin Health/Sync and shared agents).
You may also pass ``path_policy_tenant_id`` / ``path_policy_user_id`` to mirror
the same allowlist when the request ``metadata`` carries the effective user, but
``ui_session_owner`` is not yet set (e.g. legacy data).
"""
seen: set[str] = set()
ordered: list[str] = []
for raw in (
str(os.getenv("AIA_WORKSPACE_EXTRA_ROOTS") or os.getenv("OPS_WORKSPACE_EXTRA_ROOTS") or "").strip(),
str(os.getenv("AIA_MCP_FILESYSTEM_EXTRA_ROOTS") or os.getenv("OPS_MCP_FILESYSTEM_EXTRA_ROOTS") or "").strip(),
):
for s in _split_pipe_paths(raw):
dk = _dedup_key(s)
ra = _resolved_abs_for_argv(s)
if dk and ra and dk not in seen:
seen.add(dk)
ordered.append(ra)
if store is not None:
try:
raw2 = str(store.get_setting("AIA_MCP_FILESYSTEM_EXTRA_ROOTS") or "").strip()
for s in _split_pipe_paths(raw2):
dk = _dedup_key(s)
ra = _resolved_abs_for_argv(s)
if dk and ra and dk not in seen:
seen.add(dk)
ordered.append(ra)
except Exception:
pass
ps = str(policy_session_id or "").strip()
if ps:
try:
for s in _extra_roots_for_policy_session(store=store, policy_session_id=ps):
dk = _dedup_key(s)
ra = _resolved_abs_for_argv(s)
if dk and ra and dk not in seen:
seen.add(dk)
ordered.append(ra)
except Exception:
pass
t_id = (path_policy_tenant_id or "").strip()
u_id = (path_policy_user_id or "").strip()
if t_id and u_id and store is not None:
try:
for s in _allowlist_extras_tenant_user(store=store, tenant_id=t_id, user_id=u_id):
dk = _dedup_key(s)
ra = _resolved_abs_for_argv(s)
if dk and ra and dk not in seen:
seen.add(dk)
ordered.append(ra)
except Exception:
pass
return ordered
def is_modelcontext_filesystem_command(command: list[str]) -> bool:
return any("server-filesystem" in str(x) for x in command)
def augment_filesystem_mcp_argv(
command: list[str],
*,
store: Any | None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> list[str]:
"""
If ``command`` starts the official Model Context Protocol filesystem server, append
extra directory roots from env / settings / DB so tools/list and tools/call match gateway policy.
"""
if not command or not is_modelcontext_filesystem_command(command):
return command
extras = collect_filesystem_mcp_extra_roots(
store=store,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
if not extras:
return command
existing: set[str] = set()
for p in command:
s = str(p).strip()
if not s or s.startswith("-") or "server-filesystem" in s:
continue
if s in ("npx", "pnpm", "yarn", "uvx", "bun"):
continue
dk = _dedup_key(s)
if dk:
existing.add(dk)
out = list(command)
for extra_abs in extras:
dk = _dedup_key(extra_abs)
if dk and dk not in existing:
out.append(extra_abs)
existing.add(dk)
return out
def build_mcp_process_command(
cmd: str,
args: list[str],
*,
store: Any | None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> list[str]:
"""``[cmd] + args`` after filesystem argv augmentation."""
return augment_filesystem_mcp_argv(
[cmd] + [x for x in args if str(x).strip()],
store=store,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
__all__ = [
"augment_filesystem_mcp_argv",
"build_mcp_process_command",
"collect_filesystem_mcp_extra_roots",
"is_modelcontext_filesystem_command",
]

View file

@ -0,0 +1,184 @@
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import Any
from .manifest import McpServerManifest
@dataclass(frozen=True)
class McpInstallResult:
ok: bool
error_code: str = ""
error: str = ""
install_command: str = ""
details: dict[str, Any] | None = None
def _run_command(cmd: list[str], *, timeout: float) -> subprocess.CompletedProcess[str]:
kwargs: dict[str, Any] = {"capture_output": True, "text": True, "timeout": timeout}
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if creationflags:
kwargs["creationflags"] = creationflags
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0
kwargs["startupinfo"] = startupinfo
return subprocess.run(cmd, **kwargs)
def _safe_server_id(seed: str) -> str:
v = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(seed or "").strip().lower()).strip("-")
return v or "mcp-server"
def _install_command(manifest: McpServerManifest) -> list[str]:
def _bin(name: str) -> str:
n = str(name or "").strip()
if not n:
return n
p = shutil.which(n)
if p:
return p
if os.name == "nt":
for suffix in (".cmd", ".exe", ".bat"):
alt = shutil.which(n + suffix)
if alt:
return alt
return n
if manifest.source_type == "github":
return [_bin("git"), "clone", "--depth", "1", manifest.source_ref]
if manifest.source_type == "npm":
pkg = manifest.source_ref + (f"@{manifest.version}" if manifest.version else "")
return [_bin("npm"), "install", "-g", pkg]
if manifest.source_type == "pypi":
pkg = manifest.source_ref + (f"=={manifest.version}" if manifest.version else "")
return [sys.executable, "-m", "pip", "install", pkg]
raise ValueError(f"unsupported_source_type:{manifest.source_type}")
def _uninstall_command(manifest: McpServerManifest) -> list[str]:
def _bin(name: str) -> str:
n = str(name or "").strip()
if not n:
return n
p = shutil.which(n)
if p:
return p
if os.name == "nt":
for suffix in (".cmd", ".exe", ".bat"):
alt = shutil.which(n + suffix)
if alt:
return alt
return n
if manifest.source_type == "npm":
return [_bin("npm"), "uninstall", "-g", str(manifest.source_ref or "").strip()]
if manifest.source_type == "pypi":
return [sys.executable, "-m", "pip", "uninstall", "-y", str(manifest.source_ref or "").strip()]
if manifest.source_type == "github":
return []
raise ValueError(f"unsupported_source_type:{manifest.source_type}")
def install_mcp_server(manifest: McpServerManifest, *, dry_run: bool = False) -> McpInstallResult:
try:
cmd = _install_command(manifest)
except Exception as exc:
return McpInstallResult(ok=False, error_code="mcp_invalid_source", error=str(exc))
cmd_text = " ".join(cmd)
if dry_run:
return McpInstallResult(ok=True, install_command=cmd_text, details={"dry_run": True})
try:
cp = _run_command(cmd, timeout=180)
except subprocess.TimeoutExpired as exc:
return McpInstallResult(ok=False, error_code="mcp_install_timeout", error=str(exc), install_command=cmd_text)
except FileNotFoundError as exc:
return McpInstallResult(ok=False, error_code="mcp_installer_missing", error=str(exc), install_command=cmd_text)
except Exception as exc:
return McpInstallResult(ok=False, error_code="mcp_install_failed", error=str(exc), install_command=cmd_text)
if cp.returncode != 0:
err = (cp.stderr or cp.stdout or "").strip()
return McpInstallResult(ok=False, error_code="mcp_install_failed", error=err[:800] or f"exit_code:{cp.returncode}", install_command=cmd_text)
return McpInstallResult(ok=True, install_command=cmd_text, details={"stdout": (cp.stdout or "")[:800]})
def uninstall_mcp_server(manifest: McpServerManifest, *, dry_run: bool = False) -> McpInstallResult:
try:
cmd = _uninstall_command(manifest)
except Exception as exc:
return McpInstallResult(ok=False, error_code="mcp_invalid_source", error=str(exc))
if not cmd:
return McpInstallResult(ok=True, install_command="", details={"skipped": True, "reason": "unsupported_or_not_required"})
cmd_text = " ".join(cmd)
if dry_run:
return McpInstallResult(ok=True, install_command=cmd_text, details={"dry_run": True})
try:
cp = _run_command(cmd, timeout=180)
except subprocess.TimeoutExpired as exc:
return McpInstallResult(ok=False, error_code="mcp_uninstall_timeout", error=str(exc), install_command=cmd_text)
except FileNotFoundError as exc:
return McpInstallResult(ok=False, error_code="mcp_installer_missing", error=str(exc), install_command=cmd_text)
except Exception as exc:
return McpInstallResult(ok=False, error_code="mcp_uninstall_failed", error=str(exc), install_command=cmd_text)
if cp.returncode != 0:
err = (cp.stderr or cp.stdout or "").strip()
return McpInstallResult(ok=False, error_code="mcp_uninstall_failed", error=err[:800] or f"exit_code:{cp.returncode}", install_command=cmd_text)
return McpInstallResult(ok=True, install_command=cmd_text, details={"stdout": (cp.stdout or "")[:800]})
def preflight_mcp_server(manifest: McpServerManifest) -> dict[str, Any]:
warnings: list[str] = []
fix_suggestions: list[dict[str, str]] = []
entry = str(manifest.entry_command or "").strip()
if not entry:
return {
"ok": False,
"error_code": "mcp_entry_missing",
"error": "entry_command_missing",
"warnings": warnings,
"fix_suggestions": [{"title": "Set entry command", "command": "npx <pkg> | python -m <module> | node <script.js>"}],
}
found = shutil.which(entry)
if not found:
if entry in {"npx", "npm", "node"}:
fix_suggestions.append({"title": "Install Node.js", "command": "https://nodejs.org/en/download"})
elif entry in {"python", "pip"}:
fix_suggestions.append({"title": "Install Python", "command": "https://www.python.org/downloads/"})
elif entry == "git":
fix_suggestions.append({"title": "Install Git", "command": "https://git-scm.com/downloads"})
else:
fix_suggestions.append({"title": "Check PATH", "command": f"where {entry}"})
return {"ok": False, "error_code": "mcp_entry_not_found", "error": f"entry_command_not_found:{entry}", "warnings": warnings, "fix_suggestions": fix_suggestions}
env_schema = manifest.env_schema if isinstance(manifest.env_schema, dict) else {}
required_env = [str(k) for k, v in env_schema.items() if isinstance(v, dict) and bool(v.get("required"))]
return {"ok": True, "error_code": "", "error": "", "entry_command_path": found, "required_env": required_env, "warnings": warnings, "fix_suggestions": fix_suggestions}
def detect_local_dependencies() -> list[dict[str, Any]]:
deps = [{"name": "git", "version_args": ["--version"]}, {"name": "node", "version_args": ["--version"]}, {"name": "npm", "version_args": ["--version"]}, {"name": "npx", "version_args": ["--version"]}, {"name": "python", "version_args": ["--version"]}, {"name": "pip", "version_args": ["--version"]}]
out: list[dict[str, Any]] = []
for d in deps:
name = str(d["name"])
path = shutil.which(name)
if not path:
out.append({"name": name, "ok": False, "path": "", "version": ""})
continue
ver = ""
try:
cp = _run_command([name] + list(d["version_args"]), timeout=4)
ver = (cp.stdout or cp.stderr or "").strip().splitlines()[0] if (cp.stdout or cp.stderr) else ""
except Exception:
ver = ""
out.append({"name": name, "ok": True, "path": path, "version": ver})
return out
__all__ = ["McpInstallResult", "install_mcp_server", "uninstall_mcp_server", "preflight_mcp_server", "detect_local_dependencies", "_safe_server_id"]

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class McpServerManifest:
server_id: str
source_type: str # github|npm|pypi
source_ref: str
version: str = ""
entry_command: str = ""
entry_args: list[str] = field(default_factory=list)
env_schema: dict[str, Any] = field(default_factory=dict)
permissions: list[str] = field(default_factory=list)
risk_level: str = "high"
enabled: bool = False
timeout_s: float = 30.0

153
runtime/tools/mcp/market.py Normal file
View file

@ -0,0 +1,153 @@
from __future__ import annotations
from typing import Any
import time
import httpx
_TRENDING_CACHE: dict[str, Any] = {"ts": 0.0, "items": []}
_TRENDING_TTL_S = 1800
def _safe_get_json(url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
try:
with httpx.Client(timeout=8.0, follow_redirects=True) as c:
r = c.get(url, params=params or {}, headers=headers or {})
if r.status_code != 200:
return {}
obj = r.json()
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
def search_github_repos(query: str, *, limit: int = 8) -> list[dict[str, Any]]:
q = str(query or "").strip()
if not q:
return []
blob = _safe_get_json(
"https://api.github.com/search/repositories",
params={"q": f"{q} mcp server", "sort": "stars", "order": "desc", "per_page": max(1, min(limit, 20))},
headers={"Accept": "application/vnd.github+json"},
)
items = blob.get("items") if isinstance(blob.get("items"), list) else []
out: list[dict[str, Any]] = []
for it in items[:limit]:
if not isinstance(it, dict):
continue
out.append(
{
"source_type": "github",
"name": str(it.get("full_name") or ""),
"source_ref": str(it.get("clone_url") or it.get("html_url") or ""),
"description": str(it.get("description") or ""),
"version": "",
"homepage": str(it.get("html_url") or ""),
"stars": int(it.get("stargazers_count") or 0),
"install_template": infer_install_template("github", str(it.get("clone_url") or it.get("html_url") or "")),
}
)
return out
def search_npm_packages(query: str, *, limit: int = 8) -> list[dict[str, Any]]:
q = str(query or "").strip()
if not q:
return []
blob = _safe_get_json(
"https://registry.npmjs.org/-/v1/search",
params={"text": f"{q} mcp", "size": max(1, min(limit, 20))},
)
items = blob.get("objects") if isinstance(blob.get("objects"), list) else []
out: list[dict[str, Any]] = []
for it in items[:limit]:
pkg = it.get("package") if isinstance(it, dict) else None
if not isinstance(pkg, dict):
continue
out.append(
{
"source_type": "npm",
"name": str(pkg.get("name") or ""),
"source_ref": str(pkg.get("name") or ""),
"description": str(pkg.get("description") or ""),
"version": str(pkg.get("version") or ""),
"homepage": str(pkg.get("links", {}).get("npm") if isinstance(pkg.get("links"), dict) else ""),
"stars": 0,
"install_template": infer_install_template("npm", str(pkg.get("name") or "")),
}
)
return out
def search_pypi_packages(query: str, *, limit: int = 8) -> list[dict[str, Any]]:
q = str(query or "").strip()
if not q:
return []
blob = _safe_get_json(
"https://pypi.org/search/",
params={"q": f"{q} mcp"},
headers={"Accept": "application/json"},
)
# PyPI JSON search API is not officially stable; keep best-effort.
projects = blob.get("projects") if isinstance(blob.get("projects"), list) else []
out: list[dict[str, Any]] = []
for it in projects[:limit]:
if not isinstance(it, dict):
continue
name = str(it.get("name") or "")
out.append(
{
"source_type": "pypi",
"name": name,
"source_ref": name,
"description": str(it.get("description") or ""),
"version": str(it.get("version") or ""),
"homepage": f"https://pypi.org/project/{name}/" if name else "",
"stars": 0,
"install_template": infer_install_template("pypi", name),
}
)
return out
def search_mcp_market(query: str, *, per_source_limit: int = 6) -> list[dict[str, Any]]:
lim = max(1, min(int(per_source_limit or 6), 20))
out: list[dict[str, Any]] = []
out.extend(search_github_repos(query, limit=lim))
out.extend(search_npm_packages(query, limit=lim))
out.extend(search_pypi_packages(query, limit=lim))
return out
def infer_install_template(source_type: str, source_ref: str) -> dict[str, Any]:
st = str(source_type or "").strip().lower()
sr = str(source_ref or "").strip()
if st == "npm":
pkg = sr.split("/")[-1] if sr else ""
return {"entry_command": "npx", "entry_args": [pkg] if pkg else []}
if st == "pypi":
pkg = sr.replace("-", "_")
return {"entry_command": "python", "entry_args": ["-m", pkg] if pkg else []}
return {"entry_command": "python", "entry_args": []}
def trending_mcp_market(*, force_refresh: bool = False, per_source_limit: int = 5) -> list[dict[str, Any]]:
now = time.time()
if not force_refresh and _TRENDING_CACHE["items"] and (now - float(_TRENDING_CACHE["ts"] or 0.0) < _TRENDING_TTL_S):
return list(_TRENDING_CACHE["items"])
items = search_mcp_market("mcp", per_source_limit=per_source_limit)
items = sorted(items, key=lambda x: int(x.get("stars") or 0), reverse=True)
_TRENDING_CACHE["ts"] = now
_TRENDING_CACHE["items"] = list(items)
return items
__all__ = [
"search_mcp_market",
"search_github_repos",
"search_npm_packages",
"search_pypi_packages",
"infer_install_template",
"trending_mcp_market",
]

View file

@ -0,0 +1,42 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.mcp.manifest import McpServerManifest
class McpRegistry:
def __init__(self, store: SqliteStore):
self.store = store
def upsert_manifest(self, manifest: McpServerManifest) -> None:
self.store.upsert_mcp_server(
server_id=manifest.server_id,
source_type=manifest.source_type,
source_ref=manifest.source_ref,
version=manifest.version,
entry_command=manifest.entry_command,
entry_args=manifest.entry_args,
env_schema=manifest.env_schema,
required_permissions=manifest.permissions,
risk_level=manifest.risk_level,
timeout_s=manifest.timeout_s,
enabled=manifest.enabled,
)
def list_servers(self, *, enabled_only: bool = False) -> list[dict[str, Any]]:
return self.store.list_mcp_servers(enabled_only=enabled_only)
def snapshot(self) -> dict[str, Any]:
rows = self.list_servers(enabled_only=False)
return {"count": len(rows), "servers": rows}
@staticmethod
def manifest_to_dict(manifest: McpServerManifest) -> dict[str, Any]:
return asdict(manifest)
__all__ = ["McpRegistry"]

View file

@ -0,0 +1,288 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeoutError
import json
import os
import shutil
import subprocess
import threading
from dataclasses import dataclass, field
from typing import Any
@dataclass
class McpProcessRuntime:
command: list[str]
timeout_s: float = 30.0
env_allowlist: list[str] | None = None
_proc: subprocess.Popen[str] | None = None
_lock: threading.Lock = field(default_factory=threading.Lock)
_initialized: bool = False
_request_id: int = 0
@staticmethod
def _build_runtime_env(env_allowlist: list[str] | None) -> dict[str, str] | None:
if env_allowlist is None:
return None
keep_keys = {"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "TEMP", "TMP", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "SYSTEMDRIVE"}
env: dict[str, str] = {}
for k in keep_keys:
if k in os.environ:
env[k] = os.environ[k]
for k in env_allowlist:
key = str(k or "").strip()
if key and key in os.environ:
env[key] = os.environ[key]
return env
@staticmethod
def _resolve_command(executable: str, env: dict[str, str] | None) -> str:
cmd = str(executable or "").strip()
if not cmd:
return cmd
if os.path.isabs(cmd) or os.path.sep in cmd or (os.path.altsep and os.path.altsep in cmd):
return cmd
resolved = shutil.which(cmd, path=(env or os.environ).get("PATH"))
if resolved:
return resolved
if os.name == "nt":
for suffix in (".cmd", ".exe", ".bat"):
alt = shutil.which(cmd + suffix, path=(env or os.environ).get("PATH"))
if alt:
return alt
return cmd
def start(self) -> None:
if self._proc and self._proc.poll() is None:
return
env = self._build_runtime_env(self.env_allowlist)
cmd = list(self.command or [])
if cmd:
cmd[0] = self._resolve_command(str(cmd[0]), env)
popen_kwargs: dict[str, Any] = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", "env": env}
if os.name == "nt":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0
popen_kwargs["startupinfo"] = startupinfo
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
self._proc = subprocess.Popen(cmd, **popen_kwargs)
self._initialized = False
self._request_id = 0
def stop(self) -> None:
p = self._proc
if not p:
return
try:
if p.poll() is None:
try:
p.terminate()
except Exception:
pass
try:
p.wait(timeout=2)
except Exception:
try:
p.kill()
except Exception:
pass
finally:
for fp in (p.stdin, p.stdout, p.stderr):
try:
if fp:
fp.close()
except Exception:
pass
self._proc = None
self._initialized = False
self._request_id = 0
def request(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.request_with_retry(payload=payload, retries=0)
def health(self) -> dict[str, Any]:
res = self._request_jsonrpc("tools/list", {})
if not bool(res.get("ok")):
return res
tools = self._normalize_tools(res.get("result"))
return {"ok": True, "status": "ok", "tools_count": len(tools)}
def tools_list(self) -> dict[str, Any]:
res = self._request_jsonrpc("tools/list", {})
if not bool(res.get("ok")):
return res
return {"ok": True, "tools": self._normalize_tools(res.get("result"))}
def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
args = arguments if isinstance(arguments, dict) else {}
res = self._request_jsonrpc("tools/call", {"name": str(tool_name or ""), "arguments": args})
if not bool(res.get("ok")):
return res
return self._normalize_tool_call_result(res.get("result"))
def request_with_retry(self, payload: dict[str, Any], *, retries: int = 1) -> dict[str, Any]:
tries = max(0, int(retries)) + 1
last: dict[str, Any] = {"ok": False, "error_code": "mcp_runtime_failed", "error": "unknown"}
for i in range(tries):
self.start()
ex = ThreadPoolExecutor(max_workers=1)
fut = ex.submit(self._dispatch_request, payload)
try:
res = fut.result(timeout=max(0.1, float(self.timeout_s or 30.0)))
except FuturesTimeoutError:
try:
fut.cancel()
except Exception:
pass
self.stop()
last = {"ok": False, "error_code": "mcp_runtime_timeout", "error": "request_timeout"}
ex.shutdown(wait=False, cancel_futures=True)
continue
except Exception as exc:
self.stop()
last = {"ok": False, "error_code": "mcp_runtime_request_failed", "error": f"{type(exc).__name__}: {exc}"}
ex.shutdown(wait=False, cancel_futures=True)
continue
else:
ex.shutdown(wait=False, cancel_futures=True)
if bool(res.get("ok")):
return res
last = res
if i + 1 < tries:
self.stop()
return last
def _dispatch_request(self, payload: dict[str, Any]) -> dict[str, Any]:
op = str((payload or {}).get("op") or "").strip().lower()
if op:
res = self._dispatch_op_jsonrpc(payload)
if bool(res.get("ok")):
return res
if str(res.get("error_code") or "").startswith("mcp_runtime_"):
try:
return self._exchange_legacy(payload)
except Exception:
return res
return res
method = str((payload or {}).get("method") or "").strip()
if method:
params = (payload or {}).get("params")
return self._request_jsonrpc(method, params if isinstance(params, dict) else {})
return self._exchange_legacy(payload)
def _request_jsonrpc(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
self.start()
with self._lock:
return self._jsonrpc_call_locked(method=method, params=params, skip_init=False)
def _jsonrpc_call_locked(self, *, method: str, params: dict[str, Any], skip_init: bool) -> dict[str, Any]:
if not skip_init and not self._initialized:
init_res = self._jsonrpc_call_locked(
method="initialize",
params={"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "ops-assistant", "version": "0.1.0"}},
skip_init=True,
)
if not bool(init_res.get("ok")):
return init_res
self._jsonrpc_notify_locked("notifications/initialized", {})
self._initialized = True
p = self._proc
if p is None or p.stdin is None or p.stdout is None:
return {"ok": False, "error_code": "mcp_runtime_not_started", "error": "process_not_started"}
self._request_id += 1
rid = self._request_id
req = {"jsonrpc": "2.0", "id": rid, "method": str(method), "params": params or {}}
p.stdin.write(json.dumps(req, ensure_ascii=False) + "\n")
p.stdin.flush()
while True:
line = p.stdout.readline()
if not line:
return {"ok": False, "error_code": "mcp_runtime_empty_response", "error": "empty_response"}
try:
obj = json.loads(line)
except Exception as exc:
return {"ok": False, "error_code": "mcp_runtime_bad_json", "error": str(exc)}
if not isinstance(obj, dict):
return {"ok": False, "error_code": "mcp_runtime_invalid_payload", "error": "response_not_object"}
if "jsonrpc" not in obj and "id" not in obj:
return {"ok": False, "error_code": "mcp_runtime_protocol_mismatch", "error": "non_jsonrpc_response"}
if obj.get("id") != rid:
continue
if isinstance(obj.get("error"), dict):
err = obj.get("error") if isinstance(obj.get("error"), dict) else {}
code = int(err.get("code") or 0)
msg = str(err.get("message") or "jsonrpc_error")
return {"ok": False, "error_code": f"mcp_rpc_error_{code}", "error": msg, "rpc_error": err}
return {"ok": True, "result": obj.get("result"), "raw": obj}
def _jsonrpc_notify_locked(self, method: str, params: dict[str, Any]) -> None:
p = self._proc
if p is None or p.stdin is None:
return
req = {"jsonrpc": "2.0", "method": str(method), "params": params or {}}
p.stdin.write(json.dumps(req, ensure_ascii=False) + "\n")
p.stdin.flush()
@staticmethod
def _normalize_tools(result: Any) -> list[dict[str, Any]]:
row = result if isinstance(result, dict) else {}
items = row.get("tools") if isinstance(row.get("tools"), list) else []
out: list[dict[str, Any]] = []
for it in items:
if not isinstance(it, dict):
continue
name = str(it.get("name") or it.get("tool_name") or "").strip()
if not name:
continue
params = it.get("inputSchema")
if not isinstance(params, dict):
params = it.get("parameters")
out.append({"tool_name": name, "description": str(it.get("description") or ""), "parameters": params if isinstance(params, dict) else {}})
return out
@staticmethod
def _normalize_tool_call_result(result: Any) -> dict[str, Any]:
row = result if isinstance(result, dict) else {"raw": result}
if bool(row.get("isError")):
content = row.get("content") if isinstance(row.get("content"), list) else []
text = ""
for it in content:
if isinstance(it, dict) and str(it.get("type") or "") == "text":
text = str(it.get("text") or "").strip()
if text:
break
return {"ok": False, "error_code": "mcp_tool_call_failed", "error": text or "mcp_tool_call_failed", "result": row}
return {"ok": True, "result": row, "data": row}
def _dispatch_op_jsonrpc(self, payload: dict[str, Any]) -> dict[str, Any]:
op = str((payload or {}).get("op") or "").strip().lower()
if op == "tools/list":
return self.tools_list()
if op == "health":
return self.health()
if op == "call_tool":
tool_name = str((payload or {}).get("tool_name") or "").strip()
args = (payload or {}).get("arguments")
return self.call_tool(tool_name=tool_name, arguments=args if isinstance(args, dict) else {})
return {"ok": False, "error_code": "mcp_runtime_unsupported_op", "error": f"unsupported_op:{op}"}
def _exchange_legacy(self, payload: dict[str, Any]) -> dict[str, Any]:
p = self._proc
if p is None or p.stdin is None or p.stdout is None:
return {"ok": False, "error_code": "mcp_runtime_not_started", "error": "process_not_started"}
req = json.dumps(payload, ensure_ascii=False) + "\n"
with self._lock:
p.stdin.write(req)
p.stdin.flush()
line = p.stdout.readline()
if not line:
return {"ok": False, "error_code": "mcp_runtime_empty_response", "error": "empty_response"}
try:
obj = json.loads(line)
except Exception as exc:
return {"ok": False, "error_code": "mcp_runtime_bad_json", "error": str(exc)}
if not isinstance(obj, dict):
return {"ok": False, "error_code": "mcp_runtime_invalid_payload", "error": "response_not_object"}
return obj

View file

@ -0,0 +1,134 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Callable
from oclaw.runtime.tools.base import ToolSpec
logger = logging.getLogger(__name__)
ToolFactory = Callable[[], ToolSpec]
@dataclass(frozen=True)
class PluginTool:
tool: ToolSpec
plugin_name: str
plugin_version: str
entry_point: str
def _iter_entry_points() -> list[Any]:
try:
from importlib.metadata import entry_points # py3.10+
except Exception:
return []
try:
eps = entry_points()
# py3.10+ returns EntryPoints with .select
groups = []
for g in ("ai_ops_assistant.tools", "chatgpt.tools"):
try:
groups.extend(list(eps.select(group=g)))
except Exception:
pass
return groups
except Exception:
return []
def discover_plugin_tool_factories() -> list[tuple[str, str, str, ToolFactory]]:
"""Return (plugin_name, plugin_version, ep_name, factory)."""
out: list[tuple[str, str, str, ToolFactory]] = []
for ep in _iter_entry_points():
ep_name = f"{getattr(ep, 'group', '')}:{getattr(ep, 'name', '')}"
try:
obj = ep.load()
except Exception as exc:
logger.warning("skip plugin entry point %s: %s", ep_name, exc)
continue
factory: ToolFactory | None = None
if callable(obj):
factory = obj # type: ignore[assignment]
if not factory:
logger.warning("skip plugin entry point %s: not callable", ep_name)
continue
dist_name = ""
dist_ver = ""
try:
dist = getattr(ep, "dist", None)
if dist is not None:
dist_name = str(getattr(dist, "name", "") or "")
dist_ver = str(getattr(dist, "version", "") or "")
except Exception:
dist_name = ""
dist_ver = ""
out.append((dist_name or "unknown", dist_ver or "", ep_name, factory))
return out
def materialize_plugin_tools() -> list[PluginTool]:
tools: list[PluginTool] = []
for plugin_name, plugin_version, ep_name, factory in discover_plugin_tool_factories():
try:
spec = factory()
except Exception as exc:
logger.warning("skip plugin tool %s (%s): %s", ep_name, plugin_name, exc)
continue
if not isinstance(spec, ToolSpec):
logger.warning("skip plugin tool %s (%s): factory did not return ToolSpec", ep_name, plugin_name)
continue
# Tag plugin tools and default them to higher risk unless explicitly configured otherwise.
try:
spec = ToolSpec(
name=spec.name,
description=spec.description,
parameters=spec.parameters,
handler=spec.handler,
tags=frozenset(set(spec.tags) | {"plugin"}),
version=getattr(spec, "version", "v1"),
risk_level=getattr(spec, "risk_level", "high") or "high",
timeout_s=getattr(spec, "timeout_s", None),
rate_limit=getattr(spec, "rate_limit", None),
required_permissions=getattr(spec, "required_permissions", frozenset()),
execution_mode=getattr(spec, "execution_mode", "in_process"),
read_only=bool(spec.is_read_only()),
)
except Exception:
# If wrapping fails, keep original spec.
pass
tools.append(
PluginTool(
tool=spec,
plugin_name=plugin_name,
plugin_version=plugin_version,
entry_point=ep_name,
)
)
return tools
def sync_plugin_metadata(store: Any) -> int:
"""Persist discovered plugin metadata into store (best-effort)."""
count = 0
try:
rows = discover_plugin_tool_factories()
except Exception:
rows = []
for plugin_name, plugin_version, ep_name, _factory in rows:
try:
store.upsert_tool_plugin(
plugin_name=plugin_name,
plugin_version=plugin_version,
entry_point=ep_name,
enabled=True,
)
count += 1
except Exception:
continue
return count
__all__ = ["PluginTool", "discover_plugin_tool_factories", "materialize_plugin_tools", "sync_plugin_metadata"]

View file

@ -0,0 +1,2 @@
"""System-level shared tools available to all roles."""

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def system_time_tool() -> ToolSpec:
def _handler(args: dict[str, Any]) -> dict[str, Any]:
del args
now = datetime.now(timezone.utc)
return {
"ok": True,
"utc_iso": now.isoformat().replace("+00:00", "Z"),
"unix_ms": int(now.timestamp() * 1000),
}
return ToolSpec(
name="system_time",
description="Return current system time (UTC).",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=_handler,
tags=frozenset({"system", "time", "read"}),
read_only=True,
risk_level="low",
timeout_s=2.0,
)
__all__ = ["system_time_tool"]

View file

@ -0,0 +1,140 @@
from __future__ import annotations
import importlib.util
import inspect
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
logger = logging.getLogger(__name__)
ToolFactory = Callable[[], ToolSpec]
_PUBLIC_ROOT = Path(__file__).resolve().parent / "public"
_CACHED_FACTORIES: list[ToolFactory] | None = None
_CACHED_SPECS: list[ToolSpec] | None = None
def _load_module_from_path(module_path: Path, module_name: str) -> Any | None:
try:
spec = importlib.util.spec_from_file_location(module_name, module_path)
if not spec or not spec.loader:
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
except Exception as exc:
logger.warning("skip public module %s: %s", module_path, exc)
return None
def discover_public_tool_factories() -> list[ToolFactory]:
"""Discover public tool factories (naming convention: `*_tool`)."""
global _CACHED_FACTORIES
if _CACHED_FACTORIES is not None:
return list(_CACHED_FACTORIES)
if not _PUBLIC_ROOT.exists():
_CACHED_FACTORIES = []
return []
factories: list[ToolFactory] = []
for module_path in sorted(_PUBLIC_ROOT.glob("*.py")):
if module_path.name == "__init__.py":
continue
mod_name = f"oclaw.runtime.tools.public.{module_path.stem}"
module = _load_module_from_path(module_path, mod_name)
if module is None:
continue
exported = getattr(module, "__all__", None)
if isinstance(exported, list) and exported:
for name in sorted(exported):
value = getattr(module, name, None)
if callable(value) and name.endswith("_tool"):
factories.append(value)
continue
for name, value in sorted(inspect.getmembers(module)):
if callable(value) and name.endswith("_tool"):
factories.append(value)
_CACHED_FACTORIES = list(factories)
return list(factories)
def materialize_public_tools() -> list[ToolSpec]:
global _CACHED_SPECS
if _CACHED_SPECS is not None:
return list(_CACHED_SPECS)
specs: list[ToolSpec] = []
for factory in discover_public_tool_factories():
try:
specs.append(factory())
except Exception as exc:
logger.warning("skip public tool factory %s: %s", factory, exc)
_CACHED_SPECS = list(specs)
return list(specs)
def clear_public_tool_cache() -> None:
global _CACHED_FACTORIES, _CACHED_SPECS
_CACHED_FACTORIES = None
_CACHED_SPECS = None
def preview_public_tools() -> dict[str, Any]:
"""Preview public tool load outcome, including skip reasons.
This intentionally bypasses caches so Admin UI can reflect current filesystem state.
"""
out_tools: list[ToolSpec] = []
skipped: list[dict[str, str]] = []
if not _PUBLIC_ROOT.exists():
return {"tools": [], "skipped": []}
for module_path in sorted(_PUBLIC_ROOT.glob("*.py")):
if module_path.name == "__init__.py":
continue
mod_name = f"oclaw.runtime.tools.public.{module_path.stem}"
module = None
try:
module = _load_module_from_path(module_path, mod_name)
except Exception as exc:
skipped.append({"module": str(module_path), "error_code": "module_load_exception", "error": str(exc)})
continue
if module is None:
skipped.append({"module": str(module_path), "error_code": "module_load_failed", "error": "load_failed"})
continue
factories: list[ToolFactory] = []
exported = getattr(module, "__all__", None)
if isinstance(exported, list) and exported:
for name in sorted(exported):
value = getattr(module, name, None)
if callable(value) and name.endswith("_tool"):
factories.append(value)
else:
for name, value in sorted(inspect.getmembers(module)):
if callable(value) and name.endswith("_tool"):
factories.append(value)
if not factories:
continue
for factory in factories:
try:
out_tools.append(factory())
except Exception as exc:
skipped.append(
{
"module": str(module_path),
"error_code": "tool_factory_failed",
"error": str(exc),
"factory": str(getattr(factory, "__name__", "") or str(factory)),
}
)
return {"tools": out_tools, "skipped": skipped}
__all__ = [
"ToolFactory",
"clear_public_tool_cache",
"discover_public_tool_factories",
"materialize_public_tools",
"preview_public_tools",
]

View file

@ -0,0 +1,138 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import httpx
def _strip_trailing_slash(url: str) -> str:
return str(url or "").strip().rstrip("/")
def _join_url(base: str, path: str) -> str:
b = _strip_trailing_slash(base)
p = str(path or "").strip()
if not p:
return b
if not p.startswith("/"):
p = "/" + p
return b + p
def _safe_get_json(url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
try:
with httpx.Client(timeout=8.0, follow_redirects=True) as c:
r = c.get(url, params=params or {}, headers=headers or {})
if r.status_code != 200:
return {}
obj = r.json()
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
def _safe_get_json_list(url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> list[dict[str, Any]]:
try:
with httpx.Client(timeout=8.0, follow_redirects=True) as c:
r = c.get(url, params=params or {}, headers=headers or {})
if r.status_code != 200:
return []
obj = r.json()
if isinstance(obj, list):
return [x for x in obj if isinstance(x, dict)]
if isinstance(obj, dict) and isinstance(obj.get("items"), list):
return [x for x in obj.get("items") if isinstance(x, dict)]
return []
except Exception:
return []
@dataclass(frozen=True)
class ClawHubConfig:
site_base_url: str = "https://clawhub.ai"
registry_base_url: str | None = None
token: str | None = None
api_base_path: str = "/api/v1"
def load_clawhub_config() -> ClawHubConfig:
site = str(os.getenv("AIA_CLAWHUB_SITE") or os.getenv("CLAWHUB_SITE") or "https://clawhub.ai").strip() or "https://clawhub.ai"
registry = str(os.getenv("AIA_CLAWHUB_REGISTRY") or os.getenv("CLAWHUB_REGISTRY") or "").strip() or None
token = str(os.getenv("AIA_CLAWHUB_TOKEN") or os.getenv("CLAWHUB_TOKEN") or "").strip() or None
api_base = str(os.getenv("AIA_CLAWHUB_API_BASE") or "").strip() or "/api/v1"
if not api_base.startswith("/"):
api_base = "/" + api_base
return ClawHubConfig(site_base_url=site, registry_base_url=registry, token=token, api_base_path=api_base)
def discover_registry_base_url(cfg: ClawHubConfig) -> str:
if cfg.registry_base_url:
return _strip_trailing_slash(cfg.registry_base_url)
wk = _safe_get_json(_join_url(cfg.site_base_url, "/.well-known/clawhub.json"))
api_base = str(wk.get("apiBase") or "").strip()
if api_base and api_base.startswith("/"):
return _strip_trailing_slash(cfg.site_base_url)
return _strip_trailing_slash(cfg.site_base_url)
def _auth_headers(cfg: ClawHubConfig) -> dict[str, str]:
tok = str(cfg.token or "").strip()
if not tok:
return {}
return {"Authorization": f"Bearer {tok}"}
def build_download_url(*, registry_base_url: str, api_base_path: str, slug: str, version: str) -> str:
q = urlencode({"slug": slug, "version": version})
return _join_url(registry_base_url, f"{api_base_path.rstrip('/')}/download?{q}")
def search_skills(query: str, *, limit: int = 20, cfg: ClawHubConfig | None = None) -> list[dict[str, Any]]:
cfg = cfg or load_clawhub_config()
registry = discover_registry_base_url(cfg)
api = cfg.api_base_path.rstrip("/")
q = str(query or "").strip()
lim = max(1, min(int(limit or 20), 200))
items = _safe_get_json_list(_join_url(registry, f"{api}/search"), params={"q": q, "limit": lim}, headers=_auth_headers(cfg))
out: list[dict[str, Any]] = []
for it in items:
slug = str(it.get("slug") or it.get("name") or "").strip()
if not slug:
continue
version = str(it.get("version") or it.get("latestVersion") or it.get("latest") or "").strip()
archive_url = build_download_url(registry_base_url=registry, api_base_path=api, slug=slug, version=version) if version else ""
out.append({"source": "clawhub", "slug": slug, "name": str(it.get("displayName") or it.get("name") or slug), "description": str(it.get("summary") or it.get("description") or ""), "version": version, "owner": str(it.get("owner") or it.get("ownerHandle") or ""), "updatedAt": str(it.get("updatedAt") or ""), "downloads": int(it.get("downloads") or (it.get("stats") or {}).get("downloads") or 0) if isinstance(it.get("stats"), dict) else int(it.get("downloads") or 0), "stars": int(it.get("stars") or (it.get("stats") or {}).get("stars") or 0) if isinstance(it.get("stats"), dict) else int(it.get("stars") or 0), "homepage": str(it.get("homepage") or it.get("url") or ""), "archiveUrl": archive_url, "raw": it})
return out
def get_skill_detail(slug: str, *, cfg: ClawHubConfig | None = None) -> dict[str, Any]:
cfg = cfg or load_clawhub_config()
registry = discover_registry_base_url(cfg)
api = cfg.api_base_path.rstrip("/")
s = str(slug or "").strip()
if not s:
return {}
blob = _safe_get_json(_join_url(registry, f"{api}/skills/{s}"), headers=_auth_headers(cfg))
if not blob:
return {"slug": s}
versions: list[dict[str, Any]] = []
versions_raw = blob.get("versions")
if isinstance(versions_raw, list):
for v in versions_raw:
if not isinstance(v, dict):
continue
ver = str(v.get("version") or "").strip()
if not ver:
continue
versions.append({"version": ver, "changelog": str(v.get("changelog") or ""), "createdAt": str(v.get("createdAt") or ""), "archiveUrl": build_download_url(registry_base_url=registry, api_base_path=api, slug=s, version=ver), "raw": v})
latest_version_raw = blob.get("latestVersion")
latest_version = str(latest_version_raw.get("version") or "").strip() if isinstance(latest_version_raw, dict) else str(latest_version_raw or blob.get("latest") or "").strip()
if not latest_version and versions:
latest_version = str(versions[0].get("version") or "")
return {"source": "clawhub", "slug": str(blob.get("slug") or s), "name": str(blob.get("displayName") or blob.get("name") or s), "description": str(blob.get("summary") or blob.get("description") or ""), "owner": str(blob.get("ownerHandle") or (blob.get("owner") or {}).get("handle") or "") if isinstance(blob.get("owner"), dict) else str(blob.get("ownerHandle") or ""), "updatedAt": str(blob.get("updatedAt") or ""), "homepage": str(blob.get("homepage") or blob.get("url") or ""), "latestVersion": latest_version, "archiveUrl": build_download_url(registry_base_url=registry, api_base_path=api, slug=s, version=latest_version) if latest_version else "", "versions": versions, "raw": blob}
__all__ = ["ClawHubConfig", "load_clawhub_config", "discover_registry_base_url", "build_download_url", "search_skills", "get_skill_detail"]

View file

@ -0,0 +1,2 @@
"""Executable skill runtime adapters."""

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any
from oclaw.runtime.skills import discover_workspace_skill_manifests
from oclaw.runtime.tools.base import ToolSpec
def materialize_executable_skill_tools(*, store: Any | None = None) -> list[ToolSpec]:
"""Convert installed skill manifests with runtime into ToolSpec.
Tool name equals skill name so the model can call it directly.
"""
_ = store
out: list[ToolSpec] = []
for m in discover_workspace_skill_manifests():
rt = dict(m.runtime or {}) if isinstance(m.runtime, dict) else {}
if not rt:
continue
tp = str(rt.get("type") or "").strip().lower()
entry = str(rt.get("entry") or "").strip()
if not tp or not entry:
continue
schema = rt.get("schema") if isinstance(rt.get("schema"), dict) else {"type": "object", "additionalProperties": True}
name = str(m.name or "").strip()
if not name:
continue
def _handler(args: dict[str, Any], *, _manifest=m, _rt=rt) -> dict[str, Any]:
from oclaw.runtime.tools.skills_runtime.subprocess_exec import run_skill_runtime_entry
return run_skill_runtime_entry(
skill_name=str(_manifest.name or ""),
skill_dir=str(_manifest.skill_dir or ""),
runtime=dict(_rt),
args=dict(args or {}),
)
out.append(
ToolSpec(
name=name,
description=str(m.description or ""),
parameters=dict(schema),
handler=_handler,
tags=frozenset({"skill", "oclaw", "runtime"}),
risk_level="high",
timeout_s=60.0,
)
)
return out
__all__ = ["materialize_executable_skill_tools"]

View file

@ -0,0 +1,262 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.platform.config.paths import PROJECT_ROOT
_PATH_KEYS = {
"path",
"file",
"dir",
"folder",
"input_path",
"output_path",
"source_path",
"target_path",
"workspace_path",
}
_WRITE_HINT_KEYS = {
"output_path",
"target_path",
"dest_path",
"destination_path",
"write_path",
"save_path",
"out_path",
}
@dataclass(frozen=True)
class _RunResult:
ok: bool
stdout: str
stderr: str
exit_code: int
duration_ms: int
def _resolve_allowed_roots(skill_dir: Path) -> list[Path]:
roots = [Path(PROJECT_ROOT).resolve(), skill_dir.resolve()]
out: list[Path] = []
seen: set[str] = set()
for r in roots:
s = str(r)
if s in seen:
continue
seen.add(s)
out.append(r)
return out
def _is_under_roots(p: Path, roots: list[Path]) -> bool:
rp = p.resolve()
for r in roots:
try:
rp.relative_to(r)
return True
except Exception:
continue
return False
def _validate_path_value(v: str, *, skill_dir: Path, roots: list[Path]) -> None:
raw = str(v or "").strip()
if not raw:
return
# Expand and resolve relative paths against skill_dir
p = Path(raw)
if not p.is_absolute():
p = (skill_dir / p).resolve()
else:
p = p.resolve()
if not _is_under_roots(p, roots):
raise PermissionError(f"path_outside_allowed_roots:{raw}")
def _walk_and_validate_paths(obj: Any, *, skill_dir: Path, roots: list[Path], key_hint: str | None = None) -> None:
if isinstance(obj, dict):
for k, v in obj.items():
kk = str(k or "").strip().lower()
_walk_and_validate_paths(v, skill_dir=skill_dir, roots=roots, key_hint=kk)
return
if isinstance(obj, list):
for it in obj:
_walk_and_validate_paths(it, skill_dir=skill_dir, roots=roots, key_hint=key_hint)
return
if isinstance(obj, str):
if (key_hint or "") in _PATH_KEYS:
_validate_path_value(obj, skill_dir=skill_dir, roots=roots)
return
def _deny_writes_when_disabled(obj: Any, *, key_hint: str | None = None) -> None:
if isinstance(obj, dict):
for k, v in obj.items():
kk = str(k or "").strip().lower()
_deny_writes_when_disabled(v, key_hint=kk)
return
if isinstance(obj, list):
for it in obj:
_deny_writes_when_disabled(it, key_hint=key_hint)
return
if isinstance(obj, str):
if (key_hint or "") in _WRITE_HINT_KEYS and str(obj).strip():
raise PermissionError(f"fs_write_disabled:{key_hint}")
return
def _resolve_entry(skill_dir: Path, entry: str) -> Path:
rel = str(entry or "").strip().replace("\\", "/")
if not rel or rel.startswith("/") or ".." in rel.split("/"):
raise ValueError("invalid_entry_path")
p = (skill_dir / rel).resolve()
try:
p.relative_to(skill_dir.resolve())
except Exception:
raise ValueError("entry_outside_skill_dir")
# If entry doesn't exist and is .ts, try .js (common in marketplace packages)
if not p.exists() and p.suffix.lower() == ".ts":
alt = p.with_suffix(".js")
if alt.exists():
return alt
return p
def _which(cmd: str) -> str | None:
return shutil.which(cmd)
def _pick_shell() -> list[str] | None:
# Prefer explicit env override, otherwise bash then sh.
raw = str(os.getenv("OCLAW_SHELL") or "").strip()
if raw:
exe = _which(raw)
return [exe] if exe else None
for c in ("bash", "sh"):
exe = _which(c)
if exe:
return [exe]
return None
def _run(argv: list[str], *, cwd: Path, stdin_json: dict[str, Any], timeout_s: float) -> _RunResult:
import time
start = time.time()
p = subprocess.run(
argv,
input=json.dumps(stdin_json, ensure_ascii=False),
text=True,
capture_output=True,
cwd=str(cwd),
timeout=max(1, int(timeout_s)),
check=False,
env=_build_env_allowlist(),
)
dur_ms = int((time.time() - start) * 1000)
return _RunResult(
ok=p.returncode == 0,
stdout=str(p.stdout or ""),
stderr=str(p.stderr or ""),
exit_code=int(p.returncode),
duration_ms=dur_ms,
)
def _build_env_allowlist() -> dict[str, str]:
# Minimal env for subprocesses; keep PATH for finding interpreters.
allow = {"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "HOME", "USERPROFILE", "TMP", "TEMP"}
out: dict[str, str] = {}
for k in allow:
v = os.environ.get(k)
if v:
out[k] = str(v)
# Explicitly carry OpenAI/MCP keys? No: skills should not get host secrets by default.
return out
def run_skill_runtime_entry(
*,
skill_name: str,
skill_dir: str,
runtime: dict[str, Any],
args: dict[str, Any],
) -> dict[str, Any]:
"""Execute one runtime entry (shell/python/node) with restricted filesystem checks."""
name = str(skill_name or "").strip()
root = Path(skill_dir).resolve()
if not root.exists() or not root.is_dir():
return {"ok": False, "error_code": "skill_dir_missing", "error": f"skill_dir_not_found:{skill_dir}"}
tp = str((runtime or {}).get("type") or "").strip().lower()
entry = str((runtime or {}).get("entry") or "").strip()
if tp not in {"shell", "python", "node"}:
return {"ok": False, "error_code": "unsupported_runtime_type", "error": f"unsupported:{tp}"}
try:
entry_path = _resolve_entry(root, entry)
except Exception as exc:
return {"ok": False, "error_code": "invalid_entry", "error": str(exc)}
if not entry_path.exists() or not entry_path.is_file():
return {"ok": False, "error_code": "entry_missing", "error": f"entry_not_found:{entry}"}
roots = _resolve_allowed_roots(root)
perms = runtime.get("permissions") if isinstance(runtime.get("permissions"), dict) else {}
fs_write = bool(perms.get("fs_write")) if isinstance(perms, dict) and "fs_write" in perms else False
try:
if not fs_write:
_deny_writes_when_disabled(args)
_walk_and_validate_paths(args, skill_dir=root, roots=roots)
except PermissionError as exc:
return {"ok": False, "error_code": "path_restricted", "error": str(exc)}
timeout_s = float((runtime or {}).get("timeout_s") or 60.0)
stdin_json = {"skill": name, "args": args, "permissions": {"fs_write": fs_write}}
try:
if tp == "python":
argv = [sys.executable, str(entry_path)]
elif tp == "node":
node = _which("node")
if not node:
return {"ok": False, "error_code": "node_missing", "error": "node_not_found_in_PATH"}
argv = [node, str(entry_path)]
else:
sh = _pick_shell()
if not sh:
return {"ok": False, "error_code": "shell_missing", "error": "bash_or_sh_not_found_in_PATH"}
argv = [*sh, str(entry_path)]
rr = _run(argv, cwd=root, stdin_json=stdin_json, timeout_s=timeout_s)
payload: dict[str, Any] = {
"ok": bool(rr.ok),
"exit_code": int(rr.exit_code),
"duration_ms": int(rr.duration_ms),
"stdout": rr.stdout,
"stderr": rr.stderr,
}
# Best-effort JSON decode for programmatic skills.
out_obj: Any = None
try:
out_obj = json.loads(rr.stdout) if rr.stdout.strip().startswith(("{", "[")) else None
except Exception:
out_obj = None
if out_obj is not None:
payload["result"] = out_obj
return payload
except subprocess.TimeoutExpired:
return {"ok": False, "error_code": "timeout", "error": f"timeout_s_exceeded:{timeout_s}"}
except Exception as exc:
return {"ok": False, "error_code": "runtime_error", "error": f"{type(exc).__name__}:{exc}"}
__all__ = ["run_skill_runtime_entry"]

View file

@ -0,0 +1,7 @@
from __future__ import annotations
# Backward-compat import path.
from oclaw.runtime.tools.public.system_time_tool import system_time_tool
__all__ = ["system_time_tool"]

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Any
try:
import jsonschema
from jsonschema import validators
except Exception: # pragma: no cover
jsonschema = None
validators = None
def validate_tool_arguments(parameters: dict[str, Any], arguments: dict[str, Any]) -> tuple[bool, str | None]:
"""校验模型给出的 arguments 是否符合工具的 JSON Schema(OpenAI function parameters)。"""
if not isinstance(arguments, dict):
return False, "arguments must be an object"
schema = parameters or {}
if not schema:
return True, None
if jsonschema is None or validators is None:
# 依赖未安装时:不阻塞主流程(工具 handler 内仍可自行校验)。
return True, None
try:
cls = validators.validator_for(schema)
cls(schema).validate(arguments)
except jsonschema.ValidationError as e:
return False, e.message
except Exception as e:
return False, str(e)
return True, None
__all__ = ["validate_tool_arguments"]

View file

@ -0,0 +1,101 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.platform.embeddings.embedding_client import build_default_embedding_client
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.platform.config.paths import PROJECT_ROOT
def _default_workspace_root() -> Path:
import os
override = (os.getenv("AIA_WORKSPACE_ROOT") or "").strip()
if override:
return Path(override).expanduser().resolve()
return Path(PROJECT_ROOT).resolve()
@dataclass(frozen=True)
class IndexStats:
files_seen: int
chunks_upserted: int
embeddings_upserted: int
def _chunk_id(source: str, text: str) -> str:
raw = f"{source}\n{text}".encode("utf-8", errors="ignore")
return hashlib.sha1(raw).hexdigest()
def _iter_files(root: Path, patterns: list[str], *, max_files: int) -> list[Path]:
files: list[Path] = []
for pat in patterns:
for p in root.glob(pat):
if p.is_dir():
continue
files.append(p)
if len(files) >= max_files:
return files
# de-dup by path
uniq = []
seen = set()
for p in files:
rp = str(p)
if rp in seen:
continue
seen.add(rp)
uniq.append(p)
return uniq[:max_files]
def index_workspace(
store: SqliteStore,
*,
root: str | None = None,
patterns: list[str] | None = None,
max_files: int = 200,
max_chars_per_chunk: int = 1600,
) -> IndexStats:
r = Path(root).resolve() if root else _default_workspace_root()
pats = patterns or ["oclaw/**/*.py", "README*.md", "**/*.md"]
files = _iter_files(r, pats, max_files=max(1, int(max_files)))
client = build_default_embedding_client()
chunks_upserted = 0
embeds_upserted = 0
for p in files:
try:
text = p.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
rel = str(p.relative_to(r))
# naive chunking by chars (good enough for MVP)
for idx in range(0, len(text), max(200, int(max_chars_per_chunk))):
chunk = text[idx : idx + int(max_chars_per_chunk)].strip()
if not chunk:
continue
source = f"workspace:{rel}#c{idx//max_chars_per_chunk:04d}"
cid = _chunk_id(source, chunk)
store.upsert_knowledge_chunk(
chunk_id=cid,
source=source,
content=chunk,
metadata={"source": source, "path": rel, "offset": idx},
)
chunks_upserted += 1
try:
emb = client.embed(chunk[:8000])
store.upsert_knowledge_embedding(chunk_id=cid, model=emb.model, vector=emb.vector)
embeds_upserted += 1
except Exception:
continue
return IndexStats(files_seen=len(files), chunks_upserted=chunks_upserted, embeddings_upserted=embeds_upserted)
__all__ = ["IndexStats", "index_workspace"]