nanobot/tests/cron/test_cron_tool_schema_contract.py
chengyongru 043f0e67f7 feat(tools): introduce plugin-based tool discovery and runtime context protocol
This commit implements a progressive refactoring of the tool system to support
plugin discovery, scoped loading, and protocol-driven runtime context injection.

Key changes:
- Add Tool ABC metadata (tool_name, _scopes) and ToolContext dataclass for
dependency injection.
- Introduce ToolLoader with pkgutil-based builtin discovery and
entry_points-based third-party plugin loading.
- Add scope filtering (core/subagent/memory) so different contexts load
appropriate tool sets.
- Introduce ContextAware protocol and RequestContext dataclass to replace
hardcoded per-tool context injection in AgentLoop.
- Add RuntimeState / MutableRuntimeState protocols to decouple MyTool from
AgentLoop.
- Migrate all built-in tools to declare scopes and implement create()/enabled()
hooks.
- Migrate MessageTool, SpawnTool, CronTool, and MyTool to ContextAware.
- Refactor AgentLoop to use ToolLoader and protocol-driven context injection.
- Refactor SubagentManager to use ToolLoader(scope="subagent") with per-run
FileStates isolation.
- Register all built-in tools via pyproject.toml entry_points.
- Add comprehensive tests for loader scopes, entry_points, ContextAware,
subagent tools, and runtime state sync.
2026-05-12 11:28:20 +08:00

101 lines
3.7 KiB
Python

"""Regression tests for the cron tool's JSON-schema / runtime contract (#3113).
The schema advertised ``required=["action"]`` while ``_add_job`` rejected empty
``message``; LLMs rationally omitted ``message`` and looped on the runtime
error. The fix keeps ``required=["action"]`` (so ``list``/``remove`` stay
callable) but states the per-action requirement in each field's description
and tightens the runtime error for ``add`` without ``message``.
"""
from __future__ import annotations
import pytest
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.registry import ToolRegistry
class _SvcStub:
"""Minimal CronService stand-in; we only exercise schema/dispatch paths."""
def list_jobs(self):
return []
def get_job(self, _job_id):
return None
def remove_job(self, _job_id):
return "not-found"
def add_job(self, **kwargs):
class _J:
pass
j = _J()
j.id = "id1"
j.name = kwargs.get("name", "x")
return j
@pytest.fixture
def registry() -> ToolRegistry:
tool = CronTool(_SvcStub(), default_timezone="UTC")
tool.set_context(RequestContext(channel="channel", chat_id="chat-id"))
reg = ToolRegistry()
reg.register(tool)
return reg
class TestSchemaContract:
def test_list_accepted_without_message(self, registry: ToolRegistry) -> None:
# action='list' must pass schema validation with nothing but 'action'.
_, _, err = registry.prepare_call("cron", {"action": "list"})
assert err is None
def test_remove_accepted_without_message(self, registry: ToolRegistry) -> None:
# action='remove' must pass schema validation with just 'action' + 'job_id'.
_, _, err = registry.prepare_call("cron", {"action": "remove", "job_id": "abc"})
assert err is None
def test_add_with_message_accepted(self, registry: ToolRegistry) -> None:
_, _, err = registry.prepare_call(
"cron", {"action": "add", "message": "ping", "at": "2030-01-01T00:00:00"}
)
assert err is None
def test_add_without_message_surfaces_actionable_runtime_error(
self, registry: ToolRegistry
) -> None:
# Schema permits omitting message; the runtime must return a message
# that tells the LLM exactly what's missing and how to retry, so it
# doesn't loop like #3113 reports.
import asyncio
tool = registry._tools["cron"] # type: ignore[attr-defined]
out = asyncio.run(tool.execute(action="add", at="2030-01-01T00:00:00"))
assert "message" in out
assert "add" in out
assert "Retry" in out or "retry" in out
class TestSchemaSelfDescribesRequirements:
def test_message_description_flags_add_requirement(self) -> None:
# LLMs rely on field descriptions to infer when something is actually
# needed. Without this hint, #3113's loop returns.
tool = CronTool(_SvcStub())
desc = tool.parameters["properties"]["message"]["description"]
assert "REQUIRED" in desc and "action='add'" in desc
def test_job_id_description_flags_remove_requirement(self) -> None:
tool = CronTool(_SvcStub())
desc = tool.parameters["properties"]["job_id"]["description"]
assert "REQUIRED" in desc and "action='remove'" in desc
def test_top_level_required_stays_narrow(self) -> None:
# If 'message' or 'job_id' ever creep back into top-level required,
# list/remove start failing schema validation (the bug PR #3163 v1
# accidentally introduced).
tool = CronTool(_SvcStub())
assert tool.parameters["required"] == ["action"]