mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
feat(extensions): project native capabilities into catalog
This commit is contained in:
parent
863b02d215
commit
1e573c75ae
@ -89,6 +89,11 @@ and are exposed only through declared host interfaces. Existing workspace,
|
|||||||
network, SSRF, and shell restrictions continue to apply to host-provided
|
network, SSRF, and shell restrictions continue to apply to host-provided
|
||||||
operations.
|
operations.
|
||||||
|
|
||||||
|
Untrusted packages remain visible in the catalog with an inactive state. They
|
||||||
|
do not own active contributions and their runtime is not imported. Built-in
|
||||||
|
capabilities are trusted by construction; installed and workspace packages
|
||||||
|
need an explicit trusted entry or an allowed workspace trust policy.
|
||||||
|
|
||||||
The root `extensions` config controls explicit search paths, allow/deny policy,
|
The root `extensions` config controls explicit search paths, allow/deny policy,
|
||||||
per-extension enablement, package-owned config, and workspace trust. Discovery
|
per-extension enablement, package-owned config, and workspace trust. Discovery
|
||||||
does not import extension code. Installation does not imply workspace trust,
|
does not import extension code. Installation does not imply workspace trust,
|
||||||
|
|||||||
@ -86,9 +86,21 @@ class ToolLoader:
|
|||||||
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
|
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
|
||||||
registered: list[str] = []
|
registered: list[str] = []
|
||||||
builtin_names: set[str] = set()
|
builtin_names: set[str] = set()
|
||||||
sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
|
sources = [
|
||||||
|
(
|
||||||
|
(("nanobot.core", tool_cls) for tool_cls in self.discover()),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
(f"legacy.tool.{entry_point_name}", tool_cls)
|
||||||
|
for entry_point_name, tool_cls in self._discover_plugins().items()
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
]
|
||||||
for source, is_plugin_source in sources:
|
for source, is_plugin_source in sources:
|
||||||
for tool_cls in source:
|
for owner, tool_cls in source:
|
||||||
cls_label = tool_cls.__name__
|
cls_label = tool_cls.__name__
|
||||||
try:
|
try:
|
||||||
if scope not in getattr(tool_cls, "_scopes", {"core"}):
|
if scope not in getattr(tool_cls, "_scopes", {"core"}):
|
||||||
@ -109,7 +121,7 @@ class ToolLoader:
|
|||||||
"Tool name collision: %s from %s overwrites existing",
|
"Tool name collision: %s from %s overwrites existing",
|
||||||
tool.name, cls_label,
|
tool.name, cls_label,
|
||||||
)
|
)
|
||||||
registry.register(tool)
|
registry.register(tool, owner=owner)
|
||||||
registered.append(tool.name)
|
registered.append(tool.name)
|
||||||
if not is_plugin_source:
|
if not is_plugin_source:
|
||||||
builtin_names.add(tool.name)
|
builtin_names.add(tool.name)
|
||||||
|
|||||||
@ -963,7 +963,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
|
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
|
||||||
registry.register(wrapper)
|
registry.register(wrapper, owner=f"nanobot.mcp.{name}")
|
||||||
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
|
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
|
||||||
registered_count += 1
|
registered_count += 1
|
||||||
if enabled_tools:
|
if enabled_tools:
|
||||||
@ -1000,7 +1000,7 @@ async def connect_mcp_servers(
|
|||||||
wrapper = MCPResourceWrapper(
|
wrapper = MCPResourceWrapper(
|
||||||
session, name, resource, resource_timeout=cfg.tool_timeout
|
session, name, resource, resource_timeout=cfg.tool_timeout
|
||||||
)
|
)
|
||||||
registry.register(wrapper)
|
registry.register(wrapper, owner=f"nanobot.mcp.{name}")
|
||||||
registered_count += 1
|
registered_count += 1
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"MCP: registered resource '{}' from server '{}'",
|
"MCP: registered resource '{}' from server '{}'",
|
||||||
@ -1018,7 +1018,7 @@ async def connect_mcp_servers(
|
|||||||
wrapper = MCPPromptWrapper(
|
wrapper = MCPPromptWrapper(
|
||||||
session, name, prompt, prompt_timeout=cfg.tool_timeout
|
session, name, prompt, prompt_timeout=cfg.tool_timeout
|
||||||
)
|
)
|
||||||
registry.register(wrapper)
|
registry.register(wrapper, owner=f"nanobot.mcp.{name}")
|
||||||
registered_count += 1
|
registered_count += 1
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"MCP: registered prompt '{}' from server '{}'",
|
"MCP: registered prompt '{}' from server '{}'",
|
||||||
|
|||||||
@ -25,22 +25,29 @@ class ToolRegistry:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._tools: dict[str, Tool] = {}
|
self._tools: dict[str, Tool] = {}
|
||||||
|
self._owners: dict[str, str] = {}
|
||||||
self._cached_definitions: list[dict[str, Any]] | None = None
|
self._cached_definitions: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
def register(self, tool: Tool) -> None:
|
def register(self, tool: Tool, *, owner: str = "nanobot.core") -> None:
|
||||||
"""Register a tool."""
|
"""Register a tool."""
|
||||||
self._tools[tool.name] = tool
|
self._tools[tool.name] = tool
|
||||||
|
self._owners[tool.name] = owner
|
||||||
self._cached_definitions = None
|
self._cached_definitions = None
|
||||||
|
|
||||||
def unregister(self, name: str) -> None:
|
def unregister(self, name: str) -> None:
|
||||||
"""Unregister a tool by name."""
|
"""Unregister a tool by name."""
|
||||||
self._tools.pop(name, None)
|
self._tools.pop(name, None)
|
||||||
|
self._owners.pop(name, None)
|
||||||
self._cached_definitions = None
|
self._cached_definitions = None
|
||||||
|
|
||||||
def get(self, name: str) -> Tool | None:
|
def get(self, name: str) -> Tool | None:
|
||||||
"""Get a tool by name."""
|
"""Get a tool by name."""
|
||||||
return self._tools.get(name)
|
return self._tools.get(name)
|
||||||
|
|
||||||
|
def owner(self, name: str) -> str | None:
|
||||||
|
"""Return the extension ID that registered a tool."""
|
||||||
|
return self._owners.get(name)
|
||||||
|
|
||||||
def get_runtime_context_providers(self) -> list[RuntimeContextProvider]:
|
def get_runtime_context_providers(self) -> list[RuntimeContextProvider]:
|
||||||
"""Return tool-owned providers in stable tool-name order."""
|
"""Return tool-owned providers in stable tool-name order."""
|
||||||
providers: list[RuntimeContextProvider] = []
|
providers: list[RuntimeContextProvider] = []
|
||||||
|
|||||||
@ -64,16 +64,45 @@ class CommandRouter:
|
|||||||
self._priority: dict[str, Handler] = {}
|
self._priority: dict[str, Handler] = {}
|
||||||
self._exact: dict[str, Handler] = {}
|
self._exact: dict[str, Handler] = {}
|
||||||
self._prefix: list[tuple[str, Handler]] = []
|
self._prefix: list[tuple[str, Handler]] = []
|
||||||
|
self._owners: dict[tuple[str, str], str] = {}
|
||||||
|
|
||||||
def priority(self, cmd: str, handler: Handler) -> None:
|
def priority(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
handler: Handler,
|
||||||
|
*,
|
||||||
|
owner: str = "nanobot.core",
|
||||||
|
) -> None:
|
||||||
self._priority[cmd] = handler
|
self._priority[cmd] = handler
|
||||||
|
self._owners[("priority", cmd)] = owner
|
||||||
|
|
||||||
def exact(self, cmd: str, handler: Handler) -> None:
|
def exact(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
handler: Handler,
|
||||||
|
*,
|
||||||
|
owner: str = "nanobot.core",
|
||||||
|
) -> None:
|
||||||
self._exact[cmd] = handler
|
self._exact[cmd] = handler
|
||||||
|
self._owners[("exact", cmd)] = owner
|
||||||
|
|
||||||
def prefix(self, pfx: str, handler: Handler) -> None:
|
def prefix(
|
||||||
|
self,
|
||||||
|
pfx: str,
|
||||||
|
handler: Handler,
|
||||||
|
*,
|
||||||
|
owner: str = "nanobot.core",
|
||||||
|
) -> None:
|
||||||
self._prefix.append((pfx, handler))
|
self._prefix.append((pfx, handler))
|
||||||
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
|
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
|
||||||
|
self._owners[("prefix", pfx)] = owner
|
||||||
|
|
||||||
|
def registrations(self) -> tuple[tuple[str, str, str], ...]:
|
||||||
|
"""Return ``(tier, command, owner)`` rows for extension inspection."""
|
||||||
|
return tuple(
|
||||||
|
(tier, command, owner)
|
||||||
|
for (tier, command), owner in sorted(self._owners.items())
|
||||||
|
)
|
||||||
|
|
||||||
def is_priority(self, text: str) -> bool:
|
def is_priority(self, text: str) -> bool:
|
||||||
return normalize_command_text(text).lower() in self._priority
|
return normalize_command_text(text).lower() in self._priority
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""First-class extension metadata and discovery primitives."""
|
"""First-class extension metadata and discovery primitives."""
|
||||||
|
|
||||||
|
from nanobot.extensions.catalog import ExtensionCatalog, build_extension_catalog
|
||||||
from nanobot.extensions.codec import (
|
from nanobot.extensions.codec import (
|
||||||
MANIFEST_FILENAME,
|
MANIFEST_FILENAME,
|
||||||
ManifestFormatError,
|
ManifestFormatError,
|
||||||
@ -18,6 +19,7 @@ from nanobot.extensions.manifest import (
|
|||||||
ExtensionPermission,
|
ExtensionPermission,
|
||||||
ExtensionRuntime,
|
ExtensionRuntime,
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.native import discover_native_extensions
|
||||||
from nanobot.extensions.registry import (
|
from nanobot.extensions.registry import (
|
||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionDiagnostic,
|
ExtensionDiagnostic,
|
||||||
@ -33,6 +35,7 @@ __all__ = [
|
|||||||
"ContributionKind",
|
"ContributionKind",
|
||||||
"DependencyKind",
|
"DependencyKind",
|
||||||
"ExtensionCandidate",
|
"ExtensionCandidate",
|
||||||
|
"ExtensionCatalog",
|
||||||
"ExtensionContribution",
|
"ExtensionContribution",
|
||||||
"ExtensionDependency",
|
"ExtensionDependency",
|
||||||
"ExtensionDiagnostic",
|
"ExtensionDiagnostic",
|
||||||
@ -46,7 +49,9 @@ __all__ = [
|
|||||||
"MANIFEST_FILENAME",
|
"MANIFEST_FILENAME",
|
||||||
"ManifestFormatError",
|
"ManifestFormatError",
|
||||||
"ResolvedContribution",
|
"ResolvedContribution",
|
||||||
|
"build_extension_catalog",
|
||||||
"dump_manifest",
|
"dump_manifest",
|
||||||
|
"discover_native_extensions",
|
||||||
"load_manifest",
|
"load_manifest",
|
||||||
"manifest_from_mapping",
|
"manifest_from_mapping",
|
||||||
"manifest_to_mapping",
|
"manifest_to_mapping",
|
||||||
|
|||||||
136
nanobot/extensions/catalog.py
Normal file
136
nanobot/extensions/catalog.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"""Assemble native and installed extensions into one inspectable catalog."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
|
from nanobot.extensions.discovery import (
|
||||||
|
ExtensionDiscoveryResult,
|
||||||
|
discover_manifest_root,
|
||||||
|
)
|
||||||
|
from nanobot.extensions.native import discover_native_extensions
|
||||||
|
from nanobot.extensions.registry import (
|
||||||
|
ExtensionCandidate,
|
||||||
|
ExtensionDiagnostic,
|
||||||
|
ExtensionPolicy,
|
||||||
|
ExtensionRegistry,
|
||||||
|
ExtensionScope,
|
||||||
|
ExtensionSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.command.router import CommandRouter
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExtensionCatalog:
|
||||||
|
"""Discovered candidates plus the active, policy-resolved snapshot."""
|
||||||
|
|
||||||
|
candidates: tuple[ExtensionCandidate, ...]
|
||||||
|
snapshot: ExtensionSnapshot
|
||||||
|
diagnostics: tuple[ExtensionDiagnostic, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def build_extension_catalog(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
skills: SkillsLoader | None = None,
|
||||||
|
tools: ToolRegistry | None = None,
|
||||||
|
commands: CommandRouter | None = None,
|
||||||
|
user_root: Path | None = None,
|
||||||
|
) -> ExtensionCatalog:
|
||||||
|
"""Build the authoritative extension view without executing plugin code."""
|
||||||
|
native = discover_native_extensions(
|
||||||
|
config,
|
||||||
|
skills=skills,
|
||||||
|
tools=tools,
|
||||||
|
commands=commands,
|
||||||
|
)
|
||||||
|
discoveries = [native]
|
||||||
|
if config.extensions.enabled:
|
||||||
|
discoveries.extend(
|
||||||
|
_external_discoveries(
|
||||||
|
config,
|
||||||
|
user_root=user_root or Path.home() / ".nanobot" / "extensions",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = tuple(
|
||||||
|
_apply_entry_config(config, candidate)
|
||||||
|
for result in discoveries
|
||||||
|
for candidate in result.candidates
|
||||||
|
)
|
||||||
|
discovery_diagnostics = tuple(
|
||||||
|
diagnostic
|
||||||
|
for result in discoveries
|
||||||
|
for diagnostic in result.diagnostics
|
||||||
|
)
|
||||||
|
registry = ExtensionRegistry(
|
||||||
|
ExtensionPolicy(
|
||||||
|
allow=frozenset(config.extensions.allow),
|
||||||
|
deny=frozenset(config.extensions.deny),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry_diagnostics: list[ExtensionDiagnostic] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
registry.register(candidate)
|
||||||
|
except ValueError as exc:
|
||||||
|
registry_diagnostics.append(
|
||||||
|
ExtensionDiagnostic(
|
||||||
|
code="duplicate_installation",
|
||||||
|
extension_id=candidate.manifest.id,
|
||||||
|
message=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
snapshot = registry.snapshot()
|
||||||
|
diagnostics = (
|
||||||
|
discovery_diagnostics
|
||||||
|
+ tuple(registry_diagnostics)
|
||||||
|
+ snapshot.diagnostics
|
||||||
|
)
|
||||||
|
return ExtensionCatalog(candidates, snapshot, diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _external_discoveries(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
user_root: Path,
|
||||||
|
) -> Iterable[ExtensionDiscoveryResult]:
|
||||||
|
yield discover_manifest_root(
|
||||||
|
user_root,
|
||||||
|
scope=ExtensionScope.USER,
|
||||||
|
)
|
||||||
|
for raw_path in config.extensions.paths:
|
||||||
|
yield discover_manifest_root(
|
||||||
|
Path(raw_path).expanduser(),
|
||||||
|
scope=ExtensionScope.USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace_root = config.workspace_path / ".nanobot" / "extensions"
|
||||||
|
workspace_trust = config.extensions.workspace_trust
|
||||||
|
if workspace_trust != "deny":
|
||||||
|
yield discover_manifest_root(
|
||||||
|
workspace_root,
|
||||||
|
scope=ExtensionScope.WORKSPACE,
|
||||||
|
trusted=workspace_trust == "allow",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_entry_config(
|
||||||
|
config: Config,
|
||||||
|
candidate: ExtensionCandidate,
|
||||||
|
) -> ExtensionCandidate:
|
||||||
|
entry = config.extensions.entries.get(candidate.manifest.id)
|
||||||
|
if entry is None or candidate.scope is ExtensionScope.BUILTIN:
|
||||||
|
return candidate
|
||||||
|
return replace(
|
||||||
|
candidate,
|
||||||
|
enabled=entry.enabled,
|
||||||
|
trusted=candidate.trusted or entry.trusted,
|
||||||
|
)
|
||||||
339
nanobot/extensions/native.py
Normal file
339
nanobot/extensions/native.py
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
"""Project existing nanobot registries into the extension control plane."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
|
from packaging.requirements import Requirement
|
||||||
|
|
||||||
|
from nanobot import __version__
|
||||||
|
from nanobot.extensions.discovery import ExtensionDiscoveryResult
|
||||||
|
from nanobot.extensions.manifest import (
|
||||||
|
ContributionKind,
|
||||||
|
DependencyKind,
|
||||||
|
ExtensionContribution,
|
||||||
|
ExtensionDependency,
|
||||||
|
ExtensionManifest,
|
||||||
|
ExtensionRuntime,
|
||||||
|
)
|
||||||
|
from nanobot.extensions.registry import ExtensionCandidate, ExtensionScope
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.command.router import CommandRouter
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
_NON_ID_CHARACTER = re.compile(r"[^a-z0-9._-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def discover_native_extensions(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
skills: SkillsLoader | None = None,
|
||||||
|
tools: ToolRegistry | None = None,
|
||||||
|
commands: CommandRouter | None = None,
|
||||||
|
) -> ExtensionDiscoveryResult:
|
||||||
|
"""Return built-in, workspace, and configured capabilities as extensions."""
|
||||||
|
candidates = [
|
||||||
|
*_channel_candidates(),
|
||||||
|
*_provider_candidates(),
|
||||||
|
*_transcription_candidates(),
|
||||||
|
*_image_generation_candidates(),
|
||||||
|
*_mcp_candidates(config),
|
||||||
|
]
|
||||||
|
if skills is not None:
|
||||||
|
candidates.extend(_skill_candidates(skills))
|
||||||
|
if tools is not None:
|
||||||
|
candidates.extend(_tool_candidates(tools))
|
||||||
|
if commands is not None:
|
||||||
|
candidates.extend(_command_candidates(commands))
|
||||||
|
return ExtensionDiscoveryResult(candidates=_merge_candidates(candidates))
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_candidates() -> list[ExtensionCandidate]:
|
||||||
|
from nanobot.channels.registry import discover_plugins
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for name, plugin in sorted(discover_plugins().items()):
|
||||||
|
contributions = [
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.CHANNEL,
|
||||||
|
name=name,
|
||||||
|
target=plugin.runtime,
|
||||||
|
description=plugin.display_name,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if plugin.webui:
|
||||||
|
contributions.append(
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.WEBUI,
|
||||||
|
name=f"channel-{name}",
|
||||||
|
target=plugin.webui,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
_candidate(
|
||||||
|
f"nanobot.channel.{name}",
|
||||||
|
plugin.display_name,
|
||||||
|
contributions,
|
||||||
|
dependencies=_python_dependencies(plugin.dependencies),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_candidates() -> list[ExtensionCandidate]:
|
||||||
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for spec in PROVIDERS:
|
||||||
|
if spec.settings_alias_for or spec.is_transcription_only:
|
||||||
|
continue
|
||||||
|
candidates.append(
|
||||||
|
_candidate(
|
||||||
|
f"nanobot.provider.{spec.name}",
|
||||||
|
spec.label,
|
||||||
|
(
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.LLM_PROVIDER,
|
||||||
|
name=spec.name,
|
||||||
|
target=spec.backend,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _transcription_candidates() -> list[ExtensionCandidate]:
|
||||||
|
from nanobot.audio.transcription_registry import TRANSCRIPTION_PROVIDERS
|
||||||
|
|
||||||
|
return [
|
||||||
|
_candidate(
|
||||||
|
f"nanobot.transcription.{spec.name}",
|
||||||
|
f"{spec.name} transcription",
|
||||||
|
(
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.TRANSCRIPTION_PROVIDER,
|
||||||
|
name=spec.name,
|
||||||
|
target=spec.adapter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for spec in TRANSCRIPTION_PROVIDERS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _image_generation_candidates() -> list[ExtensionCandidate]:
|
||||||
|
from nanobot.providers.image_generation import image_gen_provider_names
|
||||||
|
|
||||||
|
return [
|
||||||
|
_candidate(
|
||||||
|
f"nanobot.image-generation.{name}",
|
||||||
|
f"{name} image generation",
|
||||||
|
(
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.IMAGE_GENERATION_PROVIDER,
|
||||||
|
name=name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name in image_gen_provider_names()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_candidates(config: Config) -> list[ExtensionCandidate]:
|
||||||
|
return [
|
||||||
|
_candidate(
|
||||||
|
f"nanobot.mcp.{_identifier(name)}",
|
||||||
|
name,
|
||||||
|
(
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.MCP_SERVER,
|
||||||
|
name=_identifier(name),
|
||||||
|
target=name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scope=ExtensionScope.USER,
|
||||||
|
)
|
||||||
|
for name in sorted(config.tools.mcp_servers)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_candidates(skills: SkillsLoader) -> list[ExtensionCandidate]:
|
||||||
|
candidates = []
|
||||||
|
for entry in skills.list_skills(filter_unavailable=False):
|
||||||
|
name = entry["name"]
|
||||||
|
metadata = skills.get_skill_metadata(name) or {}
|
||||||
|
requirements = skills.get_skill_requirements(name)
|
||||||
|
dependencies = tuple(
|
||||||
|
ExtensionDependency(DependencyKind.EXECUTABLE, value)
|
||||||
|
for value in requirements["bins"]
|
||||||
|
) + tuple(
|
||||||
|
ExtensionDependency(DependencyKind.ENVIRONMENT, value)
|
||||||
|
for value in requirements["env"]
|
||||||
|
)
|
||||||
|
scope = (
|
||||||
|
ExtensionScope.WORKSPACE
|
||||||
|
if entry["source"] == "workspace"
|
||||||
|
else ExtensionScope.BUILTIN
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
_candidate(
|
||||||
|
f"nanobot.skill.{_identifier(name)}",
|
||||||
|
name,
|
||||||
|
(
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.SKILL,
|
||||||
|
name=_identifier(name),
|
||||||
|
target=entry["path"],
|
||||||
|
description=str(metadata.get("description") or name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dependencies=dependencies,
|
||||||
|
scope=scope,
|
||||||
|
location=Path(entry["path"]).parent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_candidates(tools: ToolRegistry) -> list[ExtensionCandidate]:
|
||||||
|
grouped: dict[str, list[ExtensionContribution]] = defaultdict(list)
|
||||||
|
for name in tools.tool_names:
|
||||||
|
owner = tools.owner(name) or "nanobot.core"
|
||||||
|
tool = tools.get(name)
|
||||||
|
grouped[owner].append(
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.TOOL,
|
||||||
|
name=_identifier(name),
|
||||||
|
description=tool.description if tool is not None else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
_candidate(
|
||||||
|
owner,
|
||||||
|
owner,
|
||||||
|
contributions,
|
||||||
|
scope=(
|
||||||
|
ExtensionScope.USER
|
||||||
|
if owner.startswith(("legacy.", "nanobot.mcp."))
|
||||||
|
else ExtensionScope.BUILTIN
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for owner, contributions in sorted(grouped.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _command_candidates(commands: CommandRouter) -> list[ExtensionCandidate]:
|
||||||
|
grouped: dict[str, list[ExtensionContribution]] = defaultdict(list)
|
||||||
|
for _tier, command, owner in commands.registrations():
|
||||||
|
grouped[owner].append(
|
||||||
|
ExtensionContribution(
|
||||||
|
kind=ContributionKind.COMMAND,
|
||||||
|
name=_identifier(command.lstrip("/").rstrip()),
|
||||||
|
target=command,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
_candidate(owner, owner, contributions)
|
||||||
|
for owner, contributions in sorted(grouped.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(
|
||||||
|
extension_id: str,
|
||||||
|
name: str,
|
||||||
|
contributions: Iterable[ExtensionContribution],
|
||||||
|
*,
|
||||||
|
dependencies: tuple[ExtensionDependency, ...] = (),
|
||||||
|
scope: ExtensionScope = ExtensionScope.BUILTIN,
|
||||||
|
location: Path | None = None,
|
||||||
|
) -> ExtensionCandidate:
|
||||||
|
return ExtensionCandidate(
|
||||||
|
manifest=ExtensionManifest(
|
||||||
|
id=_identifier(extension_id),
|
||||||
|
name=name,
|
||||||
|
version=__version__,
|
||||||
|
runtime=ExtensionRuntime.PYTHON,
|
||||||
|
contributions=tuple(contributions),
|
||||||
|
dependencies=dependencies,
|
||||||
|
),
|
||||||
|
scope=scope,
|
||||||
|
location=location,
|
||||||
|
enabled=True,
|
||||||
|
trusted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _python_dependencies(
|
||||||
|
requirements: tuple[str, ...],
|
||||||
|
) -> tuple[ExtensionDependency, ...]:
|
||||||
|
dependencies = []
|
||||||
|
for raw in requirements:
|
||||||
|
requirement = Requirement(raw)
|
||||||
|
name = requirement.name
|
||||||
|
if requirement.extras:
|
||||||
|
name += f"[{','.join(sorted(requirement.extras))}]"
|
||||||
|
specifier = str(requirement.specifier)
|
||||||
|
if requirement.marker:
|
||||||
|
specifier += f"; {requirement.marker}"
|
||||||
|
dependencies.append(
|
||||||
|
ExtensionDependency(
|
||||||
|
kind=DependencyKind.PYTHON,
|
||||||
|
name=name,
|
||||||
|
specifier=specifier,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(dependencies)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_candidates(
|
||||||
|
candidates: Iterable[ExtensionCandidate],
|
||||||
|
) -> tuple[ExtensionCandidate, ...]:
|
||||||
|
merged: dict[tuple[str, ExtensionScope], ExtensionCandidate] = {}
|
||||||
|
for candidate in candidates:
|
||||||
|
key = (candidate.manifest.id, candidate.scope)
|
||||||
|
existing = merged.get(key)
|
||||||
|
if existing is None:
|
||||||
|
merged[key] = candidate
|
||||||
|
continue
|
||||||
|
manifest = existing.manifest
|
||||||
|
incoming = candidate.manifest
|
||||||
|
merged[key] = ExtensionCandidate(
|
||||||
|
manifest=ExtensionManifest(
|
||||||
|
id=manifest.id,
|
||||||
|
name=manifest.name,
|
||||||
|
version=manifest.version,
|
||||||
|
runtime=manifest.runtime,
|
||||||
|
contributions=manifest.contributions + incoming.contributions,
|
||||||
|
description=manifest.description or incoming.description,
|
||||||
|
dependencies=tuple(
|
||||||
|
dict.fromkeys(manifest.dependencies + incoming.dependencies)
|
||||||
|
),
|
||||||
|
permissions=tuple(
|
||||||
|
dict.fromkeys(manifest.permissions + incoming.permissions)
|
||||||
|
),
|
||||||
|
homepage=manifest.homepage or incoming.homepage,
|
||||||
|
license=manifest.license or incoming.license,
|
||||||
|
),
|
||||||
|
scope=existing.scope,
|
||||||
|
location=existing.location or candidate.location,
|
||||||
|
enabled=existing.enabled and candidate.enabled,
|
||||||
|
trusted=existing.trusted and candidate.trusted,
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
merged.values(),
|
||||||
|
key=lambda item: (item.scope, item.manifest.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier(value: str) -> str:
|
||||||
|
normalized = _NON_ID_CHARACTER.sub("-", value.strip().lower()).strip("._-")
|
||||||
|
return normalized or "unnamed"
|
||||||
@ -62,6 +62,7 @@ class ExtensionPolicy:
|
|||||||
extension_id = candidate.manifest.id
|
extension_id = candidate.manifest.id
|
||||||
return (
|
return (
|
||||||
candidate.enabled
|
candidate.enabled
|
||||||
|
and (candidate.scope is ExtensionScope.BUILTIN or candidate.trusted)
|
||||||
and extension_id not in self.deny
|
and extension_id not in self.deny
|
||||||
and (not self.allow or extension_id in self.allow)
|
and (not self.allow or extension_id in self.allow)
|
||||||
)
|
)
|
||||||
|
|||||||
73
tests/extensions/test_catalog.py
Normal file
73
tests/extensions/test_catalog.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.extensions import build_extension_catalog
|
||||||
|
|
||||||
|
|
||||||
|
def _write_extension(root, extension_id: str) -> None:
|
||||||
|
package = root / extension_id
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(package / "nanobot.extension.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": extension_id,
|
||||||
|
"name": extension_id,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"runtime": "python",
|
||||||
|
"contributions": [{"kind": "tool", "name": f"{extension_id}_tool"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog(config: Config, user_root):
|
||||||
|
with (
|
||||||
|
patch("nanobot.channels.registry.discover_plugins", return_value={}),
|
||||||
|
patch("nanobot.providers.registry.PROVIDERS", ()),
|
||||||
|
patch("nanobot.audio.transcription_registry.TRANSCRIPTION_PROVIDERS", ()),
|
||||||
|
patch(
|
||||||
|
"nanobot.providers.image_generation.image_gen_provider_names",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return build_extension_catalog(config, user_root=user_root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_extension_requires_explicit_trust(tmp_path) -> None:
|
||||||
|
_write_extension(tmp_path, "acme")
|
||||||
|
|
||||||
|
catalog = _catalog(Config(), tmp_path)
|
||||||
|
|
||||||
|
assert [item.manifest.id for item in catalog.candidates] == ["acme"]
|
||||||
|
assert catalog.snapshot.extensions == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_config_trust_activates_installed_extension(tmp_path) -> None:
|
||||||
|
_write_extension(tmp_path, "acme")
|
||||||
|
config = Config.model_validate(
|
||||||
|
{
|
||||||
|
"extensions": {
|
||||||
|
"entries": {
|
||||||
|
"acme": {
|
||||||
|
"enabled": True,
|
||||||
|
"trusted": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = _catalog(config, tmp_path)
|
||||||
|
|
||||||
|
assert [item.manifest.id for item in catalog.snapshot.extensions] == ["acme"]
|
||||||
|
assert catalog.snapshot.contributions[0].contribution.name == "acme_tool"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extensions_disabled_keeps_native_catalog_only(tmp_path) -> None:
|
||||||
|
_write_extension(tmp_path, "acme")
|
||||||
|
config = Config.model_validate({"extensions": {"enabled": False}})
|
||||||
|
|
||||||
|
catalog = _catalog(config, tmp_path)
|
||||||
|
|
||||||
|
assert all(item.manifest.id != "acme" for item in catalog.candidates)
|
||||||
96
tests/extensions/test_native.py
Normal file
96
tests/extensions/test_native.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
from nanobot.agent.tools.base import Tool
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.command.router import CommandRouter
|
||||||
|
from nanobot.config.schema import Config, MCPServerConfig
|
||||||
|
from nanobot.extensions import ContributionKind, discover_native_extensions
|
||||||
|
|
||||||
|
|
||||||
|
class _Tool(Tool):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "acme_tool"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return "Acme tool"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> dict:
|
||||||
|
return {"type": "object"}
|
||||||
|
|
||||||
|
async def execute(self, **kwargs):
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_inventory_preserves_runtime_ownership(tmp_path) -> None:
|
||||||
|
tools = ToolRegistry()
|
||||||
|
tools.register(_Tool(), owner="acme.extension")
|
||||||
|
commands = CommandRouter()
|
||||||
|
|
||||||
|
async def _handler(_ctx):
|
||||||
|
return None
|
||||||
|
|
||||||
|
commands.exact("/acme", _handler, owner="acme.extension")
|
||||||
|
config = Config()
|
||||||
|
config.tools.mcp_servers["docs"] = MCPServerConfig(url="https://example.com")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("nanobot.channels.registry.discover_plugins", return_value={}),
|
||||||
|
patch("nanobot.providers.registry.PROVIDERS", ()),
|
||||||
|
patch("nanobot.audio.transcription_registry.TRANSCRIPTION_PROVIDERS", ()),
|
||||||
|
patch(
|
||||||
|
"nanobot.providers.image_generation.image_gen_provider_names",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = discover_native_extensions(
|
||||||
|
config,
|
||||||
|
skills=SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "missing"),
|
||||||
|
tools=tools,
|
||||||
|
commands=commands,
|
||||||
|
)
|
||||||
|
|
||||||
|
extensions = {item.manifest.id: item for item in result.candidates}
|
||||||
|
assert {
|
||||||
|
contribution.kind
|
||||||
|
for contribution in extensions["acme.extension"].manifest.contributions
|
||||||
|
} == {ContributionKind.TOOL, ContributionKind.COMMAND}
|
||||||
|
assert "nanobot.mcp.docs" in extensions
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_inventory_projects_workspace_skill(tmp_path) -> None:
|
||||||
|
skill_dir = tmp_path / "skills" / "release"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"name: release\n"
|
||||||
|
"description: Prepare a release.\n"
|
||||||
|
"metadata:\n"
|
||||||
|
" nanobot:\n"
|
||||||
|
" requires:\n"
|
||||||
|
" bins: [gh]\n"
|
||||||
|
"---\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("nanobot.channels.registry.discover_plugins", return_value={}),
|
||||||
|
patch("nanobot.providers.registry.PROVIDERS", ()),
|
||||||
|
patch("nanobot.audio.transcription_registry.TRANSCRIPTION_PROVIDERS", ()),
|
||||||
|
patch(
|
||||||
|
"nanobot.providers.image_generation.image_gen_provider_names",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = discover_native_extensions(
|
||||||
|
Config(),
|
||||||
|
skills=SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "missing"),
|
||||||
|
)
|
||||||
|
|
||||||
|
skill = next(
|
||||||
|
item for item in result.candidates if item.manifest.id == "nanobot.skill.release"
|
||||||
|
)
|
||||||
|
assert skill.scope.name == "WORKSPACE"
|
||||||
|
assert skill.manifest.contributions[0].description == "Prepare a release."
|
||||||
@ -16,6 +16,7 @@ def _candidate(
|
|||||||
scope: ExtensionScope,
|
scope: ExtensionScope,
|
||||||
contribution_name: str = "",
|
contribution_name: str = "",
|
||||||
replaces: tuple[str, ...] = (),
|
replaces: tuple[str, ...] = (),
|
||||||
|
trusted: bool = True,
|
||||||
) -> ExtensionCandidate:
|
) -> ExtensionCandidate:
|
||||||
contributions = (
|
contributions = (
|
||||||
ExtensionContribution(
|
ExtensionContribution(
|
||||||
@ -33,6 +34,7 @@ def _candidate(
|
|||||||
contributions=contributions,
|
contributions=contributions,
|
||||||
),
|
),
|
||||||
scope=scope,
|
scope=scope,
|
||||||
|
trusted=trusted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -75,6 +77,23 @@ def test_policy_filters_extensions_before_contribution_resolution() -> None:
|
|||||||
] == ["allowed_tool"]
|
] == ["allowed_tool"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_untrusted_external_extension_is_visible_to_discovery_but_not_active() -> None:
|
||||||
|
registry = ExtensionRegistry()
|
||||||
|
registry.register(
|
||||||
|
_candidate(
|
||||||
|
"untrusted",
|
||||||
|
scope=ExtensionScope.USER,
|
||||||
|
contribution_name="unsafe_tool",
|
||||||
|
trusted=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = registry.snapshot()
|
||||||
|
|
||||||
|
assert snapshot.extensions == ()
|
||||||
|
assert snapshot.contributions == ()
|
||||||
|
|
||||||
|
|
||||||
def test_conflicting_contribution_does_not_silently_replace_owner() -> None:
|
def test_conflicting_contribution_does_not_silently_replace_owner() -> None:
|
||||||
registry = ExtensionRegistry()
|
registry = ExtensionRegistry()
|
||||||
registry.register(
|
registry.register(
|
||||||
|
|||||||
@ -108,6 +108,16 @@ def test_suggest_name_updates_after_register_and_unregister() -> None:
|
|||||||
assert registry._suggest_name("read-file") == "readFile"
|
assert registry._suggest_name("read-file") == "readFile"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_tracks_and_removes_tool_owner() -> None:
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.register(_FakeTool("custom"), owner="acme.tools")
|
||||||
|
|
||||||
|
assert registry.owner("custom") == "acme.tools"
|
||||||
|
|
||||||
|
registry.unregister("custom")
|
||||||
|
assert registry.owner("custom") is None
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None:
|
def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None:
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
registry.register(_FakeTool("read_file"))
|
registry.register(_FakeTool("read_file"))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user