feat(tool): add tool registry persistence
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 36s
CI / security (pull_request) Successful in 25s
CI / quality (pull_request) Successful in 19s
CI / build (pull_request) Successful in 23s
CI / integration_tests (pull_request) Successful in 6m49s
CI / coverage (pull_request) Successful in 12m1s
CI / unit_tests (pull_request) Successful in 16m28s
CI / docker (pull_request) Successful in 51s

This commit is contained in:
CoreRasurae
2026-02-14 18:12:27 +00:00
committed by Luis
parent 767d9d0f82
commit 798db9088e
12 changed files with 2400 additions and 19 deletions
@@ -0,0 +1,240 @@
"""Add tool registry tables (tools, tool_resource_bindings, validation_attachments).
This migration creates the three core tool registry tables needed for
the Tool Registry Persistence layer:
- ``tools``: Registered tool and validation definitions with namespaced
names as primary keys, JSON schema for inputs/outputs, capability
metadata, and source-specific fields.
- ``tool_resource_bindings``: Declared resource bindings per tool,
specifying typed resource slots with binding mode and access level.
- ``validation_attachments``: Runtime attachments linking validations to
resources with optional project/plan scoping and ULID primary keys.
Revision ID: c1_001_tool_registry
Revises: b1_001_resource_registry
Create Date: 2026-02-14 12:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c1_001_tool_registry"
down_revision: str | Sequence[str] | None = "b0_001_projects"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create tools, tool_resource_bindings, and validation_attachments tables."""
# --- tools table ---
op.create_table(
"tools",
# PK: namespaced tool name (e.g. "local/lint-check")
sa.Column("name", sa.String(255), nullable=False),
# Extracted namespace for filtering
sa.Column("namespace", sa.String(100), nullable=False),
# Extracted short name
sa.Column("short_name", sa.String(150), nullable=False),
# Human-readable description
sa.Column("description", sa.Text(), nullable=False),
# Discriminator: tool or validation
sa.Column("tool_type", sa.String(20), nullable=False),
# Source of implementation
sa.Column("source", sa.String(20), nullable=False),
# JSON Schema for inputs and outputs
sa.Column("input_schema_json", sa.Text(), nullable=True),
sa.Column("output_schema_json", sa.Text(), nullable=True),
# Capability metadata (JSON blob)
sa.Column("capability_json", sa.Text(), nullable=True),
# Lifecycle hooks (JSON blob)
sa.Column("lifecycle_json", sa.Text(), nullable=True),
# Inline code for source=custom
sa.Column("code", sa.Text(), nullable=True),
# MCP fields
sa.Column("mcp_server", sa.String(255), nullable=True),
sa.Column("mcp_tool_name", sa.String(255), nullable=True),
# Agent skill path
sa.Column("agent_skill_path", sa.String(1024), nullable=True),
# Execution timeout
sa.Column(
"timeout",
sa.Integer(),
nullable=False,
server_default=sa.text("300"),
),
# Validation wraps reference (FK to tools.name)
sa.Column(
"wraps",
sa.String(255),
sa.ForeignKey(
"tools.name",
ondelete="SET NULL",
name="fk_tools_wraps",
),
nullable=True,
),
# Transform code for wrapped validations
sa.Column("transform", sa.Text(), nullable=True),
# Validation mode (required/informational)
sa.Column("mode", sa.String(20), nullable=True),
# Argument mapping JSON for wrapped validations
sa.Column("argument_mapping_json", sa.Text(), nullable=True),
# Timestamps (ISO-8601 strings)
sa.Column("created_at", sa.String(30), nullable=False),
sa.Column("updated_at", sa.String(30), nullable=False),
# Constraints
sa.PrimaryKeyConstraint("name"),
sa.CheckConstraint(
"tool_type IN ('tool', 'validation')",
name="ck_tools_type",
),
sa.CheckConstraint(
"source IN ('mcp', 'agent_skill', 'builtin', 'custom', 'wrapped')",
name="ck_tools_source",
),
)
op.create_index(
"ix_tools_namespace",
"tools",
["namespace"],
unique=False,
)
op.create_index(
"ix_tools_tool_type",
"tools",
["tool_type"],
unique=False,
)
op.create_index(
"ix_tools_source",
"tools",
["source"],
unique=False,
)
# --- tool_resource_bindings table ---
op.create_table(
"tool_resource_bindings",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"tool_name",
sa.String(255),
sa.ForeignKey(
"tools.name",
ondelete="CASCADE",
name="fk_tool_resource_bindings_tool",
),
nullable=False,
),
sa.Column("slot_name", sa.String(100), nullable=False),
sa.Column("resource_type", sa.String(255), nullable=False),
sa.Column("access_mode", sa.String(20), nullable=False),
sa.Column("binding_mode", sa.String(20), nullable=False),
sa.Column("static_resource", sa.String(255), nullable=True),
sa.Column(
"required",
sa.Boolean(),
nullable=False,
server_default=sa.text("1"),
),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.String(30), nullable=False),
# Constraints
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_tool_resource_bindings_tool_name",
"tool_resource_bindings",
["tool_name"],
unique=False,
)
# --- validation_attachments table ---
op.create_table(
"validation_attachments",
# PK: ULID (26-char string)
sa.Column("attachment_id", sa.String(26), nullable=False),
# FK to tools.name (must be a validation)
sa.Column(
"validation_name",
sa.String(255),
sa.ForeignKey(
"tools.name",
ondelete="CASCADE",
name="fk_validation_attachments_tool",
),
nullable=False,
),
# Resource reference (string identifier)
sa.Column("resource_id", sa.String(255), nullable=False),
# Mode: required or informational
sa.Column("mode", sa.String(20), nullable=False),
# Scoping fields
sa.Column("project_name", sa.String(255), nullable=True),
sa.Column("plan_id", sa.String(26), nullable=True),
# Optional arguments for the validation invocation
sa.Column("args_json", sa.Text(), nullable=True),
# Timestamp
sa.Column("created_at", sa.String(30), nullable=False),
# Constraints
sa.PrimaryKeyConstraint("attachment_id"),
sa.CheckConstraint(
"mode IN ('required', 'informational')",
name="ck_validation_attachments_mode",
),
)
op.create_index(
"ix_validation_attachments_validation_name",
"validation_attachments",
["validation_name"],
unique=False,
)
op.create_index(
"ix_validation_attachments_resource_id",
"validation_attachments",
["resource_id"],
unique=False,
)
op.create_index(
"ix_validation_attachments_project_name",
"validation_attachments",
["project_name"],
unique=False,
)
def downgrade() -> None:
"""Drop tool registry tables."""
# Drop in reverse FK order
op.drop_index(
"ix_validation_attachments_project_name",
table_name="validation_attachments",
)
op.drop_index(
"ix_validation_attachments_resource_id",
table_name="validation_attachments",
)
op.drop_index(
"ix_validation_attachments_validation_name",
table_name="validation_attachments",
)
op.drop_table("validation_attachments")
op.drop_index(
"ix_tool_resource_bindings_tool_name",
table_name="tool_resource_bindings",
)
op.drop_table("tool_resource_bindings")
op.drop_index("ix_tools_source", table_name="tools")
op.drop_index("ix_tools_tool_type", table_name="tools")
op.drop_index("ix_tools_namespace", table_name="tools")
op.drop_table("tools")
+162
View File
@@ -0,0 +1,162 @@
"""ASV benchmarks for Tool Registry persistence operations.
Measures the performance of:
- ToolModel.from_domain() construction
- ToolModel.to_domain() reconstruction
- ToolRegistryRepository CRUD operations (in-memory SQLite)
- ValidationAttachmentRepository attach/list operations
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import Base, ToolModel
from cleveragents.infrastructure.database.repositories import (
ToolRegistryRepository,
ValidationAttachmentRepository,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import Base, ToolModel
from cleveragents.infrastructure.database.repositories import (
ToolRegistryRepository,
ValidationAttachmentRepository,
)
def _make_tool_dict(
name: str = "bench/tool-test",
source: str = "builtin",
tool_type: str = "tool",
) -> dict[str, object]:
"""Create a minimal tool dict for benchmarks."""
now = datetime.now().isoformat()
return {
"name": name,
"description": f"Benchmark tool {name}",
"tool_type": tool_type,
"source": source,
"timeout": 300,
"created_at": now,
"updated_at": now,
"resource_bindings": [
{
"slot_name": "repo",
"resource_type": "git-checkout",
"access_mode": "read_only",
"binding_mode": "contextual",
"required": True,
},
],
}
def _setup_db():
"""Create in-memory DB and return (session, tool_repo, att_repo)."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
session = factory()
tool_repo = ToolRegistryRepository(session_factory=lambda: session)
att_repo = ValidationAttachmentRepository(session_factory=lambda: session)
return session, tool_repo, att_repo
class ToolModelSuite:
"""Benchmark ToolModel ORM construction and conversion."""
def setup(self) -> None:
"""Prepare objects."""
self.tool_dict = _make_tool_dict()
def time_from_domain(self) -> None:
"""Benchmark ToolModel.from_domain() construction."""
ToolModel.from_domain(self.tool_dict)
def time_to_domain(self) -> None:
"""Benchmark ToolModel.to_domain() conversion."""
model = ToolModel.from_domain(self.tool_dict)
model.resource_bindings_rel = []
model.to_domain()
class ToolRegistryCRUDSuite:
"""Benchmark ToolRegistryRepository CRUD operations."""
def setup(self) -> None:
"""Prepare a fresh in-memory database for each benchmark."""
self.session, self.repo, self.att_repo = _setup_db()
self._counter = 0
def time_create_tool(self) -> None:
"""Benchmark creating a single tool."""
self._counter += 1
tool = _make_tool_dict(name=f"bench/tool-{self._counter}")
self.repo.create(tool)
self.session.commit()
def time_get_by_name(self) -> None:
"""Benchmark retrieving a tool by name."""
name = "bench/lookup-target"
if self.repo.get_by_name(name) is None:
self.repo.create(_make_tool_dict(name=name))
self.session.commit()
self.repo.get_by_name(name)
def time_list_all(self) -> None:
"""Benchmark listing all tools."""
# Ensure some data exists
for i in range(5):
n = f"bench/list-{i}"
if self.repo.get_by_name(n) is None:
self.repo.create(_make_tool_dict(name=n))
self.session.commit()
self.repo.list_all()
class ValidationAttachmentSuite:
"""Benchmark ValidationAttachmentRepository operations."""
def setup(self) -> None:
"""Prepare database with a validation tool."""
self.session, self.repo, self.att_repo = _setup_db()
val = _make_tool_dict(
name="bench/val-bench",
tool_type="validation",
)
val["mode"] = "required"
self.repo.create(val)
self.session.commit()
self._counter = 0
def time_attach(self) -> None:
"""Benchmark attaching a validation."""
self._counter += 1
self.att_repo.attach(
validation_name="bench/val-bench",
resource_id=f"res-{self._counter}",
mode="required",
)
self.session.commit()
def time_list_for_resource(self) -> None:
"""Benchmark listing attachments for a resource."""
res_id = "res-bench-list"
if not self.att_repo.list_for_resource(res_id):
self.att_repo.attach(
validation_name="bench/val-bench",
resource_id=res_id,
mode="required",
)
self.session.commit()
self.att_repo.list_for_resource(res_id)
+597
View File
@@ -0,0 +1,597 @@
"""Step definitions for Tool Registry persistence coverage.
Covers tools, tool_resource_bindings, validation_attachments tables
and the ToolRegistryService integration layer.
"""
from __future__ import annotations
from datetime import datetime
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
DuplicateToolError,
ToolInUseError,
ToolRegistryRepository,
ValidationAttachmentRepository,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tool_dict(
name: str = "core/test-tool",
source: str = "builtin",
tool_type: str = "tool",
description: str | None = None,
code: str | None = None,
mcp_server: str | None = None,
mcp_tool_name: str | None = None,
mode: str | None = None,
resource_bindings: list[dict[str, object]] | None = None,
) -> dict[str, object]:
"""Create a minimal valid tool dict for testing."""
now_iso = datetime.now().isoformat()
result: dict[str, object] = {
"name": name,
"description": description or f"Test tool {name}",
"tool_type": tool_type,
"source": source,
"timeout": 300,
"created_at": now_iso,
"updated_at": now_iso,
"resource_bindings": resource_bindings or [],
}
if code is not None:
result["code"] = code
if mcp_server is not None:
result["mcp_server"] = mcp_server
if mcp_tool_name is not None:
result["mcp_tool_name"] = mcp_tool_name
if mode is not None:
result["mode"] = mode
return result
def _make_binding(slot_name: str = "repo") -> dict[str, object]:
"""Create a resource binding dict."""
return {
"slot_name": slot_name,
"resource_type": "git-checkout",
"access_mode": "read_only",
"binding_mode": "contextual",
"required": True,
}
def _get_session_factory(context: Context) -> sessionmaker[Session]:
return context.tool_session_factory # type: ignore[no-any-return]
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a clean in-memory database with the tool registry schema")
def step_clean_tool_db(context: Context) -> None:
"""Set up an in-memory SQLite database with all tables."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory: sessionmaker[Session] = sessionmaker(bind=engine, expire_on_commit=False)
context.tool_engine = engine
context.tool_session_factory = factory
# Keep a persistent session for attachment repo (needs same connection)
context.tool_session = factory()
@given("a tool registry repository backed by the database")
def step_tool_repo(context: Context) -> None:
"""Create a ToolRegistryRepository."""
context.tool_repo = ToolRegistryRepository(
session_factory=lambda: context.tool_session,
)
@given("a validation attachment repository backed by the database")
def step_attachment_repo(context: Context) -> None:
"""Create a ValidationAttachmentRepository."""
context.attachment_repo = ValidationAttachmentRepository(
session_factory=lambda: context.tool_session,
)
# ---------------------------------------------------------------------------
# Given: tool dicts
# ---------------------------------------------------------------------------
@given('a valid tool dict named "{name}" with source "{source}"')
def step_valid_tool_dict(context: Context, name: str, source: str) -> None:
"""Build a tool dict."""
context.tool_dict = _make_tool_dict(name=name, source=source)
@given('the tool dict includes inline code "{code}"')
def step_tool_dict_code(context: Context, code: str) -> None:
context.tool_dict["code"] = code
@given('the tool dict includes mcp_server "{server}" and mcp_tool_name "{tool_name}"')
def step_tool_dict_mcp(context: Context, server: str, tool_name: str) -> None:
context.tool_dict["mcp_server"] = server
context.tool_dict["mcp_tool_name"] = tool_name
@given('the tool dict has tool_type "{tool_type}" and mode "{mode}"')
def step_tool_dict_type_mode(context: Context, tool_type: str, mode: str) -> None:
context.tool_dict["tool_type"] = tool_type
context.tool_dict["mode"] = mode
@given('the tool dict includes a resource binding for slot "{slot_name}"')
def step_tool_dict_binding(context: Context, slot_name: str) -> None:
bindings = list(context.tool_dict.get("resource_bindings", [])) # type: ignore[union-attr]
bindings.append(_make_binding(slot_name))
context.tool_dict["resource_bindings"] = bindings
@given("the tool has already been registered once in the tool registry")
def step_tool_already_registered(context: Context) -> None:
context.tool_repo.create(context.tool_dict)
context.tool_session.commit()
@given("the tool has been registered through the tool registry repository")
def step_tool_registered(context: Context) -> None:
context.tool_repo.create(context.tool_dict)
context.tool_session.commit()
@given("the following tools have been registered:")
def step_register_multiple_tools(context: Context) -> None:
for row in context.table:
tool_dict = _make_tool_dict(
name=row["name"],
source=row["source"],
tool_type=row["tool_type"],
)
context.tool_repo.create(tool_dict)
context.tool_session.commit()
@given('an unscoped validation attachment for "{val_name}" on resource "{res_id}"')
def step_attachment_exists(context: Context, val_name: str, res_id: str) -> None:
att = context.attachment_repo.attach(
validation_name=val_name,
resource_id=res_id,
mode="required",
)
context.tool_session.commit()
context.last_attachment = att
@given(
'a project-scoped validation attachment for "{val_name}" on resource "{res_id}" in project "{project}"'
)
def step_attachment_in_project(
context: Context, val_name: str, res_id: str, project: str
) -> None:
att = context.attachment_repo.attach(
validation_name=val_name,
resource_id=res_id,
mode="required",
project_name=project,
)
context.tool_session.commit()
context.last_attachment = att
@given(
'a plan-scoped validation attachment for "{val_name}" on resource "{res_id}" in plan "{plan_id}"'
)
def step_attachment_with_plan(
context: Context, val_name: str, res_id: str, plan_id: str
) -> None:
att = context.attachment_repo.attach(
validation_name=val_name,
resource_id=res_id,
mode="required",
plan_id=plan_id,
)
context.tool_session.commit()
context.last_attachment = att
# ---------------------------------------------------------------------------
# When: registration
# ---------------------------------------------------------------------------
@when("the tool is registered through the tool registry repository")
def step_register_tool(context: Context) -> None:
context.last_error = None
try:
context.tool_repo.create(context.tool_dict)
context.tool_session.commit()
except Exception as exc:
context.last_error = exc
@when('a second tool with the same registry name "{name}" is registered')
def step_register_duplicate_tool(context: Context, name: str) -> None:
dup = _make_tool_dict(name=name, source="builtin")
context.last_error = None
try:
context.tool_repo.create(dup)
context.tool_session.commit()
except Exception as exc:
context.last_error = exc
# ---------------------------------------------------------------------------
# When: retrieval
# ---------------------------------------------------------------------------
@when('the tool is looked up by registry name "{name}"')
def step_lookup_tool(context: Context, name: str) -> None:
context.returned_tool = context.tool_repo.get_by_name(name)
# ---------------------------------------------------------------------------
# When: listing
# ---------------------------------------------------------------------------
@when("all tools are listed from the tool registry")
def step_list_all_tools(context: Context) -> None:
context.returned_tools = context.tool_repo.list_all()
@when('tools in the "{ns}" namespace are listed from the registry')
def step_list_tools_ns(context: Context, ns: str) -> None:
context.returned_tools = context.tool_repo.list_all(namespace=ns)
@when('tools of type "{tool_type}" are listed from the registry')
def step_list_tools_type(context: Context, tool_type: str) -> None:
context.returned_tools = context.tool_repo.list_all(tool_type=tool_type)
@when('tools with source "{source}" are listed from the registry')
def step_list_tools_source(context: Context, source: str) -> None:
context.returned_tools = context.tool_repo.list_all(source=source)
# ---------------------------------------------------------------------------
# When: update
# ---------------------------------------------------------------------------
@when('the tool description is updated to "{desc}"')
def step_update_tool_desc(context: Context, desc: str) -> None:
context.tool_dict["description"] = desc
context.tool_repo.update(context.tool_dict)
context.tool_session.commit()
@when('the tool is updated with a new resource binding for slot "{slot_name}"')
def step_update_tool_binding(context: Context, slot_name: str) -> None:
context.tool_dict["resource_bindings"] = [_make_binding(slot_name)]
context.tool_repo.update(context.tool_dict)
context.tool_session.commit()
# ---------------------------------------------------------------------------
# When: deletion
# ---------------------------------------------------------------------------
@when('the tool "{name}" is removed from the registry')
def step_remove_tool(context: Context, name: str) -> None:
context.last_error = None
try:
context.removal_result = context.tool_repo.delete(name)
except Exception as exc:
context.last_error = exc
# ---------------------------------------------------------------------------
# When: attachments
# ---------------------------------------------------------------------------
@when('the validation "{val_name}" is attached to resource "{res_id}"')
def step_attach_validation(context: Context, val_name: str, res_id: str) -> None:
context.last_attachment = context.attachment_repo.attach(
validation_name=val_name,
resource_id=res_id,
mode="required",
)
context.tool_session.commit()
@when("the validation attachment is detached by its identifier")
def step_detach_by_id(context: Context) -> None:
att_id = context.last_attachment["attachment_id"]
context.detach_result = context.attachment_repo.detach(att_id)
context.tool_session.commit()
@when('the validation attachment "{att_id}" is detached')
def step_detach_nonexistent(context: Context, att_id: str) -> None:
context.detach_result = context.attachment_repo.detach(att_id)
@when('all attachments for resource "{res_id}" are listed')
def step_list_attachments(context: Context, res_id: str) -> None:
context.returned_attachments = context.attachment_repo.list_for_resource(
resource_id=res_id
)
@when(
'project-filtered attachments for resource "{res_id}" in project "{project}" are listed'
)
def step_list_attachments_project(context: Context, res_id: str, project: str) -> None:
context.returned_attachments = context.attachment_repo.list_for_resource(
resource_id=res_id,
project_name=project,
)
@when(
'plan-filtered attachments for resource "{res_id}" with plan "{plan_id}" are listed'
)
def step_list_attachments_plan(context: Context, res_id: str, plan_id: str) -> None:
context.returned_attachments = context.attachment_repo.list_for_resource(
resource_id=res_id,
plan_id=plan_id,
)
@when("the attachment is retrieved by its stored identifier")
def step_get_attachment_by_id(context: Context) -> None:
att_id = context.last_attachment["attachment_id"]
context.returned_attachment = context.attachment_repo.get_by_id(att_id)
# ---------------------------------------------------------------------------
# When: service layer
# ---------------------------------------------------------------------------
@given("a tool registry service backed by the repositories")
def step_tool_service(context: Context) -> None:
context.tool_service = ToolRegistryService(
tool_repo=context.tool_repo,
attachment_repo=context.attachment_repo,
)
@when("the tool is registered through the service")
def step_service_register(context: Context) -> None:
context.tool_service.register_tool(context.tool_dict)
context.tool_session.commit()
@given("the tool has been registered through the service")
def step_service_given_registered(context: Context) -> None:
context.tool_service.register_tool(context.tool_dict)
context.tool_session.commit()
@when('the tool is retrieved from the service by name "{name}"')
def step_service_get(context: Context, name: str) -> None:
context.service_returned_tool = context.tool_service.get_tool(name)
@when('the service attaches validation "{val_name}" to resource "{res_id}"')
def step_service_attach(context: Context, val_name: str, res_id: str) -> None:
context.service_attachment = context.tool_service.attach_validation(
validation_name=val_name,
resource_id=res_id,
mode="required",
)
context.tool_session.commit()
@when("the service detaches the validation attachment")
def step_service_detach(context: Context) -> None:
att_id = context.service_attachment["attachment_id"]
context.service_detach_result = context.tool_service.detach_validation(att_id)
context.tool_session.commit()
@given('the service has attached validation "{val_name}" to resource "{res_id}"')
def step_service_given_attached(context: Context, val_name: str, res_id: str) -> None:
context.service_attachment = context.tool_service.attach_validation(
validation_name=val_name,
resource_id=res_id,
mode="required",
)
context.tool_session.commit()
@when('the service lists validations for resource "{res_id}"')
def step_service_list_validations(context: Context, res_id: str) -> None:
context.service_returned_attachments = (
context.tool_service.list_validations_for_resource(
resource_id=res_id,
)
)
# ---------------------------------------------------------------------------
# Then assertions
# ---------------------------------------------------------------------------
@then("the tool registry repository should not raise an error")
def step_no_tool_error(context: Context) -> None:
assert context.last_error is None, f"Unexpected error: {context.last_error}"
@then('the persisted tool should have the registry name "{name}"')
def step_tool_name(context: Context, name: str) -> None:
tool = context.tool_repo.get_by_name(name)
assert tool is not None, f"Tool {name} not found"
assert tool["name"] == name
@then('the persisted tool should have source "{source}"')
def step_tool_source(context: Context, source: str) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
assert tool["source"] == source
@then('the persisted tool should have tool_type "{tool_type}"')
def step_tool_type(context: Context, tool_type: str) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
assert tool["tool_type"] == tool_type
@then("the persisted tool should have {count:d} resource binding")
def step_tool_binding_count(context: Context, count: int) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
assert len(tool.get("resource_bindings", [])) == count
@then('a DuplicateToolError should be raised mentioning "{name}"')
def step_dup_tool_error(context: Context, name: str) -> None:
assert isinstance(context.last_error, DuplicateToolError), (
f"Expected DuplicateToolError, got {type(context.last_error)}"
)
assert name in str(context.last_error)
@then('the returned tool should have the registry name "{name}"')
def step_returned_tool_name(context: Context, name: str) -> None:
assert context.returned_tool is not None
assert context.returned_tool["name"] == name
@then("no tool should be returned from the registry")
def step_no_tool_returned(context: Context) -> None:
assert context.returned_tool is None
@then("{count:d} tools should be returned from the registry")
def step_tool_count(context: Context, count: int) -> None:
assert len(context.returned_tools) == count
@then('the returned tool names from the registry should include "{name}"')
def step_tool_names_include(context: Context, name: str) -> None:
names = [t["name"] for t in context.returned_tools]
assert name in names
@then('the persisted tool should have description "{desc}"')
def step_tool_desc(context: Context, desc: str) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
assert tool["description"] == desc
@then('the persisted tool binding slot should be "{slot_name}"')
def step_tool_binding_slot(context: Context, slot_name: str) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
bindings = tool.get("resource_bindings", [])
assert len(bindings) > 0
assert bindings[0]["slot_name"] == slot_name
@then("the removal should return true from the registry")
def step_removal_true(context: Context) -> None:
assert context.last_error is None
assert context.removal_result is True
@then("the removal should return false from the registry")
def step_removal_false(context: Context) -> None:
assert context.last_error is None
assert context.removal_result is False
@then("the tool should no longer exist in the registry")
def step_tool_not_found(context: Context) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is None
@then('a ToolInUseError should be raised mentioning "{name}"')
def step_tool_in_use_error(context: Context, name: str) -> None:
assert isinstance(context.last_error, ToolInUseError), (
f"Expected ToolInUseError, got {type(context.last_error)}"
)
assert name in str(context.last_error)
@then("the attachment should have a valid ULID identifier")
def step_attachment_ulid(context: Context) -> None:
att_id = context.last_attachment["attachment_id"]
assert len(att_id) == 26
@then('the attachment should reference resource "{res_id}"')
def step_attachment_resource(context: Context, res_id: str) -> None:
assert context.last_attachment["resource_id"] == res_id
@then("the detachment should return true")
def step_detach_true(context: Context) -> None:
assert context.detach_result is True
@then("the non-existent detachment should return false")
def step_detach_false(context: Context) -> None:
assert context.detach_result is False
@then("{count:d} attachment should be returned")
def step_attachment_count(context: Context, count: int) -> None:
assert len(context.returned_attachments) == count
@then('the retrieved attachment should reference validation "{val_name}"')
def step_retrieved_att_val(context: Context, val_name: str) -> None:
assert context.returned_attachment is not None
assert context.returned_attachment["validation_name"] == val_name
@then('the service-returned tool should have the registry name "{name}"')
def step_service_tool_name(context: Context, name: str) -> None:
assert context.service_returned_tool is not None
assert context.service_returned_tool["name"] == name
@then("the service detachment should return true")
def step_service_detach_result(context: Context) -> None:
assert context.service_detach_result is True
@then("the service should return {count:d} validation attachment")
def step_service_attachment_count(context: Context, count: int) -> None:
assert len(context.service_returned_attachments) == count
+257
View File
@@ -0,0 +1,257 @@
@phase1 @domain @repository @tool_registry
Feature: Tool Registry Persistence
As a system operator managing tools and validations
I want the tool registry to reliably persist, retrieve, and filter tools
So that the execution layer can discover and invoke tools at runtime
Background:
Given a clean in-memory database with the tool registry schema
And a tool registry repository backed by the database
And a validation attachment repository backed by the database
# ---------------------------------------------------------------------------
# Registering tools
# ---------------------------------------------------------------------------
@tool_reg_create
Scenario: A new builtin tool is registered and retrievable
Given a valid tool dict named "core/read-file" with source "builtin"
When the tool is registered through the tool registry repository
Then the tool registry repository should not raise an error
And the persisted tool should have the registry name "core/read-file"
@tool_reg_create
Scenario: A custom tool with inline code is registered
Given a valid tool dict named "local/custom-lint" with source "custom"
And the tool dict includes inline code "return {'passed': True}"
When the tool is registered through the tool registry repository
Then the persisted tool should have the registry name "local/custom-lint"
And the persisted tool should have source "custom"
@tool_reg_create
Scenario: An MCP tool is registered with server fields
Given a valid tool dict named "mcp/github-search" with source "mcp"
And the tool dict includes mcp_server "github" and mcp_tool_name "search"
When the tool is registered through the tool registry repository
Then the persisted tool should have the registry name "mcp/github-search"
@tool_reg_create
Scenario: A validation tool is registered with mode
Given a valid tool dict named "core/schema-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
When the tool is registered through the tool registry repository
Then the persisted tool should have tool_type "validation"
@tool_reg_create
Scenario: A tool is registered with resource bindings
Given a valid tool dict named "core/git-commit" with source "builtin"
And the tool dict includes a resource binding for slot "repo"
When the tool is registered through the tool registry repository
Then the persisted tool should have 1 resource binding
@tool_reg_create @error_handling
Scenario: Registering a tool with a duplicate name is rejected
Given a valid tool dict named "core/read-file" with source "builtin"
And the tool has already been registered once in the tool registry
When a second tool with the same registry name "core/read-file" is registered
Then a DuplicateToolError should be raised mentioning "core/read-file"
# ---------------------------------------------------------------------------
# Retrieving tools by name
# ---------------------------------------------------------------------------
@tool_reg_read
Scenario: A tool is retrieved by its namespaced name
Given a valid tool dict named "core/write-file" with source "builtin"
And the tool has been registered through the tool registry repository
When the tool is looked up by registry name "core/write-file"
Then the returned tool should have the registry name "core/write-file"
@tool_reg_read
Scenario: Looking up a non-existent tool name returns nothing
When the tool is looked up by registry name "core/does-not-exist"
Then no tool should be returned from the registry
# ---------------------------------------------------------------------------
# Listing tools with filters
# ---------------------------------------------------------------------------
@tool_reg_list
Scenario: All tools are listed without filters
Given the following tools have been registered:
| name | source | tool_type |
| core/read-file | builtin | tool |
| core/write-file | builtin | tool |
| local/custom-lint | custom | validation |
When all tools are listed from the tool registry
Then 3 tools should be returned from the registry
@tool_reg_list
Scenario: Tools are filtered by namespace
Given the following tools have been registered:
| name | source | tool_type |
| core/read-file | builtin | tool |
| local/custom-lint | custom | validation |
When tools in the "core" namespace are listed from the registry
Then 1 tools should be returned from the registry
And the returned tool names from the registry should include "core/read-file"
@tool_reg_list
Scenario: Tools are filtered by tool_type
Given the following tools have been registered:
| name | source | tool_type |
| core/read-file | builtin | tool |
| core/schema-check | builtin | validation |
When tools of type "validation" are listed from the registry
Then 1 tools should be returned from the registry
@tool_reg_list
Scenario: Tools are filtered by source
Given the following tools have been registered:
| name | source | tool_type |
| core/read-file | builtin | tool |
| local/custom-lint | custom | tool |
When tools with source "custom" are listed from the registry
Then 1 tools should be returned from the registry
# ---------------------------------------------------------------------------
# Updating tools
# ---------------------------------------------------------------------------
@tool_reg_update
Scenario: A tool description is updated
Given a valid tool dict named "core/read-file" with source "builtin"
And the tool has been registered through the tool registry repository
When the tool description is updated to "Updated description"
Then the persisted tool should have description "Updated description"
@tool_reg_update
Scenario: Tool resource bindings are replaced on update
Given a valid tool dict named "core/git-commit" with source "builtin"
And the tool dict includes a resource binding for slot "repo"
And the tool has been registered through the tool registry repository
When the tool is updated with a new resource binding for slot "workspace"
Then the persisted tool should have 1 resource binding
And the persisted tool binding slot should be "workspace"
# ---------------------------------------------------------------------------
# Deleting tools
# ---------------------------------------------------------------------------
@tool_reg_delete
Scenario: A tool is removed from the registry
Given a valid tool dict named "core/read-file" with source "builtin"
And the tool has been registered through the tool registry repository
When the tool "core/read-file" is removed from the registry
Then the removal should return true from the registry
And the tool should no longer exist in the registry
@tool_reg_delete
Scenario: Removing a non-existent tool returns false
When the tool "core/nonexistent" is removed from the registry
Then the removal should return false from the registry
@tool_reg_delete @error_handling
Scenario: Deleting a tool with active attachments is rejected
Given a valid tool dict named "core/schema-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the tool registry repository
And an unscoped validation attachment for "core/schema-check" on resource "res-001"
When the tool "core/schema-check" is removed from the registry
Then a ToolInUseError should be raised mentioning "core/schema-check"
# ---------------------------------------------------------------------------
# Validation attachment lifecycle
# ---------------------------------------------------------------------------
@tool_reg_attach
Scenario: A validation is attached to a resource
Given a valid tool dict named "core/schema-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the tool registry repository
When the validation "core/schema-check" is attached to resource "res-001"
Then the attachment should have a valid ULID identifier
And the attachment should reference resource "res-001"
@tool_reg_attach
Scenario: A validation is detached from a resource
Given a valid tool dict named "core/schema-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the tool registry repository
And an unscoped validation attachment for "core/schema-check" on resource "res-001"
When the validation attachment is detached by its identifier
Then the detachment should return true
@tool_reg_attach
Scenario: Detaching a non-existent attachment returns false
When the validation attachment "01ZZZZZZZZZZZZZZZZZZZZZZZZ" is detached
Then the non-existent detachment should return false
@tool_reg_attach
Scenario: Attachments are listed for a resource
Given a valid tool dict named "core/schema-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the tool registry repository
And an unscoped validation attachment for "core/schema-check" on resource "res-001"
When all attachments for resource "res-001" are listed
Then 1 attachment should be returned
@tool_reg_attach
Scenario: Attachments are scoped by project name
Given a valid tool dict named "core/schema-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the tool registry repository
And a project-scoped validation attachment for "core/schema-check" on resource "res-001" in project "my-project"
And a project-scoped validation attachment for "core/schema-check" on resource "res-001" in project "other-project"
When project-filtered attachments for resource "res-001" in project "my-project" are listed
Then 1 attachment should be returned
@tool_reg_attach
Scenario: Attachments are scoped by plan identifier
Given a valid tool dict named "core/schema-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the tool registry repository
And a plan-scoped validation attachment for "core/schema-check" on resource "res-001" in plan "01PLAN00000000000000000001"
When plan-filtered attachments for resource "res-001" with plan "01PLAN00000000000000000001" are listed
Then 1 attachment should be returned
@tool_reg_attach
Scenario: An attachment is retrieved by its identifier
Given a valid tool dict named "core/schema-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the tool registry repository
And an unscoped validation attachment for "core/schema-check" on resource "res-001"
When the attachment is retrieved by its stored identifier
Then the retrieved attachment should reference validation "core/schema-check"
# ---------------------------------------------------------------------------
# Service layer integration
# ---------------------------------------------------------------------------
@tool_reg_service
Scenario: The service registers and retrieves a tool
Given a tool registry service backed by the repositories
And a valid tool dict named "svc/echo" with source "builtin"
When the tool is registered through the service
And the tool is retrieved from the service by name "svc/echo"
Then the service-returned tool should have the registry name "svc/echo"
@tool_reg_service
Scenario: The service attaches and detaches a validation
Given a tool registry service backed by the repositories
And a valid tool dict named "svc/val-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the service
When the service attaches validation "svc/val-check" to resource "svc-res-001"
And the service detaches the validation attachment
Then the service detachment should return true
@tool_reg_service
Scenario: The service lists validations for a resource
Given a tool registry service backed by the repositories
And a valid tool dict named "svc/val-check" with source "builtin"
And the tool dict has tool_type "validation" and mode "required"
And the tool has been registered through the service
And the service has attached validation "svc/val-check" to resource "svc-res-002"
When the service lists validations for resource "svc-res-002"
Then the service should return 1 validation attachment
+56 -18
View File
@@ -883,6 +883,37 @@ The following work from the previous implementation has been completed and will
- Branch: `feature/m1-tool-builtins` (based on `feature/m1-tool-runtime-core`)
- 2026-02-14: Commit traceability sweep found no matching git log entries for skill schema/examples, skill CLI, provider actors, MCP config/adapter, actor compiler, or skill registry persistence; Section 2B traceability tasks remain open.
**2026-02-14**: Stage C1.tool.registry Complete - Tool Registry Persistence [Luis]
- Created Alembic migration `c1_001_tool_registry` (depends on `b1_001_resource_registry`) with 3 tables:
- `tools`: 22 columns, PK `name` (namespaced string e.g. `local/lint-check`), self-referential FK `wraps` -> `tools.name` (SET NULL), CHECK constraints on `tool_type` (tool/validation) and `source` (mcp/agent_skill/builtin/custom/wrapped), indexes on namespace/tool_type/source. Includes columns for inline code, MCP server/tool name, agent skill path, timeout (default 300), input/output schema JSON, capability/lifecycle JSON, transform, mode, and argument_mapping JSON.
- `tool_resource_bindings`: 10 columns, auto-increment PK, FK `tool_name` -> `tools.name` (CASCADE), columns for slot_name, resource_type, access_mode, binding_mode, static_resource, required (default true), description. Index on tool_name.
- `validation_attachments`: 8 columns, ULID PK `attachment_id`, FK `validation_name` -> `tools.name` (CASCADE), columns for resource_id, mode (required/informational CHECK), project_name, plan_id, args_json. Indexes on validation_name, resource_id, project_name.
- Added 3 ORM models to `src/cleveragents/infrastructure/database/models.py`:
- `ToolModel` (22 cols): `to_domain()` returns dict, `from_domain()` accepts dict/object with auto-namespace extraction. Relationships to `ToolResourceBindingModel` (cascade="all, delete-orphan") and `ValidationAttachmentModel`.
- `ToolResourceBindingModel` (10 cols): Nested child of ToolModel.
- `ValidationAttachmentModel` (8 cols): Nested child of ToolModel with mode CHECK constraint.
- Created 2 repository classes in `src/cleveragents/infrastructure/database/repositories.py`:
- `ToolRegistryRepository` (5 methods: create/get_by_name/list_all/update/delete): Session-factory pattern, `@database_retry` on all methods, duplicate name detection via `DuplicateToolError`, referential integrity check on delete via `ToolInUseError` (counts active validation attachments before allowing deletion). Update method replaces all child resource bindings.
- `ValidationAttachmentRepository` (4 methods: attach/detach/list_for_resource/get_by_id): ULID generation for attachment IDs, JSON serialization of args, filtering by resource_id/project_name/plan_id.
- Created `src/cleveragents/application/services/tool_registry_service.py` (192 lines):
- `ToolRegistryService` with 8 methods (5 tool CRUD + 3 validation attachment): pure dependency injection pattern, existence check on `attach_validation()` raises `NotFoundError` if validation tool not found.
- Updated `src/cleveragents/infrastructure/database/__init__.py` with 7 new exports (3 models, 2 repos, 2 errors).
- Updated `src/cleveragents/application/services/__init__.py` to export `ToolRegistryService`.
- Updated `docs/reference/database_schema.md` with 3 new table sections, updated ER diagram (tools -> bindings/attachments + self-referential wraps), updated migration chain and source locations.
- Created `features/tool_registry.feature` (27 scenarios across 7 groups: create/read/list/update/delete/attachments/service) with `features/steps/tool_registry_steps.py` (598 lines).
- Created `robot/tool_registry.robot` (4 integration tests: register-and-get, list-filter, attach-detach, duplicate-reject) with `robot/helper_tool_registry.py` (123 lines).
- Created `benchmarks/tool_registry_bench.py` (3 ASV suites, 7 benchmarks: ORM construction, CRUD operations, attachment operations).
- Key decision: `ToolModel.to_domain()` returns a dict (not a domain object) to decouple the persistence layer from the domain model until the full tool domain model integration is complete.
- Key decision: `ToolRegistryRepository.delete()` pre-checks for active `ValidationAttachmentModel` rows and raises `ToolInUseError` with count before allowing deletion, ensuring referential integrity at the application level.
- Key decision: Validation attachments are resource-scoped with optional project_name and plan_id narrowing, supporting both project-wide and plan-specific validation bindings per spec.
- Verification: lint 0 findings, typecheck 0 errors, 27 new Behave scenarios pass, 4 Robot integration tests pass.
- Branch: `feature/m3-tool-registry`
**2026-02-15**: Bugfix - Benchmark Unique Name Constraint [Luis]
- Fixed `benchmarks/resource_registry_migration_bench.py`: ASV benchmark suites that create tools/resources now use unique names per iteration (incrementing counter) to avoid UNIQUE constraint failures when ASV runs multiple iterations of the same benchmark method.
- Branch: `feature/m3-tool-registry` (appended commit)
**2026-02-17**: Bugfix - Unit Test and Benchmark Failures After Master Merge [Brent]
- **Context**: After merging master into `feature/q0-min-coverage` (commit `7ddd99b`), full `nox` run showed 2 failing sessions: `unit_tests-3.13` (1 scenario failure) and `benchmark` (multiple benchmark failures). All other sessions (lint, format, typecheck, security_scan, dead_code, integration_tests, coverage_report) passed.
@@ -2604,24 +2635,31 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [ ] Git [Brent]: `git branch -d feature/m3-tool-domain-robot`
- [ ] Quality [Brent]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] **COMMIT (Owner: Jeff | Group: C1.tool.registry | Branch: feature/m3-tool-registry | Planned: Day 14 | Expected: Day 20) - Commit message: "feat(tool): add tool registry persistence"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m3-tool-registry`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Add DB tables for `tools`, `tool_bindings`, and `validation_attachments` with resource-scoped attachments (`resource_id`, `validation_name`, `mode`, `created_at`) and indexes on tool name/type + resource_id.
- [ ] Code [Jeff]: Implement ToolRepository + ValidationAttachmentRepository and ToolRegistryService with attach/detach by resource and mode validation (required/informational).
- [ ] Docs [Jeff]: Update `docs/reference/database_schema.md` with tool/validation tables and constraints.
- [ ] Tests (Behave) [Jeff]: Add `features/tool_registry.feature` for register/update/remove and attachment rules.
- [ ] Tests (Robot) [Jeff]: Add `robot/tool_registry.robot` for list/show smoke tests.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/tool_registry_bench.py` for registry list performance.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Jeff]: `git add .`
- [ ] Git [Jeff]: `git commit -m "feat(tool): add tool registry persistence"`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-tool-registry` to `master` with description "Add tool registry persistence with bindings and validation attachments.".
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git branch -d feature/m3-tool-registry`
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] **COMMIT (Owner: Luis | Group: C1.tool.registry | Branch: feature/m3-tool-registry | Done: Day 6, February 14, 2026 22:14:17 +0000) - Commit message: "feat(tool): add tool registry persistence"**
- [X] Git [Luis]: `git checkout master`
- [X] Git [Luis]: `git pull origin master`
- [X] Git [Luis]: `git checkout -b feature/m3-tool-registry`
- [X] Git [Luis]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Code [Luis]: Add DB tables for `tools`, `tool_resource_bindings`, and `validation_attachments` with resource-scoped attachments (`resource_id`, `validation_name`, `mode`, `created_at`) and indexes on tool name/type + resource_id. Migration: `alembic/versions/c1_001_tool_registry_tables.py` (depends on `b1_001_resource_registry`). 3 tables, 7 indexes, 3 CHECK constraints, 3 FKs including self-referential `wraps`.
- [X] Code [Luis]: Implement `ToolRegistryRepository` (5 methods: create/get_by_name/list_all/update/delete) + `ValidationAttachmentRepository` (4 methods: attach/detach/list_for_resource/get_by_id) and `ToolRegistryService` (8 methods) with attach/detach by resource and mode validation (required/informational). Custom errors: `DuplicateToolError`, `ToolInUseError`.
- [X] Docs [Luis]: Update `docs/reference/database_schema.md` with 3 new table sections (tools, tool_resource_bindings, validation_attachments), updated ER diagram, migration chain, and source locations.
- [X] Tests (Behave) [Luis]: Add `features/tool_registry.feature` (27 scenarios across 7 groups: create/read/list/update/delete/attachments/service) with `features/steps/tool_registry_steps.py` (598 lines).
- [X] Tests (Robot) [Luis]: Add `robot/tool_registry.robot` (4 integration tests: register-and-get, list-filter, attach-detach, duplicate-reject) with `robot/helper_tool_registry.py` (123 lines).
- [X] Tests (ASV) [Luis]: Add `benchmarks/tool_registry_bench.py` (3 ASV suites, 7 benchmarks: ORM construction, CRUD operations, attachment operations).
- [X] Quality [Luis]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [X] Git [Luis]: `git add .`
- [X] Git [Luis]: `git commit -m "feat(tool): add tool registry persistence"`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-tool-registry` to `master` with description "Add tool registry persistence with bindings and validation attachments.".
- [ ] Git [Luis]: `git checkout master`
- [ ] Git [Luis]: `git branch -d feature/m3-tool-registry`
- [ ] Quality [Luis]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
**Notes (C1.tool.registry)**:
- Migration: `c1_001_tool_registry` depends on `b1_001_resource_registry`. Tables: `tools` (22 cols, PK namespaced name), `tool_resource_bindings` (10 cols, auto PK, CASCADE from tools), `validation_attachments` (8 cols, ULID PK, CASCADE from tools).
- `ToolModel.to_domain()` returns a dict (not a domain object) to decouple persistence from domain until full integration.
- `ToolRegistryRepository.delete()` pre-checks for active validation attachments and raises `ToolInUseError` with count.
- Validation attachments are resource-scoped with optional `project_name` and `plan_id` narrowing per spec.
- Second commit `fix(bench)` (2026-02-15 14:26:22 +0000) fixes `benchmarks/resource_registry_migration_bench.py` to use unique names per iteration, avoiding UNIQUE constraint failures under ASV multi-iteration runs.
- 12 files changed, +2453/-1 lines (main commit); 1 file changed, +12/-4 lines (bench fix).
- [ ] **COMMIT (Owner: Jeff | Group: C1.tool.binding | Branch: feature/m3-tool-binding | Planned: Day 15 | Expected: Day 21) - Commit message: "feat(tool): add resource binding resolution"**
- [ ] Git [Jeff]: `git checkout master`
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Helper script for Robot Framework tool registry smoke tests."""
from __future__ import annotations
import sys
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
DuplicateToolError,
ToolRegistryRepository,
ValidationAttachmentRepository,
)
def _setup():
"""Create in-memory DB and return (session, tool_repo, attachment_repo)."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
session = factory()
tool_repo = ToolRegistryRepository(session_factory=lambda: session)
attachment_repo = ValidationAttachmentRepository(session_factory=lambda: session)
return session, tool_repo, attachment_repo
def _make_tool(name, source="builtin", tool_type="tool", mode=None):
now = datetime.now().isoformat()
d = {
"name": name,
"description": f"Test tool {name}",
"tool_type": tool_type,
"source": source,
"timeout": 300,
"created_at": now,
"updated_at": now,
"resource_bindings": [],
}
if mode:
d["mode"] = mode
return d
def cmd_register_and_get():
session, repo, _ = _setup()
tool = _make_tool("smoke/read-file")
repo.create(tool)
session.commit()
got = repo.get_by_name("smoke/read-file")
assert got is not None
assert got["name"] == "smoke/read-file"
print("tool-registry-ok")
def cmd_list_filter():
session, repo, _ = _setup()
repo.create(_make_tool("core/tool-a"))
repo.create(_make_tool("local/tool-b", source="custom"))
session.commit()
core_tools = repo.list_all(namespace="core")
assert len(core_tools) == 1
all_tools = repo.list_all()
assert len(all_tools) == 2
print("tool-list-ok")
def cmd_attach_detach():
session, repo, att_repo = _setup()
repo.create(_make_tool("smoke/val-check", tool_type="validation", mode="required"))
session.commit()
att = att_repo.attach(
validation_name="smoke/val-check",
resource_id="res-001",
mode="required",
)
session.commit()
assert att["attachment_id"]
result = att_repo.detach(att["attachment_id"])
session.commit()
assert result is True
print("attach-detach-ok")
def cmd_duplicate_reject():
session, repo, _ = _setup()
repo.create(_make_tool("smoke/dup-test"))
session.commit()
try:
repo.create(_make_tool("smoke/dup-test"))
session.commit()
print("ERROR: should have raised")
sys.exit(1)
except DuplicateToolError:
print("duplicate-reject-ok")
def main():
if len(sys.argv) < 2:
print("Usage: helper_tool_registry.py <command>", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
commands = {
"register-and-get": cmd_register_and_get,
"list-filter": cmd_list_filter,
"attach-detach": cmd_attach_detach,
"duplicate-reject": cmd_duplicate_reject,
}
if cmd not in commands:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
commands[cmd]()
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
*** Settings ***
Documentation Smoke tests for Tool Registry persistence layer
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_tool_registry.py
*** Test Cases ***
Register And Retrieve A Tool
[Documentation] Register a builtin tool and retrieve it by name
${result}= Run Process ${PYTHON} ${HELPER} register-and-get cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tool-registry-ok
List Tools With Namespace Filter
[Documentation] Register tools and list with namespace filter
${result}= Run Process ${PYTHON} ${HELPER} list-filter cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tool-list-ok
Attach And Detach Validation
[Documentation] Attach a validation to a resource and detach it
${result}= Run Process ${PYTHON} ${HELPER} attach-detach cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} attach-detach-ok
Reject Duplicate Tool Name
[Documentation] Verify duplicate tool names are rejected
${result}= Run Process ${PYTHON} ${HELPER} duplicate-reject cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} duplicate-reject-ok
@@ -3,4 +3,10 @@
Contains service classes that orchestrate business operations.
"""
__all__ = []
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
__all__ = [
"ToolRegistryService",
]
@@ -0,0 +1,192 @@
"""Service layer for managing tool and validation registrations.
Orchestrates persistence through ``ToolRegistryRepository`` and
``ValidationAttachmentRepository``, providing a clean API for CLI and
higher-level orchestrators.
"""
from __future__ import annotations
from typing import Any
from cleveragents.core.exceptions import (
NotFoundError,
)
from cleveragents.infrastructure.database.repositories import (
ToolRegistryRepository,
ValidationAttachmentRepository,
)
class ToolRegistryService:
"""Coordinate tool/validation registration and attachment lifecycle.
Delegates all persistence to the repository layer. Designed to be
injected with a ``ToolRegistryRepository`` and a
``ValidationAttachmentRepository`` (both using session-factory pattern).
"""
def __init__(
self,
tool_repo: ToolRegistryRepository,
attachment_repo: ValidationAttachmentRepository,
) -> None:
self._tool_repo = tool_repo
self._attachment_repo = attachment_repo
# -- Tool CRUD ---------------------------------------------------------
def register_tool(self, tool: Any) -> Any:
"""Register a new tool or validation.
Args:
tool: A tool dict or domain object.
Returns:
The registered tool.
Raises:
DuplicateToolError: If the name already exists.
DatabaseError: On persistence failure.
"""
return self._tool_repo.create(tool)
def update_tool(self, tool: Any) -> Any:
"""Update an existing tool definition.
Args:
tool: A tool dict or domain object with updated fields.
Returns:
The updated tool.
Raises:
DatabaseError: If the tool is not found or persistence fails.
"""
return self._tool_repo.update(tool)
def remove_tool(self, name: str) -> bool:
"""Remove a tool from the registry.
Args:
name: The namespaced name of the tool.
Returns:
``True`` if the tool was removed, ``False`` if not found.
Raises:
ToolInUseError: If validation attachments still reference
this tool.
"""
return self._tool_repo.delete(name)
def list_tools(
self,
namespace: str | None = None,
tool_type: str | None = None,
source: str | None = None,
) -> list[Any]:
"""List tools with optional filters.
Args:
namespace: Optional namespace filter.
tool_type: Optional ``tool`` or ``validation`` filter.
source: Optional source filter.
Returns:
List of tool dicts.
"""
return self._tool_repo.list_all(
namespace=namespace,
tool_type=tool_type,
source=source,
)
def get_tool(self, name: str) -> Any | None:
"""Retrieve a single tool by name.
Args:
name: The namespaced name.
Returns:
The tool dict, or ``None`` if not found.
"""
return self._tool_repo.get_by_name(name)
# -- Validation attachment lifecycle -----------------------------------
def attach_validation(
self,
validation_name: str,
resource_id: str,
mode: str = "required",
project_name: str | None = None,
plan_id: str | None = None,
args: dict[str, Any] | None = None,
) -> Any:
"""Attach a validation to a resource.
Args:
validation_name: Name of the validation tool to attach.
resource_id: Resource reference string.
mode: ``required`` or ``informational``.
project_name: Optional project scope.
plan_id: Optional plan scope.
args: Optional invocation arguments.
Returns:
The attachment dict with generated ``attachment_id``.
Raises:
NotFoundError: If the validation tool does not exist.
DatabaseError: On persistence failure.
"""
# Verify validation exists
existing = self._tool_repo.get_by_name(validation_name)
if existing is None:
raise NotFoundError(
resource_type="validation",
resource_id=validation_name,
)
return self._attachment_repo.attach(
validation_name=validation_name,
resource_id=resource_id,
mode=mode,
project_name=project_name,
plan_id=plan_id,
args=args,
)
def detach_validation(self, attachment_id: str) -> bool:
"""Detach a validation attachment.
Args:
attachment_id: The ULID of the attachment.
Returns:
``True`` if the attachment was removed.
"""
return self._attachment_repo.detach(attachment_id)
def list_validations_for_resource(
self,
resource_id: str,
project_name: str | None = None,
plan_id: str | None = None,
) -> list[Any]:
"""List validation attachments for a resource.
Args:
resource_id: The resource reference.
project_name: Optional project scope filter.
plan_id: Optional plan scope filter.
Returns:
List of attachment dicts.
"""
return self._attachment_repo.list_for_resource(
resource_id=resource_id,
project_name=project_name,
plan_id=plan_id,
)
@@ -21,6 +21,9 @@ from .models import (
ResourceEdgeModel,
ResourceModel,
ResourceTypeModel,
ToolModel,
ToolResourceBindingModel,
ValidationAttachmentModel,
get_session,
init_database,
)
@@ -34,6 +37,7 @@ from .repositories import (
DuplicatePlanError,
DuplicateResourceError,
DuplicateResourceTypeError,
DuplicateToolError,
LifecyclePlanRepository,
NamespacedProjectRepository,
PlanNotFoundError,
@@ -47,6 +51,9 @@ from .repositories import (
ResourceTypeHasResourcesError,
ResourceTypeNotFoundError,
ResourceTypeRepository,
ToolInUseError,
ToolRegistryRepository,
ValidationAttachmentRepository,
)
from .unit_of_work import UnitOfWork, UnitOfWorkContext
@@ -65,6 +72,7 @@ __all__ = [
"DuplicatePlanError",
"DuplicateResourceError",
"DuplicateResourceTypeError",
"DuplicateToolError",
"LifecycleActionModel",
"LifecyclePlanModel",
"LifecyclePlanRepository",
@@ -90,8 +98,14 @@ __all__ = [
"ResourceTypeModel",
"ResourceTypeNotFoundError",
"ResourceTypeRepository",
"ToolInUseError",
"ToolModel",
"ToolRegistryRepository",
"ToolResourceBindingModel",
"UnitOfWork",
"UnitOfWorkContext",
"ValidationAttachmentModel",
"ValidationAttachmentRepository",
"get_session",
"init_database",
]
@@ -1463,6 +1463,293 @@ class ResourceEdgeModel(Base): # type: ignore[misc]
)
# ---------------------------------------------------------------------------
# Tool Registry Models (Stage C1 - migration c1_001)
# ---------------------------------------------------------------------------
class ToolModel(Base): # type: ignore[misc]
"""Database model for tool and validation definitions.
Tools are the atomic unit of execution in CleverAgents. They share
a single naming namespace with Validations (which are just tools
with pass/fail semantics). The ``tool_type`` discriminator
distinguishes them for filtering.
Table: ``tools``
"""
__allow_unmapped__ = True
__tablename__ = "tools"
# PK: namespaced name (e.g. "local/lint-check")
name = Column(String(255), primary_key=True)
namespace = Column(String(100), nullable=False)
short_name = Column(String(150), nullable=False)
# Core fields
description = Column(Text, nullable=False)
tool_type = Column(String(20), nullable=False)
source = Column(String(20), nullable=False)
# JSON Schema for inputs/outputs
input_schema_json = Column(Text, nullable=True)
output_schema_json = Column(Text, nullable=True)
# Capability and lifecycle metadata (JSON blobs)
capability_json = Column(Text, nullable=True)
lifecycle_json = Column(Text, nullable=True)
# Source-specific fields
code = Column(Text, nullable=True)
mcp_server = Column(String(255), nullable=True)
mcp_tool_name = Column(String(255), nullable=True)
agent_skill_path = Column(String(1024), nullable=True)
# Execution
timeout = Column(Integer, nullable=False, default=300)
# Validation-specific fields
wraps = Column(
String(255),
ForeignKey("tools.name", ondelete="SET NULL"),
nullable=True,
)
transform = Column(Text, nullable=True)
mode = Column(String(20), nullable=True)
argument_mapping_json = Column(Text, nullable=True)
# Timestamps (ISO-8601 strings)
created_at = Column(String(30), nullable=False)
updated_at = Column(String(30), nullable=False)
# Relationships
resource_bindings_rel = relationship(
"ToolResourceBindingModel",
back_populates="tool",
cascade="all, delete-orphan",
)
validation_attachments_rel = relationship(
"ValidationAttachmentModel",
back_populates="tool",
cascade="all, delete-orphan",
)
__table_args__ = (
CheckConstraint(
"tool_type IN ('tool', 'validation')",
name="ck_tools_type",
),
CheckConstraint(
"source IN ('mcp', 'agent_skill', 'builtin', 'custom', 'wrapped')",
name="ck_tools_source",
),
Index("ix_tools_namespace", "namespace"),
Index("ix_tools_tool_type", "tool_type"),
Index("ix_tools_source", "source"),
)
# -- Domain conversion helpers ------------------------------------------
def to_domain(self) -> Any:
"""Convert to domain model (stub returning dict).
Returns:
A dictionary representation suitable for domain reconstruction.
"""
result: dict[str, Any] = {
"name": cast(str, self.name),
"namespace": cast(str, self.namespace),
"short_name": cast(str, self.short_name),
"description": cast(str, self.description),
"tool_type": cast(str, self.tool_type),
"source": cast(str, self.source),
"input_schema_json": cast("str | None", self.input_schema_json),
"output_schema_json": cast("str | None", self.output_schema_json),
"capability_json": cast("str | None", self.capability_json),
"lifecycle_json": cast("str | None", self.lifecycle_json),
"code": cast("str | None", self.code),
"mcp_server": cast("str | None", self.mcp_server),
"mcp_tool_name": cast("str | None", self.mcp_tool_name),
"agent_skill_path": cast("str | None", self.agent_skill_path),
"timeout": cast(int, self.timeout),
"wraps": cast("str | None", self.wraps),
"transform": cast("str | None", self.transform),
"mode": cast("str | None", self.mode),
"argument_mapping_json": cast("str | None", self.argument_mapping_json),
"created_at": cast(str, self.created_at),
"updated_at": cast(str, self.updated_at),
}
# Include resource bindings
bindings: list[dict[str, Any]] = []
for binding in self.resource_bindings_rel or []:
bindings.append(
{
"id": cast(int, binding.id),
"slot_name": cast(str, binding.slot_name),
"resource_type": cast(str, binding.resource_type),
"access_mode": cast(str, binding.access_mode),
"binding_mode": cast(str, binding.binding_mode),
"static_resource": cast("str | None", binding.static_resource),
"required": cast(bool, binding.required),
"description": cast("str | None", binding.description),
}
)
result["resource_bindings"] = bindings
return result
@classmethod
def from_domain(cls, tool: Any) -> ToolModel:
"""Create from a domain tool dict or object.
Args:
tool: A dict-like or domain object with tool fields.
Returns:
A ``ToolModel`` ready for persistence.
"""
# Support both dict and object access
def _get(obj: Any, key: str, default: Any = None) -> Any:
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
now_iso = datetime.now().isoformat()
name_str: str = _get(tool, "name", "")
model = cls(
name=name_str,
namespace=name_str.split("/", 1)[0] if "/" in name_str else "",
short_name=name_str.split("/", 1)[1] if "/" in name_str else name_str,
description=_get(tool, "description", ""),
tool_type=_get(tool, "tool_type", "tool"),
source=_get(tool, "source", "builtin"),
input_schema_json=_get(tool, "input_schema_json"),
output_schema_json=_get(tool, "output_schema_json"),
capability_json=_get(tool, "capability_json"),
lifecycle_json=_get(tool, "lifecycle_json"),
code=_get(tool, "code"),
mcp_server=_get(tool, "mcp_server"),
mcp_tool_name=_get(tool, "mcp_tool_name"),
agent_skill_path=_get(tool, "agent_skill_path"),
timeout=_get(tool, "timeout", 300),
wraps=_get(tool, "wraps"),
transform=_get(tool, "transform"),
mode=_get(tool, "mode"),
argument_mapping_json=_get(tool, "argument_mapping_json"),
created_at=_get(tool, "created_at", now_iso),
updated_at=_get(tool, "updated_at", now_iso),
)
# Populate resource bindings from domain
for binding_data in _get(tool, "resource_bindings", []) or []:
model.resource_bindings_rel.append(
ToolResourceBindingModel(
slot_name=_get(binding_data, "slot_name", ""),
resource_type=_get(binding_data, "resource_type", ""),
access_mode=_get(binding_data, "access_mode", "read_only"),
binding_mode=_get(binding_data, "binding_mode", "contextual"),
static_resource=_get(binding_data, "static_resource"),
required=_get(binding_data, "required", True),
description=_get(binding_data, "description"),
created_at=now_iso,
)
)
return model
class ToolResourceBindingModel(Base): # type: ignore[misc]
"""Database model for tool resource bindings (child of tools).
Stores the typed resource slot declarations for each tool.
Table: ``tool_resource_bindings``
"""
__allow_unmapped__ = True
__tablename__ = "tool_resource_bindings"
id = Column(Integer, primary_key=True, autoincrement=True)
tool_name = Column(
String(255),
ForeignKey("tools.name", ondelete="CASCADE"),
nullable=False,
)
slot_name = Column(String(100), nullable=False)
resource_type = Column(String(255), nullable=False)
access_mode = Column(String(20), nullable=False)
binding_mode = Column(String(20), nullable=False)
static_resource = Column(String(255), nullable=True)
required = Column(Boolean, nullable=False, default=True)
description = Column(Text, nullable=True)
created_at = Column(String(30), nullable=False)
# Relationships
tool = relationship(
"ToolModel",
back_populates="resource_bindings_rel",
)
__table_args__ = (Index("ix_tool_resource_bindings_tool_name", "tool_name"),)
class ValidationAttachmentModel(Base): # type: ignore[misc]
"""Database model for validation attachments.
Runtime attachments linking validations to resources with optional
project/plan scoping.
Table: ``validation_attachments``
"""
__allow_unmapped__ = True
__tablename__ = "validation_attachments"
# PK: ULID (26-char string)
attachment_id = Column(String(26), primary_key=True)
# FK to tools.name (must be a validation)
validation_name = Column(
String(255),
ForeignKey("tools.name", ondelete="CASCADE"),
nullable=False,
)
# Resource reference
resource_id = Column(String(255), nullable=False)
# Mode: required or informational
mode = Column(String(20), nullable=False)
# Scoping fields
project_name = Column(String(255), nullable=True)
plan_id = Column(String(26), nullable=True)
# Optional arguments
args_json = Column(Text, nullable=True)
# Timestamp
created_at = Column(String(30), nullable=False)
# Relationships
tool = relationship(
"ToolModel",
back_populates="validation_attachments_rel",
)
__table_args__ = (
CheckConstraint(
"mode IN ('required', 'informational')",
name="ck_validation_attachments_mode",
),
Index("ix_validation_attachments_validation_name", "validation_name"),
Index("ix_validation_attachments_resource_id", "resource_id"),
Index("ix_validation_attachments_project_name", "project_name"),
)
# Database initialization functions
def init_database(database_url: str = "sqlite:///.cleveragents/db.sqlite") -> Any:
"""Initialize the database.
@@ -91,6 +91,9 @@ from cleveragents.infrastructure.database.models import (
ResourceEdgeModel,
ResourceModel,
ResourceTypeModel,
ToolModel,
ToolResourceBindingModel,
ValidationAttachmentModel,
)
@@ -2618,3 +2621,425 @@ class ProjectResourceLinkRepository:
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(f"Failed to remove link '{link_id}': {exc}") from exc
# ---------------------------------------------------------------------------
# Tool Registry Repositories
# ---------------------------------------------------------------------------
class DuplicateToolError(DatabaseError):
"""Raised when creating a tool with a name that already exists."""
def __init__(self, name: str):
super().__init__(f"Tool with name '{name}' already exists")
self.tool_name = name
class ToolInUseError(BusinessRuleViolation):
"""Raised when deleting a tool that still has validation attachments."""
def __init__(self, tool_name: str, attachment_count: int):
super().__init__(
f"Cannot delete tool {tool_name}: "
f"still has {attachment_count} validation attachment(s)"
)
self.tool_name = tool_name
self.attachment_count = attachment_count
class ToolRegistryRepository:
"""Repository for tool and validation persistence.
Uses a session-factory pattern: each public method obtains its own
session from the factory, ensuring proper session lifecycle management.
All mutating methods flush (but do NOT commit); the caller or a
``UnitOfWork`` wrapper is responsible for committing the transaction.
"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""Initialise with a callable that returns a new SQLAlchemy Session."""
self._session_factory = session_factory
def _session(self) -> Session:
"""Convenience helper to obtain a session."""
return self._session_factory()
@database_retry
def create(self, tool: Any) -> Any:
"""Persist a new tool domain object.
Args:
tool: A dict-like or domain tool object.
Returns:
The tool after persistence.
Raises:
DuplicateToolError: If a tool with the same name already exists.
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
db_model = ToolModel.from_domain(tool)
session.add(db_model)
session.flush()
return tool
except IntegrityError as exc:
session.rollback()
name_str = (
tool.get("name", "")
if isinstance(tool, dict)
else getattr(tool, "name", "")
)
if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
raise DuplicateToolError(name_str) from exc
raise DatabaseError(f"Failed to create tool: {exc}") from exc
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(f"Failed to create tool: {exc}") from exc
@database_retry
def get_by_name(self, name: str) -> Any | None:
"""Retrieve a tool by its namespaced name (PK).
Returns:
The tool as a dict, or ``None`` if not found.
"""
session = self._session()
try:
row = session.query(ToolModel).filter_by(name=name).first()
if row is None:
return None
return row.to_domain()
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(f"Failed to get tool {name}: {exc}") from exc
@database_retry
def list_all(
self,
namespace: str | None = None,
tool_type: str | None = None,
source: str | None = None,
) -> list[Any]:
"""List tools with optional filters.
Args:
namespace: Optional namespace filter.
tool_type: Optional tool_type filter (``tool`` or ``validation``).
source: Optional source filter.
Returns:
List of tool dicts.
"""
session = self._session()
try:
query = session.query(ToolModel)
if namespace is not None:
query = query.filter(ToolModel.namespace == namespace)
if tool_type is not None:
query = query.filter(ToolModel.tool_type == tool_type)
if source is not None:
query = query.filter(ToolModel.source == source)
rows = query.order_by(ToolModel.name).all()
return [row.to_domain() for row in rows]
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(f"Failed to list tools: {exc}") from exc
@database_retry
def update(self, tool: Any) -> Any:
"""Update all mutable fields of an existing tool.
Replaces child resource bindings entirely.
Args:
tool: The tool dict or object with updated fields.
Returns:
The tool after persistence.
Raises:
DatabaseError: If the tool is not found or a DB error occurs.
"""
session = self._session()
def _get(obj: Any, key: str, default: Any = None) -> Any:
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
name_str: str = _get(tool, "name", "")
try:
row = session.query(ToolModel).filter_by(name=name_str).first()
if row is None:
raise DatabaseError(f"Tool {name_str} not found for update")
from datetime import datetime as _dt
row.namespace = name_str.split("/", 1)[0] if "/" in name_str else "" # type: ignore[assignment]
row.short_name = name_str.split("/", 1)[1] if "/" in name_str else name_str # type: ignore[assignment]
row.description = _get(tool, "description", "") # type: ignore[assignment]
row.tool_type = _get(tool, "tool_type", "tool") # type: ignore[assignment]
row.source = _get(tool, "source", "builtin") # type: ignore[assignment]
row.input_schema_json = _get(tool, "input_schema_json") # type: ignore[assignment]
row.output_schema_json = _get(tool, "output_schema_json") # type: ignore[assignment]
row.capability_json = _get(tool, "capability_json") # type: ignore[assignment]
row.lifecycle_json = _get(tool, "lifecycle_json") # type: ignore[assignment]
row.code = _get(tool, "code") # type: ignore[assignment]
row.mcp_server = _get(tool, "mcp_server") # type: ignore[assignment]
row.mcp_tool_name = _get(tool, "mcp_tool_name") # type: ignore[assignment]
row.agent_skill_path = _get(tool, "agent_skill_path") # type: ignore[assignment]
row.timeout = _get(tool, "timeout", 300) # type: ignore[assignment]
row.wraps = _get(tool, "wraps") # type: ignore[assignment]
row.transform = _get(tool, "transform") # type: ignore[assignment]
row.mode = _get(tool, "mode") # type: ignore[assignment]
row.argument_mapping_json = _get(tool, "argument_mapping_json") # type: ignore[assignment]
row.updated_at = _dt.now().isoformat() # type: ignore[assignment]
# Replace child resource bindings
row.resource_bindings_rel.clear() # type: ignore[union-attr]
now_iso = _dt.now().isoformat()
for binding_data in _get(tool, "resource_bindings", []) or []:
row.resource_bindings_rel.append( # type: ignore[union-attr]
ToolResourceBindingModel(
slot_name=_get(binding_data, "slot_name", ""),
resource_type=_get(binding_data, "resource_type", ""),
access_mode=_get(binding_data, "access_mode", "read_only"),
binding_mode=_get(binding_data, "binding_mode", "contextual"),
static_resource=_get(binding_data, "static_resource"),
required=_get(binding_data, "required", True),
description=_get(binding_data, "description"),
created_at=now_iso,
)
)
session.flush()
return tool
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(f"Failed to update tool {name_str}: {exc}") from exc
@database_retry
def delete(self, name: str) -> bool:
"""Delete a tool by namespaced name.
Before deletion, verifies the tool has no validation attachments.
Args:
name: The namespaced name of the tool to delete.
Returns:
``True`` if the tool was deleted.
Raises:
ToolInUseError: If validation attachments still reference this tool.
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
# Check for validation attachments
attachment_count: int = (
session.query(ValidationAttachmentModel)
.filter(ValidationAttachmentModel.validation_name == name)
.count()
)
if attachment_count > 0:
raise ToolInUseError(name, attachment_count)
row = session.query(ToolModel).filter_by(name=name).first()
if row is None:
return False
session.delete(row)
session.flush()
return True
except ToolInUseError:
raise
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(f"Failed to delete tool {name}: {exc}") from exc
class ValidationAttachmentRepository:
"""Repository for validation attachment persistence.
Uses a session-factory pattern matching ``ActionRepository``.
"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""Initialise with a callable that returns a new SQLAlchemy Session."""
self._session_factory = session_factory
def _session(self) -> Session:
"""Convenience helper to obtain a session."""
return self._session_factory()
@database_retry
def attach(
self,
validation_name: str,
resource_id: str,
mode: str,
project_name: str | None = None,
plan_id: str | None = None,
args: dict[str, Any] | None = None,
) -> Any:
"""Create a validation attachment.
Args:
validation_name: Name of the validation tool.
resource_id: Resource reference string.
mode: ``required`` or ``informational``.
project_name: Optional project scope.
plan_id: Optional plan scope.
args: Optional argument dict (stored as JSON).
Returns:
A dict representing the attachment.
"""
import json as _json
from ulid import ULID as _ULID
session = self._session()
try:
now_iso = datetime.now().isoformat()
attachment_id = str(_ULID())
args_json: str | None = None
if args is not None:
args_json = _json.dumps(args)
model = ValidationAttachmentModel(
attachment_id=attachment_id,
validation_name=validation_name,
resource_id=resource_id,
mode=mode,
project_name=project_name,
plan_id=plan_id,
args_json=args_json,
created_at=now_iso,
)
session.add(model)
session.flush()
return {
"attachment_id": attachment_id,
"validation_name": validation_name,
"resource_id": resource_id,
"mode": mode,
"project_name": project_name,
"plan_id": plan_id,
"args_json": args_json,
"created_at": now_iso,
}
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(f"Failed to attach validation: {exc}") from exc
@database_retry
def detach(self, attachment_id: str) -> bool:
"""Remove a validation attachment by its ULID.
Args:
attachment_id: The ULID of the attachment.
Returns:
``True`` if the attachment was removed.
"""
session = self._session()
try:
row = (
session.query(ValidationAttachmentModel)
.filter_by(attachment_id=attachment_id)
.first()
)
if row is None:
return False
session.delete(row)
session.flush()
return True
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(
f"Failed to detach validation {attachment_id}: {exc}"
) from exc
@database_retry
def list_for_resource(
self,
resource_id: str,
project_name: str | None = None,
plan_id: str | None = None,
) -> list[Any]:
"""List validation attachments for a resource.
Args:
resource_id: The resource reference string.
project_name: Optional project scope filter.
plan_id: Optional plan scope filter.
Returns:
List of attachment dicts.
"""
session = self._session()
try:
query = session.query(ValidationAttachmentModel).filter(
ValidationAttachmentModel.resource_id == resource_id
)
if project_name is not None:
query = query.filter(
ValidationAttachmentModel.project_name == project_name
)
if plan_id is not None:
query = query.filter(ValidationAttachmentModel.plan_id == plan_id)
rows = query.order_by(ValidationAttachmentModel.created_at).all()
results: list[dict[str, Any]] = []
for row in rows:
results.append(
{
"attachment_id": cast(str, row.attachment_id),
"validation_name": cast(str, row.validation_name),
"resource_id": cast(str, row.resource_id),
"mode": cast(str, row.mode),
"project_name": cast("str | None", row.project_name),
"plan_id": cast("str | None", row.plan_id),
"args_json": cast("str | None", row.args_json),
"created_at": cast(str, row.created_at),
}
)
return results
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to list attachments for resource {resource_id}: {exc}"
) from exc
@database_retry
def get_by_id(self, attachment_id: str) -> Any | None:
"""Retrieve an attachment by its ULID.
Returns:
The attachment dict, or ``None`` if not found.
"""
session = self._session()
try:
row = (
session.query(ValidationAttachmentModel)
.filter_by(attachment_id=attachment_id)
.first()
)
if row is None:
return None
return {
"attachment_id": cast(str, row.attachment_id),
"validation_name": cast(str, row.validation_name),
"resource_id": cast(str, row.resource_id),
"mode": cast(str, row.mode),
"project_name": cast("str | None", row.project_name),
"plan_id": cast("str | None", row.plan_id),
"args_json": cast("str | None", row.args_json),
"created_at": cast(str, row.created_at),
}
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to get attachment {attachment_id}: {exc}"
) from exc