feat(resource): add handler runtime for git-checkout and fs-directory

This commit is contained in:
khyari hamza
2026-02-23 14:06:05 +00:00
parent 832fe369fe
commit bfc6ab0fb1
12 changed files with 1899 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
"""ASV benchmarks for resource handler resolution overhead.
Measures the performance of:
- Handler resolver (import + cache lookup)
- GitCheckoutHandler.resolve() with mock sandbox
- FsDirectoryHandler.resolve() with mock sandbox
- ResourceHandlerService.resolve_binding() end-to-end
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock
try:
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
)
from cleveragents.domain.models.core.resource_slot import BindingResult
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
)
from cleveragents.domain.models.core.resource_type import (
SandboxStrategy as TypeSandboxStrategy,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.protocol import (
SandboxContext,
SandboxStatus,
)
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
from cleveragents.resource.handlers.resolver import (
clear_handler_cache,
resolve_handler,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
)
from cleveragents.domain.models.core.resource_slot import BindingResult
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
)
from cleveragents.domain.models.core.resource_type import (
SandboxStrategy as TypeSandboxStrategy,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.protocol import (
SandboxContext,
SandboxStatus,
)
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
from cleveragents.resource.handlers.resolver import (
clear_handler_cache,
resolve_handler,
)
def _make_resource(rtype: str, location: str) -> Resource:
return Resource(
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
resource_type_name=rtype,
classification=PhysVirt.PHYSICAL,
location=location,
capabilities=ResourceCapabilities(
readable=True, writable=True, sandboxable=True
),
)
def _make_mock_manager() -> SandboxManager:
mock_factory = MagicMock(spec=SandboxFactory)
mock_sandbox = MagicMock()
mock_sandbox.sandbox_id = "sb-bench-001"
type(mock_sandbox).status = PropertyMock(return_value=SandboxStatus.CREATED)
mock_sandbox.context = SandboxContext(
sandbox_id="sb-bench-001",
sandbox_path="/tmp/sandbox/sb-bench-001",
original_path="/tmp/original",
resource_id="res-bench",
plan_id="plan-bench",
created_at=datetime.now(),
)
mock_sandbox.create.return_value = mock_sandbox.context
mock_factory.create_sandbox.return_value = mock_sandbox
return SandboxManager(factory=mock_factory, cleanup_on_exit=False)
class HandlerResolverSuite:
"""Benchmark handler resolver import and cache performance."""
def time_resolve_git_handler_cold(self) -> None:
"""Benchmark cold resolve (no cache)."""
clear_handler_cache()
resolve_handler(
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
)
def time_resolve_git_handler_cached(self) -> None:
"""Benchmark cached resolve."""
resolve_handler(
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
)
def time_resolve_fs_handler_cold(self) -> None:
"""Benchmark cold resolve for fs-directory."""
clear_handler_cache()
resolve_handler(
"cleveragents.resource.handlers.fs_directory:FsDirectoryHandler"
)
class GitCheckoutHandlerSuite:
"""Benchmark GitCheckoutHandler.resolve() overhead."""
def setup(self) -> None:
self.resource = _make_resource("git-checkout", "/tmp/bench-repo")
self.manager = _make_mock_manager()
self.handler = GitCheckoutHandler()
def time_resolve_git_checkout(self) -> None:
"""Benchmark single git-checkout resolution."""
self.handler.resolve(
resource=self.resource,
plan_id="PLAN_BENCH",
slot_name="repo",
sandbox_manager=self.manager,
)
class FsDirectoryHandlerSuite:
"""Benchmark FsDirectoryHandler.resolve() overhead."""
def setup(self) -> None:
self.resource = _make_resource("fs-directory", "/tmp/bench-dir")
self.manager = _make_mock_manager()
self.handler = FsDirectoryHandler()
def time_resolve_fs_directory(self) -> None:
"""Benchmark single fs-directory resolution."""
self.handler.resolve(
resource=self.resource,
plan_id="PLAN_BENCH",
slot_name="workdir",
sandbox_manager=self.manager,
)
class ResourceHandlerServiceSuite:
"""Benchmark ResourceHandlerService end-to-end resolution."""
def setup(self) -> None:
from cleveragents.application.services.resource_handler_service import (
ResourceHandlerService,
)
self.resource = _make_resource("git-checkout", "/tmp/bench-repo")
self.type_spec = ResourceTypeSpec(
name="git-checkout",
description="Test git-checkout type",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=TypeSandboxStrategy.GIT_WORKTREE,
user_addable=True,
built_in=True,
handler="cleveragents.resource.handlers.git_checkout:GitCheckoutHandler",
)
self.manager = _make_mock_manager()
self.service = ResourceHandlerService(
sandbox_manager=self.manager,
resource_lookup=lambda _: self.resource,
type_lookup=lambda _: self.type_spec,
)
self.binding = BindingResult(
slot_name="repo",
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
resource_name="test-repo",
binding_mode="contextual",
)
def time_resolve_binding(self) -> None:
"""Benchmark single binding resolution."""
self.service.resolve_binding(
binding=self.binding,
plan_id="PLAN_BENCH",
)
def time_resolve_bindings_batch(self) -> None:
"""Benchmark batch binding resolution (5 bindings)."""
bindings = [
BindingResult(
slot_name=f"slot-{i}",
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
resource_name=f"res-{i}",
binding_mode="contextual",
)
for i in range(5)
]
self.service.resolve_bindings(
bindings=bindings,
plan_id="PLAN_BENCH",
)
+172
View File
@@ -0,0 +1,172 @@
# Resource Handlers
Resource handlers bridge resource types to sandbox provisioning. When a plan
executes, each tool's resource slot is resolved to a physical sandbox path
through the handler pipeline.
## Architecture
```
BindingResolutionService ResourceHandlerService
resolve(tool, project) resolve_binding(binding, plan_id)
│ │
▼ ▼
BindingResult(resource_id) ┌── Resource (location)
│ ResourceTypeSpec (handler, strategy)
│ │
│ ▼
│ resolve_handler("module:Class")
│ │
│ ▼
│ handler.resolve(resource, sandbox_manager)
│ │
│ ▼
└── BoundResource(sandbox_path="/tmp/sandbox/...")
```
## Handler Protocol
All handlers implement the `ResourceHandler` protocol:
```python
class ResourceHandler(Protocol):
def resolve(
self,
*,
resource: Resource,
plan_id: str,
slot_name: str,
sandbox_manager: SandboxManager,
access: str = "read_only",
) -> BoundResource: ...
```
## Built-in Handlers
### GitCheckoutHandler
Resolves `git-checkout` resources using the `git_worktree` sandbox strategy.
| Property | Value |
|----------|-------|
| Module | `cleveragents.resource.handlers.git_checkout` |
| Class | `GitCheckoutHandler` |
| Default strategy | `git_worktree` |
| Fallback strategy | `copy_on_write` |
| Required fields | `resource.location` (path to git repo root) |
### FsDirectoryHandler
Resolves `fs-directory` resources using the `copy_on_write` sandbox strategy.
| Property | Value |
|----------|-------|
| Module | `cleveragents.resource.handlers.fs_directory` |
| Class | `FsDirectoryHandler` |
| Default strategy | `copy_on_write` |
| Required fields | `resource.location` (path to directory) |
## Handler Resolution
Handler strings use the `module.path:ClassName` format and are stored on
`ResourceTypeSpec.handler`. Resolution is dynamic via `importlib`:
```python
from cleveragents.resource.handlers import resolve_handler
handler = resolve_handler(
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
)
```
Resolved handlers are cached for the process lifetime. Call
`clear_handler_cache()` to reset.
### Fallback Behavior
If no handler string is set (or resolution fails), the
`ResourceHandlerService` uses a default handler that delegates directly
to `SandboxManager` using the type's `sandbox_strategy`.
## Strategy Precedence
The sandbox strategy is determined in this order:
1. **Resource-level override** (`resource.sandbox_strategy`) — per-resource
2. **Type default** (`ResourceTypeSpec.sandbox_strategy`) — per-type
3. **Fallback** (`none`) — no sandboxing
## ResourceHandlerService
The orchestration service that chains the resolution pipeline:
```python
from cleveragents.application.services.resource_handler_service import (
ResourceHandlerService,
)
service = ResourceHandlerService(
sandbox_manager=sandbox_manager,
resource_lookup=registry_service.show_resource,
type_lookup=registry_service.show_type,
)
# Resolve a single binding
bound = service.resolve_binding(binding, plan_id="01ARZ3...")
# Resolve all bindings for a tool
bindings_map = service.resolve_bindings(bindings, plan_id="01ARZ3...")
# -> {"repo": BoundResource(sandbox_path="/tmp/sandbox/...")}
```
## Sandbox Outputs
After resolution, each `BoundResource` carries:
| Field | Description |
|-------|-------------|
| `slot_name` | Tool slot this binding fills |
| `resource_id` | ULID of the resolved resource |
| `resource_type` | Type name (e.g. `git-checkout`) |
| `sandbox_path` | Root path of the provisioned sandbox |
| `access` | `read_only` or `read_write` |
The `sandbox_path` points to an isolated directory managed by
`SandboxManager`. Changes are committed or rolled back via
`SandboxManager.commit_all()` / `rollback_all()`.
## Writing Custom Handlers
To add a handler for a new resource type:
1. Create a module under `cleveragents/resource/handlers/`.
2. Implement a class satisfying `ResourceHandler`.
3. Register it on the `ResourceTypeSpec` via the `handler` field:
`"cleveragents.resource.handlers.my_handler:MyHandler"`.
```python
class MyHandler:
def resolve(
self,
*,
resource: Resource,
plan_id: str,
slot_name: str,
sandbox_manager: SandboxManager,
access: str = "read_only",
) -> BoundResource:
# Custom validation / setup
sandbox = sandbox_manager.get_or_create_sandbox(
plan_id=plan_id,
resource_id=resource.resource_id,
original_path=resource.location,
sandbox_strategy="copy_on_write",
)
return BoundResource(
slot_name=slot_name,
resource_id=resource.resource_id,
resource_type=resource.resource_type_name,
sandbox_path=sandbox.context.sandbox_path if sandbox.context else "",
access=access,
)
```
+124
View File
@@ -0,0 +1,124 @@
Feature: Resource handler runtime
As a developer
I want resource handlers that resolve resources into sandbox-backed paths
So that tools receive isolated working directories during plan execution
# === Handler Protocol ===
Scenario: GitCheckoutHandler satisfies ResourceHandler protocol
Then GitCheckoutHandler should satisfy the ResourceHandler protocol
Scenario: FsDirectoryHandler satisfies ResourceHandler protocol
Then FsDirectoryHandler should satisfy the ResourceHandler protocol
# === GitCheckoutHandler ===
Scenario: GitCheckoutHandler resolves a git-checkout resource
Given a git-checkout resource with location "/tmp/test-repo"
And a sandbox manager with a mock factory
When I resolve the resource with GitCheckoutHandler for plan "PLAN001"
Then the bound resource should have slot name "repo"
And the bound resource should have resource type "git-checkout"
And the bound resource sandbox path should not be empty
Scenario: GitCheckoutHandler uses resource-level strategy override
Given a git-checkout resource at "/tmp/test-repo" using strategy "copy_on_write"
And a sandbox manager with a mock factory
When I resolve the resource with GitCheckoutHandler for plan "PLAN002"
Then the sandbox was created with strategy "copy_on_write"
Scenario: GitCheckoutHandler rejects resource without location
Given a git-checkout resource without location
And a sandbox manager with a mock factory
When I try to resolve the resource with GitCheckoutHandler
Then a handler ValueError should be raised
And the handler error should mention "no location"
# === FsDirectoryHandler ===
Scenario: FsDirectoryHandler resolves an fs-directory resource
Given an fs-directory resource with location "/tmp/test-dir"
And a sandbox manager with a mock factory
When I resolve the resource with FsDirectoryHandler for plan "PLAN003"
Then the bound resource should have slot name "workdir"
And the bound resource should have resource type "fs-directory"
And the bound resource sandbox path should not be empty
Scenario: FsDirectoryHandler uses copy_on_write by default
Given an fs-directory resource with location "/tmp/test-dir"
And a sandbox manager with a mock factory
When I resolve the resource with FsDirectoryHandler for plan "PLAN004"
Then the sandbox was created with strategy "copy_on_write"
Scenario: FsDirectoryHandler rejects resource without location
Given an fs-directory resource without location
And a sandbox manager with a mock factory
When I try to resolve the resource with FsDirectoryHandler
Then a handler ValueError should be raised
# === Handler Resolver ===
Scenario: Resolve handler from valid module:class string
When I resolve handler "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
Then the resolved handler should be a GitCheckoutHandler instance
Scenario: Resolve handler from fs-directory reference
When I resolve handler "cleveragents.resource.handlers.fs_directory:FsDirectoryHandler"
Then the resolved handler should be a FsDirectoryHandler instance
Scenario: Resolve handler caches instances
When I resolve handler "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
And I resolve the same handler reference again
Then both resolved handlers should be the same object
Scenario: Resolve handler rejects empty string
When I try to resolve an empty handler reference
Then a HandlerResolutionError should be raised
And the resolution error should mention "empty"
Scenario: Resolve handler rejects missing colon separator
When I try to resolve handler "cleveragents.resource.handlers.git_checkout"
Then a HandlerResolutionError should be raised
And the resolution error should mention "module.path:ClassName"
Scenario: Resolve handler rejects nonexistent module
When I try to resolve handler "nonexistent.module:SomeClass"
Then a HandlerResolutionError should be raised
And the resolution error should mention "Cannot import"
Scenario: Resolve handler rejects nonexistent class in valid module
When I try to resolve handler "cleveragents.resource.handlers.git_checkout:NonExistentClass"
Then a HandlerResolutionError should be raised
And the resolution error should mention "not found"
# === ResourceHandlerService ===
Scenario: ResourceHandlerService resolves a binding to BoundResource
Given a resource handler service with mock lookups
And a binding result for slot "repo" with resource id "RES001"
When I resolve the binding via resource handler service for plan "PLAN010"
Then the handler service should return a BoundResource
And the handler service BoundResource slot should be "repo"
And the handler service BoundResource sandbox path should not be empty
Scenario: ResourceHandlerService skips deferred bindings
Given a resource handler service with mock lookups
And a deferred binding result for slot "extra"
And a binding result for slot "repo" with resource id "RES001"
When I resolve all bindings via resource handler service for plan "PLAN011"
Then the handler service should return 1 bound resource
And the handler service should have resolved slot "repo"
Scenario: ResourceHandlerService rejects deferred single binding
Given a resource handler service with mock lookups
And a deferred binding result for slot "extra"
When I try to resolve the single deferred binding via resource handler service
Then a handler ValueError should be raised
And the handler error should mention "deferred"
Scenario: ResourceHandlerService uses fallback handler when type has no handler string
Given a resource handler service with mock lookups and no handler string
And a binding result for slot "data" with resource id "RES002"
When I resolve the binding via resource handler service for plan "PLAN012"
Then the handler service should return a BoundResource
And the handler service BoundResource sandbox path should not be empty
+460
View File
@@ -0,0 +1,460 @@
"""Step definitions for resource_handlers.feature.
Tests the resource handler protocol, GitCheckoutHandler, FsDirectoryHandler,
handler resolver, and ResourceHandlerService orchestration bridge.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, PropertyMock
from behave import given, then, when # type: ignore[import-untyped]
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
SandboxStrategy,
)
from cleveragents.domain.models.core.resource_slot import BindingResult
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
)
from cleveragents.domain.models.core.resource_type import (
SandboxStrategy as TypeSandboxStrategy,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.protocol import (
SandboxContext,
SandboxStatus,
)
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
from cleveragents.resource.handlers.protocol import ResourceHandler
from cleveragents.resource.handlers.resolver import (
HandlerResolutionError,
clear_handler_cache,
resolve_handler,
)
from cleveragents.tool.context import BoundResource
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_resource(
resource_type_name: str,
location: str | None = None,
sandbox_strategy: SandboxStrategy | None = None,
resource_id: str = "01KJ5C5TPMP8GGX3QC83E2MAQS",
) -> Resource:
"""Create a test Resource domain object."""
return Resource(
resource_id=resource_id,
resource_type_name=resource_type_name,
classification=PhysVirt.PHYSICAL,
location=location,
sandbox_strategy=sandbox_strategy,
capabilities=ResourceCapabilities(
readable=True, writable=True, sandboxable=True
),
)
def _make_mock_sandbox_manager() -> tuple[SandboxManager, MagicMock]:
"""Create a SandboxManager with a mock factory that tracks calls."""
mock_factory = MagicMock(spec=SandboxFactory)
mock_sandbox = MagicMock()
mock_sandbox.sandbox_id = "sb-test-001"
type(mock_sandbox).status = PropertyMock(return_value=SandboxStatus.CREATED)
mock_sandbox.context = SandboxContext(
sandbox_id="sb-test-001",
sandbox_path="/tmp/sandbox/sb-test-001",
original_path="/tmp/original",
resource_id="res-test",
plan_id="plan-test",
created_at=datetime.now(),
)
mock_sandbox.create.return_value = mock_sandbox.context
mock_factory.create_sandbox.return_value = mock_sandbox
manager = SandboxManager(factory=mock_factory, cleanup_on_exit=False)
return manager, mock_factory
def _make_type_spec(
name: str = "git-checkout",
sandbox_strategy: str = "git_worktree",
handler: str
| None = "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler",
) -> ResourceTypeSpec:
"""Create a minimal ResourceTypeSpec for testing."""
return ResourceTypeSpec(
name=name,
description=f"Test {name} type",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=TypeSandboxStrategy(sandbox_strategy),
user_addable=True,
built_in=True,
handler=handler,
)
# ---------------------------------------------------------------------------
# Protocol conformance
# ---------------------------------------------------------------------------
@then("GitCheckoutHandler should satisfy the ResourceHandler protocol")
def step_git_handler_protocol(context: Any) -> None:
handler = GitCheckoutHandler()
assert isinstance(handler, ResourceHandler), (
"GitCheckoutHandler does not satisfy ResourceHandler protocol"
)
@then("FsDirectoryHandler should satisfy the ResourceHandler protocol")
def step_fs_handler_protocol(context: Any) -> None:
handler = FsDirectoryHandler()
assert isinstance(handler, ResourceHandler), (
"FsDirectoryHandler does not satisfy ResourceHandler protocol"
)
# ---------------------------------------------------------------------------
# GitCheckoutHandler
# ---------------------------------------------------------------------------
@given('a git-checkout resource with location "{location}"')
def step_git_resource(context: Any, location: str) -> None:
context.handler_resource = _make_resource("git-checkout", location=location)
@given('a git-checkout resource at "{location}" using strategy "{strategy}"')
def step_git_resource_with_strategy(context: Any, location: str, strategy: str) -> None:
context.handler_resource = _make_resource(
"git-checkout",
location=location,
sandbox_strategy=SandboxStrategy(strategy),
)
@given("a git-checkout resource without location")
def step_git_resource_no_location(context: Any) -> None:
context.handler_resource = _make_resource("git-checkout", location=None)
@given("a sandbox manager with a mock factory")
def step_mock_sandbox_manager(context: Any) -> None:
context.handler_sandbox_manager, context.handler_mock_factory = (
_make_mock_sandbox_manager()
)
@when('I resolve the resource with GitCheckoutHandler for plan "{plan_id}"')
def step_resolve_git(context: Any, plan_id: str) -> None:
handler = GitCheckoutHandler()
context.handler_bound = handler.resolve(
resource=context.handler_resource,
plan_id=plan_id,
slot_name="repo",
sandbox_manager=context.handler_sandbox_manager,
)
@when("I try to resolve the resource with GitCheckoutHandler")
def step_try_resolve_git(context: Any) -> None:
handler = GitCheckoutHandler()
try:
handler.resolve(
resource=context.handler_resource,
plan_id="PLAN_ERR",
slot_name="repo",
sandbox_manager=context.handler_sandbox_manager,
)
context.handler_error = None
except ValueError as exc:
context.handler_error = exc
# ---------------------------------------------------------------------------
# FsDirectoryHandler
# ---------------------------------------------------------------------------
@given('an fs-directory resource with location "{location}"')
def step_fs_resource(context: Any, location: str) -> None:
context.handler_resource = _make_resource("fs-directory", location=location)
@given("an fs-directory resource without location")
def step_fs_resource_no_location(context: Any) -> None:
context.handler_resource = _make_resource("fs-directory", location=None)
@when('I resolve the resource with FsDirectoryHandler for plan "{plan_id}"')
def step_resolve_fs(context: Any, plan_id: str) -> None:
handler = FsDirectoryHandler()
context.handler_bound = handler.resolve(
resource=context.handler_resource,
plan_id=plan_id,
slot_name="workdir",
sandbox_manager=context.handler_sandbox_manager,
)
@when("I try to resolve the resource with FsDirectoryHandler")
def step_try_resolve_fs(context: Any) -> None:
handler = FsDirectoryHandler()
try:
handler.resolve(
resource=context.handler_resource,
plan_id="PLAN_ERR",
slot_name="workdir",
sandbox_manager=context.handler_sandbox_manager,
)
context.handler_error = None
except ValueError as exc:
context.handler_error = exc
# ---------------------------------------------------------------------------
# Shared handler assertions
# ---------------------------------------------------------------------------
@then('the bound resource should have slot name "{name}"')
def step_bound_slot(context: Any, name: str) -> None:
assert context.handler_bound.slot_name == name
@then('the bound resource should have resource type "{rtype}"')
def step_bound_type(context: Any, rtype: str) -> None:
assert context.handler_bound.resource_type == rtype
@then("the bound resource sandbox path should not be empty")
def step_bound_sandbox_not_empty(context: Any) -> None:
assert context.handler_bound.sandbox_path, (
f"sandbox_path is empty: {context.handler_bound.sandbox_path!r}"
)
@then('the sandbox was created with strategy "{strategy}"')
def step_sandbox_strategy(context: Any, strategy: str) -> None:
call_args = context.handler_mock_factory.create_sandbox.call_args
assert call_args is not None, "create_sandbox was not called"
assert call_args.kwargs.get("sandbox_strategy") == strategy or (
len(call_args.args) >= 3 and call_args.args[2] == strategy
), f"Expected strategy {strategy}, got {call_args}"
@then("a handler ValueError should be raised")
def step_handler_value_error(context: Any) -> None:
assert context.handler_error is not None
assert isinstance(context.handler_error, ValueError)
@then('the handler error should mention "{text}"')
def step_handler_error_text(context: Any, text: str) -> None:
assert text.lower() in str(context.handler_error).lower(), (
f"Expected '{text}' in error: {context.handler_error}"
)
# ---------------------------------------------------------------------------
# Handler Resolver
# ---------------------------------------------------------------------------
@when('I resolve handler "{ref}"')
def step_resolve_handler(context: Any, ref: str) -> None:
clear_handler_cache()
context.handler_resolved = resolve_handler(ref)
context.handler_ref_used = ref
@when("I resolve the same handler reference again")
def step_resolve_handler_again(context: Any) -> None:
context.handler_resolved_second = resolve_handler(context.handler_ref_used)
@then("the resolved handler should be a GitCheckoutHandler instance")
def step_resolved_is_git(context: Any) -> None:
assert isinstance(context.handler_resolved, GitCheckoutHandler)
@then("the resolved handler should be a FsDirectoryHandler instance")
def step_resolved_is_fs(context: Any) -> None:
assert isinstance(context.handler_resolved, FsDirectoryHandler)
@then("both resolved handlers should be the same object")
def step_same_object(context: Any) -> None:
assert context.handler_resolved is context.handler_resolved_second
@when("I try to resolve an empty handler reference")
def step_try_resolve_empty_handler(context: Any) -> None:
clear_handler_cache()
try:
resolve_handler("")
context.handler_resolution_error = None
except HandlerResolutionError as exc:
context.handler_resolution_error = exc
@when('I try to resolve handler "{ref}"')
def step_try_resolve_handler(context: Any, ref: str) -> None:
clear_handler_cache()
try:
resolve_handler(ref)
context.handler_resolution_error = None
except HandlerResolutionError as exc:
context.handler_resolution_error = exc
@then("a HandlerResolutionError should be raised")
def step_resolution_error(context: Any) -> None:
assert context.handler_resolution_error is not None
assert isinstance(context.handler_resolution_error, HandlerResolutionError)
@then('the resolution error should mention "{text}"')
def step_resolution_error_text(context: Any, text: str) -> None:
assert text.lower() in str(context.handler_resolution_error).lower(), (
f"Expected '{text}' in error: {context.handler_resolution_error}"
)
# ---------------------------------------------------------------------------
# ResourceHandlerService
# ---------------------------------------------------------------------------
@given("a resource handler service with mock lookups")
def step_handler_service(context: Any) -> None:
from cleveragents.application.services.resource_handler_service import (
ResourceHandlerService,
)
manager, _mock_factory = _make_mock_sandbox_manager()
resource = _make_resource("git-checkout", location="/tmp/test-repo")
type_spec = _make_type_spec()
context.handler_svc = ResourceHandlerService(
sandbox_manager=manager,
resource_lookup=lambda _name_or_id: resource,
type_lookup=lambda _name: type_spec,
)
context.handler_svc_manager = manager
@given("a resource handler service with mock lookups and no handler string")
def step_handler_service_no_handler(context: Any) -> None:
from cleveragents.application.services.resource_handler_service import (
ResourceHandlerService,
)
manager, _mock_factory = _make_mock_sandbox_manager()
resource = _make_resource("fs-directory", location="/tmp/test-dir")
type_spec = _make_type_spec(
name="fs-directory",
sandbox_strategy="copy_on_write",
handler=None,
)
context.handler_svc = ResourceHandlerService(
sandbox_manager=manager,
resource_lookup=lambda _name_or_id: resource,
type_lookup=lambda _name: type_spec,
)
@given('a binding result for slot "{slot}" with resource id "{res_id}"')
def step_binding_result(context: Any, slot: str, res_id: str) -> None:
if not hasattr(context, "handler_bindings"):
context.handler_bindings = []
context.handler_binding = BindingResult(
slot_name=slot,
resource_id=res_id,
resource_name=f"test-{res_id}",
binding_mode="contextual",
)
context.handler_bindings.append(context.handler_binding)
@given('a deferred binding result for slot "{slot}"')
def step_deferred_binding(context: Any, slot: str) -> None:
if not hasattr(context, "handler_bindings"):
context.handler_bindings = []
context.handler_deferred_binding = BindingResult(
slot_name=slot,
resource_id=None,
binding_mode="parameter",
deferred=True,
)
context.handler_bindings.append(context.handler_deferred_binding)
@when('I resolve the binding via resource handler service for plan "{plan_id}"')
def step_svc_resolve_binding(context: Any, plan_id: str) -> None:
context.handler_svc_bound = context.handler_svc.resolve_binding(
binding=context.handler_binding,
plan_id=plan_id,
)
@when('I resolve all bindings via resource handler service for plan "{plan_id}"')
def step_svc_resolve_all(context: Any, plan_id: str) -> None:
context.handler_svc_all = context.handler_svc.resolve_bindings(
bindings=context.handler_bindings,
plan_id=plan_id,
)
@when("I try to resolve the single deferred binding via resource handler service")
def step_svc_try_resolve_deferred(context: Any) -> None:
try:
context.handler_svc.resolve_binding(
binding=context.handler_deferred_binding,
plan_id="PLAN_ERR",
)
context.handler_error = None
except ValueError as exc:
context.handler_error = exc
@then("the handler service should return a BoundResource")
def step_svc_is_bound(context: Any) -> None:
assert isinstance(context.handler_svc_bound, BoundResource)
@then('the handler service BoundResource slot should be "{slot}"')
def step_svc_bound_slot(context: Any, slot: str) -> None:
assert context.handler_svc_bound.slot_name == slot
@then("the handler service BoundResource sandbox path should not be empty")
def step_svc_bound_path(context: Any) -> None:
assert context.handler_svc_bound.sandbox_path, (
f"sandbox_path is empty: {context.handler_svc_bound.sandbox_path!r}"
)
@then("the handler service should return {count:d} bound resource")
def step_svc_count(context: Any, count: int) -> None:
assert len(context.handler_svc_all) == count
@then('the handler service should have resolved slot "{slot}"')
def step_svc_has_slot(context: Any, slot: str) -> None:
assert slot in context.handler_svc_all
+147
View File
@@ -0,0 +1,147 @@
"""Helper utilities for resource handler Robot smoke tests.
Each command prints a single ``<command>-ok`` token on success so the
calling Robot test can assert on ``stdout``.
"""
from __future__ import annotations
import sys
from datetime import datetime
from unittest.mock import MagicMock, PropertyMock
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.protocol import (
SandboxContext,
SandboxStatus,
)
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
from cleveragents.resource.handlers.protocol import ResourceHandler
from cleveragents.resource.handlers.resolver import (
HandlerResolutionError,
clear_handler_cache,
resolve_handler,
)
from cleveragents.tool.context import BoundResource
def _make_resource(rtype: str, location: str) -> Resource:
"""Create a test resource with a valid ULID."""
return Resource(
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
resource_type_name=rtype,
classification=PhysVirt.PHYSICAL,
location=location,
capabilities=ResourceCapabilities(
readable=True, writable=True, sandboxable=True
),
)
def _make_mock_manager() -> SandboxManager:
"""Create a SandboxManager with a mock factory."""
mock_factory = MagicMock(spec=SandboxFactory)
mock_sandbox = MagicMock()
mock_sandbox.sandbox_id = "sb-robot-001"
type(mock_sandbox).status = PropertyMock(return_value=SandboxStatus.CREATED)
mock_sandbox.context = SandboxContext(
sandbox_id="sb-robot-001",
sandbox_path="/tmp/sandbox/sb-robot-001",
original_path="/tmp/original",
resource_id="res-robot",
plan_id="plan-robot",
created_at=datetime.now(),
)
mock_sandbox.create.return_value = mock_sandbox.context
mock_factory.create_sandbox.return_value = mock_sandbox
return SandboxManager(factory=mock_factory, cleanup_on_exit=False)
def _protocol_check() -> None:
"""Verify both handlers satisfy the ResourceHandler protocol."""
git = GitCheckoutHandler()
fs = FsDirectoryHandler()
assert isinstance(git, ResourceHandler), "GitCheckoutHandler not a ResourceHandler"
assert isinstance(fs, ResourceHandler), "FsDirectoryHandler not a ResourceHandler"
print("protocol-check-ok")
def _git_resolve() -> None:
"""Verify GitCheckoutHandler resolves a resource."""
resource = _make_resource("git-checkout", "/tmp/test-repo")
manager = _make_mock_manager()
handler = GitCheckoutHandler()
bound = handler.resolve(
resource=resource,
plan_id="PLAN_ROBOT",
slot_name="repo",
sandbox_manager=manager,
)
assert isinstance(bound, BoundResource)
assert bound.sandbox_path, "sandbox_path empty"
assert bound.resource_type == "git-checkout"
print("git-resolve-ok")
def _fs_resolve() -> None:
"""Verify FsDirectoryHandler resolves a resource."""
resource = _make_resource("fs-directory", "/tmp/test-dir")
manager = _make_mock_manager()
handler = FsDirectoryHandler()
bound = handler.resolve(
resource=resource,
plan_id="PLAN_ROBOT",
slot_name="workdir",
sandbox_manager=manager,
)
assert isinstance(bound, BoundResource)
assert bound.sandbox_path, "sandbox_path empty"
assert bound.resource_type == "fs-directory"
print("fs-resolve-ok")
def _resolver_import() -> None:
"""Verify handler resolver loads handlers from module:class strings."""
clear_handler_cache()
git = resolve_handler(
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
)
assert isinstance(git, GitCheckoutHandler)
fs = resolve_handler(
"cleveragents.resource.handlers.fs_directory:FsDirectoryHandler"
)
assert isinstance(fs, FsDirectoryHandler)
print("resolver-import-ok")
def _resolver_error() -> None:
"""Verify handler resolver raises on bad references."""
clear_handler_cache()
try:
resolve_handler("nonexistent.module:FakeClass")
print("resolver-error-FAIL")
except HandlerResolutionError:
print("resolver-error-ok")
_COMMANDS = {
"protocol-check": _protocol_check,
"git-resolve": _git_resolve,
"fs-resolve": _fs_resolve,
"resolver-import": _resolver_import,
"resolver-error": _resolver_error,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(1)
_COMMANDS[sys.argv[1]]()
+49
View File
@@ -0,0 +1,49 @@
*** Settings ***
Documentation Smoke tests for resource handler runtime
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_resource_handlers.py
*** Test Cases ***
Handler Protocol Conformance
[Documentation] Verify GitCheckoutHandler and FsDirectoryHandler satisfy ResourceHandler
${result}= Run Process ${PYTHON} ${HELPER} protocol-check cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} protocol-check-ok
GitCheckout Handler Resolution
[Documentation] Verify GitCheckoutHandler resolves a git-checkout resource to a sandbox
${result}= Run Process ${PYTHON} ${HELPER} git-resolve cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} git-resolve-ok
FsDirectory Handler Resolution
[Documentation] Verify FsDirectoryHandler resolves an fs-directory resource to a sandbox
${result}= Run Process ${PYTHON} ${HELPER} fs-resolve cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} fs-resolve-ok
Handler Resolver Dynamic Import
[Documentation] Verify resolve_handler loads handlers from module:class strings
${result}= Run Process ${PYTHON} ${HELPER} resolver-import cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} resolver-import-ok
Handler Resolver Error On Bad Reference
[Documentation] Verify resolve_handler raises HandlerResolutionError on bad references
${result}= Run Process ${PYTHON} ${HELPER} resolver-error cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} resolver-error-ok
@@ -0,0 +1,290 @@
"""Resource handler orchestration service.
The :class:`ResourceHandlerService` is the **orchestration bridge** that
connects the existing infrastructure pieces into a complete resource
resolution pipeline:
1. :class:`BindingResolutionService` resolves tool slots to
:class:`BindingResult` (resource_id).
2. :class:`ResourceRegistryService` looks up the :class:`Resource` to
get ``location`` and ``resource_type_name``.
3. :class:`ResourceRegistryService` looks up the :class:`ResourceTypeSpec`
to get ``sandbox_strategy`` and ``handler`` reference.
4. :func:`resolve_handler` dynamically loads the handler class.
5. The handler calls :meth:`SandboxManager.get_or_create_sandbox` and
returns a :class:`BoundResource` with ``sandbox_path`` populated.
The service also provides a convenience method that resolves ALL tool
bindings in one call, producing a ``dict[str, BoundResource]`` ready
for :class:`PlanExecutionContext`.
Based on:
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
"""
from __future__ import annotations
import logging
from typing import Any, cast
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.resource import Resource
from cleveragents.domain.models.core.resource_slot import BindingResult
from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
from cleveragents.infrastructure.sandbox.factory import SandboxStrategyStr
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.resource.handlers.protocol import ResourceHandler
from cleveragents.resource.handlers.resolver import (
HandlerResolutionError,
resolve_handler,
)
from cleveragents.tool.context import BoundResource
logger = logging.getLogger(__name__)
class ResourceHandlerService:
"""Orchestrates resource resolution from binding results to bound resources.
Bridges the gap between :class:`BindingResolutionService` output
(``BindingResult`` with ``resource_id``) and the execution context's
``BoundResource`` (with ``sandbox_path``).
Parameters
----------
sandbox_manager:
The sandbox lifecycle manager for creating/reusing sandboxes.
resource_lookup:
Callable that takes a resource name-or-id and returns a
:class:`Resource`. Typically
``ResourceRegistryService.show_resource``.
type_lookup:
Callable that takes a type name and returns a
:class:`ResourceTypeSpec`. Typically
``ResourceRegistryService.show_type``.
"""
def __init__(
self,
*,
sandbox_manager: SandboxManager,
resource_lookup: Any,
type_lookup: Any,
) -> None:
self._sandbox_manager = sandbox_manager
self._resource_lookup = resource_lookup
self._type_lookup = type_lookup
self._logger = logger
def resolve_binding(
self,
binding: BindingResult,
plan_id: str,
access: str = "read_only",
) -> BoundResource:
"""Resolve a single binding result into a BoundResource.
Steps:
1. Look up the Resource by ``binding.resource_id``.
2. Look up the ResourceTypeSpec by ``resource.resource_type_name``.
3. Resolve or fallback the handler.
4. Call ``handler.resolve()`` to provision a sandbox and
produce a BoundResource.
Args:
binding: A resolved binding with a ``resource_id``.
plan_id: The plan requesting the resolution.
access: Access mode for the bound resource.
Returns:
A :class:`BoundResource` with ``sandbox_path`` populated.
Raises:
ValueError: If the binding has no resource_id (deferred).
NotFoundError: If the resource or type is not found.
HandlerResolutionError: If the handler cannot be loaded.
"""
if binding.deferred or not binding.resource_id:
raise ValueError(
f"Cannot resolve deferred binding for slot '{binding.slot_name}'"
)
# Step 1: Look up resource
resource: Resource = self._resource_lookup(binding.resource_id)
# Step 2: Look up type spec
type_spec: ResourceTypeSpec = self._type_lookup(resource.resource_type_name)
# Step 3: Resolve handler
handler = self._resolve_handler_for_type(type_spec, resource)
# Step 4: Delegate to handler
return handler.resolve(
resource=resource,
plan_id=plan_id,
slot_name=binding.slot_name,
sandbox_manager=self._sandbox_manager,
access=access,
)
def resolve_bindings(
self,
bindings: list[BindingResult],
plan_id: str,
access: str = "read_only",
) -> dict[str, BoundResource]:
"""Resolve all non-deferred bindings into BoundResources.
Deferred bindings (parameter mode) are silently skipped.
Args:
bindings: List of binding results from
:class:`BindingResolutionService`.
plan_id: The plan requesting the resolution.
access: Default access mode for all bindings.
Returns:
A dict mapping slot names to :class:`BoundResource` objects.
"""
result: dict[str, BoundResource] = {}
for binding in bindings:
if binding.deferred or not binding.resource_id:
self._logger.debug(
"Skipping deferred binding for slot '%s'",
binding.slot_name,
)
continue
try:
bound = self.resolve_binding(
binding=binding,
plan_id=plan_id,
access=access,
)
result[binding.slot_name] = bound
self._logger.info(
"Resolved binding slot='%s' -> resource='%s' sandbox='%s'",
binding.slot_name,
binding.resource_id,
bound.sandbox_path,
)
except (NotFoundError, HandlerResolutionError, ValueError) as exc:
self._logger.warning(
"Failed to resolve binding for slot '%s': %s",
binding.slot_name,
exc,
)
raise
return result
def resolve_resource(
self,
resource: Resource,
plan_id: str,
slot_name: str,
access: str = "read_only",
) -> BoundResource:
"""Resolve a resource directly (without going through BindingResult).
Convenience method when you already have the Resource object.
Args:
resource: The resource to resolve.
plan_id: The plan requesting the resolution.
slot_name: Name for the binding slot.
access: Access mode.
Returns:
A :class:`BoundResource` with ``sandbox_path`` populated.
"""
type_spec = self._type_lookup(resource.resource_type_name)
handler = self._resolve_handler_for_type(type_spec, resource)
return handler.resolve(
resource=resource,
plan_id=plan_id,
slot_name=slot_name,
sandbox_manager=self._sandbox_manager,
access=access,
)
def _resolve_handler_for_type(
self,
type_spec: ResourceTypeSpec,
resource: Resource,
) -> ResourceHandler:
"""Resolve the handler for a resource type spec.
If the type spec has a handler reference string, resolves it
dynamically. Otherwise falls back to a default handler based
on the sandbox strategy.
Args:
type_spec: The resource type specification.
resource: The resource being resolved.
Returns:
A handler instance satisfying :class:`ResourceHandler`.
"""
if type_spec.handler:
try:
return resolve_handler(type_spec.handler)
except HandlerResolutionError:
self._logger.warning(
"Failed to resolve handler '%s' for type '%s', "
"falling back to default handler",
type_spec.handler,
type_spec.name,
)
# Fallback: use DefaultHandler which delegates to SandboxManager
return _DefaultHandler(type_spec=type_spec)
class _DefaultHandler:
"""Fallback handler when no handler class is specified or resolution fails.
Delegates directly to :class:`SandboxManager` using the type's
default sandbox strategy (or resource override).
"""
def __init__(self, type_spec: ResourceTypeSpec) -> None:
self._type_spec = type_spec
def resolve(
self,
*,
resource: Resource,
plan_id: str,
slot_name: str,
sandbox_manager: SandboxManager,
access: str = "read_only",
) -> BoundResource:
"""Resolve using the default sandbox strategy."""
if not resource.location:
raise ValueError(f"Resource '{resource.resource_id}' has no location")
# resource.sandbox_strategy may be a StrEnum or plain str (Pydantic coercion)
if resource.sandbox_strategy:
strategy_str = str(resource.sandbox_strategy)
else:
strategy_str = str(self._type_spec.sandbox_strategy)
sandbox = sandbox_manager.get_or_create_sandbox(
plan_id=plan_id,
resource_id=resource.resource_id,
original_path=resource.location,
sandbox_strategy=cast(SandboxStrategyStr, strategy_str),
)
sandbox_path = ""
if sandbox.context is not None:
sandbox_path = sandbox.context.sandbox_path
return BoundResource(
slot_name=slot_name,
resource_id=resource.resource_id,
resource_type=resource.resource_type_name,
sandbox_path=sandbox_path,
access=access,
)
@@ -0,0 +1,43 @@
"""Resource handler implementations for CleverAgents.
This package provides the handler runtime that bridges resource types
to sandbox provisioning. Each handler resolves a :class:`Resource`
into a sandboxed working path via the :class:`SandboxManager`.
## Handler Protocol
All handlers implement
:class:`~cleveragents.resource.handlers.protocol.ResourceHandler`,
which defines a single ``resolve`` method returning a
:class:`~cleveragents.tool.context.BoundResource` with a populated
``sandbox_path``.
## Built-in Handlers
| Handler | Resource Type | Sandbox Strategy |
|-----------------------|------------------|------------------|
| ``GitCheckoutHandler``| ``git-checkout`` | ``git_worktree`` |
| ``FsDirectoryHandler``| ``fs-directory`` | ``copy_on_write``|
## Handler Resolution
Handler strings stored on :class:`ResourceTypeSpec` use the format
``module.path:ClassName``. The :func:`resolve_handler` function
dynamically imports the module and returns an instance.
"""
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
from cleveragents.resource.handlers.protocol import ResourceHandler
from cleveragents.resource.handlers.resolver import (
HandlerResolutionError,
resolve_handler,
)
__all__ = [
"FsDirectoryHandler",
"GitCheckoutHandler",
"HandlerResolutionError",
"ResourceHandler",
"resolve_handler",
]
@@ -0,0 +1,103 @@
"""Filesystem-directory resource handler.
Resolves ``fs-directory`` resources into sandbox-backed
:class:`BoundResource` instances using the ``copy_on_write`` sandbox
strategy.
The handler:
1. Validates that the resource has a non-empty ``location``.
2. Determines the sandbox strategy: resource-level override takes
precedence over the default ``copy_on_write``.
3. Calls :meth:`SandboxManager.get_or_create_sandbox` to provision
(or reuse) an isolated copy-on-write directory.
4. Returns a :class:`BoundResource` with ``sandbox_path`` set to the
sandbox root.
Based on:
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
- Built-in type definition in resource_registry_service.py L99-123
"""
from __future__ import annotations
import logging
from typing import cast
from cleveragents.domain.models.core.resource import Resource, SandboxStrategy
from cleveragents.infrastructure.sandbox.factory import SandboxStrategyStr
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.tool.context import BoundResource
logger = logging.getLogger(__name__)
# Default strategy for fs-directory resources
_DEFAULT_STRATEGY = SandboxStrategy.COPY_ON_WRITE
class FsDirectoryHandler:
"""Handler for ``fs-directory`` resource types.
Provisions a copy-on-write sandbox for a local filesystem directory.
"""
def resolve(
self,
*,
resource: Resource,
plan_id: str,
slot_name: str,
sandbox_manager: SandboxManager,
access: str = "read_only",
) -> BoundResource:
"""Resolve an fs-directory resource into a sandboxed BoundResource.
Args:
resource: An ``fs-directory`` resource with ``location``
pointing to the directory.
plan_id: The plan requesting the sandbox.
slot_name: Name of the tool resource slot being filled.
sandbox_manager: The sandbox lifecycle manager.
access: Access mode (``read_only`` or ``read_write``).
Returns:
A :class:`BoundResource` with ``sandbox_path`` populated.
Raises:
ValueError: If the resource has no location.
"""
if not resource.location:
raise ValueError(
f"fs-directory resource '{resource.resource_id}' has no location"
)
strategy_raw = resource.sandbox_strategy or _DEFAULT_STRATEGY
strategy_str = (
strategy_raw.value if hasattr(strategy_raw, "value") else str(strategy_raw)
)
logger.debug(
"Resolving fs-directory resource %s (location=%s, strategy=%s)",
resource.resource_id,
resource.location,
strategy_str,
)
sandbox = sandbox_manager.get_or_create_sandbox(
plan_id=plan_id,
resource_id=resource.resource_id,
original_path=resource.location,
sandbox_strategy=cast(SandboxStrategyStr, strategy_str),
)
sandbox_path = ""
if sandbox.context is not None:
sandbox_path = sandbox.context.sandbox_path
return BoundResource(
slot_name=slot_name,
resource_id=resource.resource_id,
resource_type=resource.resource_type_name,
sandbox_path=sandbox_path,
access=access,
)
@@ -0,0 +1,104 @@
"""Git-checkout resource handler.
Resolves ``git-checkout`` resources into sandbox-backed
:class:`BoundResource` instances using the ``git_worktree`` sandbox
strategy (with fallback to ``copy_on_write``).
The handler:
1. Validates that the resource has a non-empty ``location``.
2. Determines the sandbox strategy: resource-level override takes
precedence over the default ``git_worktree``.
3. Calls :meth:`SandboxManager.get_or_create_sandbox` to provision
(or reuse) an isolated git worktree.
4. Returns a :class:`BoundResource` with ``sandbox_path`` set to the
sandbox root.
Based on:
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
- Built-in type definition in resource_registry_service.py L62-98
"""
from __future__ import annotations
import logging
from typing import cast
from cleveragents.domain.models.core.resource import Resource, SandboxStrategy
from cleveragents.infrastructure.sandbox.factory import SandboxStrategyStr
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.tool.context import BoundResource
logger = logging.getLogger(__name__)
# Default strategy for git-checkout resources
_DEFAULT_STRATEGY = SandboxStrategy.GIT_WORKTREE
class GitCheckoutHandler:
"""Handler for ``git-checkout`` resource types.
Provisions a git-worktree sandbox (or copy-on-write fallback)
for a git repository checkout.
"""
def resolve(
self,
*,
resource: Resource,
plan_id: str,
slot_name: str,
sandbox_manager: SandboxManager,
access: str = "read_only",
) -> BoundResource:
"""Resolve a git-checkout resource into a sandboxed BoundResource.
Args:
resource: A ``git-checkout`` resource with ``location``
pointing to the repository root.
plan_id: The plan requesting the sandbox.
slot_name: Name of the tool resource slot being filled.
sandbox_manager: The sandbox lifecycle manager.
access: Access mode (``read_only`` or ``read_write``).
Returns:
A :class:`BoundResource` with ``sandbox_path`` populated.
Raises:
ValueError: If the resource has no location.
"""
if not resource.location:
raise ValueError(
f"git-checkout resource '{resource.resource_id}' has no location"
)
strategy_raw = resource.sandbox_strategy or _DEFAULT_STRATEGY
strategy_str = (
strategy_raw.value if hasattr(strategy_raw, "value") else str(strategy_raw)
)
logger.debug(
"Resolving git-checkout resource %s (location=%s, strategy=%s)",
resource.resource_id,
resource.location,
strategy_str,
)
sandbox = sandbox_manager.get_or_create_sandbox(
plan_id=plan_id,
resource_id=resource.resource_id,
original_path=resource.location,
sandbox_strategy=cast(SandboxStrategyStr, strategy_str),
)
sandbox_path = ""
if sandbox.context is not None:
sandbox_path = sandbox.context.sandbox_path
return BoundResource(
slot_name=slot_name,
resource_id=resource.resource_id,
resource_type=resource.resource_type_name,
sandbox_path=sandbox_path,
access=access,
)
@@ -0,0 +1,78 @@
"""Resource handler protocol for CleverAgents.
Defines the :class:`ResourceHandler` protocol that all resource type
handlers must satisfy. A handler bridges a :class:`Resource` domain
object to sandbox provisioning by:
1. Reading the resource's ``location`` (the original filesystem path).
2. Determining the sandbox strategy (from resource override or type default).
3. Calling :meth:`SandboxManager.get_or_create_sandbox` to provision an
isolated working directory.
4. Returning a :class:`BoundResource` with ``sandbox_path`` populated.
Based on:
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
- docs/specification.md Resource Handler architecture
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from cleveragents.domain.models.core.resource import Resource
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.tool.context import BoundResource
@runtime_checkable
class ResourceHandler(Protocol):
"""Protocol for resource type handlers.
Each handler knows how to resolve a specific resource type into a
sandboxed working path. Implementations are registered via the
``handler`` field on :class:`ResourceTypeSpec` using the
``module:ClassName`` string format.
Lifecycle::
handler = resolve_handler(
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
)
bound = handler.resolve(
resource=resource,
plan_id="01ARZ3...",
slot_name="repo",
sandbox_manager=sandbox_manager,
access="read_write",
)
# bound.sandbox_path is now populated
"""
def resolve(
self,
*,
resource: Resource,
plan_id: str,
slot_name: str,
sandbox_manager: SandboxManager,
access: str = "read_only",
) -> BoundResource:
"""Resolve a resource into a sandbox-backed BoundResource.
Args:
resource: The resource domain object to resolve.
plan_id: The plan requesting the sandbox.
slot_name: Name of the tool resource slot being filled.
sandbox_manager: The sandbox lifecycle manager.
access: Access mode (``read_only`` or ``read_write``).
Returns:
A :class:`BoundResource` with ``sandbox_path`` populated
from the provisioned sandbox.
Raises:
ValueError: If the resource lacks a location or has an
incompatible type.
SandboxError: If sandbox creation fails.
"""
...
@@ -0,0 +1,115 @@
"""Dynamic handler resolution from ``module:ClassName`` strings.
The :func:`resolve_handler` function takes a handler reference string
(as stored on :class:`ResourceTypeSpec`) and returns an instantiated
handler object that satisfies the :class:`ResourceHandler` protocol.
Format::
"cleveragents.resource.handlers.git_checkout:GitCheckoutHandler"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
module path class name
The function uses :func:`importlib.import_module` for dynamic loading,
with a cache to avoid repeated imports.
Based on:
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
"""
from __future__ import annotations
import importlib
import logging
from typing import Any
from cleveragents.resource.handlers.protocol import ResourceHandler
logger = logging.getLogger(__name__)
# Cache of already-resolved handler instances keyed by reference string
_handler_cache: dict[str, ResourceHandler] = {}
class HandlerResolutionError(Exception):
"""Raised when a handler reference string cannot be resolved."""
def resolve_handler(handler_ref: str) -> ResourceHandler:
"""Resolve a handler reference string to an instance.
Args:
handler_ref: A string in ``module.path:ClassName`` format.
Returns:
An instantiated handler that satisfies :class:`ResourceHandler`.
Raises:
HandlerResolutionError: If the reference is malformed, the
module cannot be imported, or the class does not exist.
"""
if not handler_ref or not handler_ref.strip():
raise HandlerResolutionError("Handler reference must not be empty")
handler_ref = handler_ref.strip()
# Return cached instance if available
if handler_ref in _handler_cache:
return _handler_cache[handler_ref]
# Parse module:class format
if ":" not in handler_ref:
raise HandlerResolutionError(
f"Invalid handler reference format '{handler_ref}': "
"expected 'module.path:ClassName'"
)
module_path, class_name = handler_ref.rsplit(":", 1)
if not module_path or not class_name:
raise HandlerResolutionError(
f"Invalid handler reference '{handler_ref}': "
"both module path and class name are required"
)
# Import module
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as exc:
raise HandlerResolutionError(
f"Cannot import handler module '{module_path}': {exc}"
) from exc
except Exception as exc:
raise HandlerResolutionError(
f"Error importing handler module '{module_path}': {exc}"
) from exc
# Get class
handler_cls: Any = getattr(module, class_name, None)
if handler_cls is None:
raise HandlerResolutionError(
f"Handler class '{class_name}' not found in module '{module_path}'"
)
# Instantiate
try:
instance = handler_cls()
except Exception as exc:
raise HandlerResolutionError(
f"Cannot instantiate handler '{handler_ref}': {exc}"
) from exc
# Validate protocol conformance
if not isinstance(instance, ResourceHandler):
raise HandlerResolutionError(
f"Handler '{handler_ref}' does not satisfy ResourceHandler protocol"
)
# Cache and return
_handler_cache[handler_ref] = instance
logger.debug("Resolved handler: %s", handler_ref)
return instance
def clear_handler_cache() -> None:
"""Clear the handler instance cache (useful for testing)."""
_handler_cache.clear()