mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 21:30:45 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
17
runtime/tools/__init__.py
Normal file
17
runtime/tools/__init__.py
Normal 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
80
runtime/tools/base.py
Normal 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
308
runtime/tools/catalog.py
Normal 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",
|
||||
]
|
||||
0
runtime/tools/evals/__init__.py
Normal file
0
runtime/tools/evals/__init__.py
Normal file
105
runtime/tools/evals/assistant_runner.py
Normal file
105
runtime/tools/evals/assistant_runner.py
Normal 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))
|
||||
|
||||
125
runtime/tools/evals/runner.py
Normal file
125
runtime/tools/evals/runner.py
Normal 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))
|
||||
207
runtime/tools/expert_registry.py
Normal file
207
runtime/tools/expert_registry.py
Normal 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",
|
||||
]
|
||||
2
runtime/tools/experts/__init__.py
Normal file
2
runtime/tools/experts/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""按专家维度组织的工具目录。"""
|
||||
|
||||
29
runtime/tools/experts/generalist/generalist_tools.py
Normal file
29
runtime/tools/experts/generalist/generalist_tools.py
Normal 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"]
|
||||
66
runtime/tools/experts/generalist/geo_http.py
Normal file
66
runtime/tools/experts/generalist/geo_http.py
Normal 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"]
|
||||
78
runtime/tools/experts/generalist/geo_info.py
Normal file
78
runtime/tools/experts/generalist/geo_info.py
Normal 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"]
|
||||
129
runtime/tools/experts/generalist/image_edit.py
Normal file
129
runtime/tools/experts/generalist/image_edit.py
Normal 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"]
|
||||
|
||||
33
runtime/tools/experts/generalist/system_info.py
Normal file
33
runtime/tools/experts/generalist/system_info.py
Normal 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"]
|
||||
150
runtime/tools/experts/generalist/tabular_query.py
Normal file
150
runtime/tools/experts/generalist/tabular_query.py
Normal 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"]
|
||||
|
||||
150
runtime/tools/experts/generalist/weather.py
Normal file
150
runtime/tools/experts/generalist/weather.py
Normal 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"]
|
||||
136
runtime/tools/experts/generalist/web_search.py
Normal file
136
runtime/tools/experts/generalist/web_search.py
Normal 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"]
|
||||
15
runtime/tools/experts/memory_curator/__init__.py
Normal file
15
runtime/tools/experts/memory_curator/__init__.py
Normal 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",
|
||||
]
|
||||
156
runtime/tools/experts/memory_curator/wiki_curator_tools.py
Normal file
156
runtime/tools/experts/memory_curator/wiki_curator_tools.py
Normal 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",
|
||||
]
|
||||
40
runtime/tools/experts/network_ops/config_diff.py
Normal file
40
runtime/tools/experts/network_ops/config_diff.py
Normal 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"]
|
||||
78
runtime/tools/experts/network_ops/device_status.py
Normal file
78
runtime/tools/experts/network_ops/device_status.py
Normal 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"]
|
||||
99
runtime/tools/experts/network_ops/get_path.py
Normal file
99
runtime/tools/experts/network_ops/get_path.py
Normal 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"]
|
||||
53
runtime/tools/experts/network_ops/log_analysis.py
Normal file
53
runtime/tools/experts/network_ops/log_analysis.py
Normal 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"]
|
||||
259
runtime/tools/experts/network_ops/network_probe_tools.py
Normal file
259
runtime/tools/experts/network_ops/network_probe_tools.py
Normal 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"]
|
||||
77
runtime/tools/experts/network_ops/ops_tools.py
Normal file
77
runtime/tools/experts/network_ops/ops_tools.py
Normal 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",
|
||||
]
|
||||
51
runtime/tools/experts/network_ops/query_route.py
Normal file
51
runtime/tools/experts/network_ops/query_route.py
Normal 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"]
|
||||
10
runtime/tools/experts/network_ops/tabular_query.py
Normal file
10
runtime/tools/experts/network_ops/tabular_query.py
Normal 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"]
|
||||
|
||||
4
runtime/tools/experts/productivity/__init__.py
Normal file
4
runtime/tools/experts/productivity/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
96
runtime/tools/experts/productivity/kb_tools.py
Normal file
96
runtime/tools/experts/productivity/kb_tools.py
Normal 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"]
|
||||
146
runtime/tools/experts/productivity/todo_tools.py
Normal file
146
runtime/tools/experts/productivity/todo_tools.py
Normal 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"]
|
||||
|
||||
4
runtime/tools/experts/workspace/__init__.py
Normal file
4
runtime/tools/experts/workspace/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
148
runtime/tools/experts/workspace/fs_tools.py
Normal file
148
runtime/tools/experts/workspace/fs_tools.py
Normal 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"]
|
||||
|
||||
154
runtime/tools/experts/workspace/git_tools.py
Normal file
154
runtime/tools/experts/workspace/git_tools.py
Normal 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"]
|
||||
|
||||
53
runtime/tools/experts/workspace/patch_tools.py
Normal file
53
runtime/tools/experts/workspace/patch_tools.py
Normal 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"]
|
||||
|
||||
92
runtime/tools/experts/workspace/search_tools.py
Normal file
92
runtime/tools/experts/workspace/search_tools.py
Normal 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"]
|
||||
|
||||
91
runtime/tools/experts/workspace/shell_tools.py
Normal file
91
runtime/tools/experts/workspace/shell_tools.py
Normal 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"]
|
||||
|
||||
261
runtime/tools/experts/workspace/workspace_base.py
Normal file
261
runtime/tools/experts/workspace/workspace_base.py
Normal 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",
|
||||
]
|
||||
232
runtime/tools/exposure_plan.py
Normal file
232
runtime/tools/exposure_plan.py
Normal 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",
|
||||
]
|
||||
|
||||
16
runtime/tools/mcp/__init__.py
Normal file
16
runtime/tools/mcp/__init__.py
Normal 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",
|
||||
]
|
||||
165
runtime/tools/mcp/adapter.py
Normal file
165
runtime/tools/mcp/adapter.py
Normal 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",
|
||||
]
|
||||
|
||||
230
runtime/tools/mcp/filesystem_argv.py
Normal file
230
runtime/tools/mcp/filesystem_argv.py
Normal 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",
|
||||
]
|
||||
184
runtime/tools/mcp/installer.py
Normal file
184
runtime/tools/mcp/installer.py
Normal 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"]
|
||||
20
runtime/tools/mcp/manifest.py
Normal file
20
runtime/tools/mcp/manifest.py
Normal 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
153
runtime/tools/mcp/market.py
Normal 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",
|
||||
]
|
||||
|
||||
42
runtime/tools/mcp/registry.py
Normal file
42
runtime/tools/mcp/registry.py
Normal 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"]
|
||||
|
||||
288
runtime/tools/mcp/runtime.py
Normal file
288
runtime/tools/mcp/runtime.py
Normal 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
|
||||
134
runtime/tools/plugin_loader.py
Normal file
134
runtime/tools/plugin_loader.py
Normal 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"]
|
||||
|
||||
2
runtime/tools/public/__init__.py
Normal file
2
runtime/tools/public/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""System-level shared tools available to all roles."""
|
||||
|
||||
32
runtime/tools/public/system_time_tool.py
Normal file
32
runtime/tools/public/system_time_tool.py
Normal 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"]
|
||||
|
||||
140
runtime/tools/public_registry.py
Normal file
140
runtime/tools/public_registry.py
Normal 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",
|
||||
]
|
||||
|
||||
138
runtime/tools/skills/clawhub_client.py
Normal file
138
runtime/tools/skills/clawhub_client.py
Normal 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"]
|
||||
2
runtime/tools/skills_runtime/__init__.py
Normal file
2
runtime/tools/skills_runtime/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Executable skill runtime adapters."""
|
||||
|
||||
54
runtime/tools/skills_runtime/materialize_skill_tools.py
Normal file
54
runtime/tools/skills_runtime/materialize_skill_tools.py
Normal 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"]
|
||||
|
||||
262
runtime/tools/skills_runtime/subprocess_exec.py
Normal file
262
runtime/tools/skills_runtime/subprocess_exec.py
Normal 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"]
|
||||
|
||||
7
runtime/tools/system_time_tool.py
Normal file
7
runtime/tools/system_time_tool.py
Normal 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"]
|
||||
|
||||
37
runtime/tools/tool_validation.py
Normal file
37
runtime/tools/tool_validation.py
Normal 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"]
|
||||
|
||||
101
runtime/tools/workspace_indexer.py
Normal file
101
runtime/tools/workspace_indexer.py
Normal 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"]
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue