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
## 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>
241 lines
8.0 KiB
Python
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]}")
|