feat(extensions): activate packages transactionally

This commit is contained in:
Xubin Ren 2026-07-26 16:43:35 +08:00
parent 029f9bc53b
commit 0cd091ba93
8 changed files with 429 additions and 1 deletions

View File

@ -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)

View File

@ -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

View File

@ -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",

View File

@ -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,

View File

@ -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)

View File

@ -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}; "

View File

@ -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

View File

@ -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"