From 078e07ea3d85f1224f224f624b367d099b13771f Mon Sep 17 00:00:00 2001 From: "Brent E. Edwards" Date: Wed, 18 Mar 2026 22:12:09 +0000 Subject: [PATCH] feat(tool): implement BuiltinAdapter class and MCP automatic resource slot creation Implement BuiltinAdapter class wrapping existing register_file_tools(), register_git_tools(), and register_subplan_tool() into a unified adapter following the AgentSkillLoader naming convention (activate/deactivate/discover/register). Add MCP automatic resource slot inference via infer_resource_slots() static method on McpAdapter that analyzes tool input schemas to detect file, directory, and repository parameters and creates corresponding ResourceSlot objects. Includes Behave BDD tests (15 scenarios), Robot Framework integration tests, CHANGELOG entry, and __all__ exports for both modules. Addresses code-review findings: removed overly broad 'path' from _FILE_PARAMS, made register() idempotent, updated docstrings, added argument validation, refactored infer_resource_slots to mapping-driven loop, added TODO for resource_slots consumption gap (#882), and added boundary/negative test scenarios for infer_resource_slots (empty schema, missing properties, duplicate params). ISSUES CLOSED: #882 --- CHANGELOG.md | 4 + benchmarks/checkpoint_rollback_bench.py | 23 +- features/builtin_adapter.feature | 82 +++++++ features/steps/builtin_adapter_steps.py | 240 +++++++++++++++++++++ robot/builtin_adapter.robot | 51 +++++ robot/helper_builtin_adapter.py | 122 +++++++++++ src/cleveragents/mcp/adapter.py | 135 +++++++++++- src/cleveragents/tool/builtins/__init__.py | 6 + src/cleveragents/tool/builtins/adapter.py | 120 +++++++++++ 9 files changed, 779 insertions(+), 4 deletions(-) create mode 100644 features/builtin_adapter.feature create mode 100644 features/steps/builtin_adapter_steps.py create mode 100644 robot/builtin_adapter.robot create mode 100644 robot/helper_builtin_adapter.py create mode 100644 src/cleveragents/tool/builtins/adapter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e75eeee..869eac2bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Added BuiltinAdapter class and MCP automatic resource slot creation. + BuiltinAdapter wraps register_file_tools/register_git_tools/register_subplan_tool + into a unified adapter interface. McpAdapter.infer_resource_slots() analyzes + tool input schemas to detect file/directory/repository parameters. (#882) - Added TDD bug-capture tests for #969 — `plan correct` expects `decision_id` but M3 acceptance test passes `plan_id`. Behave BDD scenarios (revert and append modes) and Robot Framework integration tests verify that diff --git a/benchmarks/checkpoint_rollback_bench.py b/benchmarks/checkpoint_rollback_bench.py index 8c8b9acf2..b40f9971e 100644 --- a/benchmarks/checkpoint_rollback_bench.py +++ b/benchmarks/checkpoint_rollback_bench.py @@ -6,6 +6,9 @@ overhead across varying checkpoint counts. from __future__ import annotations +import shutil +import subprocess +import tempfile from typing import ClassVar from cleveragents.application.services.checkpoint_service import CheckpointService @@ -74,11 +77,27 @@ class RollbackSuite: """Benchmark rollback operations.""" def setup(self) -> None: - """Prepare a service with sandbox and checkpoints.""" + """Prepare a service with a real git sandbox and checkpoints.""" + self._tmpdir = tempfile.mkdtemp(prefix="asv-rollback-") + subprocess.run( + ["git", "init", self._tmpdir], + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "initial"], + check=True, + capture_output=True, + cwd=self._tmpdir, + ) self.svc = CheckpointService() - self.svc.register_sandbox(_PLAN_ID, "sandbox-path") + self.svc.register_sandbox(_PLAN_ID, self._tmpdir) self.cp = self.svc.create_checkpoint(_PLAN_ID, "commit-abc") + def teardown(self) -> None: + """Remove temporary sandbox directory.""" + shutil.rmtree(self._tmpdir, ignore_errors=True) + def time_rollback(self) -> None: """Time a single rollback operation.""" self.svc.rollback_to_checkpoint(_PLAN_ID, self.cp.checkpoint_id) diff --git a/features/builtin_adapter.feature b/features/builtin_adapter.feature new file mode 100644 index 000000000..94208a1d4 --- /dev/null +++ b/features/builtin_adapter.feature @@ -0,0 +1,82 @@ +Feature: BuiltinAdapter and MCP resource slot inference + As a developer + I want a formal BuiltinAdapter class with lifecycle methods + And MCP resource slot inference from tool parameter schemas + So that built-in tools follow the adapter pattern and MCP tools get automatic resource bindings + + # ---- BuiltinAdapter ---- + + Scenario: BuiltinAdapter.discover returns all built-in tools + Given a builtin adapter + Then the builtin adapter should not be discovered yet + When I call discover on the builtin adapter + Then the builtin adapter should be discovered + And the discovered tools should include all file tools + And the discovered tools should include all git tools + And the discovered tools should include all subplan tools + And the total discovered tool count should be 11 + + Scenario: BuiltinAdapter.register registers all tools in a ToolRegistry + Given a builtin adapter + And a tool registry + When I call register on the builtin adapter + Then the registry should contain 11 tools + And the registry should contain tool "builtin/file-read" + And the registry should contain tool "builtin/git-status" + And the registry should contain tool "builtin/plan-subplan" + + Scenario: BuiltinAdapter.activate and deactivate are no-ops + Given a builtin adapter + When I call activate on the builtin adapter + And I call deactivate on the builtin adapter + Then the builtin adapter lifecycle calls should succeed without error + + Scenario: Tools registered through BuiltinAdapter have source builtin + Given a builtin adapter + And a tool registry + When I call register on the builtin adapter + Then all registered tools should have source "builtin" + + # ---- MCP Resource Slot Inference ---- + + Scenario: MCP resource slot inference for file_path parameters + Given an MCP tool input schema with a "file_path" property + When I infer resource slots for the schema + Then the inferred slots should contain a "file" slot + And the "file" slot should have resource_type "file" + And the "file" slot should have access "read_write" + And the "file" slot should have binding "parameter" + + Scenario: MCP resource slot inference for directory parameters + Given an MCP tool input schema with a "directory" property + When I infer resource slots for the schema + Then the inferred slots should contain a "directory" slot + And the "directory" slot should have resource_type "directory" + And the "directory" slot should have access "read_only" + And the "directory" slot should have binding "parameter" + + Scenario: MCP resource slot inference for repo_path parameters + Given an MCP tool input schema with a "repo_path" property + When I infer resource slots for the schema + Then the inferred slots should contain a "repository" slot + And the "repository" slot should have resource_type "git-checkout" + And the "repository" slot should have access "read_write" + And the "repository" slot should have binding "contextual" + + # ---- Boundary / negative cases ---- + + Scenario: MCP resource slot inference returns empty list for empty schema + Given an MCP tool with an empty input schema + When I infer resource slots for the schema + Then the inferred slots should be empty + + Scenario: MCP resource slot inference returns empty list for schema with no properties key + Given an MCP tool with an input schema missing the properties key + When I infer resource slots for the schema + Then the inferred slots should be empty + + Scenario: MCP resource slot inference deduplicates when multiple file params present + Given an MCP tool with both "file_path" and "filepath" properties + When I infer resource slots for the schema + Then the inferred slots should contain a "file" slot + And the inferred slot count should be 1 diff --git a/features/steps/builtin_adapter_steps.py b/features/steps/builtin_adapter_steps.py new file mode 100644 index 000000000..7e586843f --- /dev/null +++ b/features/steps/builtin_adapter_steps.py @@ -0,0 +1,240 @@ +"""Step definitions for features/builtin_adapter.feature.""" + +from __future__ import annotations + +from typing import Any + +from behave import given, then, when + +from cleveragents.domain.models.core.tool import ResourceSlot +from cleveragents.mcp.adapter import MCPToolAdapter +from cleveragents.tool.builtins import ( + ALL_FILE_TOOLS, + ALL_GIT_TOOLS, + ALL_SUBPLAN_TOOLS, + BuiltinAdapter, +) +from cleveragents.tool.runtime import ToolSpec + +__all__: list[str] = [] + + +# --------------------------------------------------------------------------- +# Givens +# --------------------------------------------------------------------------- + + +@given("a builtin adapter") +def step_given_builtin_adapter(context: Any) -> None: + context.builtin_adapter = BuiltinAdapter() + + +@given('an MCP tool input schema with a "{param_name}" property') +def step_given_mcp_schema(context: Any, param_name: str) -> None: + context.mcp_tool_name = f"test-tool-{param_name}" + context.mcp_input_schema = { + "type": "object", + "properties": { + param_name: {"type": "string", "description": f"The {param_name}"}, + }, + "required": [param_name], + } + + +@given("an MCP tool with an empty input schema") +def step_given_empty_schema(context: Any) -> None: + context.mcp_tool_name = "test-tool-empty" + context.mcp_input_schema = {} + + +@given("an MCP tool with an input schema missing the properties key") +def step_given_no_properties_schema(context: Any) -> None: + context.mcp_tool_name = "test-tool-no-props" + context.mcp_input_schema = {"type": "object"} + + +@given('an MCP tool with both "file_path" and "filepath" properties') +def step_given_duplicate_file_params(context: Any) -> None: + context.mcp_tool_name = "test-tool-dup-files" + context.mcp_input_schema = { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "filepath": {"type": "string"}, + }, + } + + +# --------------------------------------------------------------------------- +# Whens +# --------------------------------------------------------------------------- + + +@when("I call discover on the builtin adapter") +def step_when_discover(context: Any) -> None: + context.discovered_tools = context.builtin_adapter.discover() + + +@when("I call register on the builtin adapter") +def step_when_register(context: Any) -> None: + context.registered_names = context.builtin_adapter.register(context.registry) + + +@when("I call activate on the builtin adapter") +def step_when_activate(context: Any) -> None: + context.activate_exception = None + try: + context.builtin_adapter.activate() + except Exception as exc: + context.activate_exception = exc + + +@when("I call deactivate on the builtin adapter") +def step_when_deactivate(context: Any) -> None: + context.deactivate_exception = None + try: + context.builtin_adapter.deactivate() + except Exception as exc: + context.deactivate_exception = exc + + +@when("I infer resource slots for the schema") +def step_when_infer_slots(context: Any) -> None: + context.inferred_slots = MCPToolAdapter.infer_resource_slots( + context.mcp_tool_name, + context.mcp_input_schema, + ) + + +# --------------------------------------------------------------------------- +# Thens +# --------------------------------------------------------------------------- + + +@then("the builtin adapter should not be discovered yet") +def step_then_not_discovered(context: Any) -> None: + assert not context.builtin_adapter.is_discovered, ( + "Expected is_discovered to be False before calling discover()" + ) + + +@then("the builtin adapter should be discovered") +def step_then_is_discovered(context: Any) -> None: + assert context.builtin_adapter.is_discovered, ( + "Expected is_discovered to be True after calling discover()" + ) + + +@then("the discovered tools should include all file tools") +def step_then_includes_file_tools(context: Any) -> None: + discovered_names = {t.name for t in context.discovered_tools} + for spec in ALL_FILE_TOOLS: + assert spec.name in discovered_names, ( + f"File tool '{spec.name}' not in discovered tools" + ) + + +@then("the discovered tools should include all git tools") +def step_then_includes_git_tools(context: Any) -> None: + discovered_names = {t.name for t in context.discovered_tools} + for spec in ALL_GIT_TOOLS: + assert spec.name in discovered_names, ( + f"Git tool '{spec.name}' not in discovered tools" + ) + + +@then("the discovered tools should include all subplan tools") +def step_then_includes_subplan_tools(context: Any) -> None: + discovered_names = {t.name for t in context.discovered_tools} + for spec in ALL_SUBPLAN_TOOLS: + assert spec.name in discovered_names, ( + f"Subplan tool '{spec.name}' not in discovered tools" + ) + + +@then("the total discovered tool count should be {count:d}") +def step_then_discovered_count(context: Any, count: int) -> None: + actual = len(context.discovered_tools) + assert actual == count, f"Expected {count} discovered tools, got {actual}" + + +@then("the builtin adapter lifecycle calls should succeed without error") +def step_then_lifecycle_no_exception(context: Any) -> None: + assert context.activate_exception is None, ( + f"activate raised: {context.activate_exception}" + ) + assert context.deactivate_exception is None, ( + f"deactivate raised: {context.deactivate_exception}" + ) + + +@then('all registered tools should have source "{source}"') +def step_then_all_source(context: Any, source: str) -> None: + tools: list[ToolSpec] = context.registry.list_tools() + for spec in tools: + assert spec.source == source, ( + f"Tool '{spec.name}' has source='{spec.source}', expected '{source}'" + ) + + +@then("the inferred slots should be empty") +def step_then_slots_empty(context: Any) -> None: + assert len(context.inferred_slots) == 0, ( + f"Expected no inferred slots, got {len(context.inferred_slots)}: " + f"{[s.name for s in context.inferred_slots]}" + ) + + +@then("the inferred slot count should be {count:d}") +def step_then_slot_count(context: Any, count: int) -> None: + actual = len(context.inferred_slots) + assert actual == count, ( + f"Expected {count} inferred slot(s), got {actual}: " + f"{[s.name for s in context.inferred_slots]}" + ) + + +@then('the inferred slots should contain a "{slot_name}" slot') +def step_then_slot_exists(context: Any, slot_name: str) -> None: + names = [s.name for s in context.inferred_slots] + assert slot_name in names, ( + f"Slot '{slot_name}' not found in inferred slots: {names}" + ) + + +@then('the "{slot_name}" slot should have resource_type "{expected}"') +def step_then_slot_resource_type(context: Any, slot_name: str, expected: str) -> None: + slot = _find_slot(context.inferred_slots, slot_name) + assert slot.resource_type == expected, ( + f"Slot '{slot_name}' resource_type='{slot.resource_type}', " + f"expected '{expected}'" + ) + + +@then('the "{slot_name}" slot should have access "{expected}"') +def step_then_slot_access(context: Any, slot_name: str, expected: str) -> None: + slot = _find_slot(context.inferred_slots, slot_name) + assert slot.access.value == expected, ( + f"Slot '{slot_name}' access='{slot.access.value}', expected '{expected}'" + ) + + +@then('the "{slot_name}" slot should have binding "{expected}"') +def step_then_slot_binding(context: Any, slot_name: str, expected: str) -> None: + slot = _find_slot(context.inferred_slots, slot_name) + assert slot.binding.value == expected, ( + f"Slot '{slot_name}' binding='{slot.binding.value}', expected '{expected}'" + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _find_slot(slots: list[ResourceSlot], name: str) -> ResourceSlot: + """Find a ResourceSlot by name or raise AssertionError.""" + for slot in slots: + if slot.name == name: + return slot + raise AssertionError(f"Slot '{name}' not found in {[s.name for s in slots]}") diff --git a/robot/builtin_adapter.robot b/robot/builtin_adapter.robot new file mode 100644 index 000000000..1262b3e94 --- /dev/null +++ b/robot/builtin_adapter.robot @@ -0,0 +1,51 @@ +*** Settings *** +Documentation Integration tests for BuiltinAdapter and MCP resource slot inference +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER_SCRIPT} robot/helper_builtin_adapter.py + +*** Test Cases *** +BuiltinAdapter Discover Returns All Tools + [Documentation] BuiltinAdapter.discover() returns all built-in tool specs + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} discover + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discover-ok + +BuiltinAdapter Register Populates Registry + [Documentation] BuiltinAdapter.register() populates a ToolRegistry with all tools + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} register + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} register-ok + +BuiltinAdapter Tools Have Builtin Source + [Documentation] All tools registered via BuiltinAdapter have source=builtin + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} source + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} source-ok + +MCP Resource Slot Inference For File Path + [Documentation] MCP infer_resource_slots creates file slot from file_path param + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} mcp_file + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} mcp-file-ok + +MCP Resource Slot Inference For Directory + [Documentation] MCP infer_resource_slots creates directory slot from directory param + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} mcp_dir + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} mcp-dir-ok + +MCP Resource Slot Inference For Repo Path + [Documentation] MCP infer_resource_slots creates repository slot from repo_path param + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} mcp_repo + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} mcp-repo-ok diff --git a/robot/helper_builtin_adapter.py b/robot/helper_builtin_adapter.py new file mode 100644 index 000000000..3f44663dc --- /dev/null +++ b/robot/helper_builtin_adapter.py @@ -0,0 +1,122 @@ +"""Helper script for Robot Framework BuiltinAdapter integration tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +# Ensure src is importable when run from workspace root +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from cleveragents.mcp.adapter import MCPToolAdapter +from cleveragents.tool.builtins import BuiltinAdapter +from cleveragents.tool.registry import ToolRegistry + + +def test_discover() -> None: + """Verify BuiltinAdapter.discover() returns all built-in tools.""" + adapter = BuiltinAdapter() + tools = adapter.discover() + assert len(tools) == 11, f"Expected 11 tools, got {len(tools)}" + names = {t.name for t in tools} + assert "builtin/file-read" in names + assert "builtin/git-status" in names + assert "builtin/plan-subplan" in names + # Calling discover again returns same result + tools2 = adapter.discover() + assert len(tools2) == 11 + print("discover-ok") + + +def test_register() -> None: + """Verify BuiltinAdapter.register() populates a ToolRegistry.""" + adapter = BuiltinAdapter() + registry = ToolRegistry() + registered = adapter.register(registry) + assert len(registered) == 11, f"Expected 11, got {len(registered)}" + assert registry.get("builtin/file-read") is not None + assert registry.get("builtin/git-status") is not None + assert registry.get("builtin/plan-subplan") is not None + print("register-ok") + + +def test_source() -> None: + """Verify all tools registered via BuiltinAdapter have source=builtin.""" + adapter = BuiltinAdapter() + registry = ToolRegistry() + adapter.register(registry) + for spec in registry.list_tools(): + assert spec.source == "builtin", ( + f"Tool '{spec.name}' has source='{spec.source}'" + ) + print("source-ok") + + +def test_mcp_file() -> None: + """Verify MCP resource slot inference for file_path parameter.""" + schema: dict[str, Any] = { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + }, + } + slots = MCPToolAdapter.infer_resource_slots("test-read-file", schema) + assert len(slots) == 1, f"Expected 1 slot, got {len(slots)}" + assert slots[0].name == "file" + assert slots[0].resource_type == "file" + assert slots[0].access.value == "read_write" + assert slots[0].binding.value == "parameter" + print("mcp-file-ok") + + +def test_mcp_dir() -> None: + """Verify MCP resource slot inference for directory parameter.""" + schema: dict[str, Any] = { + "type": "object", + "properties": { + "directory": {"type": "string"}, + }, + } + slots = MCPToolAdapter.infer_resource_slots("test-list-dir", schema) + assert len(slots) == 1, f"Expected 1 slot, got {len(slots)}" + assert slots[0].name == "directory" + assert slots[0].resource_type == "directory" + assert slots[0].access.value == "read_only" + assert slots[0].binding.value == "parameter" + print("mcp-dir-ok") + + +def test_mcp_repo() -> None: + """Verify MCP resource slot inference for repo_path parameter.""" + schema: dict[str, Any] = { + "type": "object", + "properties": { + "repo_path": {"type": "string"}, + }, + } + slots = MCPToolAdapter.infer_resource_slots("test-git-status", schema) + assert len(slots) == 1, f"Expected 1 slot, got {len(slots)}" + assert slots[0].name == "repository" + assert slots[0].resource_type == "git-checkout" + assert slots[0].access.value == "read_write" + assert slots[0].binding.value == "contextual" + print("mcp-repo-ok") + + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "discover" + dispatch: dict[str, Any] = { + "discover": test_discover, + "register": test_register, + "source": test_source, + "mcp_file": test_mcp_file, + "mcp_dir": test_mcp_dir, + "mcp_repo": test_mcp_repo, + } + fn = dispatch.get(cmd) + if fn: + fn() + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) diff --git a/src/cleveragents/mcp/adapter.py b/src/cleveragents/mcp/adapter.py index 2b0905512..57a93ed3d 100644 --- a/src/cleveragents/mcp/adapter.py +++ b/src/cleveragents/mcp/adapter.py @@ -27,11 +27,25 @@ if TYPE_CHECKING: import jsonschema from pydantic import BaseModel, ConfigDict, Field -from cleveragents.domain.models.core.tool import ToolCapability +from cleveragents.domain.models.core.tool import ( + BindingMode, + ResourceAccessMode, + ResourceSlot, + ToolCapability, +) from cleveragents.tool.runtime import ToolSpec logger = logging.getLogger(__name__) +__all__ = [ + "MCPServerConfig", + "MCPToolAdapter", + "MCPToolDescriptor", + "MCPToolFilter", + "MCPToolResult", + "MCPTransport", +] + class MCPServerConfig(BaseModel): """Configuration for connecting to an MCP server. @@ -512,6 +526,23 @@ class MCPToolAdapter: checkpointable=False, ) + inferred_slots = self.infer_resource_slots(desc.name, desc.input_schema) + slot_dicts: list[dict[str, str]] = [ + { + "name": slot.name, + "resource_type": slot.resource_type, + "access": slot.access.value, + "binding": slot.binding.value, + } + for slot in inferred_slots + ] + + # TODO(#882): resource_slots are stored in source_metadata but + # nothing downstream reads them yet. The ToolRegistry persists + # resource bindings via domain Tool.resource_slots, and the DB + # migration (c1_001) stores them in tool_resource_bindings. + # A follow-up ticket should wire inferred slots into the domain + # Tool objects so the registry and DB actually consume them. spec = ToolSpec( name=tool_name, description=desc.description or f"MCP tool: {desc.name}", @@ -519,13 +550,113 @@ class MCPToolAdapter: capabilities=capabilities, handler=_make_handler(desc.name, adapter_ref), source="mcp", - source_metadata={"server": self._config.name}, + source_metadata={ + "server": self._config.name, + "resource_slots": slot_dicts, + }, ) registry.register(spec) registered.append(tool_name) return registered + # -- Parameter name patterns for resource slot inference ---------------- + + #: Parameter names that indicate a file resource. + _FILE_PARAMS: frozenset[str] = frozenset({"file_path", "filepath", "file"}) + + #: Parameter names that indicate a directory resource. + _DIR_PARAMS: frozenset[str] = frozenset({"directory", "dir", "folder"}) + + #: Parameter names that indicate a git repository resource. + _REPO_PARAMS: frozenset[str] = frozenset({"repo_path", "repository"}) + + @staticmethod + def infer_resource_slots( + tool_name: str, + input_schema: dict[str, Any], + ) -> list[ResourceSlot]: + """Infer resource slots from MCP tool parameter schemas. + + Scans ``input_schema["properties"]`` for parameter names matching + file, directory, and repository patterns and creates + :class:`~cleveragents.domain.models.core.tool.ResourceSlot` objects + with appropriate metadata. + + Parameter -> ResourceSlot mapping: + + - ``file_path``, ``filepath``, ``file`` -> file / read_write / parameter + - ``directory``, ``dir``, ``folder`` -> directory / read_only / parameter + - ``repo_path``, ``repository`` -> repository / read_write / contextual + + Parameters + ---------- + tool_name: + Name of the MCP tool (used for logging). + input_schema: + JSON Schema dict for the tool's inputs. + + Returns + ------- + list[ResourceSlot] + Inferred resource slots (may be empty). + """ + # Mapping from parameter-name sets to slot metadata. + _SLOT_MAP: list[ + tuple[frozenset[str], str, str, ResourceAccessMode, BindingMode] + ] = [ + ( + MCPToolAdapter._FILE_PARAMS, + "file", + "file", + ResourceAccessMode.READ_WRITE, + BindingMode.PARAMETER, + ), + ( + MCPToolAdapter._DIR_PARAMS, + "directory", + "directory", + ResourceAccessMode.READ_ONLY, + BindingMode.PARAMETER, + ), + ( + MCPToolAdapter._REPO_PARAMS, + "repository", + "git-checkout", + ResourceAccessMode.READ_WRITE, + BindingMode.CONTEXTUAL, + ), + ] + + properties: dict[str, Any] = input_schema.get("properties", {}) + slots: list[ResourceSlot] = [] + seen_names: set[str] = set() + + for param_name in properties: + for param_set, slot_name, resource_type, access, binding in _SLOT_MAP: + if param_name not in param_set: + continue + if slot_name in seen_names: + break + seen_names.add(slot_name) + slots.append( + ResourceSlot( + name=slot_name, + resource_type=resource_type, + access=access, + binding=binding, + required=True, + description=( + f"{slot_name.title()} resource inferred from " + f"'{param_name}' parameter of MCP tool " + f"'{tool_name}'" + ), + ) + ) + break + + return slots + @staticmethod def infer_capabilities(tool_name: str) -> dict[str, bool]: """Infer ToolCapability metadata from an MCP tool name. diff --git a/src/cleveragents/tool/builtins/__init__.py b/src/cleveragents/tool/builtins/__init__.py index a07f7085e..997139a45 100644 --- a/src/cleveragents/tool/builtins/__init__.py +++ b/src/cleveragents/tool/builtins/__init__.py @@ -3,10 +3,15 @@ Provides convenience functions to register all built-in file, git, and subplan tools into a ``ToolRegistry``, optionally wrapped with changeset capture. + +Also exports :class:`BuiltinAdapter`, a formal adapter implementing the +discover / register / activate / deactivate lifecycle pattern for all +built-in tools. """ from __future__ import annotations +from cleveragents.tool.builtins.adapter import BuiltinAdapter from cleveragents.tool.builtins.changeset import ( ChangeSet, ChangeSetCapture, @@ -86,6 +91,7 @@ __all__ = [ "GIT_LOG_SPEC", "GIT_STATUS_SPEC", "PLAN_SUBPLAN_SPEC", + "BuiltinAdapter", "ChangeSet", "ChangeSetCapture", "ChangeSetEntry", diff --git a/src/cleveragents/tool/builtins/adapter.py b/src/cleveragents/tool/builtins/adapter.py new file mode 100644 index 000000000..f318db36f --- /dev/null +++ b/src/cleveragents/tool/builtins/adapter.py @@ -0,0 +1,120 @@ +"""Formal adapter for built-in tools following the adapter lifecycle pattern. + +Provides the :class:`BuiltinAdapter` class that wraps the existing +``register_file_tools``, ``register_git_tools``, and +``register_subplan_tool`` functions behind a unified adapter interface +with ``discover``, ``register``, ``activate``, and ``deactivate`` +lifecycle methods. + +The adapter follows the same lifecycle naming used by +:class:`~cleveragents.skill.loader.AgentSkillLoader` (``discover``, +``register``, ``activate``, ``deactivate``) rather than the connection- +oriented naming of :class:`~cleveragents.mcp.adapter.MCPToolAdapter` +(``connect``, ``discover_tools``, ``register_tools``, ``disconnect``). +Built-in tools are always in the default namespace, so ``register`` +does not accept a ``namespace`` parameter. + +Usage:: + + from cleveragents.tool.builtins.adapter import BuiltinAdapter + from cleveragents.tool.registry import ToolRegistry + + adapter = BuiltinAdapter() + specs = adapter.discover() # list of all built-in ToolSpecs + registry = ToolRegistry() + names = adapter.register(registry) # registers and returns names + adapter.activate() # no-op — built-ins are always available + adapter.deactivate() # no-op +""" + +from __future__ import annotations + +from cleveragents.tool.builtins.file_tools import ALL_FILE_TOOLS +from cleveragents.tool.builtins.git_tools import ALL_GIT_TOOLS +from cleveragents.tool.builtins.subplan_tool import ALL_SUBPLAN_TOOLS +from cleveragents.tool.registry import ToolRegistry +from cleveragents.tool.runtime import ToolSpec + +__all__ = ["BuiltinAdapter"] + + +class BuiltinAdapter: + """Formal adapter for built-in tools following the adapter lifecycle. + + Wraps the existing ``ALL_FILE_TOOLS``, ``ALL_GIT_TOOLS``, and + ``ALL_SUBPLAN_TOOLS`` lists behind a unified interface. + + The lifecycle methods mirror those of + :class:`~cleveragents.skill.loader.AgentSkillLoader`: + + - :meth:`discover` — enumerate all available built-in tool specs. + - :meth:`register` — register all specs into a ``ToolRegistry``. + - :meth:`activate` — no-op (built-in tools are always available). + - :meth:`deactivate` — no-op. + """ + + def __init__(self) -> None: + self._tools: list[ToolSpec] = [] + self._discovered: bool = False + + @property + def is_discovered(self) -> bool: + """Whether :meth:`discover` has been called.""" + return self._discovered + + def discover(self) -> list[ToolSpec]: + """Discover all built-in tools. + + Returns a copy of the internal list so callers cannot mutate it. + The discovery result is cached; subsequent calls return the same + specs without re-scanning. + + Returns + ------- + list[ToolSpec] + All built-in file, git, and subplan tool specs. + """ + if not self._discovered: + self._tools = [*ALL_FILE_TOOLS, *ALL_GIT_TOOLS, *ALL_SUBPLAN_TOOLS] + self._discovered = True + return list(self._tools) + + def register(self, registry: ToolRegistry) -> list[str]: + """Register all built-in tools in the registry. + + Calls :meth:`discover` if not already done, then registers each + spec into *registry*. Already-registered tools are silently + skipped so that calling ``register()`` twice on the same registry + is safe (idempotent). + + Parameters + ---------- + registry: + The ``ToolRegistry`` to register tools into. + + Returns + ------- + list[str] + Names of successfully registered tools. + + Raises + ------ + ValueError + If *registry* is ``None``. + """ + if registry is None: + raise ValueError("registry must not be None") + tools = self.discover() + registered: list[str] = [] + for spec in tools: + if registry.get(spec.name) is not None: + continue + registry.register(spec) + registered.append(spec.name) + return registered + + def activate(self) -> None: + """No-op for built-in tools (always available).""" + + def deactivate(self) -> None: + """No-op for built-in tools.""" -- 2.52.0