mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
feat(extensions): add Pi and OpenClaw sidecar protocol
This commit is contained in:
parent
1e573c75ae
commit
029f9bc53b
@ -20,6 +20,13 @@ from nanobot.extensions.manifest import (
|
|||||||
ExtensionRuntime,
|
ExtensionRuntime,
|
||||||
)
|
)
|
||||||
from nanobot.extensions.native import discover_native_extensions
|
from nanobot.extensions.native import discover_native_extensions
|
||||||
|
from nanobot.extensions.node_host import NodeSidecar
|
||||||
|
from nanobot.extensions.protocol import (
|
||||||
|
NODE_PROTOCOL_VERSION,
|
||||||
|
NodeLoadResult,
|
||||||
|
NodeProtocolError,
|
||||||
|
NodeRegistration,
|
||||||
|
)
|
||||||
from nanobot.extensions.registry import (
|
from nanobot.extensions.registry import (
|
||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionDiagnostic,
|
ExtensionDiagnostic,
|
||||||
@ -48,6 +55,11 @@ __all__ = [
|
|||||||
"ExtensionSnapshot",
|
"ExtensionSnapshot",
|
||||||
"MANIFEST_FILENAME",
|
"MANIFEST_FILENAME",
|
||||||
"ManifestFormatError",
|
"ManifestFormatError",
|
||||||
|
"NODE_PROTOCOL_VERSION",
|
||||||
|
"NodeLoadResult",
|
||||||
|
"NodeProtocolError",
|
||||||
|
"NodeRegistration",
|
||||||
|
"NodeSidecar",
|
||||||
"ResolvedContribution",
|
"ResolvedContribution",
|
||||||
"build_extension_catalog",
|
"build_extension_catalog",
|
||||||
"dump_manifest",
|
"dump_manifest",
|
||||||
|
|||||||
214
nanobot/extensions/compatibility.py
Normal file
214
nanobot/extensions/compatibility.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
"""Adapters that project Pi/OpenClaw registrations into native nanobot APIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from nanobot.agent.hook import (
|
||||||
|
AgentHook,
|
||||||
|
AgentHookContext,
|
||||||
|
AgentRunHookContext,
|
||||||
|
)
|
||||||
|
from nanobot.agent.tools.base import Tool
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.command.router import CommandContext, CommandRouter
|
||||||
|
from nanobot.extensions.node_host import NodeSidecar
|
||||||
|
from nanobot.extensions.protocol import NodeLoadResult, NodeRegistration
|
||||||
|
|
||||||
|
_EVENT_MAP = {
|
||||||
|
"pi": {
|
||||||
|
"before_run": "agent_start",
|
||||||
|
"after_run": "agent_end",
|
||||||
|
"before_execute_tool": "tool_call",
|
||||||
|
"after_execute_tool": "tool_result",
|
||||||
|
},
|
||||||
|
"openclaw": {
|
||||||
|
"before_run": "before_agent_run",
|
||||||
|
"after_run": "agent_end",
|
||||||
|
"before_execute_tool": "before_tool_call",
|
||||||
|
"after_execute_tool": "after_tool_call",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteTool(Tool):
|
||||||
|
"""Native Tool facade whose implementation remains inside a sidecar."""
|
||||||
|
|
||||||
|
def __init__(self, host: NodeSidecar, registration: NodeRegistration) -> None:
|
||||||
|
self._host = host
|
||||||
|
self._registration = registration
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._registration.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return self._registration.description
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> dict[str, Any]:
|
||||||
|
return self._registration.schema or {"type": "object", "properties": {}}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return bool((self._registration.metadata or {}).get("readOnly"))
|
||||||
|
|
||||||
|
async def execute(self, **kwargs: Any) -> Any:
|
||||||
|
result = await self._host.request(
|
||||||
|
"extension.call",
|
||||||
|
{
|
||||||
|
"kind": "tool",
|
||||||
|
"name": self.name,
|
||||||
|
"callId": uuid4().hex,
|
||||||
|
"input": kwargs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result.get("text", "")
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteHook(AgentHook):
|
||||||
|
"""Observation-only lifecycle bridge for compatible extension events."""
|
||||||
|
|
||||||
|
def __init__(self, host: NodeSidecar, runtime: str, events: set[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._host = host
|
||||||
|
self._events = events
|
||||||
|
self._mapping = _EVENT_MAP[runtime]
|
||||||
|
|
||||||
|
async def _emit(self, lifecycle: str, context: object, **extra: Any) -> None:
|
||||||
|
event = self._mapping[lifecycle]
|
||||||
|
if event not in self._events:
|
||||||
|
return
|
||||||
|
payload = _jsonable(context)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload.update(extra)
|
||||||
|
await self._host.request(
|
||||||
|
"extension.event",
|
||||||
|
{"name": event, "event": payload},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def before_run(self, context: AgentRunHookContext) -> None:
|
||||||
|
await self._emit("before_run", context)
|
||||||
|
|
||||||
|
async def after_run(self, context: AgentRunHookContext) -> None:
|
||||||
|
await self._emit("after_run", context)
|
||||||
|
|
||||||
|
async def before_execute_tool(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: Any,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
) -> None:
|
||||||
|
await self._emit(
|
||||||
|
"before_execute_tool",
|
||||||
|
context,
|
||||||
|
toolCall=_jsonable(tool_call),
|
||||||
|
tool=getattr(tool, "name", ""),
|
||||||
|
input=_jsonable(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def after_execute_tool(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: Any,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
result: Any,
|
||||||
|
) -> None:
|
||||||
|
await self._emit(
|
||||||
|
"after_execute_tool",
|
||||||
|
context,
|
||||||
|
toolCall=_jsonable(tool_call),
|
||||||
|
tool=getattr(tool, "name", ""),
|
||||||
|
input=_jsonable(params),
|
||||||
|
result=_jsonable(result),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompatibleExtension:
|
||||||
|
"""Loaded Pi/OpenClaw extension and its native projections."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
host: NodeSidecar,
|
||||||
|
runtime: str,
|
||||||
|
owner: str,
|
||||||
|
result: NodeLoadResult,
|
||||||
|
) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.runtime = runtime
|
||||||
|
self.owner = owner
|
||||||
|
self.result = result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tools(self) -> tuple[RemoteTool, ...]:
|
||||||
|
return tuple(
|
||||||
|
RemoteTool(self.host, item)
|
||||||
|
for item in self.result.registrations
|
||||||
|
if item.kind == "tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hook(self) -> RemoteHook | None:
|
||||||
|
events = {
|
||||||
|
item.name
|
||||||
|
for item in self.result.registrations
|
||||||
|
if item.kind == "hook"
|
||||||
|
}
|
||||||
|
return RemoteHook(self.host, self.runtime, events) if events else None
|
||||||
|
|
||||||
|
def register_commands(self, router: CommandRouter) -> None:
|
||||||
|
for item in self.result.registrations:
|
||||||
|
if item.kind != "command":
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def handler(ctx: CommandContext, name: str = item.name) -> OutboundMessage | None:
|
||||||
|
result = await self.host.request(
|
||||||
|
"extension.call",
|
||||||
|
{
|
||||||
|
"kind": "command",
|
||||||
|
"name": name,
|
||||||
|
"input": {
|
||||||
|
"args": ctx.args,
|
||||||
|
"raw": ctx.raw,
|
||||||
|
"channel": ctx.msg.channel,
|
||||||
|
"chatId": ctx.msg.chat_id,
|
||||||
|
"senderId": ctx.msg.sender_id,
|
||||||
|
"sessionKey": ctx.key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = str(result.get("text") or "")
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return OutboundMessage(
|
||||||
|
channel=ctx.msg.channel,
|
||||||
|
chat_id=ctx.msg.chat_id,
|
||||||
|
content=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
command = f"/{item.name}"
|
||||||
|
router.exact(command, handler, owner=self.owner)
|
||||||
|
router.prefix(f"{command} ", handler, owner=self.owner)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self.host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonable(value: Any) -> Any:
|
||||||
|
if is_dataclass(value):
|
||||||
|
return _jsonable(asdict(value))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_jsonable(item) for item in value]
|
||||||
|
if isinstance(value, BaseException):
|
||||||
|
return {"type": type(value).__name__, "message": str(value)}
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
203
nanobot/extensions/node_host.py
Normal file
203
nanobot/extensions/node_host.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
"""Async process boundary for untrusted-compatible JavaScript extension APIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.extensions.protocol import (
|
||||||
|
NODE_PROTOCOL_VERSION,
|
||||||
|
NodeLoadResult,
|
||||||
|
NodeProtocolError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeSidecar:
|
||||||
|
"""One isolated Node process hosting one extension module."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
node: str | None = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
self._node = node or os.getenv("NANOBOT_NODE") or shutil.which("node")
|
||||||
|
self._timeout = timeout
|
||||||
|
self._process: asyncio.subprocess.Process | None = None
|
||||||
|
self._reader: asyncio.Task[None] | None = None
|
||||||
|
self._stderr_reader: asyncio.Task[None] | None = None
|
||||||
|
self._pending: dict[int, asyncio.Future[Any]] = {}
|
||||||
|
self._write_lock = asyncio.Lock()
|
||||||
|
self._next_id = 0
|
||||||
|
self.stderr: list[str] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._process is not None and self._process.returncode is None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self.running:
|
||||||
|
return
|
||||||
|
if not self._node:
|
||||||
|
raise NodeProtocolError(
|
||||||
|
"Node.js is required for Pi and OpenClaw extensions; "
|
||||||
|
"install Node.js 20+ or set NANOBOT_NODE"
|
||||||
|
)
|
||||||
|
script = Path(__file__).with_name("node_sidecar.mjs")
|
||||||
|
self._process = await asyncio.create_subprocess_exec(
|
||||||
|
self._node,
|
||||||
|
str(script),
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
self._reader = asyncio.create_task(self._read_stdout())
|
||||||
|
self._stderr_reader = asyncio.create_task(self._read_stderr())
|
||||||
|
hello = await self.request("hello", {})
|
||||||
|
if hello.get("protocol") != NODE_PROTOCOL_VERSION:
|
||||||
|
await self.close()
|
||||||
|
raise NodeProtocolError(
|
||||||
|
f"sidecar protocol mismatch: expected {NODE_PROTOCOL_VERSION}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
runtime: str,
|
||||||
|
entry: Path,
|
||||||
|
extension_id: str,
|
||||||
|
name: str,
|
||||||
|
version: str,
|
||||||
|
config: dict[str, Any] | None = None,
|
||||||
|
workspace: Path | None = None,
|
||||||
|
) -> NodeLoadResult:
|
||||||
|
await self.start()
|
||||||
|
result = await self.request(
|
||||||
|
"extension.load",
|
||||||
|
{
|
||||||
|
"runtime": runtime,
|
||||||
|
"entry": str(entry.expanduser().resolve()),
|
||||||
|
"identity": {
|
||||||
|
"id": extension_id,
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
},
|
||||||
|
"config": config or {},
|
||||||
|
"workspace": str((workspace or Path.cwd()).resolve()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return NodeLoadResult.from_mapping(result)
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
*,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if method != "hello" and not self.running:
|
||||||
|
raise NodeProtocolError("sidecar is not running")
|
||||||
|
process = self._process
|
||||||
|
if process is None or process.stdin is None:
|
||||||
|
raise NodeProtocolError("sidecar failed to start")
|
||||||
|
|
||||||
|
self._next_id += 1
|
||||||
|
request_id = self._next_id
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
self._pending[request_id] = future
|
||||||
|
message = json.dumps(
|
||||||
|
{
|
||||||
|
"protocol": NODE_PROTOCOL_VERSION,
|
||||||
|
"id": request_id,
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
},
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode()
|
||||||
|
try:
|
||||||
|
async with self._write_lock:
|
||||||
|
process.stdin.write(message + b"\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
result = await asyncio.wait_for(future, timeout or self._timeout)
|
||||||
|
except Exception:
|
||||||
|
self._pending.pop(request_id, None)
|
||||||
|
raise
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise NodeProtocolError(f"sidecar method {method!r} returned a non-object result")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
process = self._process
|
||||||
|
if process is None:
|
||||||
|
return
|
||||||
|
if process.returncode is None:
|
||||||
|
with suppress(Exception):
|
||||||
|
await self.request("shutdown", {}, timeout=2.0)
|
||||||
|
if process.returncode is None:
|
||||||
|
process.terminate()
|
||||||
|
with suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(process.wait(), 2.0)
|
||||||
|
if process.returncode is None:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
for task in (self._reader, self._stderr_reader):
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
self._fail_pending(NodeProtocolError("sidecar closed"))
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
async def _read_stdout(self) -> None:
|
||||||
|
assert self._process and self._process.stdout
|
||||||
|
try:
|
||||||
|
while line := await self._process.stdout.readline():
|
||||||
|
try:
|
||||||
|
message = json.loads(line)
|
||||||
|
request_id = message["id"]
|
||||||
|
future = self._pending.pop(request_id)
|
||||||
|
if error := message.get("error"):
|
||||||
|
future.set_exception(
|
||||||
|
NodeProtocolError(
|
||||||
|
f"{error.get('code', 'sidecar_error')}: "
|
||||||
|
f"{error.get('message', 'unknown sidecar error')}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
future.set_result(message.get("result", {}))
|
||||||
|
except Exception as exc:
|
||||||
|
self._fail_pending(NodeProtocolError(f"invalid sidecar response: {exc}"))
|
||||||
|
finally:
|
||||||
|
if self._process and self._process.returncode is None:
|
||||||
|
await self._process.wait()
|
||||||
|
code = self._process.returncode if self._process else "unknown"
|
||||||
|
details = self.stderr[-1] if self.stderr else "no diagnostics"
|
||||||
|
self._fail_pending(
|
||||||
|
NodeProtocolError(f"sidecar exited with code {code}: {details}")
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _read_stderr(self) -> None:
|
||||||
|
assert self._process and self._process.stderr
|
||||||
|
while line := await self._process.stderr.readline():
|
||||||
|
text = line.decode(errors="replace").rstrip()
|
||||||
|
if text:
|
||||||
|
self.stderr.append(text)
|
||||||
|
del self.stderr[:-100]
|
||||||
|
|
||||||
|
def _fail_pending(self, error: Exception) -> None:
|
||||||
|
for future in self._pending.values():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(error)
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> NodeSidecar:
|
||||||
|
await self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
await self.close()
|
||||||
415
nanobot/extensions/node_sidecar.mjs
Normal file
415
nanobot/extensions/node_sidecar.mjs
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import readline from "node:readline";
|
||||||
|
|
||||||
|
const PROTOCOL = 1;
|
||||||
|
const invocations = new AsyncLocalStorage();
|
||||||
|
const state = {
|
||||||
|
runtime: null,
|
||||||
|
identity: null,
|
||||||
|
workspace: process.cwd(),
|
||||||
|
config: {},
|
||||||
|
tools: new Map(),
|
||||||
|
commands: new Map(),
|
||||||
|
hooks: new Map(),
|
||||||
|
registrations: [],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeError = (...parts) => process.stderr.write(`${parts.map(String).join(" ")}\n`);
|
||||||
|
console.log = writeError;
|
||||||
|
console.info = writeError;
|
||||||
|
console.warn = writeError;
|
||||||
|
console.error = writeError;
|
||||||
|
|
||||||
|
function sanitize(value, depth = 0, seen = new WeakSet()) {
|
||||||
|
if (depth > 12) return "[max depth]";
|
||||||
|
if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value;
|
||||||
|
if (typeof value === "bigint") return String(value);
|
||||||
|
if (typeof value === "function" || typeof value === "undefined") return undefined;
|
||||||
|
if (typeof value !== "object") return String(value);
|
||||||
|
if (seen.has(value)) return "[circular]";
|
||||||
|
seen.add(value);
|
||||||
|
if (Array.isArray(value)) return value.map((item) => sanitize(item, depth + 1, seen));
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
.map(([key, item]) => [key, sanitize(item, depth + 1, seen)])
|
||||||
|
.filter(([, item]) => item !== undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRegistration(kind, name, options = {}) {
|
||||||
|
const registration = {
|
||||||
|
kind,
|
||||||
|
name: String(name),
|
||||||
|
description: String(options.description || ""),
|
||||||
|
...(options.schema ? { schema: sanitize(options.schema) } : {}),
|
||||||
|
...(options.metadata ? { metadata: sanitize(options.metadata) } : {}),
|
||||||
|
};
|
||||||
|
const index = state.registrations.findIndex(
|
||||||
|
(item) => item.kind === registration.kind && item.name === registration.name,
|
||||||
|
);
|
||||||
|
if (index >= 0) state.registrations[index] = registration;
|
||||||
|
else state.registrations.push(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsupported(name) {
|
||||||
|
if (!state.diagnostics.includes(`Unsupported compatibility API: ${name}`)) {
|
||||||
|
state.diagnostics.push(`Unsupported compatibility API: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsupportedFacade(path) {
|
||||||
|
const fn = () => {
|
||||||
|
throw new Error(`${path} is not available in the nanobot compatibility host`);
|
||||||
|
};
|
||||||
|
return new Proxy(fn, {
|
||||||
|
get: (_, key) => unsupportedFacade(`${path}.${String(key)}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHook(name, handler, flavor) {
|
||||||
|
const names = Array.isArray(name) ? name : [name];
|
||||||
|
for (const item of names) {
|
||||||
|
const key = String(item);
|
||||||
|
const handlers = state.hooks.get(key) || [];
|
||||||
|
handlers.push({ handler, flavor });
|
||||||
|
state.hooks.set(key, handlers);
|
||||||
|
addRegistration("hook", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTool(tool, flavor, options = {}) {
|
||||||
|
if (typeof tool === "function") {
|
||||||
|
const resolved = tool({
|
||||||
|
config: state.config,
|
||||||
|
runtimeConfig: state.config,
|
||||||
|
getRuntimeConfig: () => state.config,
|
||||||
|
workspaceDir: state.workspace,
|
||||||
|
sandboxed: true,
|
||||||
|
});
|
||||||
|
for (const item of Array.isArray(resolved) ? resolved : [resolved]) {
|
||||||
|
if (item) addTool(item, flavor, options);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tool || typeof tool !== "object" || typeof tool.name !== "string") {
|
||||||
|
throw new Error("registered tool must define a name");
|
||||||
|
}
|
||||||
|
state.tools.set(tool.name, { tool, flavor });
|
||||||
|
addRegistration("tool", tool.name, {
|
||||||
|
description: tool.description,
|
||||||
|
schema: tool.parameters || { type: "object", properties: {} },
|
||||||
|
metadata: {
|
||||||
|
label: tool.label,
|
||||||
|
optional: options.optional === true,
|
||||||
|
readOnly: tool.readOnly === true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function invocationOutput(value) {
|
||||||
|
const context = invocations.getStore();
|
||||||
|
if (context) context.outputs.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventBus() {
|
||||||
|
return {
|
||||||
|
on: (name, handler) => addHook(`event:${name}`, handler, "pi-event"),
|
||||||
|
emit: async (name, data) => emitEvent(`event:${name}`, data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function piApi() {
|
||||||
|
const api = {
|
||||||
|
on: (name, handler) => addHook(name, handler, "pi"),
|
||||||
|
registerTool: (tool) => addTool(tool, "pi"),
|
||||||
|
registerCommand: (name, options) => {
|
||||||
|
state.commands.set(String(name), { command: options, flavor: "pi" });
|
||||||
|
addRegistration("command", name, { description: options?.description });
|
||||||
|
},
|
||||||
|
registerProvider: (nameOrProvider, config) => {
|
||||||
|
const provider =
|
||||||
|
typeof nameOrProvider === "string"
|
||||||
|
? { id: nameOrProvider, ...config }
|
||||||
|
: nameOrProvider;
|
||||||
|
addRegistration("llm_provider", provider.id || provider.name, {
|
||||||
|
description: provider.name,
|
||||||
|
metadata: provider,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unregisterProvider: () => {},
|
||||||
|
sendMessage: (message) => invocationOutput(message?.content || message),
|
||||||
|
sendUserMessage: (message) => invocationOutput(message),
|
||||||
|
appendEntry: () => unsupported("pi.appendEntry"),
|
||||||
|
setSessionName: () => unsupported("pi.setSessionName"),
|
||||||
|
getSessionName: () => undefined,
|
||||||
|
setLabel: () => unsupported("pi.setLabel"),
|
||||||
|
getActiveTools: () => [],
|
||||||
|
getAllTools: () => [],
|
||||||
|
setActiveTools: () => unsupported("pi.setActiveTools"),
|
||||||
|
getCommands: () => [],
|
||||||
|
registerShortcut: () => unsupported("pi.registerShortcut"),
|
||||||
|
registerFlag: () => unsupported("pi.registerFlag"),
|
||||||
|
getFlag: () => undefined,
|
||||||
|
registerMessageRenderer: () => unsupported("pi.registerMessageRenderer"),
|
||||||
|
registerEntryRenderer: () => unsupported("pi.registerEntryRenderer"),
|
||||||
|
exec: unsupportedFacade("pi.exec"),
|
||||||
|
setModel: async () => false,
|
||||||
|
getThinkingLevel: () => "off",
|
||||||
|
setThinkingLevel: () => unsupported("pi.setThinkingLevel"),
|
||||||
|
events: eventBus(),
|
||||||
|
};
|
||||||
|
return new Proxy(api, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key in target) return target[key];
|
||||||
|
unsupported(`pi.${String(key)}`);
|
||||||
|
return unsupportedFacade(`pi.${String(key)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openClawApi() {
|
||||||
|
const identity = state.identity;
|
||||||
|
const api = {
|
||||||
|
id: identity.id,
|
||||||
|
name: identity.name,
|
||||||
|
version: identity.version,
|
||||||
|
source: state.entry,
|
||||||
|
rootDir: state.rootDir,
|
||||||
|
registrationMode: "activate",
|
||||||
|
config: state.config,
|
||||||
|
pluginConfig: state.config,
|
||||||
|
runtime: unsupportedFacade("openclaw.runtime"),
|
||||||
|
logger: {
|
||||||
|
debug: writeError,
|
||||||
|
info: writeError,
|
||||||
|
warn: writeError,
|
||||||
|
error: writeError,
|
||||||
|
},
|
||||||
|
registerTool: (tool, options) => addTool(tool, "openclaw", options),
|
||||||
|
registerCommand: (command) => {
|
||||||
|
state.commands.set(command.name, { command, flavor: "openclaw" });
|
||||||
|
addRegistration("command", command.name, { description: command.description });
|
||||||
|
},
|
||||||
|
registerHook: (names, handler) => addHook(names, handler, "openclaw"),
|
||||||
|
on: (name, handler) => addHook(name, handler, "openclaw"),
|
||||||
|
registerProvider: (provider) =>
|
||||||
|
addRegistration("llm_provider", provider.id, {
|
||||||
|
description: provider.label,
|
||||||
|
metadata: provider,
|
||||||
|
}),
|
||||||
|
registerRealtimeTranscriptionProvider: (provider) =>
|
||||||
|
addRegistration("transcription_provider", provider.id, {
|
||||||
|
description: provider.label,
|
||||||
|
metadata: provider,
|
||||||
|
}),
|
||||||
|
registerImageGenerationProvider: (provider) =>
|
||||||
|
addRegistration("image_generation_provider", provider.id, {
|
||||||
|
description: provider.label,
|
||||||
|
metadata: provider,
|
||||||
|
}),
|
||||||
|
registerWebSearchProvider: (provider) =>
|
||||||
|
addRegistration("web_search_provider", provider.id, {
|
||||||
|
description: provider.label,
|
||||||
|
metadata: provider,
|
||||||
|
}),
|
||||||
|
resolvePath: (value) => new URL(value, pathToFileURL(`${state.rootDir}/`)).pathname,
|
||||||
|
};
|
||||||
|
const grouped = unsupportedFacade("openclaw");
|
||||||
|
api.session = grouped.session;
|
||||||
|
api.agent = grouped.agent;
|
||||||
|
api.runContext = grouped.runContext;
|
||||||
|
api.lifecycle = grouped.lifecycle;
|
||||||
|
return new Proxy(api, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key in target) return target[key];
|
||||||
|
if (String(key).startsWith("register")) unsupported(`openclaw.${String(key)}`);
|
||||||
|
return (..._args) => unsupported(`openclaw.${String(key)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapModule(value) {
|
||||||
|
const seen = new Set();
|
||||||
|
let current = value;
|
||||||
|
for (let index = 0; index < 12 && current && !seen.has(current); index += 1) {
|
||||||
|
seen.add(current);
|
||||||
|
if (typeof current === "function" || typeof current?.register === "function") return current;
|
||||||
|
current = current.default ?? current.module;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importModule(entry) {
|
||||||
|
try {
|
||||||
|
return await import(`${pathToFileURL(entry).href}?nanobot=${Date.now()}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (![".ts", ".tsx", ".cts", ".mts"].some((suffix) => entry.endsWith(suffix))) throw error;
|
||||||
|
try {
|
||||||
|
const imported = await import("jiti");
|
||||||
|
const createJiti = imported.createJiti || imported.default;
|
||||||
|
return await createJiti(import.meta.url, { interopDefault: true }).import(entry);
|
||||||
|
} catch (jitiError) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not load TypeScript extension. Use Node.js with type stripping or install jiti. ${jitiError.message}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExtension(params) {
|
||||||
|
if (!["pi", "openclaw"].includes(params.runtime)) {
|
||||||
|
throw new Error(`unsupported Node extension runtime: ${params.runtime}`);
|
||||||
|
}
|
||||||
|
state.runtime = params.runtime;
|
||||||
|
state.identity = params.identity;
|
||||||
|
state.workspace = params.workspace;
|
||||||
|
state.config = params.config || {};
|
||||||
|
state.entry = params.entry;
|
||||||
|
state.rootDir = fileURLToPath(new URL(".", pathToFileURL(params.entry)));
|
||||||
|
state.tools.clear();
|
||||||
|
state.commands.clear();
|
||||||
|
state.hooks.clear();
|
||||||
|
state.registrations.length = 0;
|
||||||
|
state.diagnostics.length = 0;
|
||||||
|
|
||||||
|
const loaded = unwrapModule(await importModule(params.entry));
|
||||||
|
const factory =
|
||||||
|
params.runtime === "openclaw" && typeof loaded?.register === "function"
|
||||||
|
? loaded.register
|
||||||
|
: loaded;
|
||||||
|
if (typeof factory !== "function") throw new Error("extension entry does not export a factory");
|
||||||
|
const result = factory(params.runtime === "pi" ? piApi() : openClawApi());
|
||||||
|
if (params.runtime === "openclaw" && result?.then) {
|
||||||
|
throw new Error("OpenClaw plugin register must be synchronous");
|
||||||
|
}
|
||||||
|
if (params.runtime === "pi") await result;
|
||||||
|
return {
|
||||||
|
registrations: state.registrations,
|
||||||
|
diagnostics: state.diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextApi() {
|
||||||
|
return {
|
||||||
|
mode: "rpc",
|
||||||
|
hasUI: false,
|
||||||
|
cwd: state.workspace,
|
||||||
|
signal: undefined,
|
||||||
|
ui: new Proxy(
|
||||||
|
{ notify: (message) => invocationOutput(message) },
|
||||||
|
{ get: (target, key) => target[key] || unsupportedFacade(`pi.ui.${String(key)}`) },
|
||||||
|
),
|
||||||
|
isIdle: () => true,
|
||||||
|
isProjectTrusted: () => true,
|
||||||
|
hasPendingMessages: () => false,
|
||||||
|
getContextUsage: () => undefined,
|
||||||
|
getSystemPrompt: () => "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultText(result, outputs = []) {
|
||||||
|
const values = [...outputs];
|
||||||
|
if (result !== undefined) values.push(result);
|
||||||
|
const text = [];
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "string") text.push(value);
|
||||||
|
else if (typeof value?.text === "string") text.push(value.text);
|
||||||
|
else if (typeof value?.content === "string") text.push(value.content);
|
||||||
|
else if (Array.isArray(value?.content)) {
|
||||||
|
for (const item of value.content) {
|
||||||
|
if (typeof item === "string") text.push(item);
|
||||||
|
else if (typeof item?.text === "string") text.push(item.text);
|
||||||
|
}
|
||||||
|
} else if (value !== undefined) text.push(JSON.stringify(sanitize(value)));
|
||||||
|
}
|
||||||
|
return text.filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callExtension(params) {
|
||||||
|
const context = { outputs: [] };
|
||||||
|
return invocations.run(context, async () => {
|
||||||
|
if (params.kind === "tool") {
|
||||||
|
const record = state.tools.get(params.name);
|
||||||
|
if (!record) throw new Error(`unknown tool: ${params.name}`);
|
||||||
|
const result = await record.tool.execute(
|
||||||
|
params.callId || "nanobot",
|
||||||
|
params.input || {},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
...(record.flavor === "pi" ? [contextApi()] : []),
|
||||||
|
);
|
||||||
|
return { text: resultText(result, context.outputs), raw: sanitize(result) };
|
||||||
|
}
|
||||||
|
if (params.kind === "command") {
|
||||||
|
const record = state.commands.get(params.name);
|
||||||
|
if (!record) throw new Error(`unknown command: ${params.name}`);
|
||||||
|
const input = params.input || {};
|
||||||
|
const result =
|
||||||
|
record.flavor === "pi"
|
||||||
|
? await record.command.handler(input.args || "", contextApi())
|
||||||
|
: await record.command.handler({
|
||||||
|
args: input.args || "",
|
||||||
|
commandBody: input.raw || `/${params.name}`,
|
||||||
|
channel: input.channel || "websocket",
|
||||||
|
senderId: input.senderId,
|
||||||
|
isAuthorizedSender: true,
|
||||||
|
config: state.config,
|
||||||
|
sessionKey: input.sessionKey,
|
||||||
|
requestConversationBinding: async () => ({ ok: false }),
|
||||||
|
detachConversationBinding: async () => ({ removed: false }),
|
||||||
|
getCurrentConversationBinding: async () => null,
|
||||||
|
});
|
||||||
|
return { text: resultText(result, context.outputs), raw: sanitize(result) };
|
||||||
|
}
|
||||||
|
throw new Error(`unsupported callable kind: ${params.kind}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitEvent(name, event) {
|
||||||
|
const handlers = state.hooks.get(name) || [];
|
||||||
|
const results = [];
|
||||||
|
for (const { handler, flavor } of handlers) {
|
||||||
|
results.push(
|
||||||
|
await handler(event, flavor === "pi" ? contextApi() : { config: state.config }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { results: sanitize(results) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatch(method, params) {
|
||||||
|
if (method === "hello") {
|
||||||
|
return { protocol: PROTOCOL, node: process.version };
|
||||||
|
}
|
||||||
|
if (method === "extension.load") return loadExtension(params);
|
||||||
|
if (method === "extension.call") return callExtension(params);
|
||||||
|
if (method === "extension.event") return emitEvent(params.name, params.event);
|
||||||
|
if (method === "shutdown") {
|
||||||
|
queueMicrotask(() => process.exit(0));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`unknown method: ${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||||
|
lines.on("line", (line) => {
|
||||||
|
queue = queue.then(async () => {
|
||||||
|
let request;
|
||||||
|
try {
|
||||||
|
request = JSON.parse(line);
|
||||||
|
if (request.protocol !== PROTOCOL) throw new Error("protocol version mismatch");
|
||||||
|
const result = await dispatch(request.method, request.params || {});
|
||||||
|
process.stdout.write(`${JSON.stringify({ id: request.id, result: sanitize(result) })}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({
|
||||||
|
id: request?.id ?? null,
|
||||||
|
error: { code: "extension_error", message: String(error?.message || error) },
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
66
nanobot/extensions/protocol.py
Normal file
66
nanobot/extensions/protocol.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""Versioned messages shared with the Node compatibility sidecar."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
NODE_PROTOCOL_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
class NodeProtocolError(RuntimeError):
|
||||||
|
"""The sidecar returned an invalid message or reported an RPC failure."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NodeRegistration:
|
||||||
|
"""One callable or inspectable contribution retained by the sidecar."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
schema: dict[str, Any] | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: object) -> NodeRegistration:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise NodeProtocolError("sidecar registration must be an object")
|
||||||
|
kind = value.get("kind")
|
||||||
|
name = value.get("name")
|
||||||
|
if not isinstance(kind, str) or not isinstance(name, str):
|
||||||
|
raise NodeProtocolError("sidecar registration requires string kind and name")
|
||||||
|
schema = value.get("schema")
|
||||||
|
metadata = value.get("metadata")
|
||||||
|
if schema is not None and not isinstance(schema, dict):
|
||||||
|
raise NodeProtocolError("sidecar registration schema must be an object")
|
||||||
|
if metadata is not None and not isinstance(metadata, dict):
|
||||||
|
raise NodeProtocolError("sidecar registration metadata must be an object")
|
||||||
|
return cls(
|
||||||
|
kind=kind,
|
||||||
|
name=name,
|
||||||
|
description=str(value.get("description") or ""),
|
||||||
|
schema=schema,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NodeLoadResult:
|
||||||
|
"""Metadata returned after a Pi or OpenClaw module registers itself."""
|
||||||
|
|
||||||
|
registrations: tuple[NodeRegistration, ...]
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: object) -> NodeLoadResult:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise NodeProtocolError("sidecar load result must be an object")
|
||||||
|
registrations = value.get("registrations", [])
|
||||||
|
diagnostics = value.get("diagnostics", [])
|
||||||
|
if not isinstance(registrations, list) or not isinstance(diagnostics, list):
|
||||||
|
raise NodeProtocolError("invalid sidecar load result")
|
||||||
|
return cls(
|
||||||
|
registrations=tuple(NodeRegistration.from_mapping(item) for item in registrations),
|
||||||
|
diagnostics=tuple(str(item) for item in diagnostics),
|
||||||
|
)
|
||||||
@ -122,6 +122,7 @@ allow-direct-references = true
|
|||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
include = [
|
include = [
|
||||||
"nanobot/**/*.py",
|
"nanobot/**/*.py",
|
||||||
|
"nanobot/extensions/**/*.mjs",
|
||||||
"nanobot/templates/**/*.md",
|
"nanobot/templates/**/*.md",
|
||||||
"nanobot/skills/**/*.md",
|
"nanobot/skills/**/*.md",
|
||||||
"nanobot/skills/**/*.sh",
|
"nanobot/skills/**/*.sh",
|
||||||
|
|||||||
123
tests/extensions/test_node_compatibility.py
Normal file
123
tests/extensions/test_node_compatibility.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.extensions.compatibility import CompatibleExtension
|
||||||
|
from nanobot.extensions.node_host import NodeSidecar
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: Path, content: str) -> Path:
|
||||||
|
path.write_text(content)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pi_extension_loads_tools_commands_and_events(tmp_path: Path) -> None:
|
||||||
|
entry = _write(
|
||||||
|
tmp_path / "pi-extension.mjs",
|
||||||
|
"""
|
||||||
|
export default function (pi) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "pi_echo",
|
||||||
|
label: "Echo",
|
||||||
|
description: "Echo input",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { text: { type: "string" } },
|
||||||
|
required: ["text"]
|
||||||
|
},
|
||||||
|
async execute(_id, params) {
|
||||||
|
return { content: [{ type: "text", text: `pi:${params.text}` }] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pi.registerCommand("hello", {
|
||||||
|
description: "Say hello",
|
||||||
|
async handler(args, ctx) { ctx.ui.notify(`hello:${args}`); }
|
||||||
|
});
|
||||||
|
pi.on("agent_start", (event) => {
|
||||||
|
if (!event.messages) throw new Error("missing messages");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
host = NodeSidecar()
|
||||||
|
try:
|
||||||
|
result = await host.load(
|
||||||
|
runtime="pi",
|
||||||
|
entry=entry,
|
||||||
|
extension_id="test.pi",
|
||||||
|
name="Pi test",
|
||||||
|
version="1.0.0",
|
||||||
|
workspace=tmp_path,
|
||||||
|
)
|
||||||
|
extension = CompatibleExtension(
|
||||||
|
host=host,
|
||||||
|
runtime="pi",
|
||||||
|
owner="test.pi",
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
assert [(item.kind, item.name) for item in result.registrations] == [
|
||||||
|
("tool", "pi_echo"),
|
||||||
|
("command", "hello"),
|
||||||
|
("hook", "agent_start"),
|
||||||
|
]
|
||||||
|
assert await extension.tools[0].execute(text="ok") == "pi:ok"
|
||||||
|
assert extension.hook is not None
|
||||||
|
finally:
|
||||||
|
await host.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openclaw_definition_loads_and_invokes_tool(tmp_path: Path) -> None:
|
||||||
|
entry = _write(
|
||||||
|
tmp_path / "openclaw-plugin.cjs",
|
||||||
|
"""
|
||||||
|
module.exports = {
|
||||||
|
id: "test.openclaw",
|
||||||
|
register(api) {
|
||||||
|
api.registerTool({
|
||||||
|
name: "claw_echo",
|
||||||
|
description: "Echo input",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { text: { type: "string" } },
|
||||||
|
required: ["text"]
|
||||||
|
},
|
||||||
|
async execute(_id, params) {
|
||||||
|
return { content: [{ type: "text", text: `claw:${params.text}` }] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
api.registerCommand({
|
||||||
|
name: "status",
|
||||||
|
description: "Show status",
|
||||||
|
handler: async () => ({ text: "ready" })
|
||||||
|
});
|
||||||
|
api.on("agent_end", () => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
host = NodeSidecar()
|
||||||
|
try:
|
||||||
|
result = await host.load(
|
||||||
|
runtime="openclaw",
|
||||||
|
entry=entry,
|
||||||
|
extension_id="test.openclaw",
|
||||||
|
name="OpenClaw test",
|
||||||
|
version="1.0.0",
|
||||||
|
workspace=tmp_path,
|
||||||
|
)
|
||||||
|
extension = CompatibleExtension(
|
||||||
|
host=host,
|
||||||
|
runtime="openclaw",
|
||||||
|
owner="test.openclaw",
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
assert await extension.tools[0].execute(text="ok") == "claw:ok"
|
||||||
|
assert {item.name for item in result.registrations} == {
|
||||||
|
"claw_echo",
|
||||||
|
"status",
|
||||||
|
"agent_end",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await host.close()
|
||||||
Loading…
x
Reference in New Issue
Block a user