mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
392 lines
10 KiB
Python
392 lines
10 KiB
Python
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
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 (
|
|
DependencyKind,
|
|
ExtensionCandidate,
|
|
ExtensionDependency,
|
|
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"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_runtime_reloads_updated_source(tmp_path: Path) -> None:
|
|
source = tmp_path / "plugin.py"
|
|
candidate = ExtensionCandidate(
|
|
ExtensionManifest(
|
|
id="test.python",
|
|
name="Python",
|
|
version="1.0.0",
|
|
runtime=ExtensionRuntime.PYTHON,
|
|
entry="plugin:register",
|
|
),
|
|
ExtensionScope.USER,
|
|
location=tmp_path,
|
|
trusted=True,
|
|
)
|
|
tools = ToolRegistry()
|
|
|
|
def write_plugin(result: str) -> None:
|
|
source.write_text(
|
|
f"""
|
|
from nanobot.agent.tools.base import Tool
|
|
|
|
class ReloadTool(Tool):
|
|
@property
|
|
def name(self):
|
|
return "reload_test"
|
|
|
|
@property
|
|
def description(self):
|
|
return "Reload test"
|
|
|
|
@property
|
|
def parameters(self):
|
|
return {{"type": "object", "properties": {{}}}}
|
|
|
|
async def execute(self):
|
|
return "{result}"
|
|
|
|
def register(api):
|
|
api.register_tool(ReloadTool())
|
|
"""
|
|
)
|
|
|
|
write_plugin("first")
|
|
first = ExtensionRuntimeManager(
|
|
tools=tools,
|
|
commands=CommandRouter(),
|
|
config=Config(),
|
|
)
|
|
first_result = await first.activate(_snapshot(candidate))
|
|
assert first_result.diagnostics == ()
|
|
assert await tools.get("reload_test").execute() == "first"
|
|
await first.close()
|
|
|
|
write_plugin("later")
|
|
second = ExtensionRuntimeManager(
|
|
tools=tools,
|
|
commands=CommandRouter(),
|
|
config=Config(),
|
|
)
|
|
second_result = await second.activate(_snapshot(candidate))
|
|
assert second_result.diagnostics == ()
|
|
assert await tools.get("reload_test").execute() == "later"
|
|
await second.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_runtime_accepts_single_entries_form(tmp_path: Path) -> None:
|
|
(tmp_path / "plugin.py").write_text(
|
|
"""
|
|
def register(_api):
|
|
pass
|
|
"""
|
|
)
|
|
candidate = ExtensionCandidate(
|
|
ExtensionManifest(
|
|
id="test.python-entries",
|
|
name="Python entries",
|
|
version="1.0.0",
|
|
runtime=ExtensionRuntime.PYTHON,
|
|
entries=("plugin:register",),
|
|
),
|
|
ExtensionScope.USER,
|
|
location=tmp_path,
|
|
trusted=True,
|
|
)
|
|
manager = ExtensionRuntimeManager(
|
|
tools=ToolRegistry(),
|
|
commands=CommandRouter(),
|
|
config=Config(),
|
|
)
|
|
|
|
result = await manager.activate(_snapshot(candidate))
|
|
|
|
assert result.diagnostics == ()
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_runtime_rejects_module_outside_package(tmp_path: Path) -> None:
|
|
candidate = ExtensionCandidate(
|
|
ExtensionManifest(
|
|
id="test.collision",
|
|
name="Collision",
|
|
version="1.0.0",
|
|
runtime=ExtensionRuntime.PYTHON,
|
|
entry="json:register",
|
|
),
|
|
ExtensionScope.USER,
|
|
location=tmp_path,
|
|
trusted=True,
|
|
)
|
|
manager = ExtensionRuntimeManager(
|
|
tools=ToolRegistry(),
|
|
commands=CommandRouter(),
|
|
config=Config(),
|
|
)
|
|
|
|
result = await manager.activate(_snapshot(candidate))
|
|
|
|
assert result.extensions == ()
|
|
assert result.diagnostics[0].code == "activation_failed"
|
|
assert "conflicts with loaded module" in result.diagnostics[0].message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_hook_factory_is_removed_without_clearing_core_hooks(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
(tmp_path / "plugin.py").write_text(
|
|
"""
|
|
from nanobot.agent.hook import AgentHook
|
|
|
|
def extension_hook(_context):
|
|
return AgentHook()
|
|
|
|
def register(api):
|
|
api.register_hook_factory(extension_hook)
|
|
"""
|
|
)
|
|
candidate = ExtensionCandidate(
|
|
ExtensionManifest(
|
|
id="test.hook",
|
|
name="Hook",
|
|
version="1.0.0",
|
|
runtime=ExtensionRuntime.PYTHON,
|
|
entry="plugin:register",
|
|
),
|
|
ExtensionScope.USER,
|
|
location=tmp_path,
|
|
trusted=True,
|
|
)
|
|
|
|
def core_hook(_context):
|
|
return None
|
|
|
|
hooks = [core_hook]
|
|
manager = ExtensionRuntimeManager(
|
|
tools=ToolRegistry(),
|
|
commands=CommandRouter(),
|
|
config=Config(),
|
|
hook_factories=hooks,
|
|
)
|
|
|
|
result = await manager.activate(_snapshot(candidate))
|
|
|
|
assert result.diagnostics == ()
|
|
assert len(hooks) == 2
|
|
await manager.close()
|
|
assert hooks == [core_hook]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_runtime_cannot_overwrite_core_tool(tmp_path: Path) -> None:
|
|
(tmp_path / "plugin.py").write_text(
|
|
"""
|
|
from nanobot.agent.tools.base import Tool
|
|
|
|
class DuplicateTool(Tool):
|
|
name = "duplicate"
|
|
description = "Duplicate"
|
|
parameters = {"type": "object", "properties": {}}
|
|
|
|
async def execute(self):
|
|
return "extension"
|
|
|
|
def register(api):
|
|
api.register_tool(DuplicateTool())
|
|
"""
|
|
)
|
|
candidate = ExtensionCandidate(
|
|
ExtensionManifest(
|
|
id="test.overwrite",
|
|
name="Overwrite",
|
|
version="1.0.0",
|
|
runtime=ExtensionRuntime.PYTHON,
|
|
entry="plugin:register",
|
|
),
|
|
ExtensionScope.USER,
|
|
location=tmp_path,
|
|
trusted=True,
|
|
)
|
|
core_tool = SimpleNamespace(name="duplicate")
|
|
tools = ToolRegistry()
|
|
tools.register(core_tool)
|
|
manager = ExtensionRuntimeManager(
|
|
tools=tools,
|
|
commands=CommandRouter(),
|
|
config=Config(),
|
|
)
|
|
|
|
result = await manager.activate(_snapshot(candidate))
|
|
|
|
assert result.extensions == ()
|
|
assert result.diagnostics[0].code == "activation_failed"
|
|
assert tools.get("duplicate") is core_tool
|
|
assert tools.owner("duplicate") == "nanobot.core"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_extension_prevents_dependent_activation(tmp_path: Path) -> None:
|
|
base_root = tmp_path / "base"
|
|
dependent_root = tmp_path / "dependent"
|
|
base_root.mkdir()
|
|
dependent_root.mkdir()
|
|
base = ExtensionCandidate(
|
|
ExtensionManifest(
|
|
id="base",
|
|
name="Base",
|
|
version="1.0.0",
|
|
runtime=ExtensionRuntime.PYTHON,
|
|
entry="missing:register",
|
|
),
|
|
ExtensionScope.USER,
|
|
location=base_root,
|
|
trusted=True,
|
|
)
|
|
dependent = ExtensionCandidate(
|
|
ExtensionManifest(
|
|
id="dependent",
|
|
name="Dependent",
|
|
version="1.0.0",
|
|
runtime=ExtensionRuntime.DECLARATIVE,
|
|
dependencies=(
|
|
ExtensionDependency(
|
|
kind=DependencyKind.EXTENSION,
|
|
name="base",
|
|
),
|
|
),
|
|
),
|
|
ExtensionScope.USER,
|
|
location=dependent_root,
|
|
trusted=True,
|
|
)
|
|
manager = ExtensionRuntimeManager(
|
|
tools=ToolRegistry(),
|
|
commands=CommandRouter(),
|
|
config=Config(),
|
|
)
|
|
|
|
result = await manager.activate(ExtensionSnapshot((base, dependent), (), ()))
|
|
|
|
assert result.extensions == ()
|
|
assert [item.code for item in result.diagnostics] == [
|
|
"activation_failed",
|
|
"dependency_activation_failed",
|
|
]
|