mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
feat(extensions): activate packages transactionally
This commit is contained in:
parent
029f9bc53b
commit
0cd091ba93
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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}; "
|
||||
|
||||
255
nanobot/extensions/runtime.py
Normal file
255
nanobot/extensions/runtime.py
Normal 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
|
||||
119
tests/extensions/test_runtime.py
Normal file
119
tests/extensions/test_runtime.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user