feat(tool): add tool lifecycle runtime #65

Merged
freemo merged 1 commits from feature/m2-tool-runtime into master 2026-02-14 18:56:52 +00:00
11 changed files with 2839 additions and 27 deletions
+165
View File
@@ -0,0 +1,165 @@
"""ASV benchmarks for tool lifecycle runtime."""
from __future__ import annotations
from typing import Any
from cleveragents.tool.context import (
BoundResource,
CancellationToken,
Change,
ChangeOperation,
ToolExecutionContext,
)
from cleveragents.tool.lifecycle import (
ToolDescriptor,
ToolLifecycleCache,
ToolResult,
)
from cleveragents.tool.schema_validator import validate_tool_input
class _DummyInstance:
"""Minimal mock for benchmarks."""
def discover(self) -> ToolDescriptor:
return ToolDescriptor(name="bench/tool", description="bench", source="builtin")
def activate(self, ctx: Any) -> None:
pass
def execute(self, params: Any, ctx: Any) -> ToolResult:
return ToolResult()
def deactivate(self, ctx: Any) -> None:
pass
class TimeToolExecutionContext:
"""Benchmark ToolExecutionContext creation and operations."""
def time_create_context(self) -> None:
ToolExecutionContext(plan_id="plan-bench-001")
def time_create_context_with_resources(self) -> None:
resources = {
f"slot_{i}": BoundResource(
slot_name=f"slot_{i}",
resource_id=f"res-{i:03d}",
resource_type="git-checkout",
access="read_write",
)
for i in range(10)
}
ToolExecutionContext(
plan_id="plan-bench-002",
resources=resources,
)
def time_record_change(self) -> None:
ctx = ToolExecutionContext(plan_id="plan-bench-003")
for i in range(100):
ctx.record_change(
Change(
operation=ChangeOperation.MODIFY,
resource_id=f"res-{i:03d}",
)
)
def time_context_summary(self) -> None:
ctx = ToolExecutionContext(plan_id="plan-bench-004")
for _ in range(50):
ctx.as_summary()
class TimeCancellationToken:
"""Benchmark CancellationToken operations."""
def time_create_token(self) -> None:
CancellationToken()
def time_check_not_cancelled(self) -> None:
token = CancellationToken()
for _ in range(1000):
_ = token.is_cancelled
def time_cancel_and_check(self) -> None:
token = CancellationToken()
token.cancel()
for _ in range(1000):
_ = token.is_cancelled
class TimeToolLifecycleCache:
"""Benchmark ToolLifecycleCache operations."""
def time_put_and_get(self) -> None:
cache = ToolLifecycleCache()
dummy = _DummyInstance()
for i in range(100):
cache.put(f"plan-{i}", f"tool/t-{i}", dummy)
for i in range(100):
cache.get(f"plan-{i}", f"tool/t-{i}")
def time_remove_plan(self) -> None:
cache = ToolLifecycleCache()
dummy = _DummyInstance()
for i in range(50):
cache.put("plan-001", f"tool/t-{i}", dummy)
cache.remove_plan("plan-001")
def time_get_stats(self) -> None:
cache = ToolLifecycleCache()
dummy = _DummyInstance()
for i in range(20):
cache.put("plan-001", f"tool/t-{i}", dummy)
for _ in range(50):
cache.get_stats("plan-001")
class TimeSchemaValidation:
"""Benchmark JSON Schema validation."""
def setup(self) -> None:
self.schema: dict[str, Any] = {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
"mode": {"type": "integer"},
},
"required": ["path"],
}
def time_validate_simple(self) -> None:
validate_tool_input({"path": "test.txt"}, self.schema)
def time_validate_full(self) -> None:
validate_tool_input(
{"path": "test.txt", "content": "hello", "mode": 644},
self.schema,
)
class TimeToolDescriptor:
"""Benchmark ToolDescriptor creation."""
def time_create_descriptor(self) -> None:
for i in range(100):
ToolDescriptor(
name=f"ns/tool-{i}",
description=f"Tool {i}",
)
def time_create_descriptor_with_schema(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"path": {"type": "string"}},
}
for i in range(100):
ToolDescriptor(
name=f"ns/tool-{i}",
description=f"Tool {i}",
input_schema=schema,
output_schema=schema,
)
+104
View File
@@ -0,0 +1,104 @@
# Tool Lifecycle Runtime
The tool lifecycle runtime orchestrates the four-stage lifecycle
(`discover`/`activate`/`execute`/`deactivate`) for all tool sources
(MCP, Agent Skills, built-ins, custom, wrapped).
## Architecture
```
ToolRuntime
├── ToolLifecycleCache (per-plan activation reuse)
├── ToolInstance (Protocol) (concrete tool implementations)
├── ToolExecutionContext (plan metadata + resources + cancellation)
└── schema_validator (JSON Schema input/output validation)
```
## Four-Stage Lifecycle
### 1. Discover
Returns lightweight `ToolDescriptor` metadata without activating the tool.
Used during registration and tool enumeration.
### 2. Activate
Prepares the tool for execution within a plan context. Called at most once
per plan via the lifecycle cache. May start external processes (MCP), load
instructions (Agent Skills), or no-op (built-ins).
### 3. Execute
Runs the tool with validated parameters. The runtime:
1. Checks cancellation
2. Auto-activates if not yet activated for the plan
3. Enforces capability flags (read-only, checkpoint requirements)
4. Validates inputs against JSON Schema
5. Executes the tool with tracing
6. Validates outputs against JSON Schema
7. Records changes into the execution context
### 4. Deactivate
Cleans up after all executions. Called once per plan on completion, failure,
or cancellation. Must be idempotent and must not raise.
## Capability Enforcement
| Constraint | Condition | Error |
|-----------|-----------|-------|
| Read-only plan | Tool has `writes=True` | `ToolAccessDeniedError` |
| Checkpoint required | Tool has `checkpointable=False` | `ToolCheckpointRequiredError` |
## Per-Plan Activation Cache
The `ToolLifecycleCache` ensures:
- Each tool is activated at most once per plan
- `deactivate()` is guaranteed for every activated tool when the plan ends
- Thread-safe via `RLock` for concurrent plan execution
## Cancellation
The `CancellationToken` propagates cancellation from the plan lifecycle:
- Set via `cancel()` when `agents plan cancel` is invoked
- Tools check `ctx.cancellation_token.is_cancelled` for long-running ops
- `ctx.cancellation_token.check()` raises `ToolCancelledError` immediately
## JSON Schema Validation
Uses JSON Schema draft 2020-12 via `jsonschema.Draft202012Validator`:
- `input_schema`: validated before execution; failures return error result
- `output_schema`: validated after execution; failures logged as warning
## Execution Tracing
Every execution produces a `ToolExecutionTrace` with:
- `started_at` / `ended_at` (ISO-8601)
- `duration_ms`
- `result_size_bytes`
- `success` / `error`
## Error Hierarchy
```
ToolRuntimeError
├── ToolAccessDeniedError
├── ToolCheckpointRequiredError
├── ToolNotActivatedError
├── ToolActivationError
├── ToolExecutionError
└── ToolDeactivationError
ToolCancelledError (standalone, not ToolRuntimeError)
ToolSchemaValidationError (standalone)
```
## Key Files
| File | Purpose |
|------|---------|
| `src/cleveragents/tool/__init__.py` | Package exports |
| `src/cleveragents/tool/context.py` | `ToolExecutionContext`, `BoundResource`, `Change`, `CancellationToken`, `ToolExecutionTrace` |
| `src/cleveragents/tool/lifecycle.py` | `ToolRuntime`, `ToolInstance`, `ToolLifecycleCache`, `ToolDescriptor`, `ToolResult` |
| `src/cleveragents/tool/schema_validator.py` | JSON Schema validation for inputs/outputs |
| `src/cleveragents/domain/models/core/tool.py` | `Tool`, `Validation`, `ToolCapability` domain models |
File diff suppressed because it is too large Load Diff
+379
View File
@@ -0,0 +1,379 @@
Feature: Tool lifecycle runtime
As the tool execution engine
I need to orchestrate discover/activate/execute/deactivate lifecycle
With capability enforcement, schema validation, caching, and cancellation
# ToolExecutionContext
Scenario: Create a tool execution context with defaults
Given I create a tool execution context with plan_id "plan-001"
Then the context plan_id should be "plan-001"
And the context should not be read-only
And the context should not require checkpoints
And the context should have 0 changes
And the context should have 0 traces
And the context cancellation token should not be cancelled
Scenario: Create a read-only execution context
Given I create a tool execution context with plan_id "plan-002" and read_only True
Then the context should be read-only
Scenario: Create a checkpoint-required execution context
Given I create a tool execution context with plan_id "plan-003" and require_checkpoints True
Then the context should require checkpoints
Scenario: Record changes in execution context
Given I create a tool execution context with plan_id "plan-004"
When I record a change with operation "create" and resource_id "res-001"
Then the context should have 1 changes
Scenario: Add traces to execution context
Given I create a tool execution context with plan_id "plan-005"
When I add a trace for tool "test/tool" with success True
Then the context should have 1 traces
Scenario: Get resource from context by slot name
Given I create a tool execution context with plan_id "plan-006"
And I bind resource "res-001" to slot "repo" with type "git-checkout"
When I get the resource for slot "repo"
Then the bound resource_id should be "res-001"
Scenario: Get resource from context with missing slot raises KeyError
Given I create a tool execution context with plan_id "plan-007"
When I try to get the resource for slot "missing"
Then a KeyError should be raised with message containing "missing"
Scenario: Context summary includes correct counts
Given I create a tool execution context with plan_id "plan-008"
And I bind resource "res-001" to slot "repo" with type "git-checkout"
When I record a change with operation "modify" and resource_id "res-001"
And I add a trace for tool "test/tool" with success True
Then the context summary should show resource_count 1
And the context summary should show change_count 1
And the context summary should show trace_count 1
# ── BoundResource ────────────────────────────────────────────────────
Scenario: Create a BoundResource with all fields
Given I create a bound resource with slot "db" and resource_id "res-db-001" and type "postgresql"
Then the bound resource slot_name should be "db"
And the bound resource access should be "read_only"
# ── CancellationToken ────────────────────────────────────────────────
Scenario: Cancellation token starts not cancelled
Given I create a cancellation token
Then the cancellation token should not be cancelled
Scenario: Cancel sets the cancellation flag
Given I create a cancellation token
When I cancel the token
Then the cancellation token should be cancelled
Scenario: Check raises ToolCancelledError when cancelled
Given I create a cancellation token
When I cancel the token
Then calling check on the token should raise ToolCancelledError
# ── Change tracking ──────────────────────────────────────────────────
Scenario: Create a change with all operations
Given I create a change with operation "create" and resource_id "res-001"
Then the change operation should be "create"
And the change resource_id should be "res-001"
Scenario: Change has automatic timestamp
Given I create a change with operation "delete" and resource_id "res-002"
Then the change timestamp should be a valid ISO-8601 string
# ── ToolDescriptor ───────────────────────────────────────────────────
Scenario: Create a tool descriptor
Given I create a tool descriptor with name "builtin/read-file" and description "Read a file"
Then the descriptor name should be "builtin/read-file"
And the descriptor source should be "builtin"
# ── ToolResult ───────────────────────────────────────────────────────
Scenario: Create a successful tool result
Given I create a tool result with success True and data "hello"
Then the tool result should indicate success
And the tool result data should be "hello"
Scenario: Create a failed tool result
Given I create a tool result with success False and error "something broke"
Then the tool result should indicate failure
And the tool result error should be "something broke"
# ── JSON Schema validation ───────────────────────────────────────────
Scenario: Validate tool input against valid schema
Given I have a JSON schema requiring property "path" of type "string"
When I validate input with path "test.txt" against the schema
Then the input validation should succeed
Scenario: Validate tool input against invalid data
Given I have a JSON schema requiring property "path" of type "string"
When I validate input with path 42 against the schema
Then the input validation should fail with ToolSchemaValidationError
Scenario: Validate tool output against valid schema
Given I have a JSON schema requiring property "content" of type "string"
When I validate output with content "file data" against the schema
Then the output validation should succeed
Scenario: Validate tool output against invalid data
Given I have a JSON schema requiring property "content" of type "string"
When I validate output with content 123 against the schema
Then the output validation should fail with ToolSchemaValidationError
Scenario: Schema validation error contains error details
Given I have a JSON schema requiring property "path" of type "string"
When I validate input with path 42 against the schema
Then the schema validation error should have errors list
And the schema validation error should reference the schema
# ── ToolLifecycleCache ───────────────────────────────────────────────
Scenario: Cache starts empty
Given I create a tool lifecycle cache
Then the cache should have 0 plans
Scenario: Put and get an instance from cache
Given I create a tool lifecycle cache
And I have a mock tool instance named "builtin/read"
When I put the instance in cache for plan "plan-001" and tool "builtin/read"
Then getting the instance for plan "plan-001" and tool "builtin/read" should return it
Scenario: Get returns None for uncached tool
Given I create a tool lifecycle cache
Then getting the instance for plan "plan-001" and tool "builtin/missing" should return None
Scenario: Increment execution count
Given I create a tool lifecycle cache
And I have a mock tool instance named "builtin/read"
When I put the instance in cache for plan "plan-001" and tool "builtin/read"
And I increment execution for plan "plan-001" and tool "builtin/read"
Then the cache stats for plan "plan-001" should show tool "builtin/read" with execution_count 1
Scenario: Get plan tools lists activated tools
Given I create a tool lifecycle cache
And I have a mock tool instance named "builtin/read"
And I have a mock tool instance named "builtin/write"
When I put the instance in cache for plan "plan-001" and tool "builtin/read"
And I put the second instance in cache for plan "plan-001" and tool "builtin/write"
Then the plan tools for plan "plan-001" should include "builtin/read"
And the plan tools for plan "plan-001" should include "builtin/write"
Scenario: Remove a single tool from cache
Given I create a tool lifecycle cache
And I have a mock tool instance named "builtin/read"
When I put the instance in cache for plan "plan-001" and tool "builtin/read"
And I remove tool "builtin/read" from plan "plan-001"
Then getting the instance for plan "plan-001" and tool "builtin/read" should return None
Scenario: Remove plan clears all tools
Given I create a tool lifecycle cache
And I have a mock tool instance named "builtin/read"
And I have a mock tool instance named "builtin/write"
When I put the instance in cache for plan "plan-001" and tool "builtin/read"
And I put the second instance in cache for plan "plan-001" and tool "builtin/write"
And I remove plan "plan-001" from cache
Then the cache should have 0 plans
# ── ToolRuntime ──────────────────────────────────────────────────────
Scenario: Register and list tools
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance
Then the tool list should contain "builtin/read-file"
Scenario: Unregister a tool
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance
When I unregister tool "builtin/read-file"
Then the tool list should not contain "builtin/read-file"
Scenario: Get a registered tool
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance
Then getting tool "builtin/read-file" should return the tool model
Scenario: Get an unregistered tool returns None
Given I create a tool runtime
Then getting tool "builtin/missing" should return None
Scenario: Discover returns a ToolDescriptor
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance
When I discover tool "builtin/read-file"
Then the discovered descriptor name should be "builtin/read-file"
Scenario: Activate a tool for a plan
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-010"
When I activate tool "builtin/read-file" in the runtime
Then the mock instance should have been activated
Scenario: Activate is idempotent for same plan
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-011"
When I activate tool "builtin/read-file" in the runtime
And I activate tool "builtin/read-file" in the runtime again
Then the mock instance activate count should be 1
Scenario: Execute a tool successfully
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-012"
When I execute tool "builtin/read-file" with params path "test.txt"
Then the execution result should be successful
And the context should have 1 traces
Scenario: Execute a tool with input schema validation failure
Given I create a tool runtime
And I register a builtin tool with input schema "builtin/typed-tool"
And I create a tool execution context with plan_id "plan-013"
When I execute tool "builtin/typed-tool" with invalid params
Then the execution result should not be successful
And the execution result error should contain "Input validation failed"
Scenario: Read-only plan rejects tool with writes
Given I create a tool runtime
And I register a builtin tool "builtin/write-file" that writes
And I create a tool execution context with plan_id "plan-014" and read_only True
When I try to execute tool "builtin/write-file"
Then a ToolAccessDeniedError should be raised
Scenario: Checkpoint-required plan rejects non-checkpointable tool
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-015" and require_checkpoints True
When I try to activate tool "builtin/read-file" for checkpoint plan
Then a ToolCheckpointRequiredError should be raised
Scenario: Execute records changes from tool result
Given I create a tool runtime
And I register a builtin tool "builtin/edit-file" that produces changes
And I create a tool execution context with plan_id "plan-016"
When I execute tool "builtin/edit-file" with params path "test.txt"
Then the context should have 1 changes
Scenario: Execute with cancelled token raises ToolCancelledError
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-017"
And I cancel the context cancellation token
When I try to execute tool "builtin/read-file" after cancellation
Then a ToolCancelledError should be raised from execution
Scenario: Deactivate a single tool
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-018"
When I activate tool "builtin/read-file" in the runtime
And I deactivate tool "builtin/read-file" in the runtime
Then the mock instance should have been deactivated
Scenario: Deactivate plan clears all activated tools
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I register a second builtin tool "builtin/list-files" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-019"
When I activate tool "builtin/read-file" in the runtime
And I activate tool "builtin/list-files" in the runtime
And I deactivate plan "plan-019" in the runtime
Then all mock instances should have been deactivated
Scenario: Execute unregistered tool raises ToolRuntimeError
Given I create a tool runtime
And I create a tool execution context with plan_id "plan-020"
When I try to execute unregistered tool "builtin/ghost"
Then a ToolRuntimeError should be raised with message containing "not registered"
Scenario: Cache stats after execution
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-021"
When I execute tool "builtin/read-file" with params path "test.txt"
Then the cache stats for plan "plan-021" should show 1 active tools
Scenario: Output schema validation failure returns error result
Given I create a tool runtime
And I register a builtin tool with output schema "builtin/validated-output"
And I create a tool execution context with plan_id "plan-022"
When I execute tool "builtin/validated-output" with params path "test.txt"
Then the execution result should not be successful
And the execution result error should contain "Output validation failed"
Scenario: Execution trace captures duration
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-023"
When I execute tool "builtin/read-file" with params path "test.txt"
Then the last trace should have a non-negative duration_ms
Scenario: ToolExecutionTrace fields
Given I create a tool execution trace for "builtin/read"
Then the trace tool_name should be "builtin/read"
And the trace should have a started_at timestamp
Scenario: Read-only plan allows read-only tools
Given I create a tool runtime
And I register a builtin tool "builtin/search" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-024" and read_only True
When I execute tool "builtin/search" with params path "*.py"
Then the execution result should be successful
Scenario: Activation failure raises ToolActivationError
Given I create a tool runtime
And I register a builtin tool "builtin/broken" with a failing activate mock
And I create a tool execution context with plan_id "plan-025"
When I try to activate the failing tool "builtin/broken"
Then a ToolActivationError should be raised
Scenario: Execution failure raises ToolExecutionError
Given I create a tool runtime
And I register a builtin tool "builtin/crashing" with a failing execute mock
And I create a tool execution context with plan_id "plan-026"
When I try to execute the crashing tool "builtin/crashing"
Then a ToolExecutionError should be raised
And the context should have 1 traces
And the last trace should show failure
Scenario: Deactivation failure is non-fatal warning
Given I create a tool runtime
And I register a builtin tool "builtin/sticky" with a failing deactivate mock
And I create a tool execution context with plan_id "plan-027"
When I activate tool "builtin/sticky" in the runtime
And I deactivate tool "builtin/sticky" in the runtime
Then no exception should have been raised from deactivation
Scenario: Plan deactivation handles failing tools gracefully
Given I create a tool runtime
And I register a builtin tool "builtin/sticky" with a failing deactivate mock
And I create a tool execution context with plan_id "plan-028"
When I activate tool "builtin/sticky" in the runtime
And I deactivate plan "plan-028" in the runtime
Then no exception should have been raised from deactivation
Scenario: Cache plan_count property tracks plans
Given I create a tool runtime
And I register a builtin tool "builtin/read-file" with a mock instance that is read_only
And I create a tool execution context with plan_id "plan-029"
When I activate tool "builtin/read-file" in the runtime
Then the runtime cache plan count should be 1
Scenario: Discover unregistered tool raises ToolRuntimeError
Given I create a tool runtime
When I try to discover unregistered tool "builtin/ghost"
Then a ToolRuntimeError should be raised with message containing "No implementation registered"
Scenario: Execution with result error records trace error field
Given I create a tool runtime
And I register a builtin tool "builtin/err-tool" with a mock that returns error result
And I create a tool execution context with plan_id "plan-030"
When I execute tool "builtin/err-tool" with params path "test.txt"
Then the last trace should show the error message
+25 -25
View File
@@ -2738,31 +2738,31 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
**Parallel Group C5: Tool Lifecycle Runtime [Jeff]** (M2; depends on C0.runtime + C1.tool.domain)
- [ ] **COMMIT (Owner: Jeff | Group: C5.lifecycle | Branch: feature/m2-tool-runtime | Planned: Day 11 | Expected: Day 13) - Commit message: "feat(tool): add tool lifecycle runtime"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m2-tool-runtime`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Implement `ToolRuntime`/`ToolInstance` interfaces with `discover/activate/execute/deactivate` hooks and lifecycle state tracking.
- [ ] Code [Jeff]: Add `ToolExecutionContext` with resolved resource bindings, sandbox paths, plan metadata, and cancellation token.
- [ ] Code [Jeff]: Add lifecycle cache with per-plan activation reuse and guaranteed `deactivate` on plan completion/cancel.
- [ ] Code [Jeff]: Enforce tool capability flags (read-only/writes/checkpointable) and read-only plan gating at runtime.
- [ ] Code [Jeff]: Validate tool inputs/outputs against JSON schema before/after execution; surface schema errors clearly.
- [ ] Code [Jeff]: Add tool execution tracing (start/end timestamps, duration, result size) for diagnostics.
- [ ] Code [Jeff]: Add cancellation propagation so long-running tools are interrupted on plan cancel.
- [ ] Docs [Jeff]: Add `docs/reference/tool_lifecycle.md` describing hook ordering, capability enforcement, and failure handling.
- [ ] Docs [Jeff]: Document schema validation behavior and error payload format for tool failures.
- [ ] Tests (Behave) [Jeff]: Add lifecycle scenarios for activate/execute/deactivate ordering and error propagation.
- [ ] Tests (Robot) [Jeff]: Add `robot/tool_lifecycle.robot` runtime smoke tests.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/tool_lifecycle_bench.py` for lifecycle overhead.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark).
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Git [Jeff]: `git add .` (only after nox passes)
- [ ] Commit [Jeff]: `git commit -m "feat(tool): add tool lifecycle runtime"`.
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m2-tool-runtime` to `master` with description "Add tool lifecycle runtime, context, and tests.".
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git branch -d feature/m2-tool-runtime`
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] **COMMIT (Owner: Jeff | Group: C5.lifecycle | Branch: feature/m2-tool-runtime | Planned: Day 11 | Expected: Day 13) - Commit message: "feat(tool): add tool lifecycle runtime"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m2-tool-runtime`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Code [Jeff]: Implement `ToolRuntime`/`ToolInstance` interfaces with `discover/activate/execute/deactivate` hooks and lifecycle state tracking.
- [X] Code [Jeff]: Add `ToolExecutionContext` with resolved resource bindings, sandbox paths, plan metadata, and cancellation token.
- [X] Code [Jeff]: Add lifecycle cache with per-plan activation reuse and guaranteed `deactivate` on plan completion/cancel.
- [X] Code [Jeff]: Enforce tool capability flags (read-only/writes/checkpointable) and read-only plan gating at runtime.
- [X] Code [Jeff]: Validate tool inputs/outputs against JSON schema before/after execution; surface schema errors clearly.
- [X] Code [Jeff]: Add tool execution tracing (start/end timestamps, duration, result size) for diagnostics.
- [X] Code [Jeff]: Add cancellation propagation so long-running tools are interrupted on plan cancel.
- [X] Docs [Jeff]: Add `docs/reference/tool_lifecycle.md` describing hook ordering, capability enforcement, and failure handling.
- [X] Docs [Jeff]: Document schema validation behavior and error payload format for tool failures.
- [X] Tests (Behave) [Jeff]: Add lifecycle scenarios for activate/execute/deactivate ordering and error propagation.
- [X] Tests (Robot) [Jeff]: Add `robot/tool_lifecycle.robot` runtime smoke tests.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/tool_lifecycle_bench.py` for lifecycle overhead.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark).
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] Git [Jeff]: `git add .` (only after nox passes)
- [X] Commit [Jeff]: `git commit -m "feat(tool): add tool lifecycle runtime"`.
- [X] Forgejo PR [Jeff]: Open PR from `feature/m2-tool-runtime` to `master` with description "Add tool lifecycle runtime, context, and tests.".
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git branch -d feature/m2-tool-runtime`
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
**Parallel Group C1: Actor Schema & Examples [Aditya + Jeff]** (start Day 5; C2 depends on this)
- [ ] **COMMIT (Owner: Aditya | Group: C1.schema | Branch: feature/m3-actor-schema-examples | Planned: Day 12 | Expected: Day 14) - Commit message: "feat(actor): add actor yaml schema models"**
+1
View File
@@ -44,6 +44,7 @@ dependencies = [
"numpy>=2.1.0",
"python-ulid>=2.7.0", # ULID generation for plan/action IDs
"RestrictedPython>=7.0", # Secure sandbox for user-supplied code
"jsonschema>=4.20.0", # JSON Schema validation for tool inputs/outputs
]
[project.optional-dependencies]
+57
View File
@@ -0,0 +1,57 @@
*** Settings ***
Documentation Tool lifecycle runtime smoke tests
Library Process
Library OperatingSystem
*** Variables ***
${PYTHON} python
*** Test Cases ***
Tool Package Is Importable
[Documentation] Verify the tool lifecycle package can be imported
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import ToolRuntime, ToolExecutionContext, ToolResult, ToolDescriptor, ToolLifecycleCache, CancellationToken, Change, ChangeOperation, BoundResource; print('OK')
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} OK
Tool Context Creation Works
[Documentation] Verify ToolExecutionContext can be created
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import ToolExecutionContext; ctx = ToolExecutionContext(plan_id='test-plan'); print(f'plan={ctx.plan_id} changes={len(ctx.changes)} traces={len(ctx.traces)}')
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plan=test-plan
Should Contain ${result.stdout} changes=0
Should Contain ${result.stdout} traces=0
Cancellation Token Works
[Documentation] Verify CancellationToken cancel and check
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import CancellationToken; t = CancellationToken(); print(f'before={t.is_cancelled}'); t.cancel(); print(f'after={t.is_cancelled}')
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} before=False
Should Contain ${result.stdout} after=True
Schema Validator Works
[Documentation] Verify JSON Schema validation
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import validate_tool_input, ToolSchemaValidationError; schema = {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}; validate_tool_input({"x": "hi"}, schema); print('valid'); ok = False\ntry:\n validate_tool_input({"x": 1}, schema)\nexcept ToolSchemaValidationError:\n ok = True\nprint(f'invalid_caught={ok}')
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} valid
Should Contain ${result.stdout} invalid_caught=True
Tool Runtime Registration Works
[Documentation] Verify ToolRuntime register and list
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import ToolRuntime; from cleveragents.domain.models.core.tool import Tool, ToolSource, ToolCapability; rt = ToolRuntime(); t = Tool(name='test/read', description='Read', source=ToolSource.BUILTIN, capability=ToolCapability(read_only=True)); print(f'before={rt.list_tools()}'); rt.register_tool(t, None); print(f'after={rt.list_tools()}')
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} before=[]
Should Contain ${result.stdout} test/read
Lifecycle Cache Works
[Documentation] Verify ToolLifecycleCache put/get
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import ToolLifecycleCache; c = ToolLifecycleCache(); print(f'plans={c.plan_count}'); c.put('p1', 'tool/a', 'dummy'); print(f'plans_after={c.plan_count}'); r = c.get('p1', 'tool/a'); print(f'got={r}')
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plans=0
Should Contain ${result.stdout} plans_after=1
Should Contain ${result.stdout} got=dummy
+62 -2
View File
@@ -1,16 +1,76 @@
"""Tool runtime package for CleverAgents.
"""Tool runtime and lifecycle package for CleverAgents v3.
Re-exports public types from the runtime, runner, and registry modules.
Re-exports public types from the runtime, runner, and registry modules
(C0.runtime) and the four-stage tool lifecycle with capability enforcement,
JSON Schema validation, per-plan activation caching, execution tracing,
and cancellation propagation (C5.lifecycle).
"""
# C0.runtime — core tool spec, runner, registry
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolError, ToolResult, ToolSpec
# C5.lifecycle — four-stage lifecycle runtime
from cleveragents.tool.context import (
BoundResource,
CancellationToken,
Change,
ChangeOperation,
ToolCancelledError,
ToolExecutionContext,
ToolExecutionTrace,
)
from cleveragents.tool.lifecycle import (
ToolAccessDeniedError,
ToolActivationError,
ToolCheckpointRequiredError,
ToolDeactivationError,
ToolDescriptor,
ToolExecutionError,
ToolInstance,
ToolLifecycleCache,
ToolNotActivatedError,
ToolResult as LifecycleToolResult,
ToolRuntime,
ToolRuntimeError,
)
from cleveragents.tool.schema_validator import (
ToolSchemaValidationError,
validate_tool_input,
validate_tool_output,
)
__all__ = [
# C0.runtime
"ToolError",
"ToolRegistry",
"ToolResult",
"ToolRunner",
"ToolSpec",
# C5.lifecycle — context
"BoundResource",
"CancellationToken",
"Change",
"ChangeOperation",
"ToolCancelledError",
"ToolExecutionContext",
"ToolExecutionTrace",
# C5.lifecycle — runtime
"LifecycleToolResult",
"ToolAccessDeniedError",
"ToolActivationError",
"ToolCheckpointRequiredError",
"ToolDeactivationError",
"ToolDescriptor",
"ToolExecutionError",
"ToolInstance",
"ToolLifecycleCache",
"ToolNotActivatedError",
"ToolRuntime",
"ToolRuntimeError",
# C5.lifecycle — schema validation
"ToolSchemaValidationError",
"validate_tool_input",
"validate_tool_output",
]
+229
View File
@@ -0,0 +1,229 @@
"""Tool execution context for CleverAgents v3.
The ``ToolExecutionContext`` is provided to every tool execution, regardless of
source. It carries the plan metadata, sandbox reference, resolved resource
bindings, a change recorder, and a cancellation token so that long-running
tools can be interrupted when a plan is cancelled.
Based on docs/specification.md Tool Interface and Architecture section.
"""
from __future__ import annotations
import threading
from collections import OrderedDict
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
# Bound resource placeholder
# ---------------------------------------------------------------------------
class BoundResource(BaseModel):
"""A resolved resource binding for a tool slot.
At execution time each ``ResourceSlot`` declared by the tool is resolved to
a ``BoundResource`` that carries the concrete resource identity, the sandbox
path (if applicable), and the access mode.
"""
slot_name: str = Field(..., min_length=1, description="Slot name from the tool")
resource_id: str = Field(..., min_length=1, description="Resource ULID or name")
resource_type: str = Field(..., min_length=1, description="Resource type name")
sandbox_path: str | None = Field(
default=None,
description="Sandbox-mapped path (None for non-file resources)",
)
access: str = Field(
"read_only", description="Access mode: 'read_only' or 'read_write'"
)
model_config = ConfigDict(str_strip_whitespace=True)
# ---------------------------------------------------------------------------
# Change tracking
# ---------------------------------------------------------------------------
class ChangeOperation(StrEnum):
"""Operation type for a recorded change."""
CREATE = "create"
MODIFY = "modify"
DELETE = "delete"
MOVE = "move"
class Change(BaseModel):
"""A single recorded change produced by a tool execution.
Every resource modification is explicit and tracked via the execution
context. The ChangeSet is built from these entries, enabling precise
rollback (replay inverse of recorded changes) and audit.
"""
operation: ChangeOperation = Field(..., description="Type of change")
resource_id: str = Field(..., description="Resource ID affected")
path: str | None = Field(default=None, description="File path (if applicable)")
previous_content: str | None = Field(
default=None, description="Content before change"
)
new_content: str | None = Field(default=None, description="Content after change")
metadata: dict[str, Any] = Field(
default_factory=dict, description="Additional change metadata"
)
timestamp: str = Field(
default_factory=lambda: datetime.now(UTC).isoformat(),
description="ISO-8601 timestamp of the change",
)
model_config = ConfigDict(str_strip_whitespace=True)
# ---------------------------------------------------------------------------
# Cancellation token
# ---------------------------------------------------------------------------
class CancellationToken:
"""Thread-safe cancellation token for tool execution.
The plan lifecycle can set the token to signal cancellation, and tools
check ``is_cancelled`` to abort long-running operations early.
"""
def __init__(self) -> None:
self._event = threading.Event()
def cancel(self) -> None:
"""Signal cancellation."""
self._event.set()
@property
def is_cancelled(self) -> bool:
"""Return True if cancellation has been requested."""
return self._event.is_set()
def check(self) -> None:
"""Raise ``ToolCancelledError`` if cancellation has been requested."""
if self._event.is_set():
raise ToolCancelledError("Tool execution was cancelled")
class ToolCancelledError(Exception):
"""Raised when a tool execution is cancelled."""
# ---------------------------------------------------------------------------
# Execution trace
# ---------------------------------------------------------------------------
class ToolExecutionTrace(BaseModel):
"""Diagnostic trace for a single tool execution.
Captures start/end timestamps, duration, result size, and any error
information for debugging and performance analysis.
"""
tool_name: str = Field(..., description="Namespaced tool name")
started_at: str = Field(..., description="ISO-8601 start timestamp")
ended_at: str | None = Field(default=None, description="ISO-8601 end timestamp")
duration_ms: float | None = Field(
default=None, description="Duration in milliseconds"
)
result_size_bytes: int | None = Field(
default=None, description="Size of the result payload"
)
error: str | None = Field(default=None, description="Error message if failed")
success: bool = Field(default=True, description="Whether execution succeeded")
model_config = ConfigDict(str_strip_whitespace=True)
# ---------------------------------------------------------------------------
# Tool execution context
# ---------------------------------------------------------------------------
class ToolExecutionContext:
"""Context provided to every tool execution, regardless of source.
Carries plan metadata, sandbox reference, resolved resource bindings,
a change list, cancellation token, and execution traces.
Parameters
----------
plan_id:
The ULID of the plan that owns this execution.
plan_read_only:
Whether the plan is read-only (restricts tool access).
require_checkpoints:
Whether the plan requires all tools to be checkpointable.
sandbox_id:
Optional sandbox identifier for the execution.
resources:
Resolved resource bindings keyed by slot name.
cancellation_token:
Token for cancelling long-running tools.
metadata:
Additional plan/execution metadata.
"""
def __init__(
self,
*,
plan_id: str,
plan_read_only: bool = False,
require_checkpoints: bool = False,
sandbox_id: str | None = None,
resources: dict[str, BoundResource] | None = None,
cancellation_token: CancellationToken | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
self.plan_id = plan_id
self.plan_read_only = plan_read_only
self.require_checkpoints = require_checkpoints
self.sandbox_id = sandbox_id
self.resources: dict[str, BoundResource] = resources or {}
self.cancellation_token = cancellation_token or CancellationToken()
self.metadata: dict[str, Any] = metadata or {}
self.changes: list[Change] = []
self.traces: list[ToolExecutionTrace] = []
def record_change(self, change: Change) -> None:
"""Record a change made by a tool."""
self.changes.append(change)
def add_trace(self, trace: ToolExecutionTrace) -> None:
"""Add an execution trace entry."""
self.traces.append(trace)
def get_resource(self, slot_name: str) -> BoundResource:
"""Get a bound resource by slot name.
Raises ``KeyError`` if the slot is not bound.
"""
if slot_name not in self.resources:
raise KeyError(
f"No resource bound to slot '{slot_name}'. "
f"Available slots: {sorted(self.resources)}"
)
return self.resources[slot_name]
def as_summary(self) -> OrderedDict[str, Any]:
"""Return a summary dict for diagnostics and CLI rendering."""
result: OrderedDict[str, Any] = OrderedDict()
result["plan_id"] = self.plan_id
result["plan_read_only"] = self.plan_read_only
result["require_checkpoints"] = self.require_checkpoints
result["sandbox_id"] = self.sandbox_id
result["resource_count"] = len(self.resources)
result["change_count"] = len(self.changes)
result["trace_count"] = len(self.traces)
return result
+649
View File
@@ -0,0 +1,649 @@
"""Tool lifecycle runtime for CleverAgents v3.
Implements the four-stage tool lifecycle (``discover``/``activate``/
``execute``/``deactivate``) with:
- ``ToolDescriptor``: lightweight metadata returned by ``discover()``
- ``ToolInstance``: Protocol for concrete tool implementations
- ``ToolRuntime``: orchestrates lifecycle, caching, capability enforcement,
JSON Schema validation, tracing, and cancellation propagation
The runtime maintains a per-plan activation cache so that tools are activated
at most once per plan and guaranteed to be deactivated when the plan
completes or is cancelled.
Based on docs/specification.md Tool Interface and Architecture section.
"""
from __future__ import annotations
import logging
import threading
import time
from collections import OrderedDict
from datetime import UTC, datetime
from typing import Any, Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field
from cleveragents.domain.models.core.tool import Tool, ToolCapability
from cleveragents.tool.context import (
Change,
ToolCancelledError,
ToolExecutionContext,
ToolExecutionTrace,
)
from cleveragents.tool.schema_validator import (
ToolSchemaValidationError,
validate_tool_input,
validate_tool_output,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class ToolRuntimeError(Exception):
"""Base error for tool runtime failures."""
class ToolAccessDeniedError(ToolRuntimeError):
"""Raised when a tool is invoked in violation of access constraints.
For example, a tool with ``writes=True`` invoked from a read-only plan.
"""
class ToolCheckpointRequiredError(ToolRuntimeError):
"""Raised when a plan requires checkpoints but the tool is not checkpointable."""
class ToolNotActivatedError(ToolRuntimeError):
"""Raised when execute is called on a tool that has not been activated."""
class ToolActivationError(ToolRuntimeError):
"""Raised when tool activation fails."""
class ToolExecutionError(ToolRuntimeError):
"""Raised when tool execution fails."""
class ToolDeactivationError(ToolRuntimeError):
"""Raised when tool deactivation fails."""
# ---------------------------------------------------------------------------
# Tool descriptor
# ---------------------------------------------------------------------------
class ToolDescriptor(BaseModel):
"""Lightweight metadata returned by ``discover()``.
Describes a tool's identity, capabilities, and schema without
carrying the full implementation.
"""
name: str = Field(..., description="Namespaced tool name")
description: str = Field(..., description="Human-readable description")
capability: ToolCapability = Field(
default_factory=ToolCapability,
description="Capability metadata",
)
input_schema: dict[str, Any] | None = Field(
default=None, description="JSON Schema for inputs"
)
output_schema: dict[str, Any] | None = Field(
default=None, description="JSON Schema for outputs"
)
source: str = Field("builtin", description="Tool source identifier")
model_config = ConfigDict(str_strip_whitespace=True)
# ---------------------------------------------------------------------------
# Tool result
# ---------------------------------------------------------------------------
class ToolResult(BaseModel):
"""Structured result from a tool execution.
Wraps the output data, any changes produced, and metadata about the
execution.
"""
success: bool = Field(True, description="Whether execution succeeded")
data: Any = Field(default=None, description="Tool output data")
changes: list[Change] = Field(default_factory=list, description="Changes produced")
error: str | None = Field(default=None, description="Error message if failed")
model_config = ConfigDict(str_strip_whitespace=True)
# ---------------------------------------------------------------------------
# Tool instance protocol
# ---------------------------------------------------------------------------
@runtime_checkable
class ToolInstance(Protocol):
"""Protocol for concrete tool implementations.
Each tool source (MCP, Agent Skill, built-in, custom) provides an
implementation of this protocol. The lifecycle methods are called
by ``ToolRuntime`` in strict order.
"""
def discover(self) -> ToolDescriptor:
"""Return lightweight metadata about the tool.
Called during tool registration to enumerate available tools.
Must be cheap (~50-100 tokens for Agent Skills).
"""
...
def activate(self, ctx: ToolExecutionContext) -> None:
"""Prepare the tool for execution within a plan context.
Called at most once per plan. May start external processes,
verify connectivity, load instructions into agent context, etc.
Raises ``ToolActivationError`` on failure.
"""
...
def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
"""Execute the tool with given parameters.
Must only be called after ``activate()``. The ``ctx`` carries
resource bindings, sandbox state, and the cancellation token.
Tools should check ``ctx.cancellation_token.is_cancelled``
periodically for long-running operations.
Returns a ``ToolResult`` with output data and any changes.
"""
...
def deactivate(self, ctx: ToolExecutionContext) -> None:
"""Clean up after all executions within a plan are done.
Called once per plan when the plan completes, fails, or is
cancelled. Must be idempotent and must not raise.
"""
...
# ---------------------------------------------------------------------------
# Lifecycle cache
# ---------------------------------------------------------------------------
class _ActivationEntry:
"""Internal cache entry for an activated tool instance."""
__slots__ = ("activated_at", "execution_count", "instance")
def __init__(self, instance: ToolInstance, activated_at: str) -> None:
self.instance = instance
self.activated_at = activated_at
self.execution_count = 0
class ToolLifecycleCache:
"""Thread-safe per-plan activation cache.
Ensures each tool is activated at most once per plan and guarantees
``deactivate()`` is called for every activated tool when the plan
ends.
"""
def __init__(self) -> None:
self._lock = threading.RLock()
# plan_id -> {tool_name -> _ActivationEntry}
self._cache: dict[str, dict[str, _ActivationEntry]] = {}
def get(self, plan_id: str, tool_name: str) -> ToolInstance | None:
"""Get an activated instance, or None if not cached."""
with self._lock:
plan_cache = self._cache.get(plan_id)
if plan_cache is None:
return None
entry = plan_cache.get(tool_name)
return entry.instance if entry else None
def put(self, plan_id: str, tool_name: str, instance: ToolInstance) -> None:
"""Store an activated instance in the cache."""
with self._lock:
if plan_id not in self._cache:
self._cache[plan_id] = {}
self._cache[plan_id][tool_name] = _ActivationEntry(
instance=instance,
activated_at=datetime.now(UTC).isoformat(),
)
def increment_execution(self, plan_id: str, tool_name: str) -> None:
"""Increment the execution count for a cached tool."""
with self._lock:
plan_cache = self._cache.get(plan_id)
if plan_cache and tool_name in plan_cache:
plan_cache[tool_name].execution_count += 1
def get_plan_tools(self, plan_id: str) -> list[str]:
"""Return the names of all activated tools for a plan."""
with self._lock:
plan_cache = self._cache.get(plan_id)
return list(plan_cache.keys()) if plan_cache else []
def remove(self, plan_id: str, tool_name: str) -> ToolInstance | None:
"""Remove and return an activated instance from the cache."""
with self._lock:
plan_cache = self._cache.get(plan_id)
if plan_cache and tool_name in plan_cache:
entry = plan_cache.pop(tool_name)
if not plan_cache:
del self._cache[plan_id]
return entry.instance
return None
def remove_plan(self, plan_id: str) -> dict[str, ToolInstance]:
"""Remove all activated instances for a plan.
Returns a mapping of tool_name -> instance for deactivation.
"""
with self._lock:
plan_cache = self._cache.pop(plan_id, {})
return {name: entry.instance for name, entry in plan_cache.items()}
def get_stats(self, plan_id: str) -> OrderedDict[str, Any]:
"""Return cache statistics for a plan."""
with self._lock:
plan_cache = self._cache.get(plan_id, {})
stats: OrderedDict[str, Any] = OrderedDict()
stats["plan_id"] = plan_id
stats["active_tools"] = len(plan_cache)
stats["tools"] = {
name: {
"activated_at": entry.activated_at,
"execution_count": entry.execution_count,
}
for name, entry in plan_cache.items()
}
return stats
@property
def plan_count(self) -> int:
"""Return the number of plans with active tools."""
with self._lock:
return len(self._cache)
# ---------------------------------------------------------------------------
# Tool runtime
# ---------------------------------------------------------------------------
class ToolRuntime:
"""Orchestrates the four-stage tool lifecycle.
Responsibilities:
- Capability enforcement (read-only plans, checkpoint requirements)
- JSON Schema validation of inputs and outputs
- Per-plan activation caching with guaranteed deactivation
- Execution tracing (timestamps, duration, result size)
- Cancellation propagation to long-running tools
Parameters
----------
tools:
Mapping of tool name -> Tool domain model for registered tools.
instances:
Mapping of tool name -> ToolInstance for concrete implementations.
"""
def __init__(
self,
*,
tools: dict[str, Tool] | None = None,
instances: dict[str, ToolInstance] | None = None,
) -> None:
self._tools: dict[str, Tool] = tools or {}
self._instances: dict[str, ToolInstance] = instances or {}
self._cache = ToolLifecycleCache()
self._lock = threading.RLock()
# -- Registration --------------------------------------------------------
def register_tool(self, tool: Tool, instance: ToolInstance) -> None:
"""Register a tool and its implementation.
Parameters
----------
tool:
The Tool domain model describing the tool.
instance:
The concrete ToolInstance implementation.
"""
with self._lock:
self._tools[tool.name] = tool
self._instances[tool.name] = instance
def unregister_tool(self, tool_name: str) -> None:
"""Remove a tool registration.
Does not deactivate existing plan-level activations; those are
handled by ``deactivate_plan()``.
"""
with self._lock:
self._tools.pop(tool_name, None)
self._instances.pop(tool_name, None)
def get_tool(self, tool_name: str) -> Tool | None:
"""Get a registered Tool domain model by name."""
return self._tools.get(tool_name)
def list_tools(self) -> list[str]:
"""Return names of all registered tools."""
return sorted(self._tools.keys())
# -- Discovery -----------------------------------------------------------
def discover(self, tool_name: str) -> ToolDescriptor:
"""Invoke the ``discover()`` lifecycle hook.
Returns a ``ToolDescriptor`` with the tool's metadata.
"""
instance = self._get_instance(tool_name)
return instance.discover()
# -- Activation ----------------------------------------------------------
def activate(self, tool_name: str, ctx: ToolExecutionContext) -> None:
"""Activate a tool for a plan context.
Uses the lifecycle cache to ensure each tool is activated at most
once per plan. Performs capability pre-checks before activation.
Raises
------
ToolAccessDeniedError:
If the plan is read-only and the tool writes.
ToolCheckpointRequiredError:
If the plan requires checkpoints and the tool is not checkpointable.
ToolActivationError:
If the tool's ``activate()`` hook fails.
"""
tool = self._get_tool(tool_name)
self._enforce_capabilities(tool, ctx)
# Check cache - already activated for this plan?
cached = self._cache.get(ctx.plan_id, tool_name)
if cached is not None:
return
instance = self._get_instance(tool_name)
try:
instance.activate(ctx)
except ToolCancelledError:
raise
except Exception as exc:
raise ToolActivationError(
f"Failed to activate tool '{tool_name}': {exc}"
) from exc
self._cache.put(ctx.plan_id, tool_name, instance)
logger.info(
"Tool activated",
extra={"tool": tool_name, "plan_id": ctx.plan_id},
)
# -- Execution -----------------------------------------------------------
def execute(
self,
tool_name: str,
params: dict[str, Any],
ctx: ToolExecutionContext,
) -> ToolResult:
"""Execute a tool with given parameters.
Performs the full execution pipeline:
1. Check cancellation
2. Auto-activate if not yet activated for this plan
3. Enforce capability flags
4. Validate inputs against JSON Schema
5. Execute the tool
6. Validate outputs against JSON Schema
7. Record trace
Parameters
----------
tool_name:
Namespaced name of the tool to execute.
params:
Input parameters for the tool.
ctx:
Execution context with plan metadata, resources, cancellation.
Returns
-------
ToolResult:
The structured result from the tool execution.
"""
# 1. Check cancellation before starting
ctx.cancellation_token.check()
tool = self._get_tool(tool_name)
# 2. Enforce capability flags
self._enforce_capabilities(tool, ctx)
# 3. Auto-activate if needed
self.activate(tool_name, ctx)
# 4. Validate inputs
if tool.input_schema:
try:
validate_tool_input(params, tool.input_schema)
except ToolSchemaValidationError as exc:
return ToolResult(
success=False,
error=f"Input validation failed: {exc}",
)
# 5. Execute with tracing
started_at = datetime.now(UTC)
trace = ToolExecutionTrace(
tool_name=tool_name,
started_at=started_at.isoformat(),
)
try:
# Check cancellation again right before execution
ctx.cancellation_token.check()
instance = self._cache.get(ctx.plan_id, tool_name)
if instance is None:
raise ToolNotActivatedError(
f"Tool '{tool_name}' is not activated for plan '{ctx.plan_id}'"
)
start_time = time.monotonic()
result = instance.execute(params, ctx)
elapsed_ms = (time.monotonic() - start_time) * 1000
# Record trace
ended_at = datetime.now(UTC)
trace.ended_at = ended_at.isoformat()
trace.duration_ms = round(elapsed_ms, 2)
trace.success = result.success
if result.data is not None:
trace.result_size_bytes = len(str(result.data).encode())
if result.error:
trace.error = result.error
self._cache.increment_execution(ctx.plan_id, tool_name)
except ToolCancelledError:
ended_at = datetime.now(UTC)
trace.ended_at = ended_at.isoformat()
trace.duration_ms = round((ended_at - started_at).total_seconds() * 1000, 2)
trace.success = False
trace.error = "Cancelled"
ctx.add_trace(trace)
raise
except Exception as exc:
ended_at = datetime.now(UTC)
trace.ended_at = ended_at.isoformat()
trace.duration_ms = round((ended_at - started_at).total_seconds() * 1000, 2)
trace.success = False
trace.error = str(exc)
ctx.add_trace(trace)
raise ToolExecutionError(
f"Tool '{tool_name}' execution failed: {exc}"
) from exc
ctx.add_trace(trace)
# 6. Validate outputs
if tool.output_schema and result.data is not None:
try:
validate_tool_output(result.data, tool.output_schema)
except ToolSchemaValidationError as exc:
logger.warning(
"Tool output validation failed",
extra={"tool": tool_name, "error": str(exc)},
)
return ToolResult(
success=False,
data=result.data,
changes=result.changes,
error=f"Output validation failed: {exc}",
)
# Record changes from result into context
for change in result.changes:
ctx.record_change(change)
return result
# -- Deactivation --------------------------------------------------------
def deactivate(self, tool_name: str, ctx: ToolExecutionContext) -> None:
"""Deactivate a single tool for a plan.
Removes the tool from the activation cache and calls the tool's
``deactivate()`` hook. Idempotent -- does nothing if the tool
is not activated for the plan.
"""
instance = self._cache.remove(ctx.plan_id, tool_name)
if instance is None:
return
try:
instance.deactivate(ctx)
except Exception as exc:
logger.warning(
"Tool deactivation failed (non-fatal)",
extra={"tool": tool_name, "plan_id": ctx.plan_id, "error": str(exc)},
)
logger.info(
"Tool deactivated",
extra={"tool": tool_name, "plan_id": ctx.plan_id},
)
def deactivate_plan(self, ctx: ToolExecutionContext) -> None:
"""Deactivate all tools for a plan.
Called when a plan completes, fails, or is cancelled. Guarantees
every activated tool receives a ``deactivate()`` call.
"""
instances = self._cache.remove_plan(ctx.plan_id)
for tool_name, instance in instances.items():
try:
instance.deactivate(ctx)
except Exception as exc:
logger.warning(
"Tool deactivation failed during plan cleanup (non-fatal)",
extra={
"tool": tool_name,
"plan_id": ctx.plan_id,
"error": str(exc),
},
)
if instances:
logger.info(
"All plan tools deactivated",
extra={
"plan_id": ctx.plan_id,
"tool_count": len(instances),
},
)
# -- Introspection -------------------------------------------------------
def get_cache_stats(self, plan_id: str) -> OrderedDict[str, Any]:
"""Return activation cache statistics for a plan."""
return self._cache.get_stats(plan_id)
@property
def cache_plan_count(self) -> int:
"""Return the number of plans with active tool caches."""
return self._cache.plan_count
# -- Internal helpers ----------------------------------------------------
def _get_tool(self, tool_name: str) -> Tool:
"""Get a registered Tool or raise."""
tool = self._tools.get(tool_name)
if tool is None:
raise ToolRuntimeError(f"Tool '{tool_name}' is not registered")
return tool
def _get_instance(self, tool_name: str) -> ToolInstance:
"""Get a registered ToolInstance or raise."""
instance = self._instances.get(tool_name)
if instance is None:
raise ToolRuntimeError(
f"No implementation registered for tool '{tool_name}'"
)
return instance
@staticmethod
def _enforce_capabilities(tool: Tool, ctx: ToolExecutionContext) -> None:
"""Enforce capability constraints against the execution context.
Raises
------
ToolAccessDeniedError:
If the plan is read-only and the tool has ``writes=True``.
ToolCheckpointRequiredError:
If the plan requires checkpoints and the tool is not
``checkpointable``.
"""
cap = tool.capability
# Read-only plan cannot use tools that write
if ctx.plan_read_only and not cap.read_only and cap.writes:
raise ToolAccessDeniedError(
f"Tool '{tool.name}' has writes=True but plan "
f"'{ctx.plan_id}' is read-only"
)
# Checkpoint-required plan cannot use non-checkpointable tools
if ctx.require_checkpoints and not cap.checkpointable:
raise ToolCheckpointRequiredError(
f"Tool '{tool.name}' is not checkpointable but plan "
f"'{ctx.plan_id}' requires checkpoints"
)
+124
View File
@@ -0,0 +1,124 @@
"""JSON Schema validation for tool inputs and outputs.
Validates tool parameters against their declared ``input_schema`` and
validates tool results against their declared ``output_schema`` using
the ``jsonschema`` library (JSON Schema draft 2020-12).
Based on docs/specification.md Tool Execution Flow section.
"""
from __future__ import annotations
from typing import Any
import jsonschema
import jsonschema.validators
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class ToolSchemaValidationError(Exception):
"""Raised when tool input or output fails JSON Schema validation.
Attributes
----------
errors:
List of individual validation error messages.
schema:
The JSON Schema that was used for validation.
instance:
The value that failed validation.
"""
def __init__(
self,
message: str,
*,
errors: list[str] | None = None,
schema: dict[str, Any] | None = None,
instance: Any = None,
) -> None:
super().__init__(message)
self.errors = errors or []
self.schema = schema
self.instance = instance
# ---------------------------------------------------------------------------
# Validation functions
# ---------------------------------------------------------------------------
def validate_tool_input(
params: dict[str, Any],
schema: dict[str, Any],
) -> None:
"""Validate tool input parameters against a JSON Schema.
Parameters
----------
params:
The parameters to validate.
schema:
The JSON Schema to validate against.
Raises
------
ToolSchemaValidationError:
If validation fails, with details about each error.
"""
_validate(params, schema, context="input")
def validate_tool_output(
result: Any,
schema: dict[str, Any],
) -> None:
"""Validate tool output against a JSON Schema.
Parameters
----------
result:
The output to validate.
schema:
The JSON Schema to validate against.
Raises
------
ToolSchemaValidationError:
If validation fails, with details about each error.
"""
_validate(result, schema, context="output")
def _validate(
instance: Any,
schema: dict[str, Any],
*,
context: str,
) -> None:
"""Internal validation helper.
Uses ``jsonschema.Draft202012Validator`` for JSON Schema draft 2020-12
compliance (per spec).
"""
validator_cls = jsonschema.Draft202012Validator
validator = validator_cls(schema)
errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.path))
if errors:
messages = []
for err in errors:
path = ".".join(str(p) for p in err.absolute_path) if err.path else "<root>"
messages.append(f" [{path}] {err.message}")
raise ToolSchemaValidationError(
f"Tool {context} schema validation failed "
f"({len(errors)} error{'s' if len(errors) > 1 else ''}):\n"
+ "\n".join(messages),
errors=messages,
schema=schema,
instance=instance,
)