Files
cleveragents-core/features/steps/builtin_adapter_steps.py
T
brent.edwards 34c6972a05
CI / build (push) Successful in 18s
CI / lint (push) Successful in 3m20s
CI / typecheck (push) Successful in 3m56s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 4m1s
CI / quality (push) Successful in 4m8s
CI / unit_tests (push) Successful in 6m40s
CI / integration_tests (push) Successful in 6m40s
CI / docker (push) Successful in 1m19s
CI / e2e_tests (push) Failing after 17m17s
CI / coverage (push) Failing after 18m20s
CI / benchmark-publish (push) Successful in 31m37s
CI / status-check (push) Failing after 1s
feat(tool): implement BuiltinAdapter class and MCP automatic resource slot creation (#964)
## Summary

Implement `BuiltinAdapter` class and MCP automatic resource slot creation. Two main changes:

### 1. BuiltinAdapter Class (`tool/builtins/adapter.py`)
Formal adapter implementing the tool adapter lifecycle pattern for built-in tools:
- `discover()` — returns all built-in tool descriptors (file, git, subplan tools)
- `register(registry)` — registers all tools in a ToolRegistry, returns registered names
- `activate()`/`deactivate()` — no-ops (built-in tools are always available)
- Wraps existing `ALL_FILE_TOOLS`, `ALL_GIT_TOOLS`, `ALL_SUBPLAN_TOOLS` lists
- Backward compatible — existing registration functions still work

### 2. MCP Resource Slot Inference (`mcp/adapter.py`)
New `infer_resource_slots()` static method on `MCPToolAdapter`:
- Scans MCP tool parameter schemas for file/directory/repo patterns
- Creates `ResourceSlot` objects with appropriate type, access mode, binding mode
- Mapping: `file_path`→file(rw), `directory`→directory(ro), `repo_path`→git-checkout(rw)
- Slots stored in `source_metadata["resource_slots"]` on registered `ToolSpec` objects

### Quality Gates

| Session | Result |
|---|---|
| `nox -s lint` | PASS |
| `nox -s typecheck` | PASS (0 errors) |
| `nox -s unit_tests` | PASS (10,817 scenarios) |
| `nox -s integration_tests` | PASS (6 new tests) |
| `nox -s coverage_report` | 97% (>= 97%) |

Closes #882

Reviewed-on: #964
Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
2026-03-21 05:30:01 +00:00

241 lines
8.0 KiB
Python

"""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]}")