mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-10-13 03:58:18 +00:00
Add non-blocking task execution to the MCP server so long-running grounding tasks no longer trip client tool-call timeouts. - execute_task now defaults to wait=False: it registers a background task, returns a task_id immediately, and streams per-step progress (engine warmup, skill registration, cloud search, each iteration, final aggregation) into an in-memory task registry. - New get_task_status(task_id, include_logs) tool polls the registry for status / percent / current_step / logs / result / error. - wait=True keeps the old blocking behaviour for CLI/sync callers. - Add an asynccontextmanager lifespan that pre-warms the OpenSpace engine in the background at server start, so the first real task starts from an already-initialized engine instead of hanging on a cold init after the MCP handshake. In-memory task registry is pruned to a bounded size; progress updates are appended as timestamped log entries (capped per task).
3017 lines
115 KiB
Python
3017 lines
115 KiB
Python
"""OpenSpace MCP Server
|
||
|
||
Exposes the following tools to MCP clients:
|
||
cloud_auth_flow — Register/login and provision cloud agent API keys
|
||
execute_task — Delegate a task (auto-registers skills, auto-searches, auto-evolves; supports async background + polling)
|
||
get_task_status — Poll an async execute_task's progress, logs, and result
|
||
search_skills — Standalone local skill search
|
||
cloud_browse_skills — LLM-guided cloud package/skill browsing and import
|
||
fix_skill — Run a manual FIX job for a broken skill through evolution
|
||
upload_skill — Upload a local skill to cloud after resolving placement/metadata
|
||
|
||
Usage:
|
||
python -m openspace.entrypoints.mcp.server # auto (TTY -> SSE, MCP host -> stdio)
|
||
python -m openspace.entrypoints.mcp.server --transport sse # SSE on port 8080
|
||
python -m openspace.entrypoints.mcp.server --transport streamable-http # Streamable HTTP on port 8081
|
||
python -m openspace.entrypoints.mcp.server --port 9090 # SSE on custom port
|
||
|
||
Environment variables: see ``openspace/host_detection/`` and ``openspace/cloud/auth_flow.py``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import inspect
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
import time
|
||
import uuid
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Mapping, Optional
|
||
|
||
|
||
class _MCPSafeStdout:
|
||
"""Stdout wrapper: binary (.buffer) → real stdout, text (.write) → stderr."""
|
||
|
||
def __init__(self, real_stdout, stderr):
|
||
self._real = real_stdout
|
||
self._stderr = stderr
|
||
|
||
@property
|
||
def buffer(self):
|
||
return self._real.buffer
|
||
|
||
def fileno(self):
|
||
return self._real.fileno()
|
||
|
||
def write(self, s):
|
||
return self._stderr.write(s)
|
||
|
||
def writelines(self, lines):
|
||
return self._stderr.writelines(lines)
|
||
|
||
def flush(self):
|
||
self._stderr.flush()
|
||
self._real.flush()
|
||
|
||
def isatty(self):
|
||
return self._stderr.isatty()
|
||
|
||
@property
|
||
def encoding(self):
|
||
return self._stderr.encoding
|
||
|
||
@property
|
||
def errors(self):
|
||
return self._stderr.errors
|
||
|
||
@property
|
||
def closed(self):
|
||
return self._stderr.closed
|
||
|
||
def readable(self):
|
||
return False
|
||
|
||
def writable(self):
|
||
return True
|
||
|
||
def seekable(self):
|
||
return False
|
||
|
||
def __getattr__(self, name):
|
||
return getattr(self._stderr, name)
|
||
|
||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
||
_LOG_DIR = _PROJECT_ROOT / "logs"
|
||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
_real_stdout = sys.stdout
|
||
|
||
# Windows pipe buffers are small. When using stdio MCP transport,
|
||
# the parent process only reads stdout for MCP messages and does NOT
|
||
# drain stderr. Heavy log/print output during execute_task fills the stderr
|
||
# pipe buffer, blocking this process on write() → deadlock → timeout.
|
||
# Redirect stderr to a log file on Windows to prevent this.
|
||
if os.name == "nt":
|
||
_stderr_file = open(
|
||
_LOG_DIR / "mcp_stderr.log", "a", encoding="utf-8", buffering=1
|
||
)
|
||
sys.stderr = _stderr_file
|
||
|
||
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||
handlers=[logging.FileHandler(_LOG_DIR / "mcp_server.log")],
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from openspace.entrypoints.mcp.response import (
|
||
format_task_result as _format_task_result,
|
||
json_error as _json_error,
|
||
json_ok as _json_ok,
|
||
)
|
||
from openspace.runtime import ExecutionRequest
|
||
|
||
from mcp.server.fastmcp import FastMCP
|
||
|
||
_fastmcp_kwargs: dict = {}
|
||
try:
|
||
if "description" in inspect.signature(FastMCP.__init__).parameters:
|
||
_fastmcp_kwargs["description"] = (
|
||
"OpenSpace: Unite the Agents. Evolve the Mind. Rebuild the World."
|
||
)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastMCP):
|
||
"""Warm up the OpenSpace engine in the background.
|
||
|
||
The heavy part of ``execute_task`` is ``_get_openspace()`` (LLM client,
|
||
grounding client, skill registry, DB...). Initializing it on the first
|
||
call adds many seconds of "silent hang" right after the MCP handshake.
|
||
Pre-warming here means the first real task starts from an already-ready
|
||
engine.
|
||
|
||
We intentionally do NOT await initialization inside lifespan: the client
|
||
handshake would block on it and defeat the purpose. Instead we fire a
|
||
background task; ``_get_openspace()`` is lock-guarded and idempotent, so
|
||
a racing first tool call simply waits on (or joins) the same init.
|
||
"""
|
||
try:
|
||
asyncio.create_task(_get_openspace())
|
||
except Exception as e: # pragma: no cover - best-effort warmup
|
||
logger.debug(f"Engine warmup task failed to start (non-fatal): {e}")
|
||
try:
|
||
yield
|
||
finally:
|
||
pass
|
||
|
||
|
||
mcp = FastMCP("OpenSpace", lifespan=lifespan, **_fastmcp_kwargs)
|
||
|
||
_openspace_instance = None
|
||
_openspace_lock = asyncio.Lock()
|
||
|
||
# Internal state: tracks bot skill directories already registered this session.
|
||
_registered_skill_dirs: set = set()
|
||
|
||
_UPLOAD_META_FILENAME = ".upload_meta.json"
|
||
|
||
# --- 异步任务注册表 ----------------------------------------------------
|
||
# execute_task 默认异步执行:立即返回 task_id,后台跑完整任务;客户端用
|
||
# get_task_status 轮询进度与结果,避免长任务在客户端硬超时被掐断。
|
||
# 单线程事件循环内读写 → 无需加锁。
|
||
_TASKS: Dict[str, Dict[str, Any]] = {}
|
||
_TASK_LOG_LIMIT = 200 # 每个任务最多保留的分步日志条数
|
||
_TASK_KEEP_MAX = 50 # 内存最多保留的任务数(超出的清理最早的已完成/失败任务)
|
||
|
||
|
||
def _resolve_session_storage_dir(workspace: str | None) -> str | None:
|
||
"""Keep MCP runtime session files inside an explicit or workspace-local root."""
|
||
|
||
explicit = os.environ.get("OPENSPACE_SESSION_STORAGE_DIR")
|
||
if explicit:
|
||
return str(Path(explicit).expanduser().resolve())
|
||
|
||
config_home = os.environ.get("OPENSPACE_CONFIG_HOME")
|
||
if config_home:
|
||
return str(Path(config_home).expanduser().resolve())
|
||
|
||
if workspace:
|
||
return str((Path(workspace).expanduser().resolve() / ".openspace"))
|
||
|
||
return None
|
||
|
||
|
||
async def _get_openspace():
|
||
"""Lazy-initialise the OpenSpace engine."""
|
||
global _openspace_instance
|
||
if _openspace_instance is not None and _openspace_instance.is_initialized():
|
||
return _openspace_instance
|
||
|
||
async with _openspace_lock:
|
||
if _openspace_instance is not None and _openspace_instance.is_initialized():
|
||
return _openspace_instance
|
||
|
||
logger.info("Initializing OpenSpace engine ...")
|
||
from openspace import OpenSpace, OpenSpaceConfig
|
||
from openspace.host_detection import (
|
||
build_grounding_config_path,
|
||
build_llm_kwargs,
|
||
load_runtime_env,
|
||
)
|
||
|
||
load_runtime_env()
|
||
|
||
env_model = os.environ.get("OPENSPACE_MODEL", "")
|
||
workspace = os.environ.get("OPENSPACE_WORKSPACE")
|
||
max_iter = int(os.environ.get("OPENSPACE_MAX_ITERATIONS", "20"))
|
||
enable_rec = os.environ.get("OPENSPACE_ENABLE_RECORDING", "true").lower() in ("true", "1", "yes")
|
||
|
||
backend_scope_raw = os.environ.get("OPENSPACE_BACKEND_SCOPE")
|
||
backend_scope = (
|
||
[b.strip() for b in backend_scope_raw.split(",") if b.strip()]
|
||
if backend_scope_raw else None
|
||
)
|
||
|
||
config_path = build_grounding_config_path()
|
||
model, llm_kwargs = build_llm_kwargs(env_model)
|
||
|
||
_pkg_root = str(_PROJECT_ROOT)
|
||
recording_base = workspace or _pkg_root
|
||
recording_log_dir = str(Path(recording_base) / "logs" / "recordings")
|
||
|
||
config = OpenSpaceConfig(
|
||
llm_model=model,
|
||
llm_kwargs=llm_kwargs,
|
||
workspace_dir=workspace,
|
||
session_storage_dir=_resolve_session_storage_dir(workspace),
|
||
grounding_max_iterations=max_iter,
|
||
enable_recording=enable_rec,
|
||
recording_backends=["shell"] if enable_rec else None, # ["shell", "mcp", "web"] if enable_rec else None
|
||
recording_log_dir=recording_log_dir,
|
||
backend_scope=backend_scope,
|
||
grounding_config_path=config_path,
|
||
)
|
||
|
||
_openspace_instance = OpenSpace(config=config)
|
||
await _openspace_instance.initialize()
|
||
logger.info("OpenSpace engine ready (model=%s).", model)
|
||
|
||
# Auto-register host bot skill directories from env (set once by human)
|
||
host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
|
||
if host_skill_dirs_raw:
|
||
dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()]
|
||
if dirs:
|
||
await _auto_register_skill_dirs(dirs)
|
||
logger.info("Auto-registered host skill dirs from OPENSPACE_HOST_SKILL_DIRS: %s", dirs)
|
||
|
||
return _openspace_instance
|
||
|
||
|
||
async def _get_runtime_store(*, required: bool = True):
|
||
"""Return the SkillStore owned by the OpenSpace runtime."""
|
||
|
||
openspace = await _get_openspace()
|
||
store = openspace.get_skill_store()
|
||
if store and not getattr(store, "_closed", False):
|
||
return store
|
||
if required:
|
||
raise RuntimeError("SkillStore is not initialized")
|
||
return None
|
||
|
||
|
||
async def _get_cloud_mapping_store():
|
||
from openspace.cloud.local_mapping import CloudLocalMappingStore
|
||
|
||
db_path = None
|
||
if _openspace_instance is not None:
|
||
try:
|
||
store = _openspace_instance.get_skill_store()
|
||
if store and not getattr(store, "_closed", False):
|
||
db_path = getattr(store, "db_path", None)
|
||
except Exception as exc:
|
||
logger.debug(f"Cloud mapping store DB path lookup failed: {exc}")
|
||
return CloudLocalMappingStore(db_path)
|
||
|
||
|
||
def _get_cloud_client(*, mapping_store=None):
|
||
"""Get a OpenSpaceClient instance (raises CloudError if not configured)."""
|
||
from openspace.cloud.client import OpenSpaceClient
|
||
from openspace.cloud.config import load_cloud_config
|
||
|
||
return OpenSpaceClient(load_cloud_config(), mapping_store=mapping_store)
|
||
|
||
|
||
def _normalize_upload_origin(origin: str) -> str:
|
||
mapping = {
|
||
"imported": "imported",
|
||
"captured": "capture",
|
||
"capture": "capture",
|
||
"derived": "derive",
|
||
"derive": "derive",
|
||
"fixed": "fix",
|
||
"fix": "fix",
|
||
}
|
||
normalized = mapping.get(origin)
|
||
if not normalized:
|
||
raise ValueError("origin must be imported, captured/capture, derived/derive, or fixed/fix")
|
||
return normalized
|
||
|
||
|
||
def _cloud_available_for_implicit_use() -> bool:
|
||
"""Return whether automatic cloud discovery should run."""
|
||
|
||
try:
|
||
from openspace.cloud.config import load_cloud_config
|
||
|
||
config = load_cloud_config()
|
||
except Exception as exc:
|
||
logger.warning("Cloud config invalid; skipping automatic cloud use: %s", exc)
|
||
return False
|
||
if not config.enabled:
|
||
return False
|
||
if not config.api_key:
|
||
logger.warning("OPENSPACE_CLOUD_API_KEY is required; skipping automatic cloud use")
|
||
return False
|
||
return True
|
||
|
||
|
||
def _write_upload_meta(skill_dir: Path, info: Dict[str, Any]) -> None:
|
||
"""Write ``.upload_meta.json`` so ``upload_skill`` can read pre-saved metadata.
|
||
|
||
Called after a validated evolution action commits.
|
||
The upload tool still requires package placement for non-fix v2 uploads
|
||
unless placement has already been saved in this metadata.
|
||
"""
|
||
meta = {
|
||
"origin": info.get("origin", "imported"),
|
||
"parent_local_skill_ids": info.get("parent_local_skill_ids", []),
|
||
"parent_cloud_skill_ids": info.get("parent_cloud_skill_ids", []),
|
||
"change_summary": info.get("change_summary", ""),
|
||
"created_by": info.get("created_by", "openspace"),
|
||
"tags": info.get("tags", []),
|
||
}
|
||
if isinstance(info.get("upload_placement"), dict):
|
||
meta["upload_placement"] = info["upload_placement"]
|
||
meta_path = skill_dir / _UPLOAD_META_FILENAME
|
||
try:
|
||
meta_path.write_text(
|
||
json.dumps(meta, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
logger.debug(f"Wrote upload metadata to {meta_path}")
|
||
except Exception as e:
|
||
logger.warning(f"Failed to write upload metadata: {e}")
|
||
|
||
|
||
def _save_upload_meta(skill_dir: Path, meta: Dict[str, Any]) -> None:
|
||
meta_path = skill_dir / _UPLOAD_META_FILENAME
|
||
meta_path.write_text(
|
||
json.dumps(meta, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
async def _read_upload_meta(skill_dir: Path) -> Dict[str, Any]:
|
||
"""Read upload metadata with three-tier fallback.
|
||
|
||
Resolution order:
|
||
1. ``.upload_meta.json`` sidecar file (written right after evolution)
|
||
2. SkillStore DB lookup by path (long-term persistence)
|
||
3. Empty dict (caller applies defaults)
|
||
|
||
This ensures metadata survives even if the sidecar file is deleted
|
||
or the user comes back to upload much later.
|
||
"""
|
||
# Tier 1: sidecar file
|
||
meta_path = skill_dir / _UPLOAD_META_FILENAME
|
||
if meta_path.exists():
|
||
try:
|
||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||
if data:
|
||
return data
|
||
except (json.JSONDecodeError, OSError) as e:
|
||
logger.warning(f"Failed to read upload metadata file: {e}")
|
||
|
||
# Tier 2: DB lookup
|
||
try:
|
||
store = await _get_runtime_store(required=False)
|
||
if store:
|
||
rec = store.load_record_by_path(str(skill_dir))
|
||
if rec:
|
||
logger.debug(f"Upload metadata resolved from DB for {skill_dir}")
|
||
return {
|
||
"origin": rec.lineage.origin.value,
|
||
"parent_local_skill_ids": rec.lineage.parent_skill_ids,
|
||
"parent_cloud_skill_ids": [],
|
||
"change_summary": rec.lineage.change_summary,
|
||
"created_by": rec.lineage.created_by or "",
|
||
"tags": rec.tags,
|
||
}
|
||
except Exception as e:
|
||
logger.debug(f"DB upload metadata lookup failed: {e}")
|
||
|
||
return {}
|
||
|
||
|
||
async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int:
|
||
"""Register bot skill directories into OpenSpace's SkillRegistry + DB.
|
||
|
||
Called automatically by ``execute_task`` on every invocation. Directories
|
||
are re-scanned each time so that skills created by the host bot since the last call are discovered immediately.
|
||
"""
|
||
global _registered_skill_dirs
|
||
|
||
valid_dirs = [Path(d) for d in skill_dirs if Path(d).is_dir()]
|
||
if not valid_dirs:
|
||
return 0
|
||
|
||
openspace = await _get_openspace()
|
||
_register_evidence_read_roots(openspace, *valid_dirs)
|
||
registry = openspace.get_skill_registry()
|
||
if not registry:
|
||
logger.warning("_auto_register_skill_dirs: SkillRegistry not initialized")
|
||
return 0
|
||
|
||
added = registry.discover_from_dirs(valid_dirs)
|
||
|
||
db_created = 0
|
||
if added:
|
||
store = await _get_runtime_store(required=False)
|
||
if store:
|
||
db_created = await store.sync_from_registry(added)
|
||
|
||
is_first = any(d not in _registered_skill_dirs for d in skill_dirs)
|
||
for d in skill_dirs:
|
||
_registered_skill_dirs.add(d)
|
||
|
||
if added:
|
||
action = "Auto-registered" if is_first else "Re-scanned & found"
|
||
logger.info(
|
||
f"{action} {len(added)} skill(s) from {len(valid_dirs)} dir(s), "
|
||
f"{db_created} new DB record(s)"
|
||
)
|
||
return len(added)
|
||
|
||
|
||
def _register_evidence_read_roots(openspace: Any, *roots: Any) -> None:
|
||
runtime = getattr(openspace, "runtime", None)
|
||
register = getattr(runtime, "register_evidence_read_roots", None)
|
||
if not callable(register):
|
||
return
|
||
try:
|
||
register(*roots)
|
||
except Exception:
|
||
logger.debug("MCP evidence read root registration skipped", exc_info=True)
|
||
|
||
|
||
async def _cloud_search_candidates(task: str, limit: int = 8) -> List[Dict[str, Any]]:
|
||
"""Search cloud for skills relevant to *task* and return import candidates.
|
||
|
||
This is intentionally discovery-only. Cloud skills must not be downloaded
|
||
silently because the agent needs to inspect the local package taxonomy and
|
||
choose a ``local_category_path`` before import.
|
||
"""
|
||
try:
|
||
normalized_task_query = task.strip()
|
||
if not normalized_task_query:
|
||
return []
|
||
|
||
cloud_client = _get_cloud_client()
|
||
cloud_search_results = await asyncio.to_thread(
|
||
cloud_client.search_cloud_skills,
|
||
query=normalized_task_query,
|
||
limit=min(limit * 2, 50),
|
||
)
|
||
if not cloud_search_results:
|
||
return []
|
||
|
||
cloud_hits = [
|
||
cloud_result for cloud_result in cloud_search_results
|
||
if cloud_result.get("cloud_skill_id")
|
||
][:limit]
|
||
|
||
import_results: List[Dict[str, Any]] = []
|
||
for cloud_hit in cloud_hits:
|
||
cloud_skill_id = str(cloud_hit.get("cloud_skill_id") or "")
|
||
if not cloud_skill_id:
|
||
continue
|
||
import_results.append({
|
||
"cloud_skill_id": cloud_skill_id,
|
||
"name": cloud_hit.get("name", ""),
|
||
"summary": cloud_hit.get("description") or cloud_hit.get("summary", ""),
|
||
"package_id": cloud_hit.get("package_id", ""),
|
||
"package_path": cloud_hit.get("package_path", ""),
|
||
"import_status": "needs_local_category_path",
|
||
"required_next_tool": "cloud_browse_skills",
|
||
"required_next_action": "local_taxonomy_then_import_skill",
|
||
})
|
||
|
||
if import_results:
|
||
logger.info(
|
||
"Cloud search found %d skill candidate(s); explicit local taxonomy "
|
||
"selection is required before import",
|
||
len(import_results),
|
||
)
|
||
return import_results
|
||
|
||
except Exception as e:
|
||
logger.warning(f"_cloud_search_candidates failed (non-fatal): {e}")
|
||
return []
|
||
|
||
|
||
async def _do_import_cloud_skill(
|
||
cloud_skill_id: str,
|
||
target_dir: Optional[str] = None,
|
||
*,
|
||
local_category: str | None = None,
|
||
local_category_path: str | None = None,
|
||
) -> Dict[str, Any]:
|
||
"""Download a cloud skill and register it locally."""
|
||
client = _get_cloud_client()
|
||
|
||
if target_dir:
|
||
base_dir = Path(target_dir)
|
||
else:
|
||
host_ws = (
|
||
os.environ.get("NANOBOT_WORKSPACE")
|
||
or os.environ.get("OPENCLAW_STATE_DIR")
|
||
)
|
||
if host_ws:
|
||
base_dir = Path(host_ws) / "skills"
|
||
base_dir.mkdir(parents=True, exist_ok=True)
|
||
else:
|
||
openspace = await _get_openspace()
|
||
grounding_config = openspace.get_grounding_config()
|
||
skill_cfg = grounding_config.skills if grounding_config else None
|
||
if skill_cfg and skill_cfg.skill_dirs:
|
||
base_dir = Path(skill_cfg.skill_dirs[0])
|
||
else:
|
||
base_dir = _PACKAGE_ROOT / "skills"
|
||
|
||
result = await asyncio.to_thread(
|
||
client.import_skill,
|
||
cloud_skill_id,
|
||
base_dir,
|
||
local_category=local_category,
|
||
local_category_path=local_category_path,
|
||
)
|
||
|
||
skill_dir = Path(result.get("local_path", ""))
|
||
if skill_dir.exists():
|
||
openspace = await _get_openspace()
|
||
_register_evidence_read_roots(openspace, skill_dir)
|
||
registry = openspace.get_skill_registry()
|
||
if registry:
|
||
meta = registry.register_skill_dir(skill_dir)
|
||
if meta:
|
||
store = await _get_runtime_store(required=False)
|
||
if store:
|
||
await store.sync_from_registry([meta])
|
||
_register_evidence_read_roots(openspace, skill_dir)
|
||
result["registered"] = True
|
||
|
||
result.setdefault("registered", False)
|
||
return result
|
||
|
||
|
||
def _resolve_cloud_import_base_dir(target_dir: Optional[str] = None) -> Path:
|
||
"""Resolve a writable local root for cloud package imports."""
|
||
|
||
if target_dir:
|
||
base_dir = Path(target_dir)
|
||
else:
|
||
host_ws = (
|
||
os.environ.get("NANOBOT_WORKSPACE")
|
||
or os.environ.get("OPENCLAW_STATE_DIR")
|
||
or os.environ.get("OPENSPACE_WORKSPACE")
|
||
)
|
||
base_dir = Path(host_ws) / "skills" if host_ws else _PACKAGE_ROOT / "skills"
|
||
base_dir.mkdir(parents=True, exist_ok=True)
|
||
return base_dir
|
||
|
||
|
||
def _summarize_recall_package(package: Dict[str, Any]) -> Dict[str, Any]:
|
||
previews = [
|
||
{
|
||
"cloud_skill_id": str(preview.get("cloud_skill_id") or ""),
|
||
"skill_name": str(preview.get("skill_name") or preview.get("title") or ""),
|
||
"preview_text": str(preview.get("preview_text") or preview.get("summary") or ""),
|
||
}
|
||
for preview in (package.get("preview_entries") or [])
|
||
if isinstance(preview, dict)
|
||
]
|
||
return {
|
||
"package_id": str(package.get("package_id") or ""),
|
||
"package_path": str(package.get("package_path") or ""),
|
||
"package_kind": str(package.get("package_kind") or ""),
|
||
"package_display_name": str(package.get("package_display_name") or ""),
|
||
"summary_line": str(package.get("summary_line") or ""),
|
||
"display_scope": str(package.get("display_scope") or ""),
|
||
"rank": package.get("rank"),
|
||
"score": package.get("score"),
|
||
"preview_entries": previews,
|
||
}
|
||
|
||
|
||
def _summarize_cloud_skill_candidate(skill: Dict[str, Any]) -> Dict[str, Any]:
|
||
metadata = skill.get("authored_metadata")
|
||
if not isinstance(metadata, dict):
|
||
metadata = {}
|
||
return {
|
||
"cloud_skill_id": str(skill.get("cloud_skill_id") or skill.get("skill_id") or ""),
|
||
"title": str(skill.get("title") or metadata.get("name") or skill.get("skill_name") or ""),
|
||
"summary": str(skill.get("summary") or metadata.get("description") or skill.get("snippet") or ""),
|
||
"package_id": str(skill.get("package_id") or ""),
|
||
"package_path": str(skill.get("package_path") or ""),
|
||
"effective_visibility": str(skill.get("effective_visibility") or skill.get("access_mode") or ""),
|
||
"manifest_hash": skill.get("manifest_hash"),
|
||
"artifact_dir": skill.get("artifact_dir"),
|
||
"artifact_state": skill.get("artifact_state"),
|
||
"downloadable": skill.get("downloadable"),
|
||
"metadata_only": skill.get("metadata_only"),
|
||
"rank": skill.get("rank"),
|
||
"score": skill.get("score"),
|
||
"match_mode": skill.get("match_mode"),
|
||
"snippet": skill.get("snippet"),
|
||
}
|
||
|
||
|
||
def _summarize_projection_pull(
|
||
pull: Dict[str, Any],
|
||
*,
|
||
max_packages: int,
|
||
max_skills: int,
|
||
) -> Dict[str, Any]:
|
||
all_packages = pull.get("packages") or []
|
||
all_skills = pull.get("skills") or []
|
||
packages = [
|
||
{
|
||
"package_id": str(package.get("package_id") or ""),
|
||
"package_path": str(package.get("package_path") or ""),
|
||
"package_kind": str(package.get("package_kind") or ""),
|
||
"package_display_name": str(package.get("package_display_name") or ""),
|
||
"parent_package_id": package.get("parent_package_id"),
|
||
"outline_path": package.get("outline_path"),
|
||
}
|
||
for package in all_packages[:max_packages]
|
||
if isinstance(package, dict)
|
||
]
|
||
skills = [
|
||
_summarize_cloud_skill_candidate(skill)
|
||
for skill in all_skills[:max_skills]
|
||
if isinstance(skill, dict)
|
||
]
|
||
return {
|
||
"root_package_id": str(pull.get("root_package_id") or ""),
|
||
"root_package_path": str(pull.get("root_package_path") or ""),
|
||
"bundle_version": pull.get("bundle_version"),
|
||
"projection_hash": pull.get("projection_hash"),
|
||
"serving_epoch": pull.get("serving_epoch"),
|
||
"source_epoch": pull.get("source_epoch"),
|
||
"packages": packages,
|
||
"skills": skills,
|
||
"package_count": len(all_packages),
|
||
"skill_count": len(all_skills),
|
||
"packages_truncated": len(all_packages) > len(packages),
|
||
"skills_truncated": len(all_skills) > len(skills),
|
||
}
|
||
|
||
|
||
# MCP Tools
|
||
@mcp.tool()
|
||
async def cloud_auth_flow(
|
||
action: str,
|
||
email: str | None = None,
|
||
password: str | None = None,
|
||
name: str | None = None,
|
||
agent_name: str = "openspace-local-agent",
|
||
agent_id: str | None = None,
|
||
persist: bool = True,
|
||
credentials_path: str | None = None,
|
||
) -> str:
|
||
"""Register/login users and provision cloud agent API keys.
|
||
|
||
Supported actions:
|
||
- register_user
|
||
- login_user
|
||
- bootstrap_agent_key
|
||
- list_agents
|
||
- rotate_agent_key
|
||
- verify_agent_key
|
||
|
||
This tool never returns raw API keys, bearer tokens, or passwords. Agent
|
||
keys created by bootstrap/rotate are saved to local OPENSPACE_CLOUD_* config
|
||
by default. ``agent_name`` is owner-scoped: recovery only searches the
|
||
currently authenticated user's agents.
|
||
"""
|
||
|
||
try:
|
||
from openspace.cloud.auth_flow import cloud_auth_flow as run_cloud_auth_flow
|
||
|
||
result = await asyncio.to_thread(
|
||
run_cloud_auth_flow,
|
||
action=action,
|
||
email=email,
|
||
password=password,
|
||
name=name,
|
||
agent_name=agent_name,
|
||
agent_id=agent_id,
|
||
persist=persist,
|
||
credentials_path=credentials_path,
|
||
)
|
||
return _json_ok(result)
|
||
except Exception as e:
|
||
from openspace.cloud.redaction import redact_cloud_secret
|
||
|
||
logger.error("cloud_auth_flow failed: %s", redact_cloud_secret(str(e)), exc_info=True)
|
||
return _json_error(redact_cloud_secret(str(e)), status="error")
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 异步任务状态辅助(execute_task 异步模式 + get_task_status 轮询)
|
||
# ----------------------------------------------------------------------
|
||
def _new_task_id() -> str:
|
||
return "task_" + uuid.uuid4().hex[:12]
|
||
|
||
|
||
def _update_task(
|
||
task_id: str,
|
||
*,
|
||
progress: float | None = None,
|
||
total: float | None = None,
|
||
message: str | None = None,
|
||
status: str | None = None,
|
||
result: Dict[str, Any] | None = None,
|
||
error: str | None = None,
|
||
) -> None:
|
||
"""更新任务状态;message 非空时追加一条分步日志(一步一报备)。"""
|
||
state = _TASKS.get(task_id)
|
||
if state is None:
|
||
return
|
||
now = time.time()
|
||
state["updated_at"] = now
|
||
if progress is not None:
|
||
state["progress"] = progress
|
||
if total is not None:
|
||
state["total"] = total
|
||
if message is not None:
|
||
state["progress_message"] = message
|
||
state["logs"].append({
|
||
"ts": now,
|
||
"percent": int(state["progress"] / state["total"] * 100) if state["total"] else 0,
|
||
"message": message,
|
||
})
|
||
if len(state["logs"]) > _TASK_LOG_LIMIT:
|
||
state["logs"] = state["logs"][-_TASK_LOG_LIMIT:]
|
||
if status is not None:
|
||
state["status"] = status
|
||
if result is not None:
|
||
state["result"] = result
|
||
if error is not None:
|
||
state["error"] = error
|
||
|
||
|
||
async def _task_progress_callback(
|
||
task_id: str, progress: float, total: float, message: str
|
||
) -> None:
|
||
"""agent 每轮迭代的进度回调 → 落到任务状态(迭代数换算成百分比)。"""
|
||
pct = round(progress / total * 100) if total else 0
|
||
_update_task(task_id, progress=pct, total=100, message=message)
|
||
|
||
|
||
def _prune_tasks() -> None:
|
||
"""限制内存中保留的任务数:优先清掉最早结束(completed/failed)的任务。"""
|
||
if len(_TASKS) <= _TASK_KEEP_MAX:
|
||
return
|
||
finished = [
|
||
tid for tid, st in _TASKS.items() if st["status"] in ("completed", "failed")
|
||
]
|
||
finished.sort(key=lambda tid: _TASKS[tid]["updated_at"])
|
||
for tid in finished[: len(_TASKS) - _TASK_KEEP_MAX]:
|
||
_TASKS.pop(tid, None)
|
||
|
||
|
||
@mcp.tool()
|
||
async def execute_task(
|
||
task: str,
|
||
workspace_dir: str | None = None,
|
||
max_iterations: int | None = None,
|
||
skill_dirs: list[str] | None = None,
|
||
search_scope: str = "all",
|
||
wait: bool = False,
|
||
) -> str:
|
||
"""Execute a task with OpenSpace's full grounding engine.
|
||
|
||
OpenSpace will:
|
||
1. Auto-register bot skills from skill_dirs (if provided)
|
||
2. Search for relevant skills (scope controls local vs cloud+local)
|
||
3. Attempt skill-guided execution → fallback to pure tools
|
||
4. Auto-analyze → auto-evolve (FIX/DERIVED/CAPTURED) if needed
|
||
|
||
If skills are auto-evolved, the response includes ``evolved_skills``
|
||
with ``upload_ready: true``. For non-fix uploads without pre-saved
|
||
placement, call ``upload_skill`` with ``skill_dir``, ``visibility``, and
|
||
``cloud_package_path``.
|
||
|
||
**异步模式(默认 wait=False)**:立即返回 ``task_id`` 并在后台执行,
|
||
长任务不会触发客户端工具调用超时。用 ``get_task_status(task_id)``
|
||
轮询分步进度(status / percent / current_step / logs / result)。
|
||
传 ``wait=True`` 则阻塞到任务结束并直接返回完整结果(兼容旧调用方)。
|
||
|
||
Args:
|
||
task: The task instruction (natural language).
|
||
workspace_dir: Working directory. Defaults to OPENSPACE_WORKSPACE env.
|
||
max_iterations: Max agent iterations (default: 20).
|
||
skill_dirs: Bot's skill directories to auto-register so OpenSpace
|
||
can select and track them. Directories are re-scanned
|
||
on every call to discover skills created since the last
|
||
invocation.
|
||
search_scope: Skill search scope before execution.
|
||
"all" (default) — local + cloud; falls back to local
|
||
if no API key is configured.
|
||
"local" — local SkillRegistry only (fast, no cloud).
|
||
wait: True 则同步阻塞并返回完整结果;默认 False(异步+轮询)。
|
||
"""
|
||
task_id = _new_task_id()
|
||
_prune_tasks()
|
||
_TASKS[task_id] = {
|
||
"task_id": task_id,
|
||
"status": "running",
|
||
"created_at": time.time(),
|
||
"updated_at": time.time(),
|
||
"task": task,
|
||
"progress": 0,
|
||
"total": 100,
|
||
"progress_message": "任务已提交,排队启动中...",
|
||
"logs": [],
|
||
"result": None,
|
||
"error": None,
|
||
}
|
||
logger.info(f"execute_task[{task_id}] submitted (wait={wait})")
|
||
|
||
runner = asyncio.create_task(
|
||
_run_execute_task(
|
||
task_id=task_id,
|
||
task=task,
|
||
workspace_dir=workspace_dir,
|
||
max_iterations=max_iterations,
|
||
skill_dirs=skill_dirs,
|
||
search_scope=search_scope,
|
||
)
|
||
)
|
||
|
||
if not wait:
|
||
# 异步:立即返回,客户端轮询 get_task_status
|
||
return _json_ok({
|
||
"task_id": task_id,
|
||
"status": "running",
|
||
"message": "任务已提交到后台执行。轮询 get_task_status(task_id) 获取进度与结果。",
|
||
"poll_tool": "get_task_status",
|
||
})
|
||
|
||
# wait=True:阻塞到完成(供 CLI / 测试脚本等同步调用方使用)
|
||
try:
|
||
await runner
|
||
except Exception as e:
|
||
logger.error(f"execute_task[{task_id}] background failed: {e}", exc_info=True)
|
||
state = _TASKS.get(task_id)
|
||
if state is None:
|
||
return _json_error("task state lost", task_id=task_id, status="error")
|
||
if state["status"] == "completed":
|
||
return _json_ok(state["result"])
|
||
if state["error"]:
|
||
return _json_error(state["error"], task_id=task_id, status="error")
|
||
return _json_error("task did not complete", task_id=task_id, status="error")
|
||
|
||
|
||
async def _run_execute_task(
|
||
task_id: str,
|
||
task: str,
|
||
workspace_dir: str | None,
|
||
max_iterations: int | None,
|
||
skill_dirs: list[str] | None,
|
||
search_scope: str,
|
||
) -> None:
|
||
"""后台执行 execute_task 的主体逻辑,并把分步进度写入任务状态。
|
||
|
||
这是原 execute_task 的同步体:预热 → 注册技能 → 云检索 → 执行 →
|
||
写 upload_meta → 格式化。每一步都以「日志条目」落到 _TASKS,
|
||
客户端通过 get_task_status 看到长任务的分步过程(一步一报备)。
|
||
"""
|
||
try:
|
||
_update_task(task_id, message="OpenSpace 引擎预热中,请稍候...")
|
||
openspace = await _get_openspace()
|
||
|
||
# Re-scan host skill directories (from env) to pick up skills
|
||
# created by the host bot since the last call.
|
||
host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
|
||
if host_skill_dirs_raw:
|
||
env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()]
|
||
if env_dirs:
|
||
_update_task(task_id, message="注册宿主技能目录...")
|
||
await _auto_register_skill_dirs(env_dirs)
|
||
|
||
# Auto-register bot skill directories (from call parameter)
|
||
if skill_dirs:
|
||
_update_task(task_id, message="注册机器人技能目录...")
|
||
await _auto_register_skill_dirs(skill_dirs)
|
||
|
||
# Determine where CAPTURED skills should be written.
|
||
# Prefer the explicit skill_dirs parameter (= calling host agent's dir),
|
||
# then fall back to the first env-based host skill dir.
|
||
capture_skill_dir: str | None = None
|
||
if skill_dirs:
|
||
capture_skill_dir = skill_dirs[0]
|
||
elif host_skill_dirs_raw:
|
||
first_env = next(
|
||
(d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()),
|
||
None,
|
||
)
|
||
if first_env:
|
||
capture_skill_dir = first_env
|
||
|
||
# Cloud search + candidates (if requested)
|
||
cloud_skill_candidates: List[Dict[str, Any]] = []
|
||
if search_scope == "all" and _cloud_available_for_implicit_use():
|
||
_update_task(task_id, message="检索云端技能...")
|
||
cloud_skill_candidates = await _cloud_search_candidates(task)
|
||
|
||
# Execute
|
||
_update_task(task_id, message="开始执行任务(每轮迭代持续上报进度)...")
|
||
result = await openspace.execute(
|
||
ExecutionRequest(
|
||
prompt=task,
|
||
workspace_dir=workspace_dir,
|
||
max_iterations=max_iterations,
|
||
capture_skill_dir=capture_skill_dir,
|
||
)
|
||
)
|
||
|
||
# Write .upload_meta.json for each evolved skill
|
||
for es in result.evolved_skills:
|
||
if not isinstance(es, dict):
|
||
continue
|
||
skill_path = es.get("path", "")
|
||
if skill_path:
|
||
_write_upload_meta(Path(skill_path).parent, es)
|
||
|
||
formatted = _format_task_result(result)
|
||
if cloud_skill_candidates:
|
||
formatted["cloud_skill_candidates"] = cloud_skill_candidates
|
||
formatted["cloud_action_required"] = (
|
||
"Cloud candidates were found but not imported automatically. "
|
||
"Use cloud_browse_skills(action='local_taxonomy') and then "
|
||
"cloud_browse_skills(action='import_skill', cloud_skill_id=..., "
|
||
"local_category_path=...) before expecting them in local retrieval."
|
||
)
|
||
|
||
_update_task(
|
||
task_id,
|
||
progress=100,
|
||
total=100,
|
||
message="任务执行完成,正在汇总结果...",
|
||
status="completed",
|
||
result=formatted,
|
||
)
|
||
logger.info(f"execute_task[{task_id}] completed")
|
||
|
||
except Exception as e:
|
||
logger.error(f"execute_task[{task_id}] failed: {e}", exc_info=True)
|
||
_update_task(
|
||
task_id,
|
||
status="failed",
|
||
message=f"任务失败:{str(e)[:200]}",
|
||
error=str(e),
|
||
)
|
||
|
||
|
||
@mcp.tool()
|
||
async def get_task_status(task_id: str, include_logs: bool = True) -> str:
|
||
"""查询异步任务的状态与进度(配合 execute_task 的异步模式轮询)。
|
||
|
||
execute_task 默认立即返回 task_id;用本工具轮询即可拿到长任务的
|
||
分步执行过程(一步一报备):
|
||
- status: running / completed / failed
|
||
- percent / current_step: 当前进度与正在进行的步骤
|
||
- logs: 分步日志(引擎预热、技能注册、云检索、每轮迭代、汇总...)
|
||
- result: 任务完成后的完整结果(含 evolved_skills 等)
|
||
- error: 失败原因(status=failed 时)
|
||
|
||
Args:
|
||
task_id: execute_task 返回的任务 ID。
|
||
include_logs: 是否包含分步日志(默认 True)。
|
||
"""
|
||
state = _TASKS.get(task_id)
|
||
if state is None:
|
||
return _json_error(f"task not found: {task_id}", task_id=task_id, status="not_found")
|
||
|
||
out: Dict[str, Any] = {
|
||
"task_id": state["task_id"],
|
||
"status": state["status"],
|
||
"created_at": state["created_at"],
|
||
"updated_at": state["updated_at"],
|
||
"progress": state["progress"],
|
||
"total": state["total"],
|
||
"percent": int(state["progress"] / state["total"] * 100) if state["total"] else 0,
|
||
"current_step": state["progress_message"],
|
||
}
|
||
if include_logs:
|
||
out["logs"] = state["logs"]
|
||
if state["status"] == "completed" and state["result"] is not None:
|
||
out["result"] = state["result"]
|
||
if state["status"] == "failed":
|
||
out["error"] = state["error"]
|
||
return _json_ok(out)
|
||
|
||
|
||
@mcp.tool()
|
||
async def search_skills(
|
||
query: str,
|
||
limit: int = 20,
|
||
) -> str:
|
||
"""Search skills in the local OpenSpace registry.
|
||
|
||
Standalone local search for browsing / discovery. Use this when the bot
|
||
wants to inspect skills that are already installed locally, then decide
|
||
whether to handle the task locally or delegate to ``execute_task``.
|
||
|
||
**Scope difference from execute_task**:
|
||
- ``search_skills`` returns results to the bot for decision-making.
|
||
- ``execute_task``'s internal search feeds directly into execution
|
||
(the bot never sees the search results).
|
||
|
||
For cloud package/skill browsing, use ``cloud_browse_skills``. Keeping
|
||
cloud browsing out of this tool avoids overlapping agent choices.
|
||
|
||
Uses hybrid ranking: BM25 → embedding re-rank → lexical boost.
|
||
Embedding uses OpenAI when configured, then OpenRouter when configured,
|
||
and falls back to lexical-only without a remote embedding provider.
|
||
|
||
Args:
|
||
query: Search query text (natural language or keywords).
|
||
limit: Maximum results to return (default: 20).
|
||
"""
|
||
try:
|
||
from openspace.cloud.search import hybrid_search_skills
|
||
|
||
q = query.strip()
|
||
if not q:
|
||
return _json_ok({"results": [], "count": 0})
|
||
|
||
# Re-scan host skill directories so newly created skills are searchable.
|
||
local_skills = None
|
||
store = None
|
||
openspace = await _get_openspace()
|
||
|
||
host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
|
||
if host_skill_dirs_raw:
|
||
env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()]
|
||
if env_dirs:
|
||
await _auto_register_skill_dirs(env_dirs)
|
||
|
||
registry = openspace.get_skill_registry()
|
||
if registry:
|
||
local_skills = registry.list_skills()
|
||
store = await _get_runtime_store(required=False)
|
||
|
||
results = await hybrid_search_skills(
|
||
query=q,
|
||
local_skills=local_skills,
|
||
store=store,
|
||
source="local",
|
||
limit=limit,
|
||
)
|
||
|
||
return _json_ok({"results": results, "count": len(results), "source": "local"})
|
||
|
||
except Exception as e:
|
||
logger.error(f"search_skills failed: {e}", exc_info=True)
|
||
return _json_error(e)
|
||
|
||
|
||
@mcp.tool()
|
||
async def cloud_browse_skills(
|
||
action: str,
|
||
query: str | None = None,
|
||
search_id: str | None = None,
|
||
package_ids: list[str] | None = None,
|
||
package_id: str | None = None,
|
||
cloud_skill_id: str | None = None,
|
||
target_dir: str | None = None,
|
||
local_category: str | None = None,
|
||
local_category_path: str | None = None,
|
||
limit: int = 8,
|
||
audience: str = "requester_visible",
|
||
artifact_filter: str = "downloadable_only",
|
||
max_packages_per_pull: int = 40,
|
||
max_skills_per_pull: int = 40,
|
||
) -> str:
|
||
"""Browse cloud packages/skills with one agent-facing stepwise tool.
|
||
|
||
This is the cloud discovery entrypoint for agents. It keeps the LLM in the
|
||
loop by returning package/skill summaries plus ``next_actions`` after each
|
||
step. The agent continues by calling this same tool with the next action.
|
||
|
||
Recommended action order:
|
||
1. ``search_skills`` with ``query`` to rank concrete downloadable skills.
|
||
Optionally pass ``package_id`` to restrict search to one package subtree.
|
||
2. ``fetch_skill_detail`` with ``cloud_skill_id`` when exact metadata is
|
||
needed before import.
|
||
3. ``local_placement`` to choose or create a local package taxonomy path.
|
||
4. ``import_skill`` with ``cloud_skill_id`` and ``local_category_path`` to
|
||
download/register the selected skill locally.
|
||
|
||
Optional package discovery actions:
|
||
1. ``recall`` with ``query`` to get package candidates and ``search_id``.
|
||
2. ``pull_projection`` with ``search_id`` + selected ``package_ids`` to
|
||
inspect JSON package projections and record package-selection telemetry.
|
||
3. ``search_skills`` with ``package_id`` to run skill-first search scoped
|
||
to a chosen package.
|
||
|
||
Optional: ``import_package_bundle`` with ``package_id`` downloads a selected
|
||
package bundle when the agent needs outline files or bundled artifacts.
|
||
|
||
Args:
|
||
action: One of ``local_placement``, ``local_taxonomy``, ``recall``,
|
||
``pull_projection``, ``search_skills``, ``fetch_skill_detail``,
|
||
``import_skill``, or ``import_package_bundle``.
|
||
query: Query text for ``search_skills`` and ``recall``.
|
||
search_id: ``search_id`` returned by ``recall``.
|
||
package_ids: Selected package ids for ``pull_projection``.
|
||
package_id: One selected package id for package-local actions.
|
||
cloud_skill_id: Selected cloud skill id for detail/import actions.
|
||
target_dir: Optional local import root.
|
||
local_category: Optional local skill type for ``import_skill``.
|
||
local_category_path: Optional local package taxonomy path for
|
||
``import_skill``. This is independent from cloud
|
||
package_path.
|
||
limit: Max package or skill candidates, depending on action.
|
||
"""
|
||
normalized_action = str(action or "").strip().lower().replace("-", "_")
|
||
aliases = {
|
||
"local_tree": "local_taxonomy",
|
||
"inspect_local_tree": "local_taxonomy",
|
||
"local_category_tree": "local_taxonomy",
|
||
"placement": "local_placement",
|
||
"local_place": "local_placement",
|
||
"choose_local_path": "local_placement",
|
||
"choose_local_category_path": "local_placement",
|
||
"recall_packages": "recall",
|
||
"pull": "pull_projection",
|
||
"projection": "pull_projection",
|
||
"package_projection": "pull_projection",
|
||
"skill_search": "search_skills",
|
||
"global_skill_search": "search_skills",
|
||
"fetch": "fetch_skill_detail",
|
||
"detail": "fetch_skill_detail",
|
||
"import": "import_skill",
|
||
"bundle": "import_package_bundle",
|
||
}
|
||
normalized_action = aliases.get(normalized_action, normalized_action)
|
||
|
||
if normalized_action == "local_placement":
|
||
return await cloud_local_placement(
|
||
query=query,
|
||
local_category=local_category,
|
||
local_category_path=local_category_path,
|
||
limit=limit,
|
||
)
|
||
if normalized_action == "local_taxonomy":
|
||
return await cloud_local_taxonomy(
|
||
query=query,
|
||
local_category=local_category,
|
||
local_category_path=local_category_path,
|
||
limit=limit,
|
||
)
|
||
if normalized_action == "recall":
|
||
return await cloud_recall_packages(
|
||
query=query or "",
|
||
limit=limit,
|
||
audience=audience,
|
||
)
|
||
if normalized_action == "pull_projection":
|
||
return await cloud_pull_package_projection(
|
||
search_id=search_id or "",
|
||
package_ids=package_ids or [],
|
||
audience=audience,
|
||
max_packages_per_pull=max_packages_per_pull,
|
||
max_skills_per_pull=max_skills_per_pull,
|
||
)
|
||
if normalized_action == "search_skills":
|
||
return await cloud_search_skills(
|
||
query=query or "",
|
||
package_id=package_id,
|
||
limit=limit,
|
||
audience=audience,
|
||
artifact_filter=artifact_filter,
|
||
)
|
||
if normalized_action == "fetch_skill_detail":
|
||
return await cloud_fetch_skill_detail(cloud_skill_id=cloud_skill_id or "")
|
||
if normalized_action == "import_skill":
|
||
return await cloud_import_skill(
|
||
cloud_skill_id=cloud_skill_id or "",
|
||
target_dir=target_dir,
|
||
local_category=local_category,
|
||
local_category_path=local_category_path,
|
||
)
|
||
if normalized_action == "import_package_bundle":
|
||
return await cloud_import_package_bundle(
|
||
package_id=package_id or "",
|
||
target_dir=target_dir,
|
||
audience=audience,
|
||
)
|
||
|
||
return _json_ok({
|
||
"status": "error",
|
||
"code": "UNKNOWN_CLOUD_BROWSE_ACTION",
|
||
"message": "Unknown cloud_browse_skills action",
|
||
"action": action,
|
||
"valid_actions": [
|
||
"local_placement",
|
||
"local_taxonomy",
|
||
"recall",
|
||
"pull_projection",
|
||
"search_skills",
|
||
"fetch_skill_detail",
|
||
"import_skill",
|
||
"import_package_bundle",
|
||
],
|
||
"recommended_sequence": [
|
||
"recall",
|
||
"pull_projection",
|
||
"search_skills",
|
||
"fetch_skill_detail",
|
||
"local_placement",
|
||
"import_skill",
|
||
],
|
||
})
|
||
|
||
|
||
async def cloud_local_taxonomy(
|
||
*,
|
||
query: str | None = None,
|
||
local_category: str | None = None,
|
||
local_category_path: str | None = None,
|
||
limit: int = 8,
|
||
) -> str:
|
||
"""Return a bounded local package taxonomy view for LLM placement."""
|
||
|
||
try:
|
||
return _json_ok(
|
||
await _local_taxonomy_payload(
|
||
query=query,
|
||
local_category=local_category,
|
||
local_category_path=local_category_path,
|
||
limit=limit,
|
||
)
|
||
)
|
||
except Exception as e:
|
||
logger.error("cloud_local_taxonomy failed: %s", e, exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
async def cloud_local_placement(
|
||
*,
|
||
query: str | None = None,
|
||
local_category: str | None = None,
|
||
local_category_path: str | None = None,
|
||
limit: int = 8,
|
||
) -> str:
|
||
"""Return an explicit local category-path placement flow for agents."""
|
||
|
||
try:
|
||
return _json_ok(
|
||
await _local_placement_payload(
|
||
query=query,
|
||
local_category=local_category,
|
||
local_category_path=local_category_path,
|
||
limit=limit,
|
||
)
|
||
)
|
||
except Exception as e:
|
||
logger.error("cloud_local_placement failed: %s", e, exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
async def _local_placement_payload(
|
||
*,
|
||
query: str | None = None,
|
||
local_category: str | None = None,
|
||
local_category_path: str | None = None,
|
||
limit: int = 8,
|
||
) -> dict[str, Any]:
|
||
taxonomy = await _local_taxonomy_payload(
|
||
query=query,
|
||
local_category=local_category,
|
||
local_category_path=local_category_path,
|
||
limit=limit,
|
||
)
|
||
candidates = _local_taxonomy_candidate_paths(taxonomy)
|
||
payload = {
|
||
**taxonomy,
|
||
"status": "needs_local_category_path",
|
||
"code": "LOCAL_CATEGORY_PATH_REQUIRED",
|
||
"placement_decision_required": True,
|
||
"interaction_flow": _local_placement_interaction_flow(),
|
||
"local_category_path_policy": _local_category_path_policy(),
|
||
"existing_path_candidates": candidates,
|
||
"new_child_path_examples": _local_new_child_path_examples(candidates),
|
||
}
|
||
if str(local_category_path or "").strip():
|
||
payload["candidate_local_category_path"] = str(local_category_path).strip()
|
||
payload["status"] = "local_category_path_candidate"
|
||
payload["code"] = "LOCAL_CATEGORY_PATH_CANDIDATE"
|
||
payload["next_actions"] = [
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "local_placement",
|
||
"reason": "Expand one returned path, search by task/skill terms, or inspect another local taxonomy branch.",
|
||
"optional_fields": ["local_category_path", "query", "local_category", "limit"],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "import_skill",
|
||
"reason": "For cloud imports, pass the selected or newly composed local_category_path.",
|
||
"required_fields": ["cloud_skill_id", "local_category_path"],
|
||
"optional_fields": ["local_category"],
|
||
},
|
||
{
|
||
"consumer": "evolution_suggestion",
|
||
"reason": "For DERIVED/CAPTURED generation, put the selected or newly composed path in decision.local_category_path.",
|
||
"required_fields": ["local_category_path"],
|
||
},
|
||
]
|
||
return payload
|
||
|
||
|
||
async def _local_taxonomy_payload(
|
||
*,
|
||
query: str | None = None,
|
||
local_category: str | None = None,
|
||
local_category_path: str | None = None,
|
||
limit: int = 8,
|
||
) -> dict[str, Any]:
|
||
from openspace.cloud.skill_classification import (
|
||
build_local_taxonomy_snapshot,
|
||
initialize_local_skill_taxonomy,
|
||
)
|
||
|
||
openspace = await _get_openspace()
|
||
registry = openspace.get_skill_registry()
|
||
local_skills = []
|
||
if registry:
|
||
local_skills = registry.list_skills()
|
||
mapping_store = await _get_cloud_mapping_store()
|
||
try:
|
||
bootstrap = initialize_local_skill_taxonomy(
|
||
mapping_store=mapping_store,
|
||
skills=local_skills,
|
||
)
|
||
payload = build_local_taxonomy_snapshot(
|
||
mapping_store=mapping_store,
|
||
skills=local_skills,
|
||
category=local_category,
|
||
path_prefix=local_category_path,
|
||
query=query,
|
||
max_paths=min(max(int(limit), 1), 25),
|
||
)
|
||
payload["bootstrap"] = {
|
||
"initialized_local_skill_count": bootstrap["created_count"],
|
||
"skipped_local_skill_count": bootstrap["skipped_count"],
|
||
}
|
||
finally:
|
||
mapping_store.close()
|
||
payload["next_actions"] = [
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "local_placement",
|
||
"reason": "Use the explicit local placement flow before choosing or creating local_category_path.",
|
||
"optional_fields": ["local_category", "local_category_path", "query", "limit"],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "import_skill",
|
||
"reason": "After selecting a local package taxonomy path, import the selected cloud skill.",
|
||
"required_fields": ["cloud_skill_id", "local_category_path"],
|
||
}
|
||
]
|
||
return payload
|
||
|
||
|
||
def _local_placement_interaction_flow() -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"step": "browse_roots_or_search",
|
||
"call": "cloud_browse_skills(action='local_placement', query=... optional)",
|
||
"agent_decision": "Inspect top-level local taxonomy roots or query-matched paths.",
|
||
},
|
||
{
|
||
"step": "expand_one_branch",
|
||
"call": "cloud_browse_skills(action='local_placement', local_category_path=...)",
|
||
"agent_decision": "Expand one candidate path instead of requesting the full local tree.",
|
||
},
|
||
{
|
||
"step": "choose_or_create",
|
||
"agent_decision": (
|
||
"Choose an existing existing_path_candidates[].local_category_path, "
|
||
"or compose a nearby new child path under a returned candidate."
|
||
),
|
||
},
|
||
{
|
||
"step": "use_selected_path",
|
||
"call": (
|
||
"cloud_browse_skills(action='import_skill', cloud_skill_id=..., "
|
||
"local_category_path=...) or include local_category_path in a DERIVED/CAPTURED decision"
|
||
),
|
||
},
|
||
]
|
||
|
||
|
||
def _local_category_path_policy() -> dict[str, Any]:
|
||
return {
|
||
"local_category_path_is_agent_selected": True,
|
||
"path_shape": "package-style taxonomy path, for example technology/computing/browser-automation",
|
||
"category_is_separate_skill_type": True,
|
||
"independent_from_cloud_package_path": True,
|
||
"allowed_forms": [
|
||
{
|
||
"form": "existing_local_taxonomy_path",
|
||
"source": "existing_path_candidates[].local_category_path",
|
||
},
|
||
{
|
||
"form": "new_child_local_taxonomy_path",
|
||
"source": "existing_path_candidates[].local_category_path + '/' + one_new_segment",
|
||
},
|
||
{
|
||
"form": "new_local_root_path",
|
||
"source": "allowed when no returned root fits the skill/task; keep it package-style and specific",
|
||
},
|
||
],
|
||
"new_path_creation": {
|
||
"allowed": True,
|
||
"preferred_increment": "one new child segment under a returned candidate",
|
||
"may_create_new_root": True,
|
||
"does_not_create_cloud_package": True,
|
||
"does_not_upload_or_download_anything": True,
|
||
},
|
||
"avoid": [
|
||
"Using category-only paths such as workflow or tool_guide as taxonomy placement.",
|
||
"Using a vague root when a more specific returned branch fits.",
|
||
"Treating local_category_path as cloud_package_path; they are separate fields.",
|
||
],
|
||
}
|
||
|
||
|
||
def _local_taxonomy_candidate_paths(taxonomy: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||
rows = []
|
||
for key in ("paths", "children", "roots", "sample_paths"):
|
||
value = taxonomy.get(key)
|
||
if isinstance(value, list):
|
||
rows.extend(item for item in value if isinstance(item, dict))
|
||
seen: set[str] = set()
|
||
candidates: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
path = str(row.get("local_category_path") or "").strip()
|
||
if not path or path in seen:
|
||
continue
|
||
seen.add(path)
|
||
candidates.append({
|
||
"local_category_path": path,
|
||
"skill_count": int(row.get("skill_count") or 0),
|
||
"skill_categories": dict(row.get("skill_categories") or {}),
|
||
"review_states": dict(row.get("review_states") or {}),
|
||
"sources": list(row.get("sources") or []),
|
||
"examples": list(row.get("examples") or [])[:3],
|
||
})
|
||
return candidates[:25]
|
||
|
||
|
||
def _local_new_child_path_examples(
|
||
candidates: list[dict[str, Any]],
|
||
*,
|
||
limit: int = 5,
|
||
) -> list[dict[str, str]]:
|
||
examples: list[dict[str, str]] = []
|
||
for candidate in candidates:
|
||
parent = str(candidate.get("local_category_path") or "").strip().strip("/")
|
||
if not parent:
|
||
continue
|
||
examples.append({
|
||
"parent_local_category_path": parent,
|
||
"local_category_path_example": f"{parent}/<one-new-local-package-segment>",
|
||
})
|
||
if len(examples) >= limit:
|
||
break
|
||
return examples
|
||
|
||
|
||
async def cloud_recall_packages(
|
||
query: str,
|
||
limit: int = 8,
|
||
audience: str = "requester_visible",
|
||
task_id: str | None = None,
|
||
) -> str:
|
||
"""Recall cloud package candidates and return them for LLM selection.
|
||
|
||
This is the first step of the LLM-guided cloud skill browsing flow. It
|
||
deliberately stops after ``POST /api/v2/recall/search`` so the agent can
|
||
inspect package summaries and choose which package ids to pull next.
|
||
|
||
Next typical call through the public tool:
|
||
``cloud_browse_skills(action="pull_projection", search_id=..., package_ids=...)``.
|
||
"""
|
||
|
||
try:
|
||
q = query.strip()
|
||
if not q:
|
||
return _json_ok({"status": "success", "search_id": "", "results": [], "count": 0})
|
||
client = _get_cloud_client()
|
||
payload = await asyncio.to_thread(
|
||
client.search_packages,
|
||
query=q,
|
||
audience=audience,
|
||
limit=min(max(int(limit), 1), 50),
|
||
task_id=task_id,
|
||
)
|
||
results = [
|
||
_summarize_recall_package(item)
|
||
for item in (payload.get("results") or [])
|
||
if isinstance(item, dict)
|
||
]
|
||
return _json_ok({
|
||
"status": "success",
|
||
"query": q,
|
||
"audience": payload.get("audience", audience),
|
||
"search_id": payload.get("search_id", ""),
|
||
"results": results,
|
||
"count": len(results),
|
||
"next_actions": [
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "pull_projection",
|
||
"reason": "Inspect selected package projection and record package-selection telemetry.",
|
||
"required_fields": ["search_id", "package_ids"],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "search_skills",
|
||
"reason": "Narrow a known package directly to concrete downloadable skills.",
|
||
"required_fields": ["package_id", "query"],
|
||
},
|
||
],
|
||
})
|
||
except Exception as e:
|
||
logger.error("cloud_recall_packages failed: %s", e, exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
async def cloud_pull_package_projection(
|
||
search_id: str,
|
||
package_ids: list[str],
|
||
audience: str = "requester_visible",
|
||
max_packages_per_pull: int = 40,
|
||
max_skills_per_pull: int = 40,
|
||
) -> str:
|
||
"""Pull JSON package projections selected from ``cloud_browse_skills(action="recall")``.
|
||
|
||
This calls ``POST /api/v2/packages/pull``. It returns package/skill
|
||
summaries for the LLM to decide whether to search inside a package, import
|
||
an exact skill, or import/download the package bundle for deeper outline
|
||
inspection.
|
||
"""
|
||
|
||
try:
|
||
clean_ids = [str(package_id).strip() for package_id in (package_ids or []) if str(package_id).strip()]
|
||
if not str(search_id or "").strip():
|
||
return _json_error("search_id is required from cloud_recall_packages")
|
||
if not clean_ids:
|
||
return _json_error("package_ids must contain at least one package_id")
|
||
client = _get_cloud_client()
|
||
payload = await asyncio.to_thread(
|
||
client.pull_packages,
|
||
package_ids=clean_ids,
|
||
search_id=str(search_id).strip(),
|
||
audience=audience,
|
||
)
|
||
pulls = [
|
||
_summarize_projection_pull(
|
||
pull,
|
||
max_packages=max(int(max_packages_per_pull), 1),
|
||
max_skills=max(int(max_skills_per_pull), 1),
|
||
)
|
||
for pull in (payload.get("pulls") or [])
|
||
if isinstance(pull, dict)
|
||
]
|
||
return _json_ok({
|
||
"status": "success",
|
||
"search_id": payload.get("search_id", search_id),
|
||
"audience": payload.get("audience", audience),
|
||
"pulls": pulls,
|
||
"count": len(pulls),
|
||
"next_actions": [
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "search_skills",
|
||
"reason": "Search concrete skills inside a chosen root_package_id.",
|
||
"required_fields": ["package_id", "query"],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "fetch_skill_detail",
|
||
"reason": "Inspect exact cloud skill metadata before import.",
|
||
"required_fields": ["cloud_skill_id"],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "local_taxonomy",
|
||
"reason": "Inspect the local package taxonomy before choosing where to import the cloud skill.",
|
||
"required_fields": [],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "import_skill",
|
||
"reason": "Download/register an exact selected cloud skill after choosing a local package taxonomy path.",
|
||
"required_fields": ["cloud_skill_id", "local_category_path"],
|
||
"optional_fields": ["local_category"],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "import_package_bundle",
|
||
"reason": "Download/import the package bundle when the agent wants the package outline and bundled artifacts.",
|
||
"required_fields": ["package_id"],
|
||
},
|
||
],
|
||
})
|
||
except Exception as e:
|
||
logger.error("cloud_pull_package_projection failed: %s", e, exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
async def cloud_search_skills(
|
||
query: str,
|
||
package_id: str | None = None,
|
||
limit: int = 10,
|
||
audience: str = "requester_visible",
|
||
artifact_filter: str = "downloadable_only",
|
||
) -> str:
|
||
"""Search concrete cloud skills through ``POST /api/v2/skills/search``."""
|
||
|
||
try:
|
||
q = str(query or "").strip()
|
||
pkg = str(package_id or "").strip()
|
||
if not q:
|
||
return _json_error("query is required")
|
||
client = _get_cloud_client()
|
||
payload = await asyncio.to_thread(
|
||
client.search_skills,
|
||
query=q,
|
||
package_id=pkg or None,
|
||
audience=audience,
|
||
limit=min(max(int(limit), 1), 50),
|
||
artifact_filter=artifact_filter,
|
||
)
|
||
results = [
|
||
_summarize_cloud_skill_candidate(item)
|
||
for item in (payload.get("results") or [])
|
||
if isinstance(item, dict)
|
||
]
|
||
return _json_ok({
|
||
"status": "success",
|
||
"endpoint": "/api/v2/skills/search",
|
||
"package_id": pkg,
|
||
"query": q,
|
||
"audience": payload.get("audience", audience),
|
||
"root_package_id": payload.get("root_package_id", pkg),
|
||
"root_package_path": payload.get("root_package_path", ""),
|
||
"skill_search_id": payload.get("skill_search_id", ""),
|
||
"requested_mode": payload.get("requested_mode", ""),
|
||
"served_mode": payload.get("served_mode", ""),
|
||
"semantic_status": payload.get("semantic_status", ""),
|
||
"fallback_reason": payload.get("fallback_reason", ""),
|
||
"artifact_filter": artifact_filter,
|
||
"results": results,
|
||
"count": len(results),
|
||
"next_actions": [
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "fetch_skill_detail",
|
||
"reason": "Inspect exact metadata for a selected cloud_skill_id.",
|
||
"required_fields": ["cloud_skill_id"],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "local_taxonomy",
|
||
"reason": "Inspect the local package taxonomy before choosing where to import the cloud skill.",
|
||
"required_fields": [],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "import_skill",
|
||
"reason": "Download/register a selected cloud skill after choosing a local package taxonomy path.",
|
||
"required_fields": ["cloud_skill_id", "local_category_path"],
|
||
"optional_fields": ["local_category"],
|
||
},
|
||
],
|
||
})
|
||
except Exception as e:
|
||
logger.error("cloud_search_skills failed: %s", e, exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
async def cloud_fetch_skill_detail(
|
||
cloud_skill_id: str,
|
||
) -> str:
|
||
"""Fetch exact cloud skill metadata before the LLM decides to import."""
|
||
|
||
try:
|
||
skill_id = str(cloud_skill_id or "").strip()
|
||
if not skill_id:
|
||
return _json_error("cloud_skill_id is required")
|
||
client = _get_cloud_client()
|
||
detail = await asyncio.to_thread(client.fetch_cloud_skill, skill_id)
|
||
return _json_ok({
|
||
"status": "success",
|
||
"skill": detail,
|
||
"summary": _summarize_cloud_skill_candidate(detail),
|
||
"next_actions": [
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "local_taxonomy",
|
||
"reason": "Inspect the local package taxonomy before choosing where to import this cloud skill.",
|
||
"required_fields": [],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "import_skill",
|
||
"reason": "Download/register this exact cloud skill after choosing a local package taxonomy path.",
|
||
"required_fields": ["cloud_skill_id", "local_category_path"],
|
||
"optional_fields": ["local_category"],
|
||
}
|
||
],
|
||
})
|
||
except Exception as e:
|
||
logger.error("cloud_fetch_skill_detail failed: %s", e, exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
async def cloud_import_skill(
|
||
cloud_skill_id: str,
|
||
target_dir: str | None = None,
|
||
local_category: str | None = None,
|
||
local_category_path: str | None = None,
|
||
) -> str:
|
||
"""Download and register one exact cloud skill selected by the agent.
|
||
|
||
``local_category_path`` is the agent-selected local package taxonomy path.
|
||
It is independent from the cloud package path stored in the binding.
|
||
"""
|
||
|
||
try:
|
||
skill_id = str(cloud_skill_id or "").strip()
|
||
if not skill_id:
|
||
return _json_error("cloud_skill_id is required")
|
||
if not str(local_category_path or "").strip():
|
||
client = _get_cloud_client()
|
||
try:
|
||
detail = await asyncio.to_thread(client.fetch_cloud_skill, skill_id)
|
||
except Exception:
|
||
detail = {"cloud_skill_id": skill_id}
|
||
taxonomy_query = " ".join(
|
||
str(detail.get(key) or "")
|
||
for key in ("title", "name", "summary", "description", "package_path")
|
||
).strip()
|
||
return _json_ok({
|
||
"status": "needs_local_category_path",
|
||
"code": "LOCAL_CATEGORY_PATH_REQUIRED",
|
||
"message": (
|
||
"Read the local package taxonomy, then choose local_category_path "
|
||
"before importing a cloud skill."
|
||
),
|
||
"policy_decision_required": True,
|
||
"cloud_skill_id": skill_id,
|
||
"cloud_skill": _summarize_cloud_skill_candidate(detail),
|
||
"local_placement": await _local_placement_payload(
|
||
query=taxonomy_query or None,
|
||
limit=8,
|
||
),
|
||
"next_actions": [
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "local_placement",
|
||
"reason": "Browse or expand the local placement flow if the initial candidates are insufficient.",
|
||
"optional_fields": ["local_category", "local_category_path", "query", "limit"],
|
||
},
|
||
{
|
||
"tool": "cloud_browse_skills",
|
||
"action": "import_skill",
|
||
"reason": "Import after the agent selects a local package taxonomy path from/under the local tree.",
|
||
"required_fields": ["cloud_skill_id", "local_category_path"],
|
||
"optional_fields": ["local_category"],
|
||
}
|
||
],
|
||
})
|
||
result = await _do_import_cloud_skill(
|
||
skill_id,
|
||
target_dir=target_dir,
|
||
local_category=local_category,
|
||
local_category_path=local_category_path,
|
||
)
|
||
return _json_ok({
|
||
**result,
|
||
"status": result.get("status", "success"),
|
||
"next_actions": [
|
||
{
|
||
"tool": "search_skills",
|
||
"reason": "Confirm the imported skill participates in local retrieval.",
|
||
"required_fields": ["query", "source"],
|
||
}
|
||
],
|
||
})
|
||
except Exception as e:
|
||
logger.error("cloud_import_skill failed: %s", e, exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
async def cloud_import_package_bundle(
|
||
package_id: str,
|
||
target_dir: str | None = None,
|
||
audience: str = "requester_visible",
|
||
) -> str:
|
||
"""Download/import a selected cloud package bundle for deeper inspection.
|
||
|
||
This is intentionally separate from search. Use it after the LLM has
|
||
selected a package and wants the package bundle index/outline/artifacts
|
||
locally rather than only JSON projection metadata.
|
||
"""
|
||
|
||
try:
|
||
pkg = str(package_id or "").strip()
|
||
if not pkg:
|
||
return _json_error("package_id is required")
|
||
client = _get_cloud_client()
|
||
base_dir = _resolve_cloud_import_base_dir(target_dir)
|
||
result = await asyncio.to_thread(
|
||
client.import_package_bundle,
|
||
pkg,
|
||
base_dir,
|
||
audience=audience,
|
||
)
|
||
local_path = str(result.get("local_path") or "").strip()
|
||
package_dir = Path(local_path) if local_path else None
|
||
if package_dir is not None and package_dir.exists():
|
||
openspace = await _get_openspace()
|
||
_register_evidence_read_roots(openspace, package_dir)
|
||
registry = openspace.get_skill_registry()
|
||
if registry:
|
||
discovered = registry.discover_from_dirs([package_dir])
|
||
store = await _get_runtime_store(required=False)
|
||
if store and discovered:
|
||
await store.sync_from_registry(discovered)
|
||
result["registered_skill_count"] = len(discovered)
|
||
result.setdefault("registered_skill_count", 0)
|
||
return _json_ok(result)
|
||
except Exception as e:
|
||
logger.error("cloud_import_package_bundle failed: %s", e, exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
@mcp.tool()
|
||
async def fix_skill(
|
||
skill_dir: str,
|
||
direction: str,
|
||
) -> str:
|
||
"""Run a manual FIX evolution job for a broken skill.
|
||
|
||
This endpoint creates durable evidence and a TriggerJob, then asks the
|
||
runtime to claim and process that exact job through the evolution engine.
|
||
It does not directly author, validate, commit, write SkillStore mutation
|
||
state, or prepare upload metadata.
|
||
|
||
The skill does not need to be pre-registered in OpenSpace. Provide the
|
||
skill directory path and OpenSpace registers it before creating the job.
|
||
|
||
Args:
|
||
skill_dir: Path to the broken skill directory (must contain SKILL.md).
|
||
direction: What's broken and how to fix it. Be specific:
|
||
e.g. "The upstream endpoint changed path" or
|
||
"Add retry logic for HTTP 429 rate limit errors".
|
||
"""
|
||
try:
|
||
from openspace.skill_engine.triggers import ManualTriggerRequest
|
||
|
||
if not direction:
|
||
return _json_error("direction is required — describe what to fix.")
|
||
|
||
skill_path = Path(skill_dir)
|
||
skill_md = skill_path / "SKILL.md"
|
||
if not skill_md.exists():
|
||
return _json_error(f"SKILL.md not found in {skill_dir}")
|
||
|
||
openspace = await _get_openspace()
|
||
registry = openspace.get_skill_registry()
|
||
if not registry:
|
||
return _json_error("SkillRegistry not initialized")
|
||
trigger_engine = openspace.get_trigger_engine()
|
||
if not trigger_engine:
|
||
return _json_error("Evolution TriggerEngine is not initialized")
|
||
|
||
meta = registry.register_skill_dir(skill_path)
|
||
if not meta:
|
||
return _json_error(f"Failed to register skill from {skill_dir}")
|
||
|
||
store = await _get_runtime_store()
|
||
await store.sync_from_registry([meta])
|
||
_register_evidence_read_roots(openspace, skill_path)
|
||
|
||
request = ManualTriggerRequest(
|
||
action="fix",
|
||
reason="manual_fix",
|
||
skill_ids=(meta.skill_id,),
|
||
metadata={
|
||
"skill_dir": str(skill_path),
|
||
"direction": direction,
|
||
"skill_name": meta.name,
|
||
},
|
||
)
|
||
jobs = trigger_engine.from_manual_request(request)
|
||
if not jobs:
|
||
return _json_ok({
|
||
"status": "rejected",
|
||
"error": "Manual fix request did not produce a TriggerJob.",
|
||
"skill_id": meta.skill_id,
|
||
})
|
||
|
||
outcomes = []
|
||
get_evolution_engine = getattr(openspace, "get_evolution_engine", None)
|
||
evolution_engine = (
|
||
get_evolution_engine() if callable(get_evolution_engine) else None
|
||
)
|
||
runtime = getattr(openspace, "runtime", None)
|
||
drain_evolution_jobs = getattr(runtime, "drain_evolution_jobs", None)
|
||
run_now_available = evolution_engine is not None and callable(
|
||
drain_evolution_jobs
|
||
)
|
||
if run_now_available:
|
||
outcomes = await drain_evolution_jobs(
|
||
job_ids=[job.job_id for job in jobs],
|
||
limit=len(jobs),
|
||
)
|
||
|
||
outcome_summaries = [_evolution_run_summary(outcome) for outcome in outcomes]
|
||
fix_result = _manual_fix_result(
|
||
outcomes,
|
||
evolution_mode=getattr(
|
||
getattr(runtime, "config", None),
|
||
"evolution_mode",
|
||
"autonomous",
|
||
),
|
||
engine_available=run_now_available,
|
||
)
|
||
|
||
return _json_ok({
|
||
**fix_result,
|
||
"target_skill_id": meta.skill_id,
|
||
"skill_id": fix_result.get("skill_id") or meta.skill_id,
|
||
"skill_dir": str(skill_path),
|
||
"jobs": [job.to_dict() for job in jobs],
|
||
"outcomes": outcome_summaries,
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f"fix_skill failed: {e}", exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
def _manual_fix_result(
|
||
outcomes: list[Any],
|
||
*,
|
||
evolution_mode: str,
|
||
engine_available: bool,
|
||
) -> dict[str, Any]:
|
||
if not engine_available:
|
||
return {
|
||
"status": "failed",
|
||
"reason": "evolution_engine_unavailable",
|
||
"retryable": True,
|
||
}
|
||
|
||
summaries = [_evolution_run_summary(outcome) for outcome in outcomes]
|
||
for summary in summaries:
|
||
committed_actions = [
|
||
action
|
||
for action in summary.get("actions", [])
|
||
if action.get("commit_status") in {"committed", "committed_reconciled"}
|
||
]
|
||
if committed_actions or summary["evolved_skill_ids"]:
|
||
action = committed_actions[0] if committed_actions else {}
|
||
skill_id = (
|
||
action.get("skill_id")
|
||
or (summary["evolved_skill_ids"][0] if summary["evolved_skill_ids"] else "")
|
||
)
|
||
return {
|
||
"status": "fixed",
|
||
"action_id": action.get("action_id") or (
|
||
summary["action_ids"][0] if summary["action_ids"] else ""
|
||
),
|
||
"skill_id": skill_id,
|
||
"changed_files": action.get("changed_files", []),
|
||
"upload_metadata_refs": action.get("upload_metadata_refs", []),
|
||
"retryable": False,
|
||
}
|
||
|
||
rejected = _manual_fix_rejection(summaries)
|
||
if rejected is not None:
|
||
return rejected
|
||
|
||
failed = _manual_fix_failure(summaries)
|
||
if failed is not None:
|
||
return failed
|
||
|
||
mode = str(evolution_mode or "autonomous").strip().lower()
|
||
if mode == "audit_only" and any(summary["admission_ids"] for summary in summaries):
|
||
return {
|
||
"status": "accepted_audit_only",
|
||
"reason": "evolution_mode_audit_only",
|
||
"decision_ids": [
|
||
item for summary in summaries for item in summary["decision_ids"]
|
||
],
|
||
"admission_ids": [
|
||
item for summary in summaries for item in summary["admission_ids"]
|
||
],
|
||
"retryable": False,
|
||
}
|
||
|
||
return {
|
||
"status": "failed",
|
||
"reason": "manual_fix_job_produced_no_committed_action",
|
||
"retryable": True,
|
||
}
|
||
|
||
|
||
def _evolution_run_summary(outcome: Any) -> dict[str, Any]:
|
||
decisions = list(getattr(outcome, "decisions", []) or [])
|
||
admissions = list(getattr(outcome, "admissions", []) or [])
|
||
candidates = list(getattr(outcome, "candidates", []) or [])
|
||
actions = list(getattr(outcome, "actions", []) or [])
|
||
evolved = list(getattr(outcome, "evolved_skill_records", []) or [])
|
||
return {
|
||
"job_id": str(getattr(outcome, "job_id", "") or ""),
|
||
"status": str(getattr(outcome, "status", "") or ""),
|
||
"decisions": [
|
||
{
|
||
"decision_id": str(getattr(item, "decision_id", "") or ""),
|
||
"proposed_action": str(getattr(item, "proposed_action", "") or ""),
|
||
"candidate_policy": str(getattr(item, "candidate_policy", "") or ""),
|
||
"noop_reason": str(getattr(item, "noop_reason", "") or ""),
|
||
}
|
||
for item in decisions
|
||
],
|
||
"decision_ids": [
|
||
str(getattr(item, "decision_id", "") or "")
|
||
for item in decisions
|
||
if getattr(item, "decision_id", None)
|
||
],
|
||
"admissions": [
|
||
{
|
||
"admission_id": str(getattr(item, "admission_id", "") or ""),
|
||
"outcome": str(getattr(item, "outcome", "") or ""),
|
||
"hard_failures": [
|
||
str(value)
|
||
for value in (getattr(item, "hard_failures", []) or [])
|
||
],
|
||
"warnings": [
|
||
str(value)
|
||
for value in (getattr(item, "warnings", []) or [])
|
||
],
|
||
}
|
||
for item in admissions
|
||
],
|
||
"admission_ids": [
|
||
str(getattr(item, "admission_id", "") or "")
|
||
for item in admissions
|
||
if getattr(item, "admission_id", None)
|
||
],
|
||
"candidate_ids": [
|
||
str(getattr(item, "candidate_id", "") or "")
|
||
for item in candidates
|
||
if getattr(item, "candidate_id", None)
|
||
],
|
||
"actions": [
|
||
{
|
||
"action_id": str(getattr(item, "action_id", "") or ""),
|
||
"validation_id": str(getattr(item, "validation_id", "") or ""),
|
||
"commit_status": str(getattr(item, "commit_status", "") or ""),
|
||
"skill_id": str(getattr(item, "skill_id", "") or ""),
|
||
"changed_files": [
|
||
str(value)
|
||
for value in (getattr(item, "changed_files", []) or [])
|
||
],
|
||
"failure_reason": str(getattr(item, "failure_reason", "") or ""),
|
||
"upload_metadata_refs": [
|
||
str(value)
|
||
for value in (
|
||
getattr(item, "upload_metadata_refs", []) or []
|
||
)
|
||
],
|
||
}
|
||
for item in actions
|
||
],
|
||
"action_ids": [
|
||
str(getattr(item, "action_id", "") or "")
|
||
for item in actions
|
||
if getattr(item, "action_id", None)
|
||
],
|
||
"validation_ids": [
|
||
str(getattr(item, "validation_id", "") or "")
|
||
for item in actions
|
||
if getattr(item, "validation_id", None)
|
||
],
|
||
"evolved_skill_ids": [
|
||
str(getattr(item, "skill_id", "") or "")
|
||
for item in evolved
|
||
if getattr(item, "skill_id", None)
|
||
],
|
||
"errors": [str(item) for item in (getattr(outcome, "errors", []) or [])],
|
||
}
|
||
|
||
|
||
def _manual_fix_rejection(summaries: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||
rejected_admissions: list[dict[str, Any]] = []
|
||
for summary in summaries:
|
||
for admission in summary.get("admissions", []):
|
||
if admission.get("outcome") in {
|
||
"reject",
|
||
"rejected",
|
||
"noop",
|
||
"needs_human_review",
|
||
"human_review",
|
||
}:
|
||
rejected_admissions.append(admission)
|
||
if not rejected_admissions:
|
||
return None
|
||
reasons: list[str] = []
|
||
for admission in rejected_admissions:
|
||
reasons.extend(admission.get("hard_failures", []))
|
||
reasons.extend(admission.get("warnings", []))
|
||
return {
|
||
"status": "rejected",
|
||
"reason": "; ".join(reason for reason in reasons if reason)[:1000]
|
||
or "admission_or_validation_rejected",
|
||
"admission_ids": [
|
||
item["admission_id"]
|
||
for item in rejected_admissions
|
||
if item.get("admission_id")
|
||
],
|
||
"retryable": False,
|
||
}
|
||
|
||
|
||
def _manual_fix_failure(summaries: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||
errors: list[str] = []
|
||
retryable = False
|
||
for summary in summaries:
|
||
if str(summary.get("status", "")).startswith("failed"):
|
||
errors.extend(summary.get("errors", []))
|
||
retryable = True
|
||
for action in summary.get("actions", []):
|
||
commit_status = str(action.get("commit_status") or "")
|
||
if commit_status and commit_status not in {
|
||
"committed",
|
||
"committed_reconciled",
|
||
}:
|
||
errors.append(action.get("failure_reason") or commit_status)
|
||
retryable = commit_status in {"failed", "failed_needs_review"}
|
||
if not errors:
|
||
return None
|
||
return {
|
||
"status": "failed",
|
||
"reason": "; ".join(error for error in errors if error)[:1000]
|
||
or "manual_fix_job_failed",
|
||
"retryable": retryable,
|
||
}
|
||
|
||
|
||
async def _resolve_and_save_upload_placement(
|
||
skill_path: Path,
|
||
cloud_package_path: str,
|
||
*,
|
||
origin: str | None = None,
|
||
mapping_store: Any | None = None,
|
||
client: Any | None = None,
|
||
) -> dict[str, Any]:
|
||
normalized_path = str(cloud_package_path or "").strip().strip("/")
|
||
if not normalized_path:
|
||
return {
|
||
"status": "error",
|
||
"code": "PACKAGE_PATH_REQUIRED",
|
||
"message": "cloud_package_path is required for non-fix uploads",
|
||
}
|
||
|
||
if mapping_store is None:
|
||
mapping_store = await _get_cloud_mapping_store()
|
||
if client is None:
|
||
client = _get_cloud_client(mapping_store=mapping_store)
|
||
|
||
from openspace.cloud.package_placement import (
|
||
PackagePlacementError,
|
||
PackagePlacementResolver,
|
||
)
|
||
|
||
try:
|
||
placement = PackagePlacementResolver(
|
||
client,
|
||
mapping_store=mapping_store,
|
||
).resolve_cloud_package_path(normalized_path)
|
||
except PackagePlacementError as exc:
|
||
return exc.to_payload()
|
||
|
||
meta = await _read_upload_meta(skill_path)
|
||
if not isinstance(meta, dict):
|
||
meta = {}
|
||
if origin is not None:
|
||
meta["origin"] = _normalize_upload_origin(origin)
|
||
else:
|
||
meta.setdefault("origin", "imported")
|
||
upload_placement = {
|
||
"requested_package_id": placement.requested_package_id,
|
||
"requested_parent_package_id": placement.requested_parent_package_id,
|
||
"requested_new_package_segment": placement.requested_new_package_segment,
|
||
"snapshot_version_used": placement.snapshot_version_used,
|
||
"root_sub_domain_package_id": placement.root_sub_domain_package_id,
|
||
"package_path": placement.package_path,
|
||
}
|
||
meta["upload_placement"] = upload_placement
|
||
_save_upload_meta(skill_path, meta)
|
||
|
||
return {
|
||
"status": "success",
|
||
"skill_dir": str(skill_path),
|
||
"cloud_package_path": placement.package_path or normalized_path,
|
||
"upload_placement": upload_placement,
|
||
}
|
||
|
||
|
||
async def _resolve_and_save_upload_placement_fields(
|
||
skill_path: Path,
|
||
*,
|
||
requested_package_id: str | None = None,
|
||
requested_parent_package_id: str | None = None,
|
||
requested_new_package_segment: str | None = None,
|
||
origin: str | None = None,
|
||
mapping_store: Any | None = None,
|
||
client: Any | None = None,
|
||
) -> dict[str, Any]:
|
||
if mapping_store is None:
|
||
mapping_store = await _get_cloud_mapping_store()
|
||
if client is None:
|
||
client = _get_cloud_client(mapping_store=mapping_store)
|
||
|
||
from openspace.cloud.package_placement import (
|
||
PackagePlacementError,
|
||
PackagePlacementResolver,
|
||
)
|
||
|
||
try:
|
||
placement = PackagePlacementResolver(
|
||
client,
|
||
mapping_store=mapping_store,
|
||
).validate_confirmed_placement(
|
||
requested_package_id=str(requested_package_id or "").strip() or None,
|
||
requested_parent_package_id=str(requested_parent_package_id or "").strip() or None,
|
||
requested_new_package_segment=str(requested_new_package_segment or "").strip() or None,
|
||
)
|
||
except PackagePlacementError as exc:
|
||
return exc.to_payload()
|
||
|
||
meta = await _read_upload_meta(skill_path)
|
||
if not isinstance(meta, dict):
|
||
meta = {}
|
||
if origin is not None:
|
||
meta["origin"] = _normalize_upload_origin(origin)
|
||
else:
|
||
meta.setdefault("origin", "imported")
|
||
upload_placement = {
|
||
"requested_package_id": placement.requested_package_id,
|
||
"requested_parent_package_id": placement.requested_parent_package_id,
|
||
"requested_new_package_segment": placement.requested_new_package_segment,
|
||
"snapshot_version_used": placement.snapshot_version_used,
|
||
"root_sub_domain_package_id": placement.root_sub_domain_package_id,
|
||
"package_path": placement.package_path,
|
||
}
|
||
meta["upload_placement"] = upload_placement
|
||
_save_upload_meta(skill_path, meta)
|
||
|
||
return {
|
||
"status": "success",
|
||
"skill_dir": str(skill_path),
|
||
"cloud_package_path": placement.package_path,
|
||
"upload_placement": upload_placement,
|
||
}
|
||
|
||
|
||
async def prepare_upload_placement(
|
||
skill_dir: str,
|
||
cloud_package_path: str,
|
||
origin: str | None = None,
|
||
) -> str:
|
||
"""Resolve a cloud package path and save confirmed upload placement.
|
||
|
||
Internal compatibility helper. The agent-facing path is now
|
||
``upload_skill(cloud_package_path=...)``; this helper remains available to
|
||
tests and direct Python callers.
|
||
|
||
Args:
|
||
skill_dir: Path to skill directory (must contain SKILL.md).
|
||
cloud_package_path: Domain/sub-domain/regular package path. Existing
|
||
regular packages are selected directly; a single
|
||
missing child regular package can be prepared when
|
||
the parent allows child creation.
|
||
origin: Optional upload origin to persist alongside placement.
|
||
"""
|
||
try:
|
||
skill_path = Path(skill_dir)
|
||
if not (skill_path / "SKILL.md").exists():
|
||
return _json_error(f"SKILL.md not found in {skill_dir}")
|
||
payload = await _resolve_and_save_upload_placement(
|
||
skill_path,
|
||
cloud_package_path,
|
||
origin=origin,
|
||
)
|
||
if payload.get("status") == "success":
|
||
payload["next_action"] = "upload_skill"
|
||
return _json_ok(payload)
|
||
except Exception as e:
|
||
logger.error(f"prepare_upload_placement failed: {e}", exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
|
||
async def _upload_cloud_tree_payload(
|
||
*,
|
||
client: Any,
|
||
skill_path: Path,
|
||
cloud_sub_domain_package_id: str | None = None,
|
||
cloud_package_query: str | None = None,
|
||
cloud_package_path_prefix: str | None = None,
|
||
limit: int = 12,
|
||
) -> dict[str, Any]:
|
||
subdomain_id = str(cloud_sub_domain_package_id or "").strip()
|
||
max_items = min(max(int(limit), 1), 50)
|
||
package_query = str(cloud_package_query or "").strip()
|
||
package_prefix = str(cloud_package_path_prefix or "").strip().strip("/")
|
||
domain_payload = await asyncio.to_thread(client.get_package_domain_index)
|
||
snapshot_version = str(domain_payload.get("snapshot_version") or "")
|
||
domain_nodes = [
|
||
_summarize_upload_package_node(node)
|
||
for node in (domain_payload.get("nodes") or [])
|
||
if isinstance(node, dict)
|
||
]
|
||
domain_node_matches = _filter_upload_package_nodes(
|
||
domain_nodes,
|
||
query=package_query,
|
||
prefix=package_prefix,
|
||
limit=max_items,
|
||
)
|
||
sub_domain_matches = _filter_upload_package_nodes(
|
||
[node for node in domain_nodes if node.get("package_kind") == "sub-domain"],
|
||
query=package_query,
|
||
prefix=package_prefix,
|
||
limit=max_items,
|
||
)
|
||
payload: dict[str, Any] = {
|
||
"status": "needs_cloud_package_path",
|
||
"code": "CLOUD_PACKAGE_PATH_REQUIRED",
|
||
"message": (
|
||
"Inspect the cloud package tree, then call upload_skill with "
|
||
"cloud_package_id for an existing regular package, or "
|
||
"cloud_parent_package_id plus cloud_new_package_segment to create "
|
||
"one new child regular package. cloud_package_path is also accepted "
|
||
"when the server returns a non-empty package path."
|
||
),
|
||
"policy_decision_required": True,
|
||
"skill_dir": str(skill_path),
|
||
"snapshot_version": snapshot_version,
|
||
"query": package_query,
|
||
"path_prefix": package_prefix,
|
||
"interaction_flow": _upload_cloud_package_interaction_flow(),
|
||
"cloud_package_path_policy": _upload_cloud_package_path_policy(),
|
||
"domain_index": {
|
||
"snapshot_version": snapshot_version,
|
||
"total_node_count": len(domain_nodes),
|
||
"nodes": domain_node_matches["items"],
|
||
"nodes_truncated": domain_node_matches["truncated"],
|
||
"sub_domain_nodes": sub_domain_matches["items"],
|
||
"sub_domain_count": sub_domain_matches["match_count"],
|
||
"sub_domain_nodes_truncated": sub_domain_matches["truncated"],
|
||
},
|
||
"next_actions": [
|
||
{
|
||
"tool": "upload_skill",
|
||
"reason": "Inspect a selected sub-domain upload subtree before choosing cloud_package_path.",
|
||
"required_fields": ["skill_dir", "cloud_sub_domain_package_id"],
|
||
"optional_fields": ["cloud_package_query", "cloud_package_path_prefix", "cloud_package_limit"],
|
||
},
|
||
{
|
||
"tool": "upload_skill",
|
||
"reason": "Upload after choosing a regular package id from the upload tree.",
|
||
"required_fields": ["skill_dir", "cloud_package_id"],
|
||
},
|
||
{
|
||
"tool": "upload_skill",
|
||
"reason": "Upload while creating one new regular child package under a chosen parent.",
|
||
"required_fields": ["skill_dir", "cloud_parent_package_id", "cloud_new_package_segment"],
|
||
},
|
||
],
|
||
}
|
||
if not subdomain_id:
|
||
return payload
|
||
|
||
subtree = await asyncio.to_thread(
|
||
client.get_package_subtree_for_upload,
|
||
subdomain_id,
|
||
snapshot_version=snapshot_version or None,
|
||
)
|
||
subtree_nodes = [
|
||
_summarize_upload_package_node(node)
|
||
for node in (subtree.get("nodes") or [])
|
||
if isinstance(node, dict)
|
||
]
|
||
selectable = _filter_upload_package_nodes(
|
||
[node for node in subtree_nodes if node.get("can_select_as_upload_target")],
|
||
query=package_query,
|
||
prefix=package_prefix,
|
||
limit=max_items,
|
||
)
|
||
creatable_parent_nodes = [
|
||
node for node in subtree_nodes if _can_create_upload_child(node)
|
||
]
|
||
creatable = _filter_upload_package_nodes(
|
||
creatable_parent_nodes,
|
||
query=package_query,
|
||
prefix=package_prefix,
|
||
limit=max_items,
|
||
)
|
||
child_view = _upload_package_children(
|
||
subtree_nodes,
|
||
prefix=package_prefix or str(subtree.get("root_package_path") or ""),
|
||
limit=max_items,
|
||
)
|
||
new_child_examples = _upload_new_child_path_examples(
|
||
creatable["items"],
|
||
limit=min(max_items, 5),
|
||
)
|
||
payload["subtree"] = {
|
||
"root_sub_domain_package_id": (
|
||
subtree.get("root_sub_domain_package_id")
|
||
or subtree.get("root_package_id")
|
||
or subdomain_id
|
||
),
|
||
"root_package_path": subtree.get("root_package_path", ""),
|
||
"snapshot_version": subtree.get("snapshot_version", snapshot_version),
|
||
"total_node_count": len(subtree_nodes),
|
||
"children": child_view["items"],
|
||
"child_count": child_view["match_count"],
|
||
"children_truncated": child_view["truncated"],
|
||
"selectable_regular_packages": selectable["items"],
|
||
"selectable_regular_package_count": selectable["match_count"],
|
||
"selectable_regular_packages_truncated": selectable["truncated"],
|
||
"creatable_parent_packages": creatable["items"],
|
||
"creatable_parent_package_count": creatable["match_count"],
|
||
"creatable_parent_packages_truncated": creatable["truncated"],
|
||
"new_child_path_examples": new_child_examples,
|
||
}
|
||
payload["next_actions"] = [
|
||
{
|
||
"tool": "upload_skill",
|
||
"reason": "Upload after choosing an existing regular package id, or creating one new child under a creatable parent.",
|
||
"accepted_forms": [
|
||
{
|
||
"required_fields": ["skill_dir", "cloud_package_id"],
|
||
"source": "subtree.selectable_regular_packages[].package_id",
|
||
},
|
||
{
|
||
"required_fields": ["skill_dir", "cloud_parent_package_id", "cloud_new_package_segment"],
|
||
"source": "subtree.creatable_parent_packages[].package_id plus one new segment",
|
||
},
|
||
{
|
||
"required_fields": ["skill_dir", "cloud_package_path"],
|
||
"source": "only when subtree package_path is non-empty",
|
||
},
|
||
],
|
||
}
|
||
]
|
||
return payload
|
||
|
||
|
||
def _upload_cloud_package_interaction_flow() -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"step": "browse_domain_index",
|
||
"call": "upload_skill(skill_dir=..., cloud_package_query=... optional)",
|
||
"agent_decision": "Choose a cloud_sub_domain_package_id to inspect.",
|
||
},
|
||
{
|
||
"step": "browse_upload_subtree",
|
||
"call": "upload_skill(skill_dir=..., cloud_sub_domain_package_id=..., cloud_package_path_prefix=... optional)",
|
||
"agent_decision": (
|
||
"Choose an existing selectable_regular_packages[].package_id, "
|
||
"or choose creatable_parent_packages[].package_id plus one new regular segment. "
|
||
"Use package_path only when it is non-empty."
|
||
),
|
||
},
|
||
{
|
||
"step": "upload",
|
||
"call": "upload_skill(skill_dir=..., visibility=..., cloud_package_id=...) or upload_skill(skill_dir=..., visibility=..., cloud_parent_package_id=..., cloud_new_package_segment=...)",
|
||
"result": (
|
||
"OpenSpace saves UUID placement fields in .upload_meta.json, "
|
||
"revalidates them against the current cloud tree, then uploads."
|
||
),
|
||
},
|
||
]
|
||
|
||
|
||
def _upload_cloud_package_path_policy() -> dict[str, Any]:
|
||
return {
|
||
"cloud_package_path_is_agent_selected": True,
|
||
"code_does_not_choose_semantic_destination": True,
|
||
"allowed_forms": [
|
||
{
|
||
"form": "existing_regular_package_id",
|
||
"source": "subtree.selectable_regular_packages[].package_id",
|
||
"upload_fields": ["requested_package_id"],
|
||
},
|
||
{
|
||
"form": "new_child_regular_package_id_plus_segment",
|
||
"source": "subtree.creatable_parent_packages[].package_id + one_new_regular_package_segment",
|
||
"upload_fields": [
|
||
"requested_parent_package_id",
|
||
"requested_new_package_segment",
|
||
],
|
||
},
|
||
{
|
||
"form": "existing_or_new_child_path",
|
||
"source": "cloud_package_path when the server exposes a non-empty package_path",
|
||
"upload_fields": ["requested_package_id or requested_parent_package_id/requested_new_package_segment"],
|
||
},
|
||
],
|
||
"new_path_creation": {
|
||
"allowed": True,
|
||
"max_missing_segments": 1,
|
||
"new_segment_kind": "regular package",
|
||
"parent_requirement": (
|
||
"Parent must be a sub-domain or regular package that does not "
|
||
"explicitly disable child regular package creation."
|
||
),
|
||
"segment_must_be_single_path_part": True,
|
||
},
|
||
"not_allowed": [
|
||
"Uploading directly to a domain or sub-domain path.",
|
||
"Creating multiple missing path segments in one upload.",
|
||
"Passing only a raw path without letting upload_skill resolve and revalidate placement.",
|
||
],
|
||
}
|
||
|
||
|
||
def _can_create_upload_child(node: dict[str, Any]) -> bool:
|
||
kind = str(node.get("package_kind") or "")
|
||
if kind not in {"sub-domain", "regular"}:
|
||
return False
|
||
return node.get("can_create_child_regular_package") is not False
|
||
|
||
|
||
def _upload_new_child_path_examples(
|
||
parent_nodes: list[dict[str, Any]],
|
||
*,
|
||
limit: int,
|
||
) -> list[dict[str, Any]]:
|
||
examples: list[dict[str, Any]] = []
|
||
for node in parent_nodes:
|
||
parent_path = str(node.get("package_path") or "").strip().strip("/")
|
||
if not parent_path:
|
||
continue
|
||
examples.append({
|
||
"parent_package_id": node.get("package_id", ""),
|
||
"parent_package_path": parent_path,
|
||
"cloud_package_path_example": (
|
||
f"{parent_path}/<one-new-regular-package-segment>"
|
||
),
|
||
})
|
||
if len(examples) >= limit:
|
||
break
|
||
return examples
|
||
|
||
|
||
def _summarize_upload_package_node(node: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"package_id": node.get("package_id", ""),
|
||
"package_path": node.get("package_path", ""),
|
||
"package_kind": node.get("package_kind", ""),
|
||
"parent_package_id": node.get("parent_package_id"),
|
||
"root_sub_domain_package_id": node.get("root_sub_domain_package_id"),
|
||
"can_select_as_upload_target": bool(node.get("can_select_as_upload_target")),
|
||
"can_create_child_regular_package": node.get("can_create_child_regular_package"),
|
||
"select_disabled_reason": node.get("select_disabled_reason"),
|
||
"child_count": node.get("child_count"),
|
||
}
|
||
|
||
|
||
def _filter_upload_package_nodes(
|
||
nodes: list[dict[str, Any]],
|
||
*,
|
||
query: str,
|
||
prefix: str,
|
||
limit: int,
|
||
) -> dict[str, Any]:
|
||
q_tokens = [token for token in str(query or "").lower().split() if token]
|
||
normalized_prefix = str(prefix or "").strip().strip("/").lower()
|
||
matches: list[dict[str, Any]] = []
|
||
for node in nodes:
|
||
package_path = str(node.get("package_path") or "")
|
||
if normalized_prefix and not package_path.lower().startswith(normalized_prefix):
|
||
continue
|
||
if q_tokens:
|
||
text = "\n".join(
|
||
str(node.get(key) or "")
|
||
for key in (
|
||
"package_id",
|
||
"package_path",
|
||
"package_kind",
|
||
"select_disabled_reason",
|
||
)
|
||
).lower()
|
||
if not all(token in text for token in q_tokens):
|
||
continue
|
||
matches.append(node)
|
||
return {
|
||
"items": matches[:limit],
|
||
"match_count": len(matches),
|
||
"truncated": len(matches) > limit,
|
||
}
|
||
|
||
|
||
def _upload_package_children(
|
||
nodes: list[dict[str, Any]],
|
||
*,
|
||
prefix: str,
|
||
limit: int,
|
||
) -> dict[str, Any]:
|
||
prefix_parts = [
|
||
part.strip().lower()
|
||
for part in str(prefix or "").replace("\\", "/").split("/")
|
||
if part.strip()
|
||
]
|
||
children: dict[str, dict[str, Any]] = {}
|
||
for node in nodes:
|
||
raw_path = str(node.get("package_path") or "")
|
||
path_parts = [part.strip() for part in raw_path.replace("\\", "/").split("/") if part.strip()]
|
||
lowered = [part.lower() for part in path_parts]
|
||
if prefix_parts:
|
||
if len(lowered) <= len(prefix_parts) or lowered[: len(prefix_parts)] != prefix_parts:
|
||
continue
|
||
child_parts = path_parts[: len(prefix_parts) + 1]
|
||
else:
|
||
if not path_parts:
|
||
continue
|
||
child_parts = path_parts[:1]
|
||
child_path = "/".join(child_parts)
|
||
existing = children.get(child_path)
|
||
if existing is None:
|
||
existing = {
|
||
"package_path": child_path,
|
||
"package_kind": "",
|
||
"package_id": "",
|
||
"subtree_package_count": 0,
|
||
"selectable_regular_package_count": 0,
|
||
"can_create_child_regular_package": False,
|
||
}
|
||
children[child_path] = existing
|
||
existing["subtree_package_count"] += 1
|
||
if node.get("can_select_as_upload_target"):
|
||
existing["selectable_regular_package_count"] += 1
|
||
if node.get("can_create_child_regular_package"):
|
||
existing["can_create_child_regular_package"] = True
|
||
if lowered == [part.lower() for part in child_parts]:
|
||
existing["package_id"] = node.get("package_id", "")
|
||
existing["package_kind"] = node.get("package_kind", "")
|
||
existing["can_select_as_upload_target"] = bool(node.get("can_select_as_upload_target"))
|
||
existing["select_disabled_reason"] = node.get("select_disabled_reason")
|
||
rows = sorted(children.values(), key=lambda item: str(item.get("package_path") or ""))
|
||
return {
|
||
"items": rows[:limit],
|
||
"match_count": len(rows),
|
||
"truncated": len(rows) > limit,
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
async def upload_skill(
|
||
skill_dir: str,
|
||
visibility: str = "private",
|
||
cloud_package_path: str | None = None,
|
||
cloud_package_id: str | None = None,
|
||
cloud_parent_package_id: str | None = None,
|
||
cloud_new_package_segment: str | None = None,
|
||
cloud_sub_domain_package_id: str | None = None,
|
||
cloud_package_query: str | None = None,
|
||
cloud_package_path_prefix: str | None = None,
|
||
cloud_package_limit: int = 12,
|
||
origin: str | None = None,
|
||
parent_local_skill_ids: list[str] | None = None,
|
||
owner_agent_id: str | None = None,
|
||
submitted_skill_id: str | None = None,
|
||
content_diff: str | None = None,
|
||
) -> str:
|
||
"""Upload a trusted local skill to the cloud.
|
||
|
||
Public and private uploads both fail closed unless ``skill_dir`` resolves
|
||
to a matching trusted record in the active local SkillStore. Trust remains
|
||
local metadata and is not sent in the cloud upload request.
|
||
|
||
For evolved skills from validated evolution actions, lineage
|
||
metadata is **pre-saved** in ``.upload_meta.json``. The bot provides:
|
||
|
||
- ``skill_dir`` — path to the skill directory
|
||
- ``visibility`` — "private" by default, or "public" when explicitly sharing
|
||
- package placement for non-fix uploads without pre-saved placement
|
||
|
||
For non-fix uploads, this tool is also the agent-facing cloud package
|
||
browser. Calling it without confirmed placement returns a bounded
|
||
step-by-step picker payload; calling it with ``cloud_sub_domain_package_id``
|
||
expands one upload subtree. After the agent chooses an existing regular
|
||
package id, or chooses an eligible parent package id plus one new child
|
||
segment, the tool saves UUID placement fields in ``.upload_meta.json`` and
|
||
revalidates the placement immediately before upload. ``cloud_package_path``
|
||
is also accepted when the cloud returns a non-empty path.
|
||
|
||
**origin + parent_local_skill_ids constraints**:
|
||
- imported / captured → parent_local_skill_ids must be empty
|
||
- derived → at least 1 parent with cloud binding
|
||
- fixed → exactly 1 parent with cloud binding
|
||
|
||
Args:
|
||
skill_dir: Path to skill directory (must contain SKILL.md).
|
||
visibility: "public" or "private". Defaults to "private"; choose
|
||
"public" only when explicitly sharing.
|
||
cloud_package_path: Agent-selected existing regular package path, or
|
||
one new child regular package segment under an
|
||
eligible parent. Required for non-fix uploads
|
||
unless upload_placement already exists in
|
||
.upload_meta.json.
|
||
cloud_package_id: Agent-selected existing regular package id from
|
||
selectable_regular_packages[].package_id.
|
||
cloud_parent_package_id: Agent-selected parent package id from
|
||
creatable_parent_packages[].package_id when
|
||
creating one new regular child package.
|
||
cloud_new_package_segment: New child regular package segment to create
|
||
under cloud_parent_package_id.
|
||
cloud_sub_domain_package_id: Optional sub-domain package id. If provided
|
||
without confirmed placement, this returns
|
||
the upload subtree for the agent to inspect.
|
||
cloud_package_query: Optional filter when browsing cloud package choices.
|
||
cloud_package_path_prefix: Optional cloud package path prefix to expand/filter.
|
||
cloud_package_limit: Maximum cloud package candidates returned while browsing.
|
||
origin: Override origin. Default: from .upload_meta.json or "imported".
|
||
parent_local_skill_ids: Override local parents. Default: from .upload_meta.json/SkillStore.
|
||
"""
|
||
try:
|
||
from openspace.cloud.local_mapping import (
|
||
UnboundLocalSkillError,
|
||
ensure_local_skill_id,
|
||
read_local_skill_id,
|
||
write_local_skill_id,
|
||
)
|
||
|
||
skill_path = Path(skill_dir)
|
||
if not (skill_path / "SKILL.md").exists():
|
||
return _json_error(f"SKILL.md not found in {skill_dir}")
|
||
|
||
from openspace.cloud.upload_trust import (
|
||
SkillUploadTrustError,
|
||
require_trusted_skill_for_upload,
|
||
)
|
||
|
||
runtime_store = await _get_runtime_store(required=False)
|
||
try:
|
||
require_trusted_skill_for_upload(
|
||
skill_path,
|
||
skill_store=runtime_store,
|
||
)
|
||
except SkillUploadTrustError as exc:
|
||
return _json_ok(exc.to_payload())
|
||
|
||
# Read pre-saved metadata (written after validated evolution commits)
|
||
meta = await _read_upload_meta(skill_path)
|
||
|
||
# Merge: explicit params override pre-saved values
|
||
final_origin = origin if origin is not None else meta.get("origin", "imported")
|
||
final_parent_local_ids = (
|
||
parent_local_skill_ids
|
||
if parent_local_skill_ids is not None
|
||
else meta.get("parent_local_skill_ids", [])
|
||
)
|
||
final_parent_local_ids = [sid for sid in (final_parent_local_ids or []) if sid]
|
||
final_owner_agent_id = owner_agent_id if owner_agent_id is not None else meta.get("owner_agent_id")
|
||
existing_local_skill_id = read_local_skill_id(skill_path)
|
||
final_submitted_skill_id = submitted_skill_id or meta.get("local_skill_id")
|
||
if final_submitted_skill_id and existing_local_skill_id and final_submitted_skill_id != existing_local_skill_id:
|
||
return _json_ok({
|
||
"status": "error",
|
||
"code": "LOCAL_SKILL_ID_MISMATCH",
|
||
"message": "submitted_skill_id must match the local .skill_id",
|
||
"submitted_skill_id": final_submitted_skill_id,
|
||
"local_skill_id": existing_local_skill_id,
|
||
})
|
||
if final_submitted_skill_id and not existing_local_skill_id:
|
||
write_local_skill_id(skill_path, final_submitted_skill_id)
|
||
local_skill_id = final_submitted_skill_id or ensure_local_skill_id(skill_path)
|
||
final_content_diff = content_diff if content_diff is not None else meta.get("content_diff")
|
||
|
||
mapping_store = await _get_cloud_mapping_store()
|
||
origin_type = _normalize_upload_origin(final_origin)
|
||
if origin_type in ("fix", "derive"):
|
||
try:
|
||
final_parent_cloud_ids = mapping_store.resolve_parent_local_ids_to_cloud_ids(
|
||
final_parent_local_ids
|
||
)
|
||
except UnboundLocalSkillError as exc:
|
||
return _json_ok(exc.to_payload())
|
||
else:
|
||
if final_parent_local_ids:
|
||
return _json_ok({
|
||
"status": "error",
|
||
"code": "PARENT_LOCAL_IDS_NOT_ALLOWED",
|
||
"message": "imported/capture uploads must not include parent local skill IDs",
|
||
"parent_local_skill_ids": final_parent_local_ids,
|
||
})
|
||
final_parent_cloud_ids = []
|
||
client = _get_cloud_client(mapping_store=mapping_store)
|
||
id_placement_selected = bool(
|
||
str(cloud_package_id or "").strip()
|
||
or str(cloud_parent_package_id or "").strip()
|
||
or str(cloud_new_package_segment or "").strip()
|
||
)
|
||
if cloud_package_path and id_placement_selected:
|
||
return _json_ok({
|
||
"status": "error",
|
||
"code": "PACKAGE_PLACEMENT_CONFLICTING_FIELDS",
|
||
"message": (
|
||
"Use either cloud_package_path, cloud_package_id, or "
|
||
"cloud_parent_package_id plus cloud_new_package_segment."
|
||
),
|
||
})
|
||
if id_placement_selected:
|
||
placement_payload = await _resolve_and_save_upload_placement_fields(
|
||
skill_path,
|
||
requested_package_id=cloud_package_id,
|
||
requested_parent_package_id=cloud_parent_package_id,
|
||
requested_new_package_segment=cloud_new_package_segment,
|
||
origin=origin,
|
||
mapping_store=mapping_store,
|
||
client=client,
|
||
)
|
||
if placement_payload.get("status") != "success":
|
||
return _json_ok(placement_payload)
|
||
meta = await _read_upload_meta(skill_path)
|
||
if cloud_package_path:
|
||
placement_payload = await _resolve_and_save_upload_placement(
|
||
skill_path,
|
||
cloud_package_path,
|
||
origin=origin,
|
||
mapping_store=mapping_store,
|
||
client=client,
|
||
)
|
||
if placement_payload.get("status") != "success":
|
||
return _json_ok(placement_payload)
|
||
meta = await _read_upload_meta(skill_path)
|
||
|
||
placement_kwargs: dict[str, str | None] = {
|
||
"requested_package_id": None,
|
||
"requested_parent_package_id": None,
|
||
"requested_new_package_segment": None,
|
||
"snapshot_version_used": None,
|
||
}
|
||
if origin_type != "fix" or isinstance(meta.get("upload_placement"), dict):
|
||
from openspace.cloud.package_placement import (
|
||
PackagePlacementError,
|
||
PackagePlacementResolver,
|
||
placement_from_upload_meta,
|
||
)
|
||
|
||
raw_placement = placement_from_upload_meta(meta)
|
||
if not raw_placement:
|
||
return _json_ok(
|
||
await _upload_cloud_tree_payload(
|
||
client=client,
|
||
skill_path=skill_path,
|
||
cloud_sub_domain_package_id=cloud_sub_domain_package_id,
|
||
cloud_package_query=cloud_package_query,
|
||
cloud_package_path_prefix=cloud_package_path_prefix,
|
||
limit=cloud_package_limit,
|
||
)
|
||
)
|
||
try:
|
||
resolved_placement = PackagePlacementResolver(
|
||
client,
|
||
mapping_store=mapping_store,
|
||
).validate_confirmed_placement(**raw_placement)
|
||
except PackagePlacementError as exc:
|
||
return _json_ok(exc.to_payload())
|
||
placement_kwargs = resolved_placement.to_upload_kwargs()
|
||
result = await asyncio.to_thread(
|
||
client.upload_skill_v2,
|
||
skill_path,
|
||
local_skill_store_db_path=getattr(runtime_store, "db_path", None),
|
||
visibility=visibility,
|
||
origin=final_origin,
|
||
parent_cloud_skill_ids=final_parent_cloud_ids,
|
||
**placement_kwargs,
|
||
owner_agent_id=final_owner_agent_id,
|
||
submitted_skill_id=local_skill_id,
|
||
content_diff=final_content_diff,
|
||
)
|
||
result["local_skill_id"] = local_skill_id
|
||
if final_parent_local_ids:
|
||
result["parent_local_skill_ids"] = final_parent_local_ids
|
||
if final_parent_cloud_ids:
|
||
result["parent_cloud_skill_ids"] = final_parent_cloud_ids
|
||
return _json_ok(result)
|
||
|
||
except Exception as e:
|
||
logger.error(f"upload_skill failed: {e}", exc_info=True)
|
||
return _json_error(e, status="error")
|
||
|
||
def run_mcp_server() -> None:
|
||
"""Console-script entry point for ``openspace-mcp``."""
|
||
import argparse
|
||
|
||
def _port_flag_was_set(argv: list[str]) -> bool:
|
||
return any(arg == "--port" or arg.startswith("--port=") for arg in argv)
|
||
|
||
def _default_port_for_transport(transport: str) -> int:
|
||
return 8081 if transport == "streamable-http" else 8080
|
||
|
||
def _parse_port_from_env(default: int) -> int:
|
||
raw_port = os.environ.get("OPENSPACE_MCP_PORT", "").strip()
|
||
if not raw_port:
|
||
return default
|
||
try:
|
||
return int(raw_port)
|
||
except ValueError:
|
||
logger.warning(
|
||
"Ignoring invalid OPENSPACE_MCP_PORT=%r; falling back to %d.",
|
||
raw_port,
|
||
default,
|
||
)
|
||
return default
|
||
|
||
def _parse_host_from_env(default: str = "127.0.0.1") -> str:
|
||
return os.environ.get("OPENSPACE_MCP_HOST", "").strip() or default
|
||
|
||
def _resolve_transport(requested_transport: str, argv: list[str]) -> str:
|
||
if requested_transport in ("stdio", "sse", "streamable-http"):
|
||
return requested_transport
|
||
|
||
env_transport = os.environ.get("OPENSPACE_MCP_TRANSPORT", "").strip().lower()
|
||
if env_transport:
|
||
if env_transport in ("stdio", "sse", "streamable-http"):
|
||
return env_transport
|
||
logger.warning(
|
||
"Ignoring invalid OPENSPACE_MCP_TRANSPORT=%r; expected 'stdio', 'sse', or 'streamable-http'.",
|
||
env_transport,
|
||
)
|
||
|
||
# Treat an explicit port override as an HTTP/SSE intent. This keeps the
|
||
# CLI behavior aligned with the usage examples above.
|
||
if _port_flag_was_set(argv):
|
||
return "sse"
|
||
|
||
stdin_is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
|
||
stdout_is_tty = _real_stdout.isatty()
|
||
return "sse" if stdin_is_tty and stdout_is_tty else "stdio"
|
||
|
||
argv = sys.argv[1:]
|
||
parser = argparse.ArgumentParser(description="OpenSpace MCP Server")
|
||
parser.add_argument(
|
||
"--transport",
|
||
choices=["auto", "stdio", "sse", "streamable-http"],
|
||
default="auto",
|
||
)
|
||
parser.add_argument("--host", default=_parse_host_from_env())
|
||
parser.add_argument("--port", type=int, default=None)
|
||
args = parser.parse_args(argv)
|
||
|
||
transport = _resolve_transport(args.transport, argv)
|
||
port = args.port
|
||
if port is None:
|
||
port = _parse_port_from_env(_default_port_for_transport(transport))
|
||
|
||
if transport == "sse":
|
||
mcp.settings.host = args.host
|
||
mcp.settings.port = port
|
||
logger.info("Starting OpenSpace MCP server with SSE transport on port %s", port)
|
||
mcp.run(transport="sse")
|
||
elif transport == "streamable-http":
|
||
mcp.settings.host = args.host
|
||
mcp.settings.port = port
|
||
logger.info(
|
||
"Starting OpenSpace MCP server with streamable HTTP transport on %s:%s",
|
||
args.host,
|
||
port,
|
||
)
|
||
mcp.run(transport="streamable-http")
|
||
else:
|
||
logger.info("Starting OpenSpace MCP server with stdio transport")
|
||
mcp.run(transport="stdio")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run_mcp_server()
|