From 0cd091ba93c76acd5694eb6f2e42f84178e8718b Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:43:35 +0800 Subject: [PATCH] feat(extensions): activate packages transactionally --- nanobot/agent/tools/registry.py | 8 + nanobot/command/router.py | 19 +++ nanobot/extensions/__init__.py | 10 ++ nanobot/extensions/codec.py | 3 + nanobot/extensions/compatibility.py | 8 +- nanobot/extensions/manifest.py | 8 + nanobot/extensions/runtime.py | 255 ++++++++++++++++++++++++++++ tests/extensions/test_runtime.py | 119 +++++++++++++ 8 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 nanobot/extensions/runtime.py create mode 100644 tests/extensions/test_runtime.py diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 788ecf4a4..35e8691ce 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -40,6 +40,14 @@ class ToolRegistry: self._owners.pop(name, None) self._cached_definitions = None + def unregister_owner(self, owner: str) -> None: + """Remove all tools registered by one extension.""" + for name in [ + name for name, registered_owner in self._owners.items() + if registered_owner == owner + ]: + self.unregister(name) + def get(self, name: str) -> Tool | None: """Get a tool by name.""" return self._tools.get(name) diff --git a/nanobot/command/router.py b/nanobot/command/router.py index 40a3a2772..7b3b97b8f 100644 --- a/nanobot/command/router.py +++ b/nanobot/command/router.py @@ -104,6 +104,25 @@ class CommandRouter: for (tier, command), owner in sorted(self._owners.items()) ) + def owner(self, tier: str, command: str) -> str | None: + """Return the extension that owns one command registration.""" + return self._owners.get((tier, command)) + + def unregister_owner(self, owner: str) -> None: + """Remove all command tiers registered by one extension.""" + for (tier, command), registered_owner in list(self._owners.items()): + if registered_owner != owner: + continue + if tier == "priority": + self._priority.pop(command, None) + elif tier == "exact": + self._exact.pop(command, None) + else: + self._prefix = [ + item for item in self._prefix if item[0] != command + ] + self._owners.pop((tier, command), None) + def is_priority(self, text: str) -> bool: return normalize_command_text(text).lower() in self._priority diff --git a/nanobot/extensions/__init__.py b/nanobot/extensions/__init__.py index f50d07b5f..0f23a9309 100644 --- a/nanobot/extensions/__init__.py +++ b/nanobot/extensions/__init__.py @@ -36,6 +36,12 @@ from nanobot.extensions.registry import ( ExtensionSnapshot, ResolvedContribution, ) +from nanobot.extensions.runtime import ( + ActivatedExtension, + ActivationResult, + ExtensionRuntimeManager, + PythonExtensionApi, +) __all__ = [ "EXTENSION_API_VERSION", @@ -51,6 +57,7 @@ __all__ = [ "ExtensionPolicy", "ExtensionRegistry", "ExtensionRuntime", + "ExtensionRuntimeManager", "ExtensionScope", "ExtensionSnapshot", "MANIFEST_FILENAME", @@ -60,6 +67,9 @@ __all__ = [ "NodeProtocolError", "NodeRegistration", "NodeSidecar", + "ActivatedExtension", + "ActivationResult", + "PythonExtensionApi", "ResolvedContribution", "build_extension_catalog", "dump_manifest", diff --git a/nanobot/extensions/codec.py b/nanobot/extensions/codec.py index b2486b5d2..97a84f2d5 100644 --- a/nanobot/extensions/codec.py +++ b/nanobot/extensions/codec.py @@ -26,6 +26,7 @@ _MANIFEST_KEYS = frozenset( "name", "version", "runtime", + "entry", "contributions", "description", "dependencies", @@ -87,6 +88,7 @@ def manifest_from_mapping(data: object) -> ExtensionManifest: name=mapping["name"], version=mapping["version"], runtime=ExtensionRuntime(mapping["runtime"]), + entry=mapping.get("entry", ""), contributions=contributions, description=mapping.get("description", ""), dependencies=dependencies, @@ -107,6 +109,7 @@ def manifest_to_mapping(manifest: ExtensionManifest) -> dict[str, Any]: "version": manifest.version, "apiVersion": manifest.api_version, "runtime": manifest.runtime.value, + "entry": manifest.entry, "description": manifest.description, "homepage": manifest.homepage, "license": manifest.license, diff --git a/nanobot/extensions/compatibility.py b/nanobot/extensions/compatibility.py index 57f988796..692cb1957 100644 --- a/nanobot/extensions/compatibility.py +++ b/nanobot/extensions/compatibility.py @@ -166,6 +166,13 @@ class CompatibleExtension: for item in self.result.registrations: if item.kind != "command": continue + command = f"/{item.name}" + for tier, value in (("exact", command), ("prefix", f"{command} ")): + existing = router.owner(tier, value) + if existing and existing != self.owner: + raise ValueError( + f"command '{command}' is already registered by '{existing}'" + ) async def handler(ctx: CommandContext, name: str = item.name) -> OutboundMessage | None: result = await self.host.request( @@ -192,7 +199,6 @@ class CompatibleExtension: content=text, ) - command = f"/{item.name}" router.exact(command, handler, owner=self.owner) router.prefix(f"{command} ", handler, owner=self.owner) diff --git a/nanobot/extensions/manifest.py b/nanobot/extensions/manifest.py index 27a8ea69a..d13b711d7 100644 --- a/nanobot/extensions/manifest.py +++ b/nanobot/extensions/manifest.py @@ -5,6 +5,7 @@ from __future__ import annotations import re from dataclasses import dataclass from enum import Enum +from pathlib import Path EXTENSION_API_VERSION = 1 @@ -116,6 +117,7 @@ class ExtensionManifest: name: str version: str runtime: ExtensionRuntime + entry: str = "" contributions: tuple[ExtensionContribution, ...] = () description: str = "" dependencies: tuple[ExtensionDependency, ...] = () @@ -130,6 +132,12 @@ class ExtensionManifest: _require_text(self.version, "extension version") if not isinstance(self.runtime, ExtensionRuntime): raise TypeError("extension runtime must be an ExtensionRuntime") + if not isinstance(self.entry, str): + raise TypeError("extension entry must be a string") + if self.entry and Path(self.entry).is_absolute(): + raise ValueError("extension entry must be relative to the package root") + if self.entry and ".." in Path(self.entry).parts: + raise ValueError("extension entry cannot escape the package root") if self.api_version != EXTENSION_API_VERSION: raise ValueError( f"unsupported extension API version {self.api_version}; " diff --git a/nanobot/extensions/runtime.py b/nanobot/extensions/runtime.py new file mode 100644 index 000000000..0870f430d --- /dev/null +++ b/nanobot/extensions/runtime.py @@ -0,0 +1,255 @@ +"""Transactional activation of external extensions at existing registry edges.""" + +from __future__ import annotations + +import importlib +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from nanobot.agent.hook import AgentHook, AgentTurnHookFactory +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.command.router import CommandRouter, Handler +from nanobot.config.schema import Config +from nanobot.extensions.compatibility import CompatibleExtension +from nanobot.extensions.node_host import NodeSidecar +from nanobot.extensions.registry import ( + ExtensionCandidate, + ExtensionDiagnostic, + ExtensionSnapshot, +) + + +@dataclass(frozen=True, slots=True) +class ActivatedExtension: + """One active runtime and the resources needed to deactivate it.""" + + candidate: ExtensionCandidate + compatible: CompatibleExtension | None = None + diagnostics: tuple[ExtensionDiagnostic, ...] = () + + +@dataclass(frozen=True, slots=True) +class ActivationResult: + """Immutable activation outcome consumed by the agent assembly layer.""" + + extensions: tuple[ActivatedExtension, ...] + hook_factories: tuple[AgentTurnHookFactory, ...] + diagnostics: tuple[ExtensionDiagnostic, ...] + + +class PythonExtensionApi: + """Small native API; extensions register into existing nanobot interfaces.""" + + def __init__( + self, + *, + owner: str, + tools: ToolRegistry, + commands: CommandRouter, + hook_factories: list[AgentTurnHookFactory], + ) -> None: + self.owner = owner + self._tools = tools + self._commands = commands + self._hook_factories = hook_factories + + def register_tool(self, tool: Tool) -> None: + self._tools.register(tool, owner=self.owner) + + def register_command( + self, + command: str, + handler: Handler, + *, + prefix: bool = False, + ) -> None: + register = self._commands.prefix if prefix else self._commands.exact + register(command, handler, owner=self.owner) + + def register_hook_factory(self, factory: AgentTurnHookFactory) -> None: + self._hook_factories.append(factory) + + +class ExtensionRuntimeManager: + """Activate a resolved snapshot and roll back failed registrations.""" + + def __init__( + self, + *, + tools: ToolRegistry, + commands: CommandRouter, + config: Config, + ) -> None: + self._tools = tools + self._commands = commands + self._config = config + self._active: list[ActivatedExtension] = [] + self._hook_factories: list[AgentTurnHookFactory] = [] + + async def activate(self, snapshot: ExtensionSnapshot) -> ActivationResult: + diagnostics: list[ExtensionDiagnostic] = [] + for candidate in snapshot.extensions: + if candidate.location is None: + continue + try: + active = await self._activate_candidate(candidate) + if active is not None: + self._active.append(active) + diagnostics.extend(active.diagnostics) + except Exception as exc: + await self._rollback_owner(candidate.manifest.id) + diagnostics.append( + ExtensionDiagnostic( + code="activation_failed", + extension_id=candidate.manifest.id, + message=str(exc), + ) + ) + return ActivationResult( + tuple(self._active), + tuple(self._hook_factories), + tuple(diagnostics), + ) + + async def close(self) -> None: + for active in reversed(self._active): + await self._rollback_owner(active.candidate.manifest.id, active) + self._active.clear() + self._hook_factories.clear() + + async def _activate_candidate( + self, + candidate: ExtensionCandidate, + ) -> ActivatedExtension | None: + runtime = candidate.manifest.runtime.value + if runtime == "declarative": + return ActivatedExtension(candidate) + entry = _resolve_entry(candidate) + if runtime == "python": + self._activate_python(candidate, entry) + return ActivatedExtension(candidate) + if runtime not in {"pi", "openclaw"}: + raise ValueError(f"unsupported extension runtime: {runtime}") + + host = NodeSidecar() + try: + entry_config = self._config.extensions.entries.get(candidate.manifest.id) + result = await host.load( + runtime=runtime, + entry=entry, + extension_id=candidate.manifest.id, + name=candidate.manifest.name, + version=candidate.manifest.version, + config=entry_config.config if entry_config else {}, + workspace=self._config.workspace_path, + ) + compatible = CompatibleExtension( + host=host, + runtime=runtime, + owner=candidate.manifest.id, + result=result, + ) + self._register_compatible(candidate, compatible) + diagnostics = [ + ExtensionDiagnostic( + code="compatibility_notice", + extension_id=candidate.manifest.id, + message=message, + ) + for message in result.diagnostics + ] + diagnostics.extend( + ExtensionDiagnostic( + code="unsupported_compatible_contribution", + extension_id=candidate.manifest.id, + message=( + f"{item.kind} '{item.name}' is visible in the catalog but " + "is not executable through this compatibility adapter" + ), + ) + for item in result.registrations + if item.kind not in {"tool", "command", "hook"} + ) + return ActivatedExtension(candidate, compatible, tuple(diagnostics)) + except Exception: + await host.close() + raise + + def _activate_python(self, candidate: ExtensionCandidate, entry: Path) -> None: + module_name, separator, attribute = candidate.manifest.entry.partition(":") + if not separator: + module_name = candidate.manifest.entry + attribute = "register" + assert candidate.location is not None + sys.path.insert(0, str(candidate.location)) + try: + register = getattr(importlib.import_module(module_name), attribute) + api = PythonExtensionApi( + owner=candidate.manifest.id, + tools=self._tools, + commands=self._commands, + hook_factories=self._hook_factories, + ) + result = register(api) + if result is not None: + raise TypeError("Python extension register function must return None") + finally: + sys.path.remove(str(candidate.location)) + + def _register_compatible( + self, + candidate: ExtensionCandidate, + compatible: CompatibleExtension, + ) -> None: + owner = candidate.manifest.id + for tool in compatible.tools: + existing = self._tools.owner(tool.name) + if existing and existing != owner: + raise ValueError( + f"tool '{tool.name}' is already registered by '{existing}'" + ) + self._tools.register(tool, owner=owner) + compatible.register_commands(self._commands) + if hook := compatible.hook: + self._hook_factories.append(_constant_hook_factory(hook, owner)) + + async def _rollback_owner( + self, + owner: str, + active: ActivatedExtension | None = None, + ) -> None: + self._tools.unregister_owner(owner) + self._commands.unregister_owner(owner) + self._hook_factories = [ + factory + for factory in self._hook_factories + if getattr(factory, "__nanobot_extension_owner__", None) != owner + ] + if active and active.compatible: + await active.compatible.close() + + +def _resolve_entry(candidate: ExtensionCandidate) -> Path: + manifest = candidate.manifest + location = candidate.location + if location is None or not manifest.entry: + raise ValueError(f"extension '{manifest.id}' does not declare an entry") + if manifest.runtime.value == "python": + return location + entry = (location / manifest.entry).resolve() + if not entry.is_relative_to(location.resolve()): + raise ValueError(f"extension '{manifest.id}' entry escapes its package") + if not entry.is_file(): + raise ValueError(f"extension '{manifest.id}' entry does not exist: {entry}") + return entry + + +def _constant_hook_factory(hook: AgentHook, owner: str) -> AgentTurnHookFactory: + def factory(_context: Any) -> AgentHook: + return hook + + setattr(factory, "__nanobot_extension_owner__", owner) + return factory diff --git a/tests/extensions/test_runtime.py b/tests/extensions/test_runtime.py new file mode 100644 index 000000000..d37ed6d38 --- /dev/null +++ b/tests/extensions/test_runtime.py @@ -0,0 +1,119 @@ +from pathlib import Path + +import pytest + +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.command.router import CommandRouter +from nanobot.config.schema import Config +from nanobot.extensions import ( + ExtensionCandidate, + ExtensionManifest, + ExtensionRuntime, + ExtensionRuntimeManager, + ExtensionScope, + ExtensionSnapshot, +) + + +def _snapshot(candidate: ExtensionCandidate) -> ExtensionSnapshot: + return ExtensionSnapshot((candidate,), (), ()) + + +@pytest.mark.asyncio +async def test_runtime_activation_and_close_are_transactional(tmp_path: Path) -> None: + (tmp_path / "index.mjs").write_text( + """ + export default function (pi) { + pi.registerTool({ + name: "remote_echo", + description: "Echo", + parameters: { type: "object", properties: {} }, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }) + }); + pi.registerCommand("remote", { + description: "Remote command", + handler: async () => undefined + }); + pi.on("agent_start", () => undefined); + } + """ + ) + candidate = ExtensionCandidate( + ExtensionManifest( + id="test.remote", + name="Remote", + version="1.0.0", + runtime=ExtensionRuntime.PI, + entry="index.mjs", + ), + ExtensionScope.USER, + location=tmp_path, + trusted=True, + ) + tools = ToolRegistry() + commands = CommandRouter() + manager = ExtensionRuntimeManager( + tools=tools, + commands=commands, + config=Config(), + ) + + result = await manager.activate(_snapshot(candidate)) + + assert not result.diagnostics + assert tools.owner("remote_echo") == "test.remote" + assert commands.owner("exact", "/remote") == "test.remote" + assert len(result.hook_factories) == 1 + + await manager.close() + + assert tools.owner("remote_echo") is None + assert commands.owner("exact", "/remote") is None + + +@pytest.mark.asyncio +async def test_runtime_rolls_back_partial_registration(tmp_path: Path) -> None: + (tmp_path / "index.mjs").write_text( + """ + export default function (pi) { + pi.registerTool({ + name: "duplicate", + description: "Duplicate", + parameters: {}, + execute: async () => ({ content: [] }) + }); + pi.registerCommand("duplicate", { + handler: async () => undefined + }); + } + """ + ) + candidate = ExtensionCandidate( + ExtensionManifest( + id="test.duplicate", + name="Duplicate", + version="1.0.0", + runtime=ExtensionRuntime.PI, + entry="index.mjs", + ), + ExtensionScope.USER, + location=tmp_path, + trusted=True, + ) + commands = CommandRouter() + + async def core_handler(_ctx): + return None + + commands.exact("/duplicate", core_handler) + manager = ExtensionRuntimeManager( + tools=ToolRegistry(), + commands=commands, + config=Config(), + ) + + result = await manager.activate(_snapshot(candidate)) + + assert result.extensions == () + assert result.diagnostics[0].code == "activation_failed" + assert commands.owner("exact", "/duplicate") == "nanobot.core"