diff --git a/benchmarks/tool_model_bench.py b/benchmarks/tool_model_bench.py new file mode 100644 index 000000000..823f179eb --- /dev/null +++ b/benchmarks/tool_model_bench.py @@ -0,0 +1,186 @@ +"""ASV benchmarks for Tool and Validation domain model validation and serialization. + +Measures the performance of: +- Tool model construction (Pydantic validation) +- Tool.as_cli_dict() serialization +- Tool.from_config() factory +- Validation model construction with forced constraints +- ResourceSlot construction +- ToolCapability construction +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +try: + from cleveragents.domain.models.core.tool import ( + BindingMode, + ResourceAccessMode, + ResourceSlot, + Tool, + ToolCapability, + ToolLifecycle, + ToolSource, + ToolType, + Validation, + ValidationMode, + ) +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + from cleveragents.domain.models.core.tool import ( + BindingMode, + ResourceAccessMode, + ResourceSlot, + Tool, + ToolCapability, + ToolLifecycle, + ToolSource, + ToolType, + Validation, + ValidationMode, + ) + + +def _make_tool() -> Tool: + """Create a fully-populated Tool for benchmarking.""" + return Tool( + name="bench/tool-test", + description="Benchmark tool for validation and serialization", + source=ToolSource.CUSTOM, + code="return {'result': True}", + input_schema={"type": "object", "properties": {"file": {"type": "string"}}}, + output_schema={"type": "object", "properties": {"result": {"type": "boolean"}}}, + capability=ToolCapability( + writes=True, + write_scope="file", + checkpointable=True, + side_effects=["filesystem"], + idempotent=False, + ), + resource_slots=[ + ResourceSlot( + name="repo", + resource_type="git-checkout", + access=ResourceAccessMode.READ_ONLY, + binding=BindingMode.CONTEXTUAL, + ), + ], + lifecycle=ToolLifecycle( + discover="bench.discover", + activate="bench.activate", + ), + timeout=120, + ) + + +def _make_validation() -> Validation: + """Create a fully-populated Validation for benchmarking.""" + return Validation( + name="bench/val-test", + description="Benchmark validation", + source=ToolSource.WRAPPED, + wraps="bench/tool-test", + transform="def transform(r): return {'passed': True}", + argument_mapping={"strict": True}, + mode=ValidationMode.REQUIRED, + ) + + +class ToolValidationSuite: + """Benchmark Tool model construction (Pydantic validation).""" + + def time_tool_construction(self) -> None: + """Benchmark creating a fully-populated Tool object.""" + _make_tool() + + def time_tool_minimal_construction(self) -> None: + """Benchmark creating a minimal Tool object.""" + Tool( + name="bench/minimal", + description="Minimal tool", + source=ToolSource.BUILTIN, + ) + + def time_validation_construction(self) -> None: + """Benchmark creating a Validation with forced constraints.""" + _make_validation() + + def time_resource_slot_construction(self) -> None: + """Benchmark ResourceSlot construction.""" + ResourceSlot( + name="repo", + resource_type="git-checkout", + access=ResourceAccessMode.READ_ONLY, + binding=BindingMode.CONTEXTUAL, + ) + + def time_capability_construction(self) -> None: + """Benchmark ToolCapability construction.""" + ToolCapability( + read_only=True, + writes=False, + checkpointable=False, + idempotent=True, + ) + + +class ToolSerializationSuite: + """Benchmark Tool serialization methods.""" + + def setup(self) -> None: + """Create objects for serialization benchmarks.""" + self.tool = _make_tool() + self.validation = _make_validation() + + def time_tool_as_cli_dict(self) -> None: + """Benchmark Tool.as_cli_dict() ordered dict generation.""" + self.tool.as_cli_dict() + + def time_validation_as_cli_dict(self) -> None: + """Benchmark Validation.as_cli_dict() ordered dict generation.""" + self.validation.as_cli_dict() + + def time_tool_model_dump(self) -> None: + """Benchmark Pydantic model_dump() serialization.""" + self.tool.model_dump() + + def time_tool_model_dump_json(self) -> None: + """Benchmark Pydantic model_dump_json() JSON serialization.""" + self.tool.model_dump_json() + + +class ToolFromConfigSuite: + """Benchmark Tool.from_config() factory.""" + + def setup(self) -> None: + """Prepare config dicts for benchmarks.""" + self.tool_config = { + "name": "bench/from-config", + "description": "Config benchmark", + "source": "builtin", + "capability": {"read_only": True}, + "resource_slots": [ + { + "name": "repo", + "resource_type": "git-checkout", + "access": "read_only", + }, + ], + } + self.validation_config = { + "name": "bench/val-config", + "description": "Config benchmark validation", + "wraps": "bench/tool-test", + "transform": "def transform(r): return r", + "mode": "required", + } + + def time_tool_from_config(self) -> None: + """Benchmark Tool.from_config().""" + Tool.from_config(self.tool_config) + + def time_validation_from_config(self) -> None: + """Benchmark Validation.from_config().""" + Validation.from_config(self.validation_config) diff --git a/docs/reference/tool_model.md b/docs/reference/tool_model.md new file mode 100644 index 000000000..52e40b85b --- /dev/null +++ b/docs/reference/tool_model.md @@ -0,0 +1,100 @@ +# Tool Domain Model + +The `Tool` is the atomic unit of execution in CleverAgents -- a namespaced, +independently registered callable operation. + +## Tool Name + +Tools use a `namespace/short_name` naming pattern: + +``` +local/line-counter +devops/docker-build +``` + +The name is the unique identifier. Tools and Validations share the same +namespace; no collisions are allowed. + +## Source Types + +| Source | Description | Required Fields | +|---------------|------------------------------------------------|---------------------------| +| `mcp` | Delegates to an MCP server | `mcp_server`, `mcp_tool_name` | +| `agent_skill` | Agent Skills Standard folder | `agent_skill_path` | +| `builtin` | Provided by the runtime | (none) | +| `custom` | Inline Python code | `code` | +| `wrapped` | Validation-only; wraps an existing tool | (set via Validation) | + +## ToolCapability + +Describes what a tool can and cannot do: + +| Field | Type | Default | Description | +|--------------------------|----------------|---------|------------------------------------------| +| `read_only` | `bool` | `False` | Tool only reads, never writes | +| `writes` | `bool` | `False` | Tool can write to resources | +| `write_scope` | `str \| None` | `None` | Scope of writes | +| `checkpointable` | `bool` | `False` | Tool supports checkpoint/rollback | +| `checkpoint_scope` | `CheckpointScope \| None` | `None` | Granularity of checkpoint support | +| `side_effects` | `list[str]` | `[]` | Known side effects | +| `idempotent` | `bool` | `False` | Safe to re-run | +| `unsafe` | `bool` | `False` | Requires extra safety checks | +| `human_approval_required`| `bool` | `False` | Requires human approval | +| `cost_profile` | `str \| None` | `None` | Named cost profile | + +**Constraint**: If `read_only=True`, then `writes` and `checkpointable` must be `False`. + +## ResourceSlot + +Declares a resource binding for a tool: + +| Field | Type | Default | Description | +|-------------------|---------------------|---------------|-------------------------------------| +| `name` | `str` | (required) | Slot name (valid Python identifier) | +| `resource_type` | `str` | (required) | Resource type name | +| `access` | `ResourceAccessMode`| (required) | `read_only` or `read_write` | +| `description` | `str` | `""` | Human-readable description | +| `binding` | `BindingMode` | `contextual` | How the resource is resolved | +| `static_resource` | `str \| None` | `None` | Resource name (required for static) | +| `required` | `bool` | `True` | Whether slot must be filled | + +**Constraint**: `static_resource` required when `binding=static`; forbidden otherwise. + +## ToolLifecycle + +Optional lifecycle hooks: + +| Field | Type | Description | +|--------------|---------------|---------------------------| +| `discover` | `str \| None` | Hook for tool discovery | +| `activate` | `str \| None` | Hook for tool activation | +| `deactivate` | `str \| None` | Hook for tool deactivation| + +## Tool Fields + +| Field | Type | Default | Description | +|--------------------|-------------------------|------------------|----------------------------------------| +| `name` | `str` | (required) | `namespace/short_name` format | +| `description` | `str` | (required) | Short description | +| `source` | `ToolSource` | (required) | Implementation source | +| `tool_type` | `ToolType` | `tool` | Registry discriminator | +| `code` | `str \| None` | `None` | Inline Python (custom source) | +| `mcp_server` | `str \| None` | `None` | MCP server name | +| `mcp_tool_name` | `str \| None` | `None` | MCP tool name | +| `agent_skill_path` | `str \| None` | `None` | Agent Skills path | +| `input_schema` | `dict \| None` | `None` | JSON Schema for inputs | +| `output_schema` | `dict \| None` | `None` | JSON Schema for outputs | +| `capability` | `ToolCapability` | `ToolCapability()`| Capability metadata | +| `resource_slots` | `list[ResourceSlot]` | `[]` | Resource bindings | +| `lifecycle` | `ToolLifecycle \| None` | `None` | Lifecycle hooks | +| `timeout` | `int` | `300` | Execution timeout (seconds, >= 1) | + +## Class Methods + +- `Tool.from_config(config: dict)` -- Create from a YAML config dict +- `tool.as_cli_dict()` -- Stable ordered dict for CLI rendering + +## Properties + +- `tool.namespace` -- Extracted namespace from name +- `tool.short_name` -- Extracted short name from name diff --git a/docs/reference/validation_model.md b/docs/reference/validation_model.md new file mode 100644 index 000000000..bcac7554c --- /dev/null +++ b/docs/reference/validation_model.md @@ -0,0 +1,57 @@ +# Validation Domain Model + +A `Validation` extends `Tool` with pass/fail semantics. Validations are +always read-only (`writes=False`, `checkpointable=False`, `read_only=True`). + +## Validation-Specific Fields + +| Field | Type | Default | Description | +|--------------------|-------------------------|-------------|------------------------------------------| +| `mode` | `ValidationMode` | `required` | Whether failure blocks execution | +| `wraps` | `str \| None` | `None` | Name of existing tool to wrap | +| `transform` | `str \| None` | `None` | Python code to transform wrapped output | +| `argument_mapping` | `dict \| None` | `None` | Args for wrapped tool (only with wraps) | + +All fields from `Tool` are also available. + +## Forced Constraints + +When constructing a Validation, the following are always enforced: + +- `tool_type` is forced to `ToolType.VALIDATION` +- `capability.read_only` is forced to `True` +- `capability.writes` is forced to `False` +- `capability.checkpointable` is forced to `False` + +## Wraps Semantics + +A Validation may wrap an existing Tool to reuse its implementation: + +- When `wraps` is set, `source` must be `wrapped` +- When `wraps` is set, `code` must not be set (mutually exclusive) +- When `wraps` is set, `transform` is required +- `argument_mapping` is only valid when `wraps` is set + +## Modes + +| Mode | Behavior | +|-----------------|-------------------------------------------------| +| `required` | Failure blocks the operation | +| `informational` | Failure is reported but does not block | + +## Class Methods + +- `Validation.from_config(config: dict)` -- Create from a YAML config dict + (auto-detects `source=wrapped` when `wraps` is present) +- `validation.as_cli_dict()` -- Stable ordered dict with validation fields + +## CLI Management + +``` +agents validation add --config [--update] +agents validation attach [--project |--plan ] +agents validation detach [--yes] +``` + +Validations are listed and inspected via standard `agents tool` commands +with `--type validation` filter. diff --git a/docs/schema/tool.schema.yaml b/docs/schema/tool.schema.yaml new file mode 100644 index 000000000..57f579896 --- /dev/null +++ b/docs/schema/tool.schema.yaml @@ -0,0 +1,115 @@ +# Tool YAML Schema +# Defines the expected structure for tool configuration files. +# See docs/specification.md for full details. + +type: object +required: + - name + - description + - source +properties: + name: + type: string + pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$" + description: "Namespaced tool name (namespace/short_name)" + description: + type: string + minLength: 1 + description: "Short description of what the tool does" + source: + type: string + enum: [mcp, agent_skill, builtin, custom] + description: "Source of the tool implementation" + tool_type: + type: string + enum: [tool, validation] + default: tool + description: "Discriminator for the tool registry" + code: + type: string + description: "Inline Python code (required when source=custom)" + mcp_server: + type: string + description: "MCP server name (required when source=mcp)" + mcp_tool_name: + type: string + description: "Tool name on MCP server (required when source=mcp)" + agent_skill_path: + type: string + description: "Path to Agent Skills Standard folder (required when source=agent_skill)" + input_schema: + type: object + description: "JSON Schema for tool inputs" + output_schema: + type: object + description: "JSON Schema for tool outputs" + capability: + type: object + properties: + read_only: + type: boolean + default: false + writes: + type: boolean + default: false + write_scope: + type: string + checkpointable: + type: boolean + default: false + checkpoint_scope: + type: string + enum: [file, transaction, snapshot, composite] + side_effects: + type: array + items: + type: string + idempotent: + type: boolean + default: false + unsafe: + type: boolean + default: false + human_approval_required: + type: boolean + default: false + cost_profile: + type: string + resource_slots: + type: array + items: + type: object + required: [name, resource_type, access] + properties: + name: + type: string + resource_type: + type: string + access: + type: string + enum: [read_only, read_write] + description: + type: string + binding: + type: string + enum: [contextual, static, parameter] + default: contextual + static_resource: + type: string + required: + type: boolean + default: true + lifecycle: + type: object + properties: + discover: + type: string + activate: + type: string + deactivate: + type: string + timeout: + type: integer + minimum: 1 + default: 300 + description: "Execution timeout in seconds" diff --git a/docs/schema/validation.schema.yaml b/docs/schema/validation.schema.yaml new file mode 100644 index 000000000..24971962d --- /dev/null +++ b/docs/schema/validation.schema.yaml @@ -0,0 +1,94 @@ +# Validation YAML Schema +# Extends the Tool schema with validation-specific fields. +# See docs/specification.md for full details. + +type: object +required: + - name + - description +properties: + name: + type: string + pattern: "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$" + description: "Namespaced validation name (namespace/short_name)" + description: + type: string + minLength: 1 + description: "Short description of what the validation checks" + source: + type: string + enum: [mcp, agent_skill, builtin, custom, wrapped] + description: "Source of the validation implementation (inferred as 'wrapped' when wraps is set)" + mode: + type: string + enum: [required, informational] + default: required + description: "Whether failure blocks execution" + wraps: + type: string + description: "Name of existing tool this validation wraps" + transform: + type: string + description: "Python function code to transform wrapped tool output (required when wraps is set)" + argument_mapping: + type: object + description: "Argument mapping for the wrapped tool (only valid when wraps is set)" + code: + type: string + description: "Inline Python code (required when source=custom, mutually exclusive with wraps)" + mcp_server: + type: string + description: "MCP server name (required when source=mcp)" + mcp_tool_name: + type: string + description: "Tool name on MCP server (required when source=mcp)" + agent_skill_path: + type: string + description: "Path to Agent Skills Standard folder (required when source=agent_skill)" + input_schema: + type: object + description: "JSON Schema for validation inputs" + output_schema: + type: object + description: "JSON Schema for validation outputs" + capability: + type: object + description: "Capability metadata (read_only, writes, checkpointable forced for validations)" + resource_slots: + type: array + items: + type: object + required: [name, resource_type, access] + properties: + name: + type: string + resource_type: + type: string + access: + type: string + enum: [read_only, read_write] + description: + type: string + binding: + type: string + enum: [contextual, static, parameter] + default: contextual + static_resource: + type: string + required: + type: boolean + default: true + lifecycle: + type: object + properties: + discover: + type: string + activate: + type: string + deactivate: + type: string + timeout: + type: integer + minimum: 1 + default: 300 + description: "Execution timeout in seconds" diff --git a/examples/tools/custom-tool.yaml b/examples/tools/custom-tool.yaml new file mode 100644 index 000000000..c5286d88c --- /dev/null +++ b/examples/tools/custom-tool.yaml @@ -0,0 +1,40 @@ +# Example: Custom Python tool +# A simple custom tool that runs inline Python code. + +name: local/line-counter +description: Count lines in a file +source: custom +code: | + import pathlib + path = pathlib.Path(inputs["file_path"]) + count = len(path.read_text().splitlines()) + return {"line_count": count} + +input_schema: + type: object + required: + - file_path + properties: + file_path: + type: string + description: Path to the file to count lines in + +output_schema: + type: object + properties: + line_count: + type: integer + +capability: + read_only: true + writes: false + idempotent: true + +resource_slots: + - name: target_file + resource_type: fs-mount + access: read_only + description: File system containing the target file + binding: contextual + +timeout: 60 diff --git a/examples/tools/mcp-tool.yaml b/examples/tools/mcp-tool.yaml new file mode 100644 index 000000000..69b484ec1 --- /dev/null +++ b/examples/tools/mcp-tool.yaml @@ -0,0 +1,53 @@ +# Example: MCP server tool +# Delegates execution to a tool on an MCP server. + +name: devops/docker-build +description: Build a Docker image via the DevOps MCP server +source: mcp +mcp_server: devops-mcp +mcp_tool_name: docker_build + +input_schema: + type: object + required: + - context_dir + - image_tag + properties: + context_dir: + type: string + description: Docker build context directory + image_tag: + type: string + description: Tag for the built image + dockerfile: + type: string + description: Path to Dockerfile (relative to context_dir) + default: Dockerfile + +output_schema: + type: object + properties: + image_id: + type: string + build_log: + type: string + +capability: + read_only: false + writes: true + write_scope: container-registry + checkpointable: false + side_effects: + - network + - container-registry + unsafe: false + human_approval_required: false + +resource_slots: + - name: source_repo + resource_type: git-checkout + access: read_only + description: Source code repository for the Docker build + binding: contextual + +timeout: 600 diff --git a/examples/validations/required-validation.yaml b/examples/validations/required-validation.yaml new file mode 100644 index 000000000..bb4c008ef --- /dev/null +++ b/examples/validations/required-validation.yaml @@ -0,0 +1,39 @@ +# Example: Required validation (standalone) +# A custom validation that checks test coverage meets a threshold. + +name: qa/coverage-check +description: Verify test coverage meets the required threshold +source: custom +mode: required +code: | + import json + report = json.loads(inputs["coverage_json"]) + total = report.get("totals", {}).get("percent_covered", 0) + passed = total >= inputs.get("threshold", 80) + return { + "passed": passed, + "data": {"coverage_percent": total}, + "message": f"Coverage {total}% {'meets' if passed else 'below'} threshold" + } + +input_schema: + type: object + required: + - coverage_json + properties: + coverage_json: + type: string + description: JSON string of the coverage report + threshold: + type: number + description: Minimum coverage percentage required + default: 80 + +resource_slots: + - name: project_repo + resource_type: git-checkout + access: read_only + description: Repository under test + binding: contextual + +timeout: 120 diff --git a/examples/validations/wrapped-validation.yaml b/examples/validations/wrapped-validation.yaml new file mode 100644 index 000000000..06db5d53e --- /dev/null +++ b/examples/validations/wrapped-validation.yaml @@ -0,0 +1,29 @@ +# Example: Wrapped validation +# Wraps an existing tool and transforms its output into a pass/fail result. + +name: qa/lint-check +description: Validate that linting passes by wrapping the lint tool +wraps: devops/run-linter +mode: required +transform: | + def transform(result): + errors = result.get("errors", []) + passed = len(errors) == 0 + return { + "passed": passed, + "data": {"error_count": len(errors), "errors": errors[:5]}, + "message": f"{len(errors)} lint errors found" if errors else "No lint errors" + } + +argument_mapping: + strict: true + config_path: .eslintrc.json + +resource_slots: + - name: source_code + resource_type: git-checkout + access: read_only + description: Source code to lint + binding: contextual + +timeout: 180 diff --git a/features/steps/tool_model_steps.py b/features/steps/tool_model_steps.py new file mode 100644 index 000000000..a9bbb6037 --- /dev/null +++ b/features/steps/tool_model_steps.py @@ -0,0 +1,927 @@ +"""Step definitions for Tool and Validation domain model tests.""" + +from typing import Any + +from behave import then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.domain.models.core.tool import ( + BindingMode, + CheckpointScope, + ResourceAccessMode, + ResourceSlot, + Tool, + ToolCapability, + ToolLifecycle, + ToolSource, + ToolType, + Validation, + ValidationMode, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_tool(**overrides: Any) -> Tool: + """Create a Tool with sensible defaults, allowing overrides.""" + defaults: dict[str, Any] = { + "name": "local/test-tool", + "description": "A test tool", + "source": ToolSource.BUILTIN, + } + defaults.update(overrides) + return Tool(**defaults) + + +def _tool_config(**overrides: Any) -> dict[str, Any]: + """Return a minimal valid config dict for Tool.from_config.""" + cfg: dict[str, Any] = { + "name": "local/my-tool", + "description": "A tool from config", + "source": "builtin", + } + cfg.update(overrides) + return cfg + + +def _validation_config(**overrides: Any) -> dict[str, Any]: + """Return a minimal valid config dict for Validation.from_config.""" + cfg: dict[str, Any] = { + "name": "qa/my-validation", + "description": "A validation from config", + "source": "builtin", + } + cfg.update(overrides) + return cfg + + +# --------------------------------------------------------------------------- +# Tool Creation Steps +# --------------------------------------------------------------------------- + + +@when('I create a tool with name "{name}" source "{source}" and code "{code}"') +def step_create_tool_custom( + context: Context, name: str, source: str, code: str +) -> None: + """Create a custom tool with inline code.""" + context.tool_model = Tool( + name=name, + description="A custom test tool", + source=ToolSource(source), + code=code, + ) + context.tool_model_error = None + + +@when('I create a tool with name "{name}" and source "{source}"') +def step_create_tool_builtin(context: Context, name: str, source: str) -> None: + """Create a tool with a given source.""" + context.tool_model = _make_tool(name=name, source=ToolSource(source)) + context.tool_model_error = None + + +@when( + 'I create an MCP tool with name "{name}" server "{server}" ' + 'and tool name "{tool_name}"' +) +def step_create_mcp_tool( + context: Context, name: str, server: str, tool_name: str +) -> None: + """Create an MCP tool with required fields.""" + context.tool_model = Tool( + name=name, + description="An MCP tool", + source=ToolSource.MCP, + mcp_server=server, + mcp_tool_name=tool_name, + ) + context.tool_model_error = None + + +@when('I create an agent skill tool with name "{name}" and path "{path}"') +def step_create_agent_skill_tool(context: Context, name: str, path: str) -> None: + """Create an agent_skill tool.""" + context.tool_model = Tool( + name=name, + description="An agent skill tool", + source=ToolSource.AGENT_SKILL, + agent_skill_path=path, + ) + context.tool_model_error = None + + +# --------------------------------------------------------------------------- +# Tool assertions +# --------------------------------------------------------------------------- + + +@then("the tool model should be created") +def step_check_tool_created(context: Context) -> None: + """Verify the tool was created.""" + assert context.tool_model is not None, "Tool should be created" + + +@then('the tool model name should be "{expected}"') +def step_check_tool_name(context: Context, expected: str) -> None: + """Check the tool name.""" + assert context.tool_model.name == expected, ( + f"Expected name '{expected}', got '{context.tool_model.name}'" + ) + + +@then('the tool model source should be "{expected}"') +def step_check_tool_source(context: Context, expected: str) -> None: + """Check the tool source.""" + assert context.tool_model.source.value == expected, ( + f"Expected source '{expected}', got '{context.tool_model.source.value}'" + ) + + +@then('the tool model type should be "{expected}"') +def step_check_tool_type(context: Context, expected: str) -> None: + """Check the tool type.""" + assert context.tool_model.tool_type.value == expected, ( + f"Expected type '{expected}', got '{context.tool_model.tool_type.value}'" + ) + + +@then('the tool model namespace should be "{expected}"') +def step_check_tool_namespace(context: Context, expected: str) -> None: + """Check the tool namespace property.""" + assert context.tool_model.namespace == expected, ( + f"Expected namespace '{expected}', got '{context.tool_model.namespace}'" + ) + + +@then('the tool model short name should be "{expected}"') +def step_check_tool_short_name(context: Context, expected: str) -> None: + """Check the tool short_name property.""" + assert context.tool_model.short_name == expected, ( + f"Expected short_name '{expected}', got '{context.tool_model.short_name}'" + ) + + +# --------------------------------------------------------------------------- +# Name validation +# --------------------------------------------------------------------------- + + +@when('I try to create a tool with invalid name "{name}"') +def step_try_create_tool_invalid_name(context: Context, name: str) -> None: + """Attempt to create a tool with an invalid name.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = _make_tool(name=name) + except ValidationError as e: + context.tool_model_error = e + + +@then("a tool model validation error should be raised") +def step_check_tool_validation_error(context: Context) -> None: + """Verify that a validation error was raised.""" + assert context.tool_model_error is not None, ( + "Expected a validation error to be raised" + ) + + +@then('the tool model error should mention "{text}"') +def step_check_tool_error_message(context: Context, text: str) -> None: + """Check the error message contains expected text.""" + error_str = str(context.tool_model_error) + assert text in error_str, f"Expected error to mention '{text}', got: {error_str}" + + +# --------------------------------------------------------------------------- +# Source-conditional field requirements +# --------------------------------------------------------------------------- + + +@when('I try to create a tool with name "{name}" source "{source}" without code') +def step_try_create_custom_without_code( + context: Context, name: str, source: str +) -> None: + """Attempt to create a custom tool without code.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Tool( + name=name, + description="Missing code", + source=ToolSource(source), + ) + except ValidationError as e: + context.tool_model_error = e + + +@when("I try to create an MCP tool without mcp_server") +def step_try_create_mcp_without_server(context: Context) -> None: + """Attempt to create an MCP tool without mcp_server.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Tool( + name="local/test", + description="Missing server", + source=ToolSource.MCP, + mcp_tool_name="some_tool", + ) + except ValidationError as e: + context.tool_model_error = e + + +@when("I try to create an MCP tool without mcp_tool_name") +def step_try_create_mcp_without_tool_name(context: Context) -> None: + """Attempt to create an MCP tool without mcp_tool_name.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Tool( + name="local/test", + description="Missing tool name", + source=ToolSource.MCP, + mcp_server="some_server", + ) + except ValidationError as e: + context.tool_model_error = e + + +@when("I try to create an agent skill tool without path") +def step_try_create_agent_skill_without_path(context: Context) -> None: + """Attempt to create an agent_skill tool without agent_skill_path.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Tool( + name="local/test", + description="Missing path", + source=ToolSource.AGENT_SKILL, + ) + except ValidationError as e: + context.tool_model_error = e + + +# --------------------------------------------------------------------------- +# Capability constraint enforcement +# --------------------------------------------------------------------------- + + +@when("I try to create a tool capability with read_only true and writes true") +def step_try_capability_readonly_writes(context: Context) -> None: + """Attempt to create read_only capability with writes=True.""" + context.tool_model_error = None + try: + ToolCapability(read_only=True, writes=True) + except ValidationError as e: + context.tool_model_error = e + + +@when("I try to create a tool capability with read_only true and checkpointable true") +def step_try_capability_readonly_checkpoint(context: Context) -> None: + """Attempt to create read_only capability with checkpointable=True.""" + context.tool_model_error = None + try: + ToolCapability(read_only=True, checkpointable=True) + except ValidationError as e: + context.tool_model_error = e + + +@when("I create a tool capability with writes true") +def step_create_capability_with_writes(context: Context) -> None: + """Create a capability with writes enabled.""" + context.tool_capability = ToolCapability(writes=True) + + +@then("the tool capability should have writes true") +def step_check_capability_writes(context: Context) -> None: + """Check capability writes is True.""" + assert context.tool_capability.writes is True + + +# --------------------------------------------------------------------------- +# ResourceSlot binding validation +# --------------------------------------------------------------------------- + + +@when("I try to create a resource slot with static binding and no static resource") +def step_try_slot_static_no_resource(context: Context) -> None: + """Attempt static binding without static_resource.""" + context.tool_model_error = None + try: + ResourceSlot( + name="repo", + resource_type="git-checkout", + access=ResourceAccessMode.READ_ONLY, + binding=BindingMode.STATIC, + ) + except ValidationError as e: + context.tool_model_error = e + + +@when("I try to create a resource slot with contextual binding and static resource set") +def step_try_slot_contextual_with_resource(context: Context) -> None: + """Attempt contextual binding with static_resource.""" + context.tool_model_error = None + try: + ResourceSlot( + name="repo", + resource_type="git-checkout", + access=ResourceAccessMode.READ_ONLY, + binding=BindingMode.CONTEXTUAL, + static_resource="my-repo", + ) + except ValidationError as e: + context.tool_model_error = e + + +@when('I create a resource slot with static binding and static resource "{resource}"') +def step_create_slot_static(context: Context, resource: str) -> None: + """Create a valid static resource slot.""" + context.tool_resource_slot = ResourceSlot( + name="repo", + resource_type="git-checkout", + access=ResourceAccessMode.READ_ONLY, + binding=BindingMode.STATIC, + static_resource=resource, + ) + + +@then("the tool resource slot should be created") +def step_check_slot_created(context: Context) -> None: + """Verify slot was created.""" + assert context.tool_resource_slot is not None + + +@when('I try to create a resource slot with invalid name "{name}"') +def step_try_slot_invalid_name(context: Context, name: str) -> None: + """Attempt to create a slot with invalid name.""" + context.tool_model_error = None + try: + ResourceSlot( + name=name, + resource_type="git-checkout", + access=ResourceAccessMode.READ_ONLY, + ) + except ValidationError as e: + context.tool_model_error = e + + +# --------------------------------------------------------------------------- +# Validation model creation +# --------------------------------------------------------------------------- + + +@when('I create a validation with name "{name}" source "{source}" and code "{code}"') +def step_create_validation_custom( + context: Context, name: str, source: str, code: str +) -> None: + """Create a custom validation.""" + context.tool_model = Validation( + name=name, + description="A test validation", + source=ToolSource(source), + code=code, + ) + context.tool_model_error = None + + +@when('I create a validation with name "{name}" source "{source}"') +def step_create_validation_builtin(context: Context, name: str, source: str) -> None: + """Create a builtin validation.""" + context.tool_model = Validation( + name=name, + description="A test validation", + source=ToolSource(source), + ) + context.tool_model_error = None + + +@when('I create an informational validation with name "{name}" source "{source}"') +def step_create_informational_validation( + context: Context, name: str, source: str +) -> None: + """Create an informational validation.""" + context.tool_model = Validation( + name=name, + description="An info validation", + source=ToolSource(source), + mode=ValidationMode.INFORMATIONAL, + ) + context.tool_model_error = None + + +@then("the tool validation capability should be read only") +def step_check_validation_readonly(context: Context) -> None: + """Check validation capability is read_only.""" + assert context.tool_model.capability.read_only is True + + +@then("the tool validation capability should not have writes") +def step_check_validation_no_writes(context: Context) -> None: + """Check validation capability has writes=False.""" + assert context.tool_model.capability.writes is False + + +@then("the tool validation capability should not be checkpointable") +def step_check_validation_no_checkpoint(context: Context) -> None: + """Check validation capability has checkpointable=False.""" + assert context.tool_model.capability.checkpointable is False + + +@then('the tool validation mode should be "{expected}"') +def step_check_validation_mode(context: Context, expected: str) -> None: + """Check validation mode.""" + assert context.tool_model.mode.value == expected, ( + f"Expected mode '{expected}', got '{context.tool_model.mode.value}'" + ) + + +# --------------------------------------------------------------------------- +# Validation wraps/transform +# --------------------------------------------------------------------------- + + +@when("I try to create a wrapped validation without transform") +def step_try_wrapped_validation_no_transform(context: Context) -> None: + """Attempt wrapped validation without transform.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Validation( + name="qa/bad-wrap", + description="Missing transform", + source=ToolSource.WRAPPED, + wraps="devops/run-linter", + ) + except ValidationError as e: + context.tool_model_error = e + + +@when("I try to create a wrapped validation with code") +def step_try_wrapped_validation_with_code(context: Context) -> None: + """Attempt wrapped validation with code (mutually exclusive).""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Validation( + name="qa/bad-wrap", + description="Code conflict", + source=ToolSource.WRAPPED, + wraps="devops/run-linter", + transform="def transform(r): return r", + code="print('bad')", + ) + except ValidationError as e: + context.tool_model_error = e + + +@when("I try to create a validation with wraps and wrong source") +def step_try_wrapped_validation_wrong_source(context: Context) -> None: + """Attempt validation with wraps but source != wrapped.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Validation( + name="qa/bad-wrap", + description="Wrong source", + source=ToolSource.BUILTIN, + wraps="devops/run-linter", + transform="def transform(r): return r", + ) + except ValidationError as e: + context.tool_model_error = e + + +@when("I try to create a validation with argument_mapping but no wraps") +def step_try_validation_argmap_no_wraps(context: Context) -> None: + """Attempt validation with argument_mapping but no wraps.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Validation( + name="qa/bad-argmap", + description="Argmap without wraps", + source=ToolSource.BUILTIN, + argument_mapping={"key": "value"}, + ) + except ValidationError as e: + context.tool_model_error = e + + +@when("I create a valid wrapped validation") +def step_create_valid_wrapped_validation(context: Context) -> None: + """Create a valid wrapped validation.""" + context.tool_model = Validation( + name="qa/lint-check", + description="Lint validation", + source=ToolSource.WRAPPED, + wraps="devops/run-linter", + transform="def transform(r): return {'passed': True}", + argument_mapping={"strict": True}, + ) + context.tool_model_error = None + + +@then('the tool validation wraps should be "{expected}"') +def step_check_validation_wraps(context: Context, expected: str) -> None: + """Check validation wraps field.""" + assert context.tool_model.wraps == expected, ( + f"Expected wraps '{expected}', got '{context.tool_model.wraps}'" + ) + + +# --------------------------------------------------------------------------- +# from_config loading +# --------------------------------------------------------------------------- + + +@when('I load a tool from config with name "{name}" source "{source}"') +def step_load_tool_from_config(context: Context, name: str, source: str) -> None: + """Load a tool from a config dict.""" + config = _tool_config(name=name, source=source) + context.tool_model = Tool.from_config(config) + context.tool_model_error = None + + +@when('I try to load a tool from config missing "{field}"') +def step_try_load_tool_config_missing(context: Context, field: str) -> None: + """Try loading a tool config with missing field.""" + config = _tool_config() + del config[field] + context.tool_model_config_error = None + try: + Tool.from_config(config) + except ValueError as e: + context.tool_model_config_error = e + + +@then('a tool model config error should be raised with "{field}"') +def step_check_tool_config_error(context: Context, field: str) -> None: + """Check that a config error mentioning field was raised.""" + assert context.tool_model_config_error is not None, ( + f"Expected ValueError for missing '{field}'" + ) + assert field in str(context.tool_model_config_error), ( + f"Expected error to mention '{field}', got: {context.tool_model_config_error}" + ) + + +@when('I load a validation from config with wraps "{wraps}"') +def step_load_validation_from_config_wraps(context: Context, wraps: str) -> None: + """Load a validation from config with wraps.""" + config = _validation_config( + wraps=wraps, + transform="def transform(r): return r", + ) + # Remove source since wraps implies wrapped + config.pop("source", None) + context.tool_model = Validation.from_config(config) + context.tool_model_error = None + + +@when('I try to load a validation config missing "{field}"') +def step_try_load_validation_config_missing(context: Context, field: str) -> None: + """Try loading a validation config with missing field.""" + config = _validation_config() + del config[field] + context.tool_model_config_error = None + try: + Validation.from_config(config) + except ValueError as e: + context.tool_model_config_error = e + + +# --------------------------------------------------------------------------- +# as_cli_dict +# --------------------------------------------------------------------------- + + +@when("I create a tool and call as_cli_dict") +def step_create_tool_cli_dict(context: Context) -> None: + """Create a tool and get CLI dict.""" + context.tool_model = _make_tool() + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +@when("I create a validation and call as_cli_dict") +def step_create_validation_cli_dict(context: Context) -> None: + """Create a validation and get CLI dict.""" + context.tool_model = Validation( + name="qa/test-check", + description="A validation", + source=ToolSource.BUILTIN, + ) + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +@then('the tool cli dict should have key "{key}"') +def step_check_tool_cli_dict_key(context: Context, key: str) -> None: + """Check CLI dict has expected key.""" + assert key in context.tool_cli_dict, ( + f"Expected key '{key}' in cli dict, keys: {list(context.tool_cli_dict)}" + ) + + +# --------------------------------------------------------------------------- +# Timeout +# --------------------------------------------------------------------------- + + +@when("I try to create a tool with timeout {timeout:d}") +def step_try_create_tool_bad_timeout(context: Context, timeout: int) -> None: + """Attempt to create a tool with invalid timeout.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = _make_tool(timeout=timeout) + except ValidationError as e: + context.tool_model_error = e + + +@then("the tool model timeout should be {expected:d}") +def step_check_tool_timeout(context: Context, expected: int) -> None: + """Check tool timeout.""" + assert context.tool_model.timeout == expected, ( + f"Expected timeout {expected}, got {context.tool_model.timeout}" + ) + + +# --------------------------------------------------------------------------- +# ToolLifecycle +# --------------------------------------------------------------------------- + + +@when('I create a tool lifecycle with discover "{discover}"') +def step_create_lifecycle(context: Context, discover: str) -> None: + """Create a ToolLifecycle.""" + context.tool_lifecycle = ToolLifecycle(discover=discover) + + +@then('the tool lifecycle discover should be "{expected}"') +def step_check_lifecycle_discover(context: Context, expected: str) -> None: + """Check lifecycle discover hook.""" + assert context.tool_lifecycle.discover == expected + + +# --------------------------------------------------------------------------- +# Enum checks +# --------------------------------------------------------------------------- + + +@then('the ToolSource enum should have values "{values}"') +def step_check_tool_source_enum(context: Context, values: str) -> None: + """Verify ToolSource enum values.""" + expected = set(values.split(",")) + actual = {e.value for e in ToolSource} + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then('the ToolType enum should have values "{values}"') +def step_check_tool_type_enum(context: Context, values: str) -> None: + """Verify ToolType enum values.""" + expected = set(values.split(",")) + actual = {e.value for e in ToolType} + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then('the ValidationMode enum should have values "{values}"') +def step_check_validation_mode_enum(context: Context, values: str) -> None: + """Verify ValidationMode enum values.""" + expected = set(values.split(",")) + actual = {e.value for e in ValidationMode} + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then('the CheckpointScope enum should have values "{values}"') +def step_check_checkpoint_scope_enum(context: Context, values: str) -> None: + """Verify CheckpointScope enum values.""" + expected = set(values.split(",")) + actual = {e.value for e in CheckpointScope} + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then('the BindingMode enum should have values "{values}"') +def step_check_binding_mode_enum(context: Context, values: str) -> None: + """Verify BindingMode enum values.""" + expected = set(values.split(",")) + actual = {e.value for e in BindingMode} + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then('the ResourceAccessMode enum should have values "{values}"') +def step_check_access_mode_enum(context: Context, values: str) -> None: + """Verify ResourceAccessMode enum values.""" + expected = set(values.split(",")) + actual = {e.value for e in ResourceAccessMode} + assert actual == expected, f"Expected {expected}, got {actual}" + + +# --------------------------------------------------------------------------- +# from_config with resource slots +# --------------------------------------------------------------------------- + + +@when("I load a tool from config with resource slots") +def step_load_tool_config_with_slots(context: Context) -> None: + """Load a tool from config that includes resource slots.""" + config = _tool_config( + resource_slots=[ + { + "name": "repo", + "resource_type": "git-checkout", + "access": "read_only", + }, + ], + ) + context.tool_model = Tool.from_config(config) + context.tool_model_error = None + + +@then("the tool model should have {count:d} resource slot") +def step_check_tool_slot_count(context: Context, count: int) -> None: + """Check tool resource slot count.""" + actual = len(context.tool_model.resource_slots) + assert actual == count, f"Expected {count} slots, got {actual}" + + +# --------------------------------------------------------------------------- +# Empty description +# --------------------------------------------------------------------------- + + +@when("I try to create a tool with empty description") +def step_try_create_tool_empty_desc(context: Context) -> None: + """Attempt to create a tool with empty description.""" + context.tool_model_error = None + context.tool_model = None + try: + context.tool_model = Tool( + name="local/bad-desc", + description="", + source=ToolSource.BUILTIN, + ) + except ValidationError as e: + context.tool_model_error = e + + +# --------------------------------------------------------------------------- +# as_cli_dict coverage for optional fields +# --------------------------------------------------------------------------- + + +@when("I create a custom tool and call as_cli_dict") +def step_create_custom_tool_cli_dict(context: Context) -> None: + """Create a custom tool and get CLI dict (covers code branch).""" + context.tool_model = Tool( + name="local/custom-cli", + description="Custom tool for CLI dict", + source=ToolSource.CUSTOM, + code="return True", + ) + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +@when("I create an MCP tool and call as_cli_dict") +def step_create_mcp_tool_cli_dict(context: Context) -> None: + """Create an MCP tool and get CLI dict (covers mcp fields).""" + context.tool_model = Tool( + name="devops/mcp-cli", + description="MCP tool for CLI dict", + source=ToolSource.MCP, + mcp_server="devops-mcp", + mcp_tool_name="docker_build", + ) + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +@when("I create an agent skill tool and call as_cli_dict") +def step_create_agent_skill_tool_cli_dict(context: Context) -> None: + """Create an agent skill tool and get CLI dict.""" + context.tool_model = Tool( + name="skills/fmt-cli", + description="Agent skill for CLI dict", + source=ToolSource.AGENT_SKILL, + agent_skill_path="/skills/fmt", + ) + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +@when("I create a tool with schemas and call as_cli_dict") +def step_create_tool_with_schemas_cli_dict(context: Context) -> None: + """Create a tool with schemas and get CLI dict.""" + context.tool_model = Tool( + name="local/schema-cli", + description="Tool with schemas", + source=ToolSource.BUILTIN, + input_schema={"type": "object"}, + output_schema={"type": "object"}, + ) + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +@when("I create a tool with side effects and cost profile and call as_cli_dict") +def step_create_tool_with_effects_cli_dict(context: Context) -> None: + """Create a tool with side effects and cost profile.""" + context.tool_model = Tool( + name="local/effects-cli", + description="Tool with effects", + source=ToolSource.BUILTIN, + capability=ToolCapability( + side_effects=["network", "filesystem"], + cost_profile="high", + ), + ) + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +@then('the tool cli dict capability should have key "{key}"') +def step_check_tool_cli_dict_cap_key(context: Context, key: str) -> None: + """Check CLI dict capability sub-dict has expected key.""" + cap = context.tool_cli_dict["capability"] + assert key in cap, f"Expected key '{key}' in capability dict, keys: {list(cap)}" + + +@when("I create a tool with resource slots and call as_cli_dict") +def step_create_tool_with_slots_cli_dict(context: Context) -> None: + """Create a tool with resource slots and get CLI dict.""" + context.tool_model = Tool( + name="local/slots-cli", + description="Tool with slots", + source=ToolSource.BUILTIN, + resource_slots=[ + ResourceSlot( + name="repo", + resource_type="git-checkout", + access=ResourceAccessMode.READ_ONLY, + ), + ], + ) + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +# --------------------------------------------------------------------------- +# Validation as_cli_dict wraps fields +# --------------------------------------------------------------------------- + + +@when("I create a wrapped validation and call as_cli_dict") +def step_create_wrapped_validation_cli_dict(context: Context) -> None: + """Create a wrapped validation and get CLI dict.""" + context.tool_model = Validation( + name="qa/wrap-cli", + description="Wrapped validation for CLI dict", + source=ToolSource.WRAPPED, + wraps="devops/run-linter", + transform="def transform(r): return {'passed': True}", + argument_mapping={"strict": True}, + ) + context.tool_cli_dict = context.tool_model.as_cli_dict() + + +# --------------------------------------------------------------------------- +# Validation from_config without wraps +# --------------------------------------------------------------------------- + + +@when("I try to load a validation config without source or wraps") +def step_try_load_validation_no_source_no_wraps(context: Context) -> None: + """Try loading a validation config with neither source nor wraps.""" + config = _validation_config() + del config["source"] + context.tool_model_config_error = None + try: + Validation.from_config(config) + except ValueError as e: + context.tool_model_config_error = e + + +@when('I load a validation from config with source "{source}"') +def step_load_validation_from_config_source(context: Context, source: str) -> None: + """Load a validation from config with explicit source.""" + config = _validation_config(source=source) + context.tool_model = Validation.from_config(config) + context.tool_model_error = None + + +# --------------------------------------------------------------------------- +# Validation forces read_only on writable capability +# --------------------------------------------------------------------------- + + +@when("I create a validation with writes capability") +def step_create_validation_with_writes(context: Context) -> None: + """Create a validation that has writes in capability (will be forced).""" + context.tool_model = Validation( + name="qa/writable-val", + description="Validation with writes cap", + source=ToolSource.BUILTIN, + capability=ToolCapability( + writes=True, + checkpointable=True, + ), + ) + context.tool_model_error = None diff --git a/features/tool_model.feature b/features/tool_model.feature new file mode 100644 index 000000000..7dd06cb64 --- /dev/null +++ b/features/tool_model.feature @@ -0,0 +1,327 @@ +Feature: Tool and Validation Domain Models + As a developer + I want tool and validation domain models for the tool registry + So that tools can be registered, validated, and managed via CLI + + # ---- Tool Creation ---- + + Scenario: Create a custom tool with valid fields + When I create a tool with name "local/line-counter" source "custom" and code "print('hello')" + Then the tool model should be created + And the tool model name should be "local/line-counter" + And the tool model source should be "custom" + And the tool model type should be "tool" + And the tool model namespace should be "local" + And the tool model short name should be "line-counter" + + Scenario: Create a builtin tool + When I create a tool with name "builtin/file-read" and source "builtin" + Then the tool model should be created + And the tool model source should be "builtin" + + Scenario: Create an MCP tool with required fields + When I create an MCP tool with name "devops/docker-build" server "devops-mcp" and tool name "docker_build" + Then the tool model should be created + And the tool model source should be "mcp" + + Scenario: Create an agent_skill tool + When I create an agent skill tool with name "skills/formatter" and path "/skills/formatter" + Then the tool model should be created + And the tool model source should be "agent_skill" + + # ---- Tool Name Validation ---- + + Scenario: Tool name must match namespace/name pattern + When I try to create a tool with invalid name "no-namespace" + Then a tool model validation error should be raised + And the tool model error should mention "namespace/name" + + Scenario: Tool name rejects spaces + When I try to create a tool with invalid name "local/bad name" + Then a tool model validation error should be raised + + Scenario: Tool name rejects double slashes + When I try to create a tool with invalid name "local//bad" + Then a tool model validation error should be raised + + Scenario: Tool name allows hyphens and underscores + When I create a tool with name "my-ns/my_tool" and source "builtin" + Then the tool model should be created + And the tool model namespace should be "my-ns" + And the tool model short name should be "my_tool" + + # ---- Source-conditional field requirements ---- + + Scenario: Custom source requires code + When I try to create a tool with name "local/test" source "custom" without code + Then a tool model validation error should be raised + And the tool model error should mention "code is required" + + Scenario: MCP source requires mcp_server + When I try to create an MCP tool without mcp_server + Then a tool model validation error should be raised + And the tool model error should mention "mcp_server is required" + + Scenario: MCP source requires mcp_tool_name + When I try to create an MCP tool without mcp_tool_name + Then a tool model validation error should be raised + And the tool model error should mention "mcp_tool_name is required" + + Scenario: Agent skill source requires agent_skill_path + When I try to create an agent skill tool without path + Then a tool model validation error should be raised + And the tool model error should mention "agent_skill_path is required" + + # ---- Capability constraint enforcement ---- + + Scenario: Read-only capability enforces writes=false + When I try to create a tool capability with read_only true and writes true + Then a tool model validation error should be raised + And the tool model error should mention "read_only tool cannot have writes" + + Scenario: Read-only capability enforces checkpointable=false + When I try to create a tool capability with read_only true and checkpointable true + Then a tool model validation error should be raised + And the tool model error should mention "read_only tool cannot have checkpointable" + + Scenario: Non-read-only capability allows writes + When I create a tool capability with writes true + Then the tool capability should have writes true + + # ---- ResourceSlot binding validation ---- + + Scenario: Static binding requires static_resource + When I try to create a resource slot with static binding and no static resource + Then a tool model validation error should be raised + And the tool model error should mention "static_resource is required" + + Scenario: Non-static binding forbids static_resource + When I try to create a resource slot with contextual binding and static resource set + Then a tool model validation error should be raised + And the tool model error should mention "static_resource must not be set" + + Scenario: Static binding with static_resource succeeds + When I create a resource slot with static binding and static resource "my-repo" + Then the tool resource slot should be created + + Scenario: ResourceSlot name must be valid identifier + When I try to create a resource slot with invalid name "123bad" + Then a tool model validation error should be raised + And the tool model error should mention "valid Python identifier" + + # ---- Validation model creation ---- + + Scenario: Create a custom validation with forced read-only + When I create a validation with name "qa/coverage-check" source "custom" and code "return True" + Then the tool model should be created + And the tool model type should be "validation" + And the tool validation capability should be read only + And the tool validation capability should not have writes + And the tool validation capability should not be checkpointable + + Scenario: Validation forces tool_type to validation + When I create a validation with name "qa/test-check" source "builtin" + Then the tool model type should be "validation" + + Scenario: Validation mode defaults to required + When I create a validation with name "qa/check" source "builtin" + Then the tool validation mode should be "required" + + Scenario: Validation can be informational + When I create an informational validation with name "qa/info-check" source "builtin" + Then the tool validation mode should be "informational" + + # ---- Validation wraps/transform requirements ---- + + Scenario: Wrapped validation requires transform + When I try to create a wrapped validation without transform + Then a tool model validation error should be raised + And the tool model error should mention "transform is required" + + Scenario: Wrapped validation forbids code + When I try to create a wrapped validation with code + Then a tool model validation error should be raised + And the tool model error should mention "code must not be set" + + Scenario: Wrapped validation requires source=wrapped + When I try to create a validation with wraps and wrong source + Then a tool model validation error should be raised + And the tool model error should mention "source must be 'wrapped'" + + Scenario: argument_mapping only valid with wraps + When I try to create a validation with argument_mapping but no wraps + Then a tool model validation error should be raised + And the tool model error should mention "argument_mapping is only valid" + + Scenario: Valid wrapped validation + When I create a valid wrapped validation + Then the tool model should be created + And the tool model type should be "validation" + And the tool validation wraps should be "devops/run-linter" + + # ---- from_config loading ---- + + Scenario: Load tool from config dict + When I load a tool from config with name "local/my-tool" source "builtin" + Then the tool model should be created + And the tool model name should be "local/my-tool" + + Scenario: Load tool from config missing name raises error + When I try to load a tool from config missing "name" + Then a tool model config error should be raised with "name" + + Scenario: Load tool from config missing description raises error + When I try to load a tool from config missing "description" + Then a tool model config error should be raised with "description" + + Scenario: Load tool from config missing source raises error + When I try to load a tool from config missing "source" + Then a tool model config error should be raised with "source" + + Scenario: Load validation from config with wraps + When I load a validation from config with wraps "devops/linter" + Then the tool model should be created + And the tool model source should be "wrapped" + And the tool validation wraps should be "devops/linter" + + Scenario: Load validation from config missing name raises error + When I try to load a validation config missing "name" + Then a tool model config error should be raised with "name" + + # ---- as_cli_dict output stability ---- + + Scenario: Tool as_cli_dict has required keys + When I create a tool and call as_cli_dict + Then the tool cli dict should have key "name" + And the tool cli dict should have key "description" + And the tool cli dict should have key "source" + And the tool cli dict should have key "tool_type" + And the tool cli dict should have key "namespace" + And the tool cli dict should have key "short_name" + And the tool cli dict should have key "capability" + And the tool cli dict should have key "timeout" + + Scenario: Validation as_cli_dict includes mode + When I create a validation and call as_cli_dict + Then the tool cli dict should have key "mode" + + # ---- Tool timeout validation ---- + + Scenario: Tool timeout must be >= 1 + When I try to create a tool with timeout 0 + Then a tool model validation error should be raised + + Scenario: Tool timeout default is 300 + When I create a tool with name "local/default-timeout" and source "builtin" + Then the tool model timeout should be 300 + + # ---- ToolLifecycle ---- + + Scenario: ToolLifecycle accepts optional hooks + When I create a tool lifecycle with discover "my.discover" + Then the tool lifecycle discover should be "my.discover" + + # ---- Enum values ---- + + Scenario: ToolSource enum has all expected values + Then the ToolSource enum should have values "mcp,agent_skill,builtin,custom,wrapped" + + Scenario: ToolType enum has all expected values + Then the ToolType enum should have values "tool,validation" + + Scenario: ValidationMode enum has all expected values + Then the ValidationMode enum should have values "required,informational" + + Scenario: CheckpointScope enum has all expected values + Then the CheckpointScope enum should have values "file,transaction,snapshot,composite" + + Scenario: BindingMode enum has all expected values + Then the BindingMode enum should have values "contextual,static,parameter" + + Scenario: ResourceAccessMode enum has all expected values + Then the ResourceAccessMode enum should have values "read_only,read_write" + + # ---- Tool with resource slots via from_config ---- + + Scenario: Load tool from config with resource slots + When I load a tool from config with resource slots + Then the tool model should be created + And the tool model should have 1 resource slot + + # ---- Validation from_config missing description ---- + + Scenario: Load validation from config missing description raises error + When I try to load a validation config missing "description" + Then a tool model config error should be raised with "description" + + # ---- Tool description validation ---- + + Scenario: Tool description must not be empty + When I try to create a tool with empty description + Then a tool model validation error should be raised + + # ---- Invalid scenarios ---- + + Scenario: Tool with conflicting capability flags + When I try to create a tool capability with read_only true and writes true + Then a tool model validation error should be raised + + Scenario: Validation wraps with custom source is rejected + When I try to create a validation with wraps and wrong source + Then a tool model validation error should be raised + + # ---- as_cli_dict coverage for optional fields ---- + + Scenario: Tool as_cli_dict includes code when set + When I create a custom tool and call as_cli_dict + Then the tool cli dict should have key "code" + + Scenario: Tool as_cli_dict includes mcp fields when set + When I create an MCP tool and call as_cli_dict + Then the tool cli dict should have key "mcp_server" + And the tool cli dict should have key "mcp_tool_name" + + Scenario: Tool as_cli_dict includes agent_skill_path when set + When I create an agent skill tool and call as_cli_dict + Then the tool cli dict should have key "agent_skill_path" + + Scenario: Tool as_cli_dict includes schemas when set + When I create a tool with schemas and call as_cli_dict + Then the tool cli dict should have key "input_schema" + And the tool cli dict should have key "output_schema" + + Scenario: Tool as_cli_dict includes side_effects and cost_profile + When I create a tool with side effects and cost profile and call as_cli_dict + Then the tool cli dict capability should have key "side_effects" + And the tool cli dict capability should have key "cost_profile" + + Scenario: Tool as_cli_dict includes resource slots when set + When I create a tool with resource slots and call as_cli_dict + Then the tool cli dict should have key "resource_slots" + + # ---- Validation as_cli_dict wraps fields ---- + + Scenario: Validation as_cli_dict includes wraps and transform + When I create a wrapped validation and call as_cli_dict + Then the tool cli dict should have key "wraps" + And the tool cli dict should have key "transform" + And the tool cli dict should have key "argument_mapping" + + # ---- Validation from_config without wraps ---- + + Scenario: Load validation from config without wraps needs source + When I try to load a validation config without source or wraps + Then a tool model config error should be raised with "source" + + Scenario: Load validation from config with explicit source + When I load a validation from config with source "builtin" + Then the tool model should be created + And the tool model source should be "builtin" + + # ---- Validation forces read_only on writable capability ---- + + Scenario: Validation forces read_only on writable capability + When I create a validation with writes capability + Then the tool validation capability should be read only + And the tool validation capability should not have writes + And the tool validation capability should not be checkpointable diff --git a/src/cleveragents/domain/models/core/__init__.py b/src/cleveragents/domain/models/core/__init__.py index 697198b3f..9a0e47569 100644 --- a/src/cleveragents/domain/models/core/__init__.py +++ b/src/cleveragents/domain/models/core/__init__.py @@ -69,11 +69,28 @@ from cleveragents.domain.models.core.resource import ( SandboxStrategy, ) +# Tool and Validation domain models +from cleveragents.domain.models.core.tool import ( + BindingMode, + CheckpointScope, + ResourceAccessMode, + ResourceSlot, + Tool, + ToolCapability, + ToolLifecycle, + ToolSource, + ToolType, + Validation, + ValidationMode, +) + __all__ = [ "ActionState", "Actor", + "BindingMode", "Change", "ChangeSet", + "CheckpointScope", "CloudBillingFields", "Context", "ContextConfig", @@ -112,11 +129,20 @@ __all__ = [ "ProjectSettings", "ProjectStats", "Resource", + "ResourceAccessMode", "ResourceCapabilities", + "ResourceSlot", "SandboxStrategy", "SummaryForUpdateContextParams", "TemporalScope", + "Tool", + "ToolCapability", + "ToolLifecycle", + "ToolSource", + "ToolType", "User", + "Validation", + "ValidationMode", "can_transition", "parse_namespaced_name", ] diff --git a/src/cleveragents/domain/models/core/tool.py b/src/cleveragents/domain/models/core/tool.py new file mode 100644 index 000000000..c9a7a5c75 --- /dev/null +++ b/src/cleveragents/domain/models/core/tool.py @@ -0,0 +1,623 @@ +"""Tool and Validation domain models for CleverAgents v3. + +A Tool is the atomic unit of execution: a namespaced, independently registered +callable operation. Defined by JSON Schema inputs/outputs, capability metadata +(``read_only``, ``writes``, ``checkpointable``), and typed resource slots. + +A Validation extends Tool with pass/fail semantics: always read-only, always +``writes=False`` and ``checkpointable=False``. May wrap an existing tool via +``wraps`` + ``transform`` to reuse its implementation. + +Tools and Validations share the same naming namespace -- no collisions allowed. + +Based on docs/specification.md and ADR-004 (Pydantic Validation). +""" + +from __future__ import annotations + +import re +from collections import OrderedDict +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +# --------------------------------------------------------------------------- +# Regex patterns +# --------------------------------------------------------------------------- + +_TOOL_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$") + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class ToolSource(StrEnum): + """Source of a tool implementation. + + Determines which fields are required on the Tool model: + - ``mcp``: requires ``mcp_server`` and ``mcp_tool_name`` + - ``agent_skill``: requires ``agent_skill_path`` + - ``builtin``: provided by the runtime + - ``custom``: requires inline ``code`` + - ``wrapped``: validation-only; set implicitly when a Validation wraps + an existing tool + """ + + MCP = "mcp" + AGENT_SKILL = "agent_skill" + BUILTIN = "builtin" + CUSTOM = "custom" + WRAPPED = "wrapped" + + +class ToolType(StrEnum): + """Discriminator for the tool registry. + + Tools and Validations share the same namespace but are distinguished by + this field for filtering and listing. + """ + + TOOL = "tool" + VALIDATION = "validation" + + +class ValidationMode(StrEnum): + """Controls whether validation failure blocks execution. + + - ``required``: failure blocks the operation + - ``informational``: failure is reported but does not block + """ + + REQUIRED = "required" + INFORMATIONAL = "informational" + + +class ResourceAccessMode(StrEnum): + """Access level a tool has on a bound resource.""" + + READ_ONLY = "read_only" + READ_WRITE = "read_write" + + +class BindingMode(StrEnum): + """How a resource slot is resolved to a concrete resource. + + - ``contextual``: resolved from the plan's project at invocation time + - ``static``: hardcoded at registration via ``static_resource`` + - ``parameter``: passed as a parameter at invocation time + """ + + CONTEXTUAL = "contextual" + STATIC = "static" + PARAMETER = "parameter" + + +class CheckpointScope(StrEnum): + """Granularity of checkpoint support for a tool.""" + + FILE = "file" + TRANSACTION = "transaction" + SNAPSHOT = "snapshot" + COMPOSITE = "composite" + + +# --------------------------------------------------------------------------- +# ResourceSlot +# --------------------------------------------------------------------------- + + +class ResourceSlot(BaseModel): + """Declaration of a resource binding for a tool. + + Each slot names a typed resource the tool operates on. The binding mode + determines how the concrete resource is resolved at invocation time. + """ + + name: str = Field( + ..., min_length=1, description="Slot name (valid Python identifier)" + ) + resource_type: str = Field( + ..., min_length=1, description="Resource type name (e.g. 'git-checkout')" + ) + access: ResourceAccessMode = Field(..., description="Access level on the resource") + description: str = Field("", description="Human-readable description of the slot") + binding: BindingMode = Field( + BindingMode.CONTEXTUAL, description="How the resource is resolved" + ) + static_resource: str | None = Field( + default=None, description="Resource name when binding=static" + ) + required: bool = Field(True, description="Whether the resource must be present") + + @field_validator("name") + @classmethod + def validate_name(cls: type[ResourceSlot], v: str) -> str: + """Validate slot name is a valid Python identifier.""" + if not v.isidentifier(): + raise ValueError( + "ResourceSlot name must be a valid Python identifier " + "(alphanumeric and underscores, not starting with a digit)" + ) + return v + + @model_validator(mode="after") + def _validate_static_binding(self) -> ResourceSlot: + """Enforce static_resource rules based on binding mode.""" + if self.binding == BindingMode.STATIC and not self.static_resource: + raise ValueError("static_resource is required when binding is 'static'") + if self.binding != BindingMode.STATIC and self.static_resource is not None: + raise ValueError( + "static_resource must not be set when binding is not 'static'" + ) + return self + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + + +# --------------------------------------------------------------------------- +# ToolCapability +# --------------------------------------------------------------------------- + + +class ToolCapability(BaseModel): + """Capability metadata describing what a tool can and cannot do. + + Used by the scheduler, sandbox, and safety layer to make decisions + about checkpoint management, human approval, and cost tracking. + """ + + read_only: bool = Field(default=False, description="Tool only reads, never writes") + writes: bool = Field(default=False, description="Tool can write to resources") + write_scope: str | None = Field( + default=None, description="Scope of writes (e.g. 'file', 'database')" + ) + checkpointable: bool = Field( + default=False, + description="Tool supports checkpoint/rollback", + ) + checkpoint_scope: CheckpointScope | None = Field( + default=None, description="Granularity of checkpoint support" + ) + side_effects: list[str] = Field( + default_factory=list, + description="Known side effects (e.g. 'network', 'filesystem')", + ) + idempotent: bool = Field( + default=False, + description="Safe to re-run without side effects", + ) + unsafe: bool = Field(default=False, description="Requires extra safety checks") + human_approval_required: bool = Field( + default=False, + description="Requires explicit human approval before execution", + ) + cost_profile: str | None = Field( + default=None, description="Named cost profile for budgeting" + ) + + @model_validator(mode="after") + def _enforce_read_only(self) -> ToolCapability: + """If read_only is True, writes and checkpointable must be False.""" + if self.read_only: + if self.writes: + raise ValueError("read_only tool cannot have writes=True") + if self.checkpointable: + raise ValueError("read_only tool cannot have checkpointable=True") + return self + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + + +# --------------------------------------------------------------------------- +# ToolLifecycle +# --------------------------------------------------------------------------- + + +class ToolLifecycle(BaseModel): + """Optional lifecycle hooks for a tool. + + Each hook is a dotted Python path or shell command string that the + runtime invokes at the corresponding lifecycle stage. + """ + + discover: str | None = Field(default=None, description="Hook for tool discovery") + activate: str | None = Field(default=None, description="Hook for tool activation") + deactivate: str | None = Field( + default=None, description="Hook for tool deactivation" + ) + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + + +# --------------------------------------------------------------------------- +# Tool +# --------------------------------------------------------------------------- + + +class Tool(BaseModel): + """Domain model for a Tool. + + A Tool is the atomic unit of execution in CleverAgents -- a namespaced, + independently registered callable operation. Created via + ``agents tool add --config ``. + + The **namespaced name** (``namespace/short_name``) is the unique identifier. + Tools and Validations share the same namespace; no collisions are allowed. + """ + + # Identity -- namespace/name format + name: str = Field( + ..., + min_length=1, + description="Namespaced tool name (namespace/short_name)", + ) + description: str = Field( + ..., + min_length=1, + description="Short description of what the tool does", + ) + + # Source + source: ToolSource = Field( + ..., description="Where the tool implementation comes from" + ) + tool_type: ToolType = Field( + ToolType.TOOL, description="Discriminator: tool or validation" + ) + + # Source-specific fields + code: str | None = Field( + default=None, description="Inline Python code (required for source=custom)" + ) + mcp_server: str | None = Field( + default=None, description="MCP server name (required for source=mcp)" + ) + mcp_tool_name: str | None = Field( + default=None, + description="Tool name on the MCP server (required for source=mcp)", + ) + agent_skill_path: str | None = Field( + default=None, + description=( + "Path to Agent Skills Standard folder (required for source=agent_skill)" + ), + ) + + # Schemas + input_schema: dict[str, Any] | None = Field( + default=None, description="JSON Schema for tool inputs" + ) + output_schema: dict[str, Any] | None = Field( + default=None, description="JSON Schema for tool outputs" + ) + + # Capability & resource bindings + capability: ToolCapability = Field( + default_factory=lambda: ToolCapability(), + description="Capability metadata for the tool", + ) + resource_slots: list[ResourceSlot] = Field( + default_factory=list, + description="Resource slot declarations for the tool", + ) + + # Lifecycle + lifecycle: ToolLifecycle | None = Field( + default=None, description="Optional lifecycle hooks" + ) + + # Execution + timeout: int = Field(300, ge=1, description="Execution timeout in seconds") + + # -- Name format validation ------------------------------------------------ + + @field_validator("name") + @classmethod + def validate_name_format(cls: type[Tool], v: str) -> str: + """Validate tool name matches namespace/name pattern.""" + if not _TOOL_NAME_PATTERN.match(v): + raise ValueError( + f"Tool name must match 'namespace/name' pattern " + f"(alphanumeric, hyphens, underscores): got '{v}'" + ) + return v + + # -- Source-conditional field validation ------------------------------------ + + @model_validator(mode="after") + def _validate_source_fields(self) -> Tool: + """Enforce source-conditional field requirements.""" + if self.source == ToolSource.CUSTOM and not self.code: + raise ValueError("code is required when source is 'custom'") + if self.source == ToolSource.MCP: + if not self.mcp_server: + raise ValueError("mcp_server is required when source is 'mcp'") + if not self.mcp_tool_name: + raise ValueError("mcp_tool_name is required when source is 'mcp'") + if self.source == ToolSource.AGENT_SKILL and not self.agent_skill_path: + raise ValueError( + "agent_skill_path is required when source is 'agent_skill'" + ) + return self + + # -- Properties ------------------------------------------------------------ + + @property + def namespace(self) -> str: + """Extract the namespace portion from the name.""" + return self.name.split("/", 1)[0] + + @property + def short_name(self) -> str: + """Extract the short name portion from the name.""" + return self.name.split("/", 1)[1] + + # -- Factories & serialization -------------------------------------------- + + @classmethod + def from_config(cls, config: dict[str, Any]) -> Tool: + """Create a Tool from a YAML configuration dict. + + The config follows the spec schema (see ``docs/specification.md`` + Tool Configuration Files section). + """ + if "name" not in config: + raise ValueError("Tool config must include 'name'") + if "description" not in config: + raise ValueError("Tool config must include 'description'") + if "source" not in config: + raise ValueError("Tool config must include 'source'") + + # Build capability from nested dict + cap_data = config.get("capability") + capability = ( + ToolCapability.model_validate(cap_data) if cap_data else ToolCapability() + ) + + # Build resource slots + slots_data: list[dict[str, Any]] = config.get("resource_slots", []) + resource_slots = [ResourceSlot.model_validate(s) for s in slots_data] + + # Build lifecycle + lc_data = config.get("lifecycle") + lifecycle = ToolLifecycle.model_validate(lc_data) if lc_data else None + + return cls( + name=config["name"], + description=config["description"], + source=ToolSource(config["source"]), + tool_type=ToolType(config.get("tool_type", "tool")), + code=config.get("code"), + mcp_server=config.get("mcp_server"), + mcp_tool_name=config.get("mcp_tool_name"), + agent_skill_path=config.get("agent_skill_path"), + input_schema=config.get("input_schema"), + output_schema=config.get("output_schema"), + capability=capability, + resource_slots=resource_slots, + lifecycle=lifecycle, + timeout=config.get("timeout", 300), + ) + + def as_cli_dict(self) -> OrderedDict[str, Any]: + """Return a stable-ordered dictionary for CLI rendering. + + Keys are ordered for consistent display across output formats. + """ + result: OrderedDict[str, Any] = OrderedDict() + result["name"] = self.name + result["description"] = self.description + result["source"] = self.source.value + result["tool_type"] = self.tool_type.value + result["namespace"] = self.namespace + result["short_name"] = self.short_name + + if self.code: + result["code"] = self.code + if self.mcp_server: + result["mcp_server"] = self.mcp_server + if self.mcp_tool_name: + result["mcp_tool_name"] = self.mcp_tool_name + if self.agent_skill_path: + result["agent_skill_path"] = self.agent_skill_path + if self.input_schema: + result["input_schema"] = self.input_schema + if self.output_schema: + result["output_schema"] = self.output_schema + + cap_dict: dict[str, Any] = { + "read_only": self.capability.read_only, + "writes": self.capability.writes, + "checkpointable": self.capability.checkpointable, + "idempotent": self.capability.idempotent, + "unsafe": self.capability.unsafe, + "human_approval_required": self.capability.human_approval_required, + } + if self.capability.side_effects: + cap_dict["side_effects"] = list(self.capability.side_effects) + if self.capability.cost_profile: + cap_dict["cost_profile"] = self.capability.cost_profile + result["capability"] = cap_dict + + if self.resource_slots: + result["resource_slots"] = [ + { + "name": slot.name, + "resource_type": slot.resource_type, + "access": slot.access.value, + "binding": slot.binding.value, + "required": slot.required, + } + for slot in self.resource_slots + ] + + result["timeout"] = self.timeout + + return result + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + use_enum_values=False, + ) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class Validation(Tool): + """Domain model for a Validation. + + A Validation extends Tool with pass/fail semantics: always read-only, + ``writes=False``, ``checkpointable=False``. May wrap an existing tool + via ``wraps`` + ``transform`` to reuse its implementation. + + Shares the Tool naming namespace -- no collisions allowed. + Managed via ``agents validation add/attach/detach``. + """ + + mode: ValidationMode = Field( + ValidationMode.REQUIRED, + description="Whether failure blocks execution", + ) + wraps: str | None = Field( + default=None, + description="Name of existing tool this validation wraps", + ) + transform: str | None = Field( + default=None, + description="Python function code to transform wrapped tool output", + ) + argument_mapping: dict[str, Any] | None = Field( + default=None, + description="Argument mapping for the wrapped tool (only valid with wraps)", + ) + + @model_validator(mode="after") + def _validate_wraps_fields(self) -> Validation: + """Enforce wraps-related constraints.""" + if self.wraps is not None: + # When wraps is set, source should be wrapped + if self.source != ToolSource.WRAPPED: + raise ValueError("source must be 'wrapped' when wraps is set") + if self.code is not None: + raise ValueError( + "code must not be set when wraps is set " + "(wraps and custom code are mutually exclusive)" + ) + if self.transform is None: + raise ValueError("transform is required when wraps is set") + else: + # No wraps: argument_mapping is forbidden + if self.argument_mapping is not None: + raise ValueError("argument_mapping is only valid when wraps is set") + return self + + @model_validator(mode="after") + def _enforce_validation_constraints(self) -> Validation: + """Force validation-specific constraints on capability and tool_type.""" + if self.tool_type != ToolType.VALIDATION: + object.__setattr__(self, "tool_type", ToolType.VALIDATION) + cap = self.capability + if cap.writes or cap.checkpointable or not cap.read_only: + forced = ToolCapability( + read_only=True, + writes=False, + write_scope=cap.write_scope, + checkpointable=False, + checkpoint_scope=cap.checkpoint_scope, + side_effects=cap.side_effects, + idempotent=cap.idempotent, + unsafe=cap.unsafe, + human_approval_required=cap.human_approval_required, + cost_profile=cap.cost_profile, + ) + object.__setattr__(self, "capability", forced) + return self + + @classmethod + def from_config(cls, config: dict[str, Any]) -> Validation: + """Create a Validation from a YAML configuration dict. + + Handles validation-specific fields (mode, wraps, transform, + argument_mapping) on top of the base Tool config. + """ + if "name" not in config: + raise ValueError("Validation config must include 'name'") + if "description" not in config: + raise ValueError("Validation config must include 'description'") + + # Determine source: if wraps is set, source is implicitly 'wrapped' + if config.get("wraps"): + source = ToolSource.WRAPPED + elif "source" not in config: + raise ValueError("Validation config must include 'source' (or 'wraps')") + else: + source = ToolSource(config["source"]) + + # Build capability from nested dict + cap_data = config.get("capability", {}) + capability = ToolCapability(**cap_data) if cap_data else ToolCapability() + + # Build resource slots + slots_data = config.get("resource_slots", []) + resource_slots = [ResourceSlot(**s) for s in slots_data] + + # Build lifecycle + lc_data = config.get("lifecycle") + lifecycle = ToolLifecycle(**lc_data) if lc_data else None + + return cls( + name=config["name"], + description=config["description"], + source=source, + tool_type=ToolType.VALIDATION, + code=config.get("code"), + mcp_server=config.get("mcp_server"), + mcp_tool_name=config.get("mcp_tool_name"), + agent_skill_path=config.get("agent_skill_path"), + input_schema=config.get("input_schema"), + output_schema=config.get("output_schema"), + capability=capability, + resource_slots=resource_slots, + lifecycle=lifecycle, + timeout=config.get("timeout", 300), + mode=ValidationMode(config.get("mode", "required")), + wraps=config.get("wraps"), + transform=config.get("transform"), + argument_mapping=config.get("argument_mapping"), + ) + + def as_cli_dict(self) -> OrderedDict[str, Any]: + """Return a stable-ordered dictionary for CLI rendering. + + Extends the base Tool CLI dict with validation-specific fields. + """ + result = super().as_cli_dict() + result["mode"] = self.mode.value + if self.wraps: + result["wraps"] = self.wraps + if self.transform: + result["transform"] = self.transform + if self.argument_mapping: + result["argument_mapping"] = dict(self.argument_mapping) + return result + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + use_enum_values=False, + )