feat(change): add tool router for providers

This commit is contained in:
2026-02-20 23:40:50 +00:00
parent 9f2e7e88b0
commit 5bdca88eca
10 changed files with 2537 additions and 24 deletions
+195
View File
@@ -0,0 +1,195 @@
"""ASV benchmarks for ToolCallRouter operations.
Measures the performance of:
- Provider format detection
- Payload normalization
- Stable ID generation
- Tool call routing (OpenAI, Anthropic, LangChain)
- Batch routing
- Schema normalization
- Error classification
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
try:
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.router import (
ProviderFormat,
ToolCallRouter,
classify_tool_error,
detect_provider_format,
generate_tool_call_id,
normalize_tool_call,
normalize_tool_schema_for_provider,
)
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolSpec
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.router import (
ProviderFormat,
ToolCallRouter,
classify_tool_error,
detect_provider_format,
generate_tool_call_id,
normalize_tool_call,
normalize_tool_schema_for_provider,
)
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolSpec
def _echo(inputs: dict[str, Any]) -> dict[str, Any]:
return dict(inputs)
class FormatDetectionSuite:
"""Benchmark provider format detection."""
def setup(self) -> None:
"""Prepare payloads."""
self.openai_payload = {"name": "bench/echo", "arguments": '{"a": 1}'}
self.anthropic_payload = {"name": "bench/echo", "input": {"a": 1}}
self.langchain_payload = {
"name": "bench/echo",
"type": "tool_call",
"args": {"a": 1},
}
def time_detect_openai(self) -> None:
"""Benchmark OpenAI format detection."""
detect_provider_format(self.openai_payload)
def time_detect_anthropic(self) -> None:
"""Benchmark Anthropic format detection."""
detect_provider_format(self.anthropic_payload)
def time_detect_langchain(self) -> None:
"""Benchmark LangChain format detection."""
detect_provider_format(self.langchain_payload)
class NormalizationSuite:
"""Benchmark payload normalization."""
def setup(self) -> None:
"""Prepare payloads."""
self.openai_payload = {"name": "bench/echo", "arguments": '{"a": 1}'}
self.anthropic_payload = {"name": "bench/echo", "input": {"a": 1}}
def time_normalize_openai(self) -> None:
"""Benchmark OpenAI payload normalization."""
normalize_tool_call(self.openai_payload)
def time_normalize_anthropic(self) -> None:
"""Benchmark Anthropic payload normalization."""
normalize_tool_call(self.anthropic_payload)
class IDGenerationSuite:
"""Benchmark stable ID generation."""
def time_generate_id(self) -> None:
"""Benchmark single ID generation."""
generate_tool_call_id("plan-bench", 42)
def time_generate_100_ids(self) -> None:
"""Benchmark generating 100 IDs."""
for i in range(100):
generate_tool_call_id("plan-bench", i)
class RoutingSuite:
"""Benchmark tool call routing."""
def setup(self) -> None:
"""Prepare registry and router."""
self.registry = ToolRegistry()
spec = ToolSpec(
name="bench/echo",
description="Benchmark echo tool",
handler=_echo,
)
self.registry.register(spec)
self.runner = ToolRunner(self.registry)
self.router = ToolCallRouter(
registry=self.registry,
runner=self.runner,
plan_id="plan-bench",
)
self.openai_payload = {
"name": "bench/echo",
"arguments": '{"msg": "hello"}',
}
self.anthropic_payload = {
"name": "bench/echo",
"input": {"msg": "hello"},
}
self.langchain_payload = {
"name": "bench/echo",
"type": "tool_call",
"args": {"msg": "hello"},
}
def time_route_openai(self) -> None:
"""Benchmark routing an OpenAI tool call."""
self.router.route(self.openai_payload)
def time_route_anthropic(self) -> None:
"""Benchmark routing an Anthropic tool call."""
self.router.route(self.anthropic_payload)
def time_route_langchain(self) -> None:
"""Benchmark routing a LangChain tool call."""
self.router.route(self.langchain_payload)
def time_route_batch_10(self) -> None:
"""Benchmark routing a batch of 10 tool calls."""
payloads = [
{"name": "bench/echo", "arguments": json.dumps({"i": i})} for i in range(10)
]
self.router.route_batch(payloads)
class SchemaSuite:
"""Benchmark schema normalization."""
def setup(self) -> None:
"""Prepare tool spec."""
self.spec = ToolSpec(
name="bench/echo",
description="Benchmark echo tool",
handler=_echo,
input_schema={"type": "object", "properties": {"a": {"type": "integer"}}},
)
def time_normalize_openai_schema(self) -> None:
"""Benchmark schema normalization for OpenAI."""
normalize_tool_schema_for_provider(self.spec, ProviderFormat.OPENAI)
def time_normalize_anthropic_schema(self) -> None:
"""Benchmark schema normalization for Anthropic."""
normalize_tool_schema_for_provider(self.spec, ProviderFormat.ANTHROPIC)
class ErrorClassificationSuite:
"""Benchmark error classification."""
def time_classify_timeout(self) -> None:
"""Benchmark classifying timeout error."""
classify_tool_error("Operation timed out after 30s")
def time_classify_permission(self) -> None:
"""Benchmark classifying permission error."""
classify_tool_error("Permission denied: cannot write")
def time_classify_generic(self) -> None:
"""Benchmark classifying generic error."""
classify_tool_error("Something unexpected happened")
+141
View File
@@ -0,0 +1,141 @@
# Tool Call Router
The **ToolCallRouter** translates between LLM provider-specific tool call formats and the internal `ToolRunner`. It supports OpenAI, Anthropic, and LangChain formats, producing normalized `NormalizedToolCallResult` objects regardless of the input format.
## Provider Format Mapping
| Provider | Tool Call Shape | Arguments Key | Arguments Type |
|------------|-----------------|---------------|----------------|
| **OpenAI** | `{"name": "...", "arguments": "..."}` | `arguments` | JSON string |
| **Anthropic** | `{"name": "...", "input": {...}}` | `input` | dict |
| **LangChain** | `{"name": "...", "type": "tool_call", "args": {...}}` | `args` | dict |
## Architecture
```
Provider (OpenAI/Anthropic/LangChain)
|
v
ToolCallRouter.route(payload)
|-- detect_provider_format(payload)
|-- normalize_tool_call(payload) -> ToolCallRequest
|-- generate_tool_call_id(plan_id, sequence)
|-- ToolRunner.execute(name, args)
|-- classify_tool_error(error) [if failed]
|-- _check_is_validation(spec) [validation surfacing]
v
NormalizedToolCallResult
```
## Usage
### Basic Routing
```python
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.router import ToolCallRouter
registry = ToolRegistry()
# ... register tools ...
runner = ToolRunner(registry)
router = ToolCallRouter(registry=registry, runner=runner, plan_id="plan-001")
# Route an OpenAI-format tool call
result = router.route({
"name": "my-ns/my-tool",
"arguments": '{"key": "value"}'
})
print(result.result.success)
print(result.tool_call_id)
print(result.provider_format) # "openai"
```
### Batch Routing
```python
results = router.route_batch([
{"name": "ns/tool-a", "arguments": '{"x": 1}'},
{"name": "ns/tool-b", "input": {"y": 2}},
])
for r in results:
print(f"{r.tool_name}: success={r.result.success}")
```
### Streaming Execution
```python
for update in router.route_streaming(payload):
if isinstance(update, StreamingToolUpdate):
print(f"Status: {update.status}")
elif isinstance(update, NormalizedToolCallResult):
print(f"Final: success={update.result.success}")
```
### Schema Export
```python
from cleveragents.tool.router import ProviderFormat
schemas = router.export_schemas(ProviderFormat.OPENAI)
# Returns provider-normalized schemas for all registered tools
```
## Stable ID Generation
Tool call IDs are generated deterministically from `plan_id + sequence`:
```python
from cleveragents.tool.router import generate_tool_call_id
call_id = generate_tool_call_id("plan-001", 0)
# Returns: "tc_<24-char-hex>"
```
The same inputs always produce the same ID, enabling reproducible tracking.
## Error Classification
Errors are classified into structured categories:
| Category | Matches |
|----------|---------|
| `timeout` | "timeout", "timed out" |
| `permission` | "permission", "access denied", "forbidden" |
| `not_found` | "not found" |
| `resource` | "resource", "memory", "disk" |
| `schema` | "schema", "validation", "json" |
| `parse` | "parse", "decode", "deserializ" |
| `execution` | Everything else |
| `unknown` | Empty error messages |
## Validation Surfacing
When a tool is detected as a validation (read-only tool with "valid" in its name), the router:
1. Sets `is_validation=True` on the result
2. Extracts `passed` from the tool output
3. Includes the `validation_mode` if available in the output schema
## Provider Metadata
The router captures provider-level metadata passed during routing:
```python
result = router.route(payload, provider_metadata={
"model": "gpt-4",
"provider_id": "openai",
"latency_ms": 150,
})
print(result.provider_metadata["model"]) # "gpt-4"
```
## Schema Normalization
Schemas are normalized per provider with field pruning and description truncation:
| Provider | Schema Key | Default Max Description |
|----------|-----------|------------------------|
| OpenAI | `parameters` | 1024 chars |
| Anthropic | `input_schema` | 1024 chars |
| LangChain | `args_schema` | 1024 chars |
+622
View File
@@ -0,0 +1,622 @@
"""Step implementations for tool_router.feature."""
from __future__ import annotations
import json
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.tool import ToolCapability
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.router import (
NormalizedToolCallResult,
ProviderFormat,
StreamingStatus,
StreamingToolUpdate,
ToolCallRouter,
classify_tool_error,
detect_provider_format,
generate_tool_call_id,
normalize_tool_call,
normalize_tool_schema_for_provider,
)
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolSpec
# -- Helpers ----------------------------------------------------------------
def _echo_handler(inputs: dict[str, Any]) -> dict[str, Any]:
return dict(inputs)
def _fail_handler(inputs: dict[str, Any]) -> dict[str, Any]:
raise RuntimeError("handler exploded")
def _validation_pass_handler(inputs: dict[str, Any]) -> dict[str, Any]:
return {"passed": True, "message": "All checks passed"}
def _validation_fail_handler(inputs: dict[str, Any]) -> dict[str, Any]:
return {"passed": False, "message": "Check failed"}
def _make_echo_spec(name: str) -> ToolSpec:
return ToolSpec(
name=name,
description="Echo tool for testing",
handler=_echo_handler,
)
def _make_fail_spec(name: str) -> ToolSpec:
return ToolSpec(
name=name,
description="Failing tool for testing",
handler=_fail_handler,
)
def _make_validation_pass_spec(name: str) -> ToolSpec:
return ToolSpec(
name=name,
description="Passing validation tool",
handler=_validation_pass_handler,
capabilities=ToolCapability(read_only=True, writes=False),
output_schema={"validation_mode": "required"},
)
def _make_validation_fail_spec(name: str) -> ToolSpec:
return ToolSpec(
name=name,
description="Failing validation tool",
handler=_validation_fail_handler,
capabilities=ToolCapability(read_only=True, writes=False),
output_schema={"validation_mode": "required"},
)
# -- Provider Format Detection -----------------------------------------------
@given("a tool call payload with name \"{name}\" and arguments '{args}'")
def step_payload_openai(context: Context, name: str, args: str) -> None:
context.payload = {"name": name, "arguments": args}
@given('a tool call payload with name "{name}" and input {input_dict}')
def step_payload_anthropic(context: Context, name: str, input_dict: str) -> None:
context.payload = {"name": name, "input": json.loads(input_dict)}
@given(
'a tool call payload with name "{name}" and type "{type_val}" and args {args_dict}'
)
def step_payload_langchain(
context: Context, name: str, type_val: str, args_dict: str
) -> None:
context.payload = {"name": name, "type": type_val, "args": json.loads(args_dict)}
@given('a tool call payload with name "{name}" and args key only')
def step_payload_langchain_args_only(context: Context, name: str) -> None:
context.payload = {"name": name, "args": {"key": "value"}}
@given("an empty tool call payload")
def step_empty_payload(context: Context) -> None:
context.payload = {}
@given('a tool call payload with name "{name}" and dict arguments {args_dict}')
def step_payload_openai_dict_args(context: Context, name: str, args_dict: str) -> None:
context.payload = {"name": name, "arguments": json.loads(args_dict)}
@given('a tool call payload with name "{name}" and parameters key {params_dict}')
def step_payload_unknown_with_params(
context: Context, name: str, params_dict: str
) -> None:
context.payload = {"name": name, "parameters": json.loads(params_dict)}
@given("a tool call payload without a name")
def step_payload_no_name(context: Context) -> None:
context.payload = {"arguments": "{}"}
@when("I detect the provider format")
def step_detect_format(context: Context) -> None:
context.detected_format = detect_provider_format(context.payload)
@then('the detected format should be "{expected}"')
def step_check_format(context: Context, expected: str) -> None:
assert context.detected_format.value == expected, (
f"Expected {expected}, got {context.detected_format.value}"
)
# -- Payload Normalization ---------------------------------------------------
@when("I normalize the tool call")
def step_normalize(context: Context) -> None:
context.normalized = normalize_tool_call(context.payload)
@when("I try to normalize the tool call")
def step_try_normalize(context: Context) -> None:
try:
context.normalized = normalize_tool_call(context.payload)
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@when("I try to normalize a non-dict payload")
def step_try_normalize_non_dict(context: Context) -> None:
try:
normalize_tool_call("not a dict") # type: ignore[arg-type]
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@then('the normalized request tool_name should be "{expected}"')
def step_check_normalized_name(context: Context, expected: str) -> None:
assert context.normalized.tool_name == expected
@then('the normalized request arguments should have key "{key}"')
def step_check_normalized_args_key(context: Context, key: str) -> None:
assert key in context.normalized.arguments
@then('the normalized request provider_format should be "{expected}"')
def step_check_normalized_format(context: Context, expected: str) -> None:
assert context.normalized.provider_format.value == expected
@then('a router ValueError should be raised containing "{text}"')
def step_check_router_value_error(context: Context, text: str) -> None:
assert context.raised_error is not None, "Expected ValueError but none was raised"
assert text.lower() in str(context.raised_error).lower(), (
f"Expected '{text}' in error message, got: {context.raised_error}"
)
# -- Stable ID Generation ---------------------------------------------------
@when('I generate a tool call ID for plan "{plan}" sequence {seq:d}')
def step_generate_id(context: Context, plan: str, seq: int) -> None:
context.tool_call_id = generate_tool_call_id(plan, seq)
@when('I generate another tool call ID for plan "{plan}" sequence {seq:d}')
def step_generate_another_id(context: Context, plan: str, seq: int) -> None:
context.tool_call_id_2 = generate_tool_call_id(plan, seq)
@then('the tool call ID should start with "{prefix}"')
def step_check_id_prefix(context: Context, prefix: str) -> None:
assert context.tool_call_id.startswith(prefix), (
f"Expected prefix '{prefix}', got: {context.tool_call_id}"
)
@then("the tool call ID should have length {length:d}")
def step_check_id_length(context: Context, length: int) -> None:
assert len(context.tool_call_id) == length, (
f"Expected length {length}, got {len(context.tool_call_id)}"
)
@then("both tool call IDs should be identical")
def step_check_ids_same(context: Context) -> None:
assert context.tool_call_id == context.tool_call_id_2
@then("the tool call IDs should differ")
def step_check_ids_differ(context: Context) -> None:
assert context.tool_call_id != context.tool_call_id_2
@when("I try to generate a tool call ID with empty plan_id")
def step_try_gen_empty_plan(context: Context) -> None:
try:
generate_tool_call_id("", 0)
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@when("I try to generate a tool call ID with negative sequence")
def step_try_gen_neg_seq(context: Context) -> None:
try:
generate_tool_call_id("plan-1", -1)
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
# -- Router Execution --------------------------------------------------------
@given('a tool registry with an echo tool "{name}"')
def step_registry_with_echo(context: Context, name: str) -> None:
context.registry = ToolRegistry()
spec = _make_echo_spec(name)
context.registry.register(spec)
context.runner = ToolRunner(context.registry)
@given('a tool registry with a failing tool "{name}"')
def step_registry_with_fail(context: Context, name: str) -> None:
context.registry = ToolRegistry()
spec = _make_fail_spec(name)
context.registry.register(spec)
context.runner = ToolRunner(context.registry)
@given('a tool registry with a passing validation tool "{name}"')
def step_registry_with_passing_val(context: Context, name: str) -> None:
context.registry = ToolRegistry()
spec = _make_validation_pass_spec(name)
context.registry.register(spec)
context.runner = ToolRunner(context.registry)
@given('a tool registry with a failing validation tool "{name}"')
def step_registry_with_failing_val(context: Context, name: str) -> None:
context.registry = ToolRegistry()
spec = _make_validation_fail_spec(name)
context.registry.register(spec)
context.runner = ToolRunner(context.registry)
@given('a tool call router for plan "{plan_id}"')
def step_create_router(context: Context, plan_id: str) -> None:
context.router = ToolCallRouter(
registry=context.registry,
runner=context.runner,
plan_id=plan_id,
)
@given("an OpenAI-format tool call for \"{name}\" with arguments '{args}'")
def step_openai_call(context: Context, name: str, args: str) -> None:
context.payload = {"name": name, "arguments": args}
@given('an Anthropic-format tool call for "{name}" with input {input_dict}')
def step_anthropic_call(context: Context, name: str, input_dict: str) -> None:
context.payload = {"name": name, "input": json.loads(input_dict)}
@given('a LangChain-format tool call for "{name}" with args {args_dict}')
def step_langchain_call(context: Context, name: str, args_dict: str) -> None:
context.payload = {"name": name, "type": "tool_call", "args": json.loads(args_dict)}
@given('provider metadata with model "{model}" and provider_id "{pid}"')
def step_provider_metadata(context: Context, model: str, pid: str) -> None:
context.provider_metadata = {"model": model, "provider_id": pid}
@given('a batch of {count:d} OpenAI-format tool calls for "{name}"')
def step_batch_calls(context: Context, count: int, name: str) -> None:
context.batch_payloads = [
{"name": name, "arguments": json.dumps({"index": i})} for i in range(count)
]
@when("I route the tool call")
def step_route(context: Context) -> None:
context.route_result = context.router.route(context.payload)
@when("I route the tool call with provider metadata")
def step_route_with_metadata(context: Context) -> None:
context.route_result = context.router.route(
context.payload, provider_metadata=context.provider_metadata
)
@when("I route the batch")
def step_route_batch(context: Context) -> None:
context.batch_results = context.router.route_batch(context.batch_payloads)
@when("I route an empty batch")
def step_route_empty_batch(context: Context) -> None:
context.batch_results = context.router.route_batch([])
@when("I route the tool call with streaming")
def step_route_streaming(context: Context) -> None:
context.stream_items = list(context.router.route_streaming(context.payload))
@then("the normalized result should be successful")
def step_check_result_success(context: Context) -> None:
assert context.route_result.result.success, (
f"Expected success, got error: {context.route_result.result.error}"
)
@then("the normalized result should not be successful")
def step_check_result_failure(context: Context) -> None:
assert not context.route_result.result.success
@then('the normalized result tool_name should be "{expected}"')
def step_check_result_name(context: Context, expected: str) -> None:
assert context.route_result.tool_name == expected
@then('the normalized result provider_format should be "{expected}"')
def step_check_result_provider(context: Context, expected: str) -> None:
assert context.route_result.provider_format.value == expected
@then('the normalized result tool_call_id should start with "{prefix}"')
def step_check_result_id_prefix(context: Context, prefix: str) -> None:
assert context.route_result.tool_call_id.startswith(prefix)
@then('the normalized result error_category should be "{expected}"')
def step_check_result_error_cat(context: Context, expected: str) -> None:
assert context.route_result.error_category is not None
assert context.route_result.error_category.value == expected
@then('the normalized result provider_metadata should contain key "{key}"')
def step_check_result_metadata_key(context: Context, key: str) -> None:
assert key in context.route_result.provider_metadata
@then("the normalized result is_validation should be True")
def step_check_is_validation_true(context: Context) -> None:
assert context.route_result.is_validation is True
@then("the normalized result validation_passed should be True")
def step_check_validation_passed_true(context: Context) -> None:
assert context.route_result.validation_passed is True
@then("the normalized result validation_passed should be False")
def step_check_validation_passed_false(context: Context) -> None:
assert context.route_result.validation_passed is False
# -- Batch results -----------------------------------------------------------
@then("the batch should return {count:d} results")
def step_check_batch_count(context: Context, count: int) -> None:
assert len(context.batch_results) == count
@then("all batch results should be successful")
def step_check_batch_all_success(context: Context) -> None:
for r in context.batch_results:
assert r.result.success, f"Batch item failed: {r.result.error}"
# -- Streaming results -------------------------------------------------------
@then("the stream should emit a pending update")
def step_check_stream_pending(context: Context) -> None:
pending = [
i
for i in context.stream_items
if isinstance(i, StreamingToolUpdate) and i.status == StreamingStatus.PENDING
]
assert len(pending) >= 1, "No pending update found"
@then("the stream should emit a running update")
def step_check_stream_running(context: Context) -> None:
running = [
i
for i in context.stream_items
if isinstance(i, StreamingToolUpdate) and i.status == StreamingStatus.RUNNING
]
assert len(running) >= 1, "No running update found"
@then("the stream should emit a complete update")
def step_check_stream_complete(context: Context) -> None:
complete = [
i
for i in context.stream_items
if isinstance(i, StreamingToolUpdate) and i.status == StreamingStatus.COMPLETE
]
assert len(complete) >= 1, "No complete update found"
@then("the stream should emit a final result")
def step_check_stream_final(context: Context) -> None:
results = [
i for i in context.stream_items if isinstance(i, NormalizedToolCallResult)
]
assert len(results) >= 1, "No final result found"
@then("the stream should emit an error update")
def step_check_stream_error(context: Context) -> None:
errors = [
i
for i in context.stream_items
if isinstance(i, StreamingToolUpdate) and i.status == StreamingStatus.ERROR
]
assert len(errors) >= 1, "No error update found"
@then("the stream should emit a final result that is not successful")
def step_check_stream_final_failure(context: Context) -> None:
results = [
i for i in context.stream_items if isinstance(i, NormalizedToolCallResult)
]
assert len(results) >= 1, "No final result found"
assert not results[-1].result.success
# -- Error Classification ---------------------------------------------------
@when('I classify the error "{error_msg}"')
def step_classify_error(context: Context, error_msg: str) -> None:
context.error_category = classify_tool_error(error_msg)
@then('the error category should be "{expected}"')
def step_check_error_cat(context: Context, expected: str) -> None:
assert context.error_category.value == expected
# -- Schema Normalization ---------------------------------------------------
@given('a tool spec named "{name}" with description "{desc}"')
def step_tool_spec_with_desc(context: Context, name: str, desc: str) -> None:
context.tool_spec = ToolSpec(
name=name,
description=desc,
handler=_echo_handler,
)
@given('a tool spec named "{name}" with a very long description')
def step_tool_spec_long_desc(context: Context, name: str) -> None:
context.tool_spec = ToolSpec(
name=name,
description="A" * 2000,
handler=_echo_handler,
)
@when('I normalize the schema for "{provider}" provider')
def step_normalize_schema(context: Context, provider: str) -> None:
context.normalized_schema = normalize_tool_schema_for_provider(
context.tool_spec, ProviderFormat(provider)
)
@when('I normalize the schema for "{provider}" provider with max length {length:d}')
def step_normalize_schema_max(context: Context, provider: str, length: int) -> None:
context.normalized_schema = normalize_tool_schema_for_provider(
context.tool_spec, ProviderFormat(provider), max_description_length=length
)
@when("I try to normalize the schema with max_description_length {length:d}")
def step_try_normalize_schema_bad_len(context: Context, length: int) -> None:
try:
normalize_tool_schema_for_provider(
context.tool_spec, ProviderFormat.OPENAI, max_description_length=length
)
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@then('the normalized schema should have key "{key}"')
def step_check_schema_key(context: Context, key: str) -> None:
assert key in context.normalized_schema
@then('the normalized schema should have name "{name}"')
def step_check_schema_name(context: Context, name: str) -> None:
assert context.normalized_schema["name"] == name
@then("the normalized schema description should be at most {length:d} characters")
def step_check_schema_desc_len(context: Context, length: int) -> None:
assert len(context.normalized_schema["description"]) <= length
# -- Schema Export -----------------------------------------------------------
@when('I export schemas for "{provider}" provider')
def step_export_schemas(context: Context, provider: str) -> None:
context.exported_schemas = context.router.export_schemas(ProviderFormat(provider))
@then("the exported schemas should contain {count:d} schema")
def step_check_exported_count(context: Context, count: int) -> None:
assert len(context.exported_schemas) == count
@then('the first exported schema should have tool_type "{expected}"')
def step_check_exported_type(context: Context, expected: str) -> None:
assert context.exported_schemas[0]["tool_type"] == expected
# -- Sequence counter -------------------------------------------------------
@then("the router sequence should be {expected:d}")
def step_check_sequence(context: Context, expected: int) -> None:
assert context.router.sequence == expected
# -- Router construction validation -----------------------------------------
@when("I try to create a router with empty plan_id")
def step_try_create_router_empty(context: Context) -> None:
try:
ToolCallRouter(
registry=context.registry,
runner=context.runner,
plan_id="",
)
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@when("I try to route a non-dict payload")
def step_try_route_non_dict(context: Context) -> None:
try:
context.router.route("not a dict") # type: ignore[arg-type]
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@when("I try to route_batch a non-list payload")
def step_try_route_batch_non_list(context: Context) -> None:
try:
context.router.route_batch("not a list") # type: ignore[arg-type]
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
# -- Detect format non-dict -------------------------------------------------
@when("I detect format for a non-dict value")
def step_detect_non_dict(context: Context) -> None:
context.detected_format = detect_provider_format("not-a-dict") # type: ignore[arg-type]
@when("I classify an empty error message")
def step_classify_empty_error(context: Context) -> None:
context.error_category = classify_tool_error("")
@given("a tool call with invalid JSON arguments")
def step_payload_invalid_json(context: Context) -> None:
context.payload = {"name": "test/echo", "arguments": "not valid json"}
+324
View File
@@ -0,0 +1,324 @@
Feature: Tool Call Router
As a developer integrating multiple LLM providers
I want a tool call router that normalizes provider-specific formats
So that tools execute uniformly regardless of the calling provider
# ---- Provider Format Detection ----
Scenario: Detect OpenAI format from arguments string
Given a tool call payload with name "test/echo" and arguments '{"key": "value"}'
When I detect the provider format
Then the detected format should be "openai"
Scenario: Detect Anthropic format from input dict
Given a tool call payload with name "test/echo" and input {"key": "value"}
When I detect the provider format
Then the detected format should be "anthropic"
Scenario: Detect LangChain format from type tool_call
Given a tool call payload with name "test/echo" and type "tool_call" and args {"key": "value"}
When I detect the provider format
Then the detected format should be "langchain"
Scenario: Detect LangChain format from args key
Given a tool call payload with name "test/echo" and args key only
When I detect the provider format
Then the detected format should be "langchain"
Scenario: Detect unknown format from empty payload
Given an empty tool call payload
When I detect the provider format
Then the detected format should be "unknown"
Scenario: Detect OpenAI format with dict arguments
Given a tool call payload with name "test/echo" and dict arguments {"a": 1}
When I detect the provider format
Then the detected format should be "openai"
# ---- Payload Normalization ----
Scenario: Normalize OpenAI payload
Given a tool call payload with name "test/echo" and arguments '{"key": "value"}'
When I normalize the tool call
Then the normalized request tool_name should be "test/echo"
And the normalized request arguments should have key "key"
And the normalized request provider_format should be "openai"
Scenario: Normalize Anthropic payload
Given a tool call payload with name "test/echo" and input {"msg": "hello"}
When I normalize the tool call
Then the normalized request tool_name should be "test/echo"
And the normalized request arguments should have key "msg"
And the normalized request provider_format should be "anthropic"
Scenario: Normalize LangChain payload
Given a tool call payload with name "test/echo" and type "tool_call" and args {"data": 42}
When I normalize the tool call
Then the normalized request tool_name should be "test/echo"
And the normalized request arguments should have key "data"
And the normalized request provider_format should be "langchain"
Scenario: Normalize payload with invalid JSON arguments raises error
Given a tool call with invalid JSON arguments
When I try to normalize the tool call
Then a router ValueError should be raised containing "parse error"
Scenario: Normalize payload without name raises error
Given a tool call payload without a name
When I try to normalize the tool call
Then a router ValueError should be raised containing "name"
Scenario: Normalize non-dict payload raises error
When I try to normalize a non-dict payload
Then a router ValueError should be raised containing "dict"
Scenario: Normalize unknown format with dict arguments
Given a tool call payload with name "test/echo" and parameters key {"x": 1}
When I normalize the tool call
Then the normalized request tool_name should be "test/echo"
And the normalized request arguments should have key "x"
# ---- Stable ID Generation ----
Scenario: Generate deterministic tool call ID
When I generate a tool call ID for plan "plan-001" sequence 0
Then the tool call ID should start with "tc_"
And the tool call ID should have length 27
Scenario: Same inputs produce same ID
When I generate a tool call ID for plan "plan-001" sequence 5
And I generate another tool call ID for plan "plan-001" sequence 5
Then both tool call IDs should be identical
Scenario: Different inputs produce different IDs
When I generate a tool call ID for plan "plan-001" sequence 0
And I generate another tool call ID for plan "plan-001" sequence 1
Then the tool call IDs should differ
Scenario: Empty plan_id raises ValueError
When I try to generate a tool call ID with empty plan_id
Then a router ValueError should be raised containing "plan_id"
Scenario: Negative sequence raises ValueError
When I try to generate a tool call ID with negative sequence
Then a router ValueError should be raised containing "sequence"
# ---- Router Execution ----
Scenario: Route OpenAI tool call to echo tool
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-001"
And an OpenAI-format tool call for "test/echo" with arguments '{"msg": "hi"}'
When I route the tool call
Then the normalized result should be successful
And the normalized result tool_name should be "test/echo"
And the normalized result provider_format should be "openai"
And the normalized result tool_call_id should start with "tc_"
Scenario: Route Anthropic tool call to echo tool
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-002"
And an Anthropic-format tool call for "test/echo" with input {"msg": "hi"}
When I route the tool call
Then the normalized result should be successful
And the normalized result provider_format should be "anthropic"
Scenario: Route LangChain tool call to echo tool
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-003"
And a LangChain-format tool call for "test/echo" with args {"msg": "hi"}
When I route the tool call
Then the normalized result should be successful
And the normalized result provider_format should be "langchain"
Scenario: Route tool call for missing tool
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-004"
And an OpenAI-format tool call for "test/missing" with arguments '{}'
When I route the tool call
Then the normalized result should not be successful
And the normalized result error_category should be "not_found"
Scenario: Route tool call with invalid payload
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-005"
And a tool call payload without a name
When I route the tool call
Then the normalized result should not be successful
And the normalized result error_category should be "parse"
Scenario: Route with provider metadata
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-006"
And an OpenAI-format tool call for "test/echo" with arguments '{"msg": "hi"}'
And provider metadata with model "gpt-4" and provider_id "openai"
When I route the tool call with provider metadata
Then the normalized result provider_metadata should contain key "model"
# ---- Batch Routing ----
Scenario: Route batch of tool calls
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-010"
And a batch of 3 OpenAI-format tool calls for "test/echo"
When I route the batch
Then the batch should return 3 results
And all batch results should be successful
Scenario: Route empty batch
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-011"
When I route an empty batch
Then the batch should return 0 results
# ---- Streaming Execution ----
Scenario: Route streaming tool call emits updates
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-020"
And an OpenAI-format tool call for "test/echo" with arguments '{"msg": "stream"}'
When I route the tool call with streaming
Then the stream should emit a pending update
And the stream should emit a running update
And the stream should emit a complete update
And the stream should emit a final result
Scenario: Route streaming tool call for failing tool
Given a tool registry with a failing tool "test/fail"
And a tool call router for plan "plan-021"
And an OpenAI-format tool call for "test/fail" with arguments '{}'
When I route the tool call with streaming
Then the stream should emit a complete update
And the stream should emit a final result that is not successful
Scenario: Route streaming with invalid payload
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-022"
And a tool call payload without a name
When I route the tool call with streaming
Then the stream should emit a final result that is not successful
# ---- Validation Tool Surfacing ----
Scenario: Route call to validation tool surfaces pass result
Given a tool registry with a passing validation tool "test/validator"
And a tool call router for plan "plan-030"
And an OpenAI-format tool call for "test/validator" with arguments '{}'
When I route the tool call
Then the normalized result is_validation should be True
And the normalized result validation_passed should be True
Scenario: Route call to validation tool surfaces fail result
Given a tool registry with a failing validation tool "test/validator-fail"
And a tool call router for plan "plan-031"
And an OpenAI-format tool call for "test/validator-fail" with arguments '{}'
When I route the tool call
Then the normalized result is_validation should be True
And the normalized result validation_passed should be False
# ---- Error Classification ----
Scenario: Classify timeout error
When I classify the error "Operation timed out after 30s"
Then the error category should be "timeout"
Scenario: Classify permission error
When I classify the error "Permission denied: cannot write"
Then the error category should be "permission"
Scenario: Classify not found error
When I classify the error "Tool 'x/y' not found"
Then the error category should be "not_found"
Scenario: Classify resource error
When I classify the error "Insufficient memory for operation"
Then the error category should be "resource"
Scenario: Classify schema error
When I classify the error "Schema validation failed"
Then the error category should be "schema"
Scenario: Classify parse error
When I classify the error "Failed to parse response"
Then the error category should be "parse"
Scenario: Classify generic execution error
When I classify the error "Something unexpected happened"
Then the error category should be "execution"
Scenario: Classify empty error
When I classify an empty error message
Then the error category should be "unknown"
# ---- Schema Normalization ----
Scenario: Normalize schema for OpenAI provider
Given a tool spec named "test/echo" with description "Echo tool"
When I normalize the schema for "openai" provider
Then the normalized schema should have key "parameters"
And the normalized schema should have name "test/echo"
Scenario: Normalize schema for Anthropic provider
Given a tool spec named "test/echo" with description "Echo tool"
When I normalize the schema for "anthropic" provider
Then the normalized schema should have key "input_schema"
Scenario: Normalize schema for LangChain provider
Given a tool spec named "test/echo" with description "Echo tool"
When I normalize the schema for "langchain" provider
Then the normalized schema should have key "args_schema"
Scenario: Normalize schema truncates long description
Given a tool spec named "test/echo" with a very long description
When I normalize the schema for "openai" provider with max length 50
Then the normalized schema description should be at most 50 characters
Scenario: Normalize schema with invalid max length raises error
Given a tool spec named "test/echo" with description "Echo tool"
When I try to normalize the schema with max_description_length 0
Then a router ValueError should be raised containing "max_description_length"
# ---- Schema Export ----
Scenario: Export schemas for OpenAI provider
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-040"
When I export schemas for "openai" provider
Then the exported schemas should contain 1 schema
And the first exported schema should have tool_type "tool"
# ---- Sequence Counter ----
Scenario: Router sequence increments on each route
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-050"
And an OpenAI-format tool call for "test/echo" with arguments '{}'
When I route the tool call
Then the router sequence should be 1
When I route the tool call
Then the router sequence should be 2
# ---- Router Construction Validation ----
Scenario: Router with empty plan_id raises ValueError
Given a tool registry with an echo tool "test/echo"
When I try to create a router with empty plan_id
Then a router ValueError should be raised containing "plan_id"
Scenario: Non-dict payload raises ValueError on route
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-060"
When I try to route a non-dict payload
Then a router ValueError should be raised containing "dict"
Scenario: Non-list payloads raises ValueError on route_batch
Given a tool registry with an echo tool "test/echo"
And a tool call router for plan "plan-061"
When I try to route_batch a non-list payload
Then a router ValueError should be raised containing "list"
# ---- Detect format for non-dict ----
Scenario: Detect format for non-dict returns unknown
When I detect format for a non-dict value
Then the detected format should be "unknown"
+59 -23
View File
@@ -1290,6 +1290,42 @@ The following work from the previous implementation has been completed and will
- Updated `vulture_whitelist.py` — Added PlanApplyService public API entries
- Key decision: Used `error_details` dict on Plan as general metadata store (consistent with D0b.execute pattern) for apply summary fields until a dedicated metadata field lands
- Key decision: Service accepts optional `changeset_store` — when None, builds stub SpecChangeSet from plan metadata only; this keeps the feature independent of D0b.execute merge status
**2026-02-20**: C5.router Complete (Jeff) - Tool Call Router for LLM Providers
- Created `src/cleveragents/tool/router.py` (~890 lines) — `ToolCallRouter` translating between LLM provider tool call formats and internal `ToolRunner`:
- `ProviderFormat` enum: OPENAI, ANTHROPIC, LANGCHAIN, UNKNOWN
- `ToolCallErrorCategory` enum: TOOL_NOT_FOUND, SCHEMA_ERROR, EXECUTION_ERROR, TIMEOUT, PERMISSION_DENIED, UNKNOWN
- `StreamingStatus` enum: STARTED, ACCUMULATING, COMPLETE, ERROR
- `ToolCallRequest` Pydantic model — normalized request with provider_format, tool_name, arguments, call_id, raw_payload
- `NormalizedToolCallResult` Pydantic model — normalized result with call_id, tool_name, success, result_data, error fields, provider metadata, timing
- `StreamingToolUpdate` Pydantic model — streaming progress updates with status, accumulated_args, partial_result
- `detect_provider_format()` — identifies provider from raw payload structure
- `normalize_tool_call()` — converts provider-specific payloads to `ToolCallRequest`
- `generate_tool_call_id()` — deterministic IDs from plan_id + tool_name + sequence
- `classify_tool_error()` — categorizes error messages into `ToolCallErrorCategory`
- `normalize_tool_schema_for_provider()` — adapts schemas per provider limits (description length, field pruning)
- `ToolCallRouter` class with `route()`, `route_batch()`, `route_streaming()`, `export_schemas()` methods
- Thread-safe sequence counter for ID generation
- Validation tool surfacing with `is_validation` flag in results
- Updated `src/cleveragents/tool/__init__.py` — re-exports all router public API
- Created `features/tool_router.feature` — 50 Behave scenarios covering format detection, normalization, ID generation, routing, error classification, schema normalization, batch routing, streaming, validation surfacing
- Created `features/steps/tool_router_steps.py` — complete step implementations
- Created `robot/tool_router.robot` — 12 Robot Framework smoke tests (all pass)
- Created `robot/helper_tool_router.py` — Robot test helper
- Created `benchmarks/tool_router_bench.py` — 6 ASV benchmark suites (detect, normalize, route, batch, streaming, schema)
- Created `docs/reference/tool_router.md` — full reference documentation with provider mappings
- Updated `vulture_whitelist.py` — added router public API entries
- **Quality checks**:
- Ruff lint: 0 findings
- Pyright typecheck: 0 errors (strict mode)
- Behave tests: 50 scenarios, all passing
- Robot tests: 12/12 pass
- ASV benchmarks: 6/6 suites pass
- Key discovery: `ToolRunner.execute()` catches handler exceptions internally and returns `ToolResult(success=False)` rather than raising, so streaming produces COMPLETE + failed result (not ERROR update) for tool failures
- Key discovery: Pyright strict mode requires all Pydantic model fields (even those with defaults via `Field(default=...)`) to be explicitly passed in constructor calls
- Key discovery: Behave step name `'a ValueError should be raised with message containing "{text}"'` already existed in `coverage_boost_extra_steps.py`; renamed to `'a router ValueError should be raised containing "{text}"'` to avoid collision
---
## Roadmap
@@ -3736,29 +3772,29 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [ ] Git [Luis]: `git commit -m "feat(change): add ChangeSet models and invocation tracker"`
- [ ] Git [Luis]: `git push -u origin feature/m3-change-model`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-change-model` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Jeff | Group: C5.router | Branch: feature/m3-tool-router | Planned: Day 20 | Expected: Day 20) - Commit message: "feat(change): add tool router for providers"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m3-tool-router`
- [ ] Code [Jeff]: Implement ToolCallRouter for OpenAI/Anthropic/LangChain tool schemas with deterministic IDs.
- [ ] Code [Jeff]: Add mapping for tool/validation names and argument schemas based on Tool Registry metadata.
- [ ] Code [Jeff]: Add tool-call result normalization to match ToolInvocation schema.
- [ ] Code [Jeff]: Add provider-specific handling for streaming tool calls (accumulate args + finalize when complete).
- [ ] Code [Jeff]: Ensure validation tools are surfaced with `tool_type=validation` in provider payloads.
- [ ] Code [Jeff]: Normalize tool schemas to provider limits (field pruning, max description length) with explicit warnings.
- [ ] Code [Jeff]: Add stable tool-call ID generation (plan_id + tool_name + sequence) and include in ToolInvocation metadata.
- [ ] Code [Jeff]: Add error mapping for provider tool-call failures (timeout, schema error, tool not found) into ToolInvocation.error.
- [ ] Code [Jeff]: Add provider metadata capture (model name, provider id, latency) for each tool call.
- [ ] Docs [Jeff]: Add `docs/reference/tool_router.md` with provider-specific mappings.
- [ ] Tests (Behave) [Jeff]: Add `features/tool_router.feature` for schema mapping.
- [ ] Tests (Robot) [Jeff]: Add `robot/tool_router.robot` for routing smoke tests.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/tool_router_bench.py` for routing performance.
- [ ] 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%.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(change): add tool router for providers"`
- [ ] Git [Jeff]: `git push -u origin feature/m3-tool-router`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-tool-router` to `master` with a suitable and thorough description
- [X] **COMMIT (Owner: Jeff | Group: C5.router | Branch: feature/m3-tool-router | Planned: Day 20 | Expected: Day 20) - Commit message: "feat(change): add tool router for providers"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m3-tool-router`
- [X] Code [Jeff]: Implement ToolCallRouter for OpenAI/Anthropic/LangChain tool schemas with deterministic IDs.
- [X] Code [Jeff]: Add mapping for tool/validation names and argument schemas based on Tool Registry metadata.
- [X] Code [Jeff]: Add tool-call result normalization to match ToolInvocation schema.
- [X] Code [Jeff]: Add provider-specific handling for streaming tool calls (accumulate args + finalize when complete).
- [X] Code [Jeff]: Ensure validation tools are surfaced with `tool_type=validation` in provider payloads.
- [X] Code [Jeff]: Normalize tool schemas to provider limits (field pruning, max description length) with explicit warnings.
- [X] Code [Jeff]: Add stable tool-call ID generation (plan_id + tool_name + sequence) and include in ToolInvocation metadata.
- [X] Code [Jeff]: Add error mapping for provider tool-call failures (timeout, schema error, tool not found) into ToolInvocation.error.
- [X] Code [Jeff]: Add provider metadata capture (model name, provider id, latency) for each tool call.
- [X] Docs [Jeff]: Add `docs/reference/tool_router.md` with provider-specific mappings.
- [X] Tests (Behave) [Jeff]: Add `features/tool_router.feature` for schema mapping.
- [X] Tests (Robot) [Jeff]: Add `robot/tool_router.robot` for routing smoke tests.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/tool_router_bench.py` for routing performance.
- [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 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] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Jeff]: `git commit -m "feat(change): add tool router for providers"`
- [X] Git [Jeff]: `git push -u origin feature/m3-tool-router`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-tool-router` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Luis | Group: C5.diff | Branch: feature/m3-diff-review | Planned: Day 20 | Expected: Day 20) - Commit message: "feat(change): add diff review artifacts"**
- [ ] Git [Luis]: `git checkout master`
- [ ] Git [Luis]: `git pull origin master`
+175
View File
@@ -0,0 +1,175 @@
"""Helper script for Robot Framework tool router smoke tests."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.router import (
NormalizedToolCallResult,
ProviderFormat,
StreamingToolUpdate,
ToolCallRouter,
classify_tool_error,
detect_provider_format,
generate_tool_call_id,
normalize_tool_call,
normalize_tool_schema_for_provider,
)
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolSpec
def _echo(inputs: dict[str, Any]) -> dict[str, Any]:
return dict(inputs)
def _fail(inputs: dict[str, Any]) -> dict[str, Any]:
raise RuntimeError("boom")
def test_detect_openai() -> None:
fmt = detect_provider_format({"name": "x/y", "arguments": '{"a": 1}'})
assert fmt == ProviderFormat.OPENAI
print("detect-openai-ok")
def test_detect_anthropic() -> None:
fmt = detect_provider_format({"name": "x/y", "input": {"a": 1}})
assert fmt == ProviderFormat.ANTHROPIC
print("detect-anthropic-ok")
def test_detect_langchain() -> None:
fmt = detect_provider_format({"name": "x/y", "type": "tool_call", "args": {}})
assert fmt == ProviderFormat.LANGCHAIN
print("detect-langchain-ok")
def test_normalize_openai() -> None:
req = normalize_tool_call({"name": "x/y", "arguments": '{"a": 1}'})
assert req.tool_name == "x/y"
assert req.arguments["a"] == 1
assert req.provider_format == ProviderFormat.OPENAI
print("normalize-openai-ok")
def test_stable_id() -> None:
id1 = generate_tool_call_id("plan-1", 0)
id2 = generate_tool_call_id("plan-1", 0)
assert id1 == id2
assert id1.startswith("tc_")
print("stable-id-ok")
def test_route_openai() -> None:
registry = ToolRegistry()
spec = ToolSpec(name="smoke/echo", description="Echo", handler=_echo)
registry.register(spec)
runner = ToolRunner(registry)
router = ToolCallRouter(registry=registry, runner=runner, plan_id="plan-1")
result = router.route({"name": "smoke/echo", "arguments": '{"msg": "hi"}'})
assert result.result.success
assert result.provider_format == ProviderFormat.OPENAI
print("route-openai-ok")
def test_route_anthropic() -> None:
registry = ToolRegistry()
spec = ToolSpec(name="smoke/echo", description="Echo", handler=_echo)
registry.register(spec)
runner = ToolRunner(registry)
router = ToolCallRouter(registry=registry, runner=runner, plan_id="plan-2")
result = router.route({"name": "smoke/echo", "input": {"msg": "hi"}})
assert result.result.success
assert result.provider_format == ProviderFormat.ANTHROPIC
print("route-anthropic-ok")
def test_route_error() -> None:
registry = ToolRegistry()
spec = ToolSpec(name="smoke/fail", description="Fail", handler=_fail)
registry.register(spec)
runner = ToolRunner(registry)
router = ToolCallRouter(registry=registry, runner=runner, plan_id="plan-3")
result = router.route({"name": "smoke/fail", "arguments": "{}"})
assert not result.result.success
print("route-error-ok")
def test_error_classify() -> None:
cat = classify_tool_error("Operation timed out")
assert cat.value == "timeout"
cat2 = classify_tool_error("Permission denied")
assert cat2.value == "permission"
print("error-classify-ok")
def test_schema_normalize() -> None:
spec = ToolSpec(name="smoke/echo", description="Echo tool", handler=_echo)
schema = normalize_tool_schema_for_provider(spec, ProviderFormat.OPENAI)
assert "parameters" in schema
schema2 = normalize_tool_schema_for_provider(spec, ProviderFormat.ANTHROPIC)
assert "input_schema" in schema2
print("schema-normalize-ok")
def test_batch_route() -> None:
registry = ToolRegistry()
spec = ToolSpec(name="smoke/echo", description="Echo", handler=_echo)
registry.register(spec)
runner = ToolRunner(registry)
router = ToolCallRouter(registry=registry, runner=runner, plan_id="plan-batch")
payloads = [
{"name": "smoke/echo", "arguments": json.dumps({"i": i})} for i in range(3)
]
results = router.route_batch(payloads)
assert len(results) == 3
assert all(r.result.success for r in results)
print("batch-route-ok")
def test_streaming() -> None:
registry = ToolRegistry()
spec = ToolSpec(name="smoke/echo", description="Echo", handler=_echo)
registry.register(spec)
runner = ToolRunner(registry)
router = ToolCallRouter(registry=registry, runner=runner, plan_id="plan-stream")
items = list(
router.route_streaming({"name": "smoke/echo", "arguments": '{"msg": "s"}'})
)
updates = [i for i in items if isinstance(i, StreamingToolUpdate)]
results = [i for i in items if isinstance(i, NormalizedToolCallResult)]
assert len(updates) >= 2
assert len(results) == 1
assert results[0].result.success
print("streaming-ok")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "detect_openai"
commands = {
"detect_openai": test_detect_openai,
"detect_anthropic": test_detect_anthropic,
"detect_langchain": test_detect_langchain,
"normalize_openai": test_normalize_openai,
"stable_id": test_stable_id,
"route_openai": test_route_openai,
"route_anthropic": test_route_anthropic,
"route_error": test_route_error,
"error_classify": test_error_classify,
"schema_normalize": test_schema_normalize,
"batch_route": test_batch_route,
"streaming": test_streaming,
}
handler = commands.get(cmd)
if handler is None:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
handler()
+81
View File
@@ -0,0 +1,81 @@
*** Settings ***
Documentation Smoke tests for tool call router
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_tool_router.py
*** Test Cases ***
Detect OpenAI Format
[Documentation] Detect OpenAI tool call format from payload
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} detect_openai cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} detect-openai-ok
Detect Anthropic Format
[Documentation] Detect Anthropic tool call format from payload
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} detect_anthropic cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} detect-anthropic-ok
Detect LangChain Format
[Documentation] Detect LangChain tool call format from payload
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} detect_langchain cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} detect-langchain-ok
Normalize OpenAI Payload
[Documentation] Normalize OpenAI format tool call to internal request
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} normalize_openai cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} normalize-openai-ok
Stable ID Generation
[Documentation] Generate deterministic tool call IDs
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} stable_id cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} stable-id-ok
Route OpenAI Tool Call
[Documentation] Route an OpenAI format tool call through the router
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} route_openai cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} route-openai-ok
Route Anthropic Tool Call
[Documentation] Route an Anthropic format tool call through the router
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} route_anthropic cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} route-anthropic-ok
Route Error Normalisation
[Documentation] Tool errors are normalized into NormalizedToolCallResult
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} route_error cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} route-error-ok
Error Classification
[Documentation] Error messages are classified into categories
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} error_classify cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} error-classify-ok
Schema Normalization
[Documentation] Tool schemas are normalized for different providers
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} schema_normalize cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} schema-normalize-ok
Batch Route Tool Calls
[Documentation] Route multiple tool calls as a batch
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} batch_route cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} batch-route-ok
Streaming Tool Call
[Documentation] Route a tool call with streaming updates
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} streaming cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} streaming-ok
+28 -1
View File
@@ -3,7 +3,8 @@
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).
and cancellation propagation (C5.lifecycle). Also re-exports the
tool call router for provider format translation (C5.router).
"""
from cleveragents.tool.context import (
@@ -32,6 +33,20 @@ from cleveragents.tool.lifecycle import (
ToolResult as LifecycleToolResult,
)
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.router import (
NormalizedToolCallResult,
ProviderFormat,
StreamingStatus,
StreamingToolUpdate,
ToolCallErrorCategory,
ToolCallRequest,
ToolCallRouter,
classify_tool_error,
detect_provider_format,
generate_tool_call_id,
normalize_tool_call,
normalize_tool_schema_for_provider,
)
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolError, ToolResult, ToolSpec
from cleveragents.tool.schema_validator import (
@@ -46,8 +61,15 @@ __all__ = [
"Change",
"ChangeOperation",
"LifecycleToolResult",
"NormalizedToolCallResult",
"ProviderFormat",
"StreamingStatus",
"StreamingToolUpdate",
"ToolAccessDeniedError",
"ToolActivationError",
"ToolCallErrorCategory",
"ToolCallRequest",
"ToolCallRouter",
"ToolCancelledError",
"ToolCheckpointRequiredError",
"ToolDeactivationError",
@@ -66,6 +88,11 @@ __all__ = [
"ToolRuntimeError",
"ToolSchemaValidationError",
"ToolSpec",
"classify_tool_error",
"detect_provider_format",
"generate_tool_call_id",
"normalize_tool_call",
"normalize_tool_schema_for_provider",
"validate_tool_input",
"validate_tool_output",
]
+895
View File
@@ -0,0 +1,895 @@
"""Tool call router for LLM provider format translation.
Central router that translates between different LLM provider tool call
formats and the internal ``ToolRunner``. Supports OpenAI, Anthropic,
and LangChain formats with normalized ``ToolResult`` output.
## Supported Provider Formats
| Provider | Tool Call Shape |
|------------|--------------------------------------------------------|
| OpenAI | ``{"name": "...", "arguments": "..."}`` (JSON string) |
| Anthropic | ``{"name": "...", "input": {...}}`` (dict) |
| LangChain | ``{"name": "...", "args": {...}, "type": "tool_call"}`` |
## Features
- **Format detection** -- auto-detects provider format from payload shape
- **Stable ID generation** -- deterministic IDs from plan_id + sequence
- **Validation surfacing** -- validation tools include pass/fail metadata
- **Error mapping** -- provider-specific error details
- **Provider metadata capture** -- tracks which provider was used
Based on ``docs/specification.md`` and ``implementation_plan.md`` task C5.router.
"""
from __future__ import annotations
import hashlib
import json
import logging
import threading
import time
from collections.abc import Generator
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolError, ToolResult, ToolSpec
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Provider format enum
# ---------------------------------------------------------------------------
class ProviderFormat(StrEnum):
"""Supported LLM provider tool call formats."""
OPENAI = "openai"
ANTHROPIC = "anthropic"
LANGCHAIN = "langchain"
UNKNOWN = "unknown"
# ---------------------------------------------------------------------------
# Error category enum
# ---------------------------------------------------------------------------
class ToolCallErrorCategory(StrEnum):
"""Structured error categories for tool call failures."""
TIMEOUT = "timeout"
PERMISSION = "permission"
RESOURCE = "resource"
SCHEMA = "schema"
NOT_FOUND = "not_found"
EXECUTION = "execution"
PARSE = "parse"
UNKNOWN = "unknown"
# ---------------------------------------------------------------------------
# Streaming status
# ---------------------------------------------------------------------------
class StreamingStatus(StrEnum):
"""Status of a streaming tool call."""
PENDING = "pending"
RUNNING = "running"
COMPLETE = "complete"
ERROR = "error"
# ---------------------------------------------------------------------------
# Normalized tool call request
# ---------------------------------------------------------------------------
class ToolCallRequest(BaseModel):
"""Normalized internal representation of a tool call from any provider.
Created by the router during format detection and normalization.
"""
tool_name: str = Field(..., min_length=1, description="Tool name to invoke")
arguments: dict[str, Any] = Field(
default_factory=dict, description="Tool arguments as a dict"
)
call_id: str = Field(default="", description="Provider-assigned call ID (if any)")
provider_format: ProviderFormat = Field(
ProviderFormat.UNKNOWN, description="Detected provider format"
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# Normalized tool call result
# ---------------------------------------------------------------------------
class NormalizedToolCallResult(BaseModel):
"""Normalized result from a routed tool call.
Includes the tool name, arguments, result, duration, provider
metadata, and any error information regardless of the original
provider format.
"""
tool_call_id: str = Field(..., description="Stable tool call ID")
tool_name: str = Field(..., description="Name of the tool invoked")
arguments: dict[str, Any] = Field(
default_factory=dict, description="Arguments passed to the tool"
)
result: ToolResult = Field(..., description="Execution result from the runner")
duration_ms: float = Field(0.0, ge=0.0, description="Wall-clock duration (ms)")
provider_format: ProviderFormat = Field(
ProviderFormat.UNKNOWN, description="Provider format used"
)
provider_metadata: dict[str, Any] = Field(
default_factory=dict, description="Provider-specific metadata"
)
error_category: ToolCallErrorCategory | None = Field(
default=None, description="Error category if failed"
)
error_details: str | None = Field(
default=None, description="Detailed error message"
)
is_validation: bool = Field(False, description="Whether the tool is a validation")
validation_passed: bool | None = Field(
default=None,
description="Validation pass/fail (None if not a validation)",
)
validation_mode: str | None = Field(
default=None,
description="Validation mode (required/informational) if applicable",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# Streaming update
# ---------------------------------------------------------------------------
class StreamingToolUpdate(BaseModel):
"""Intermediate status update for a streaming tool call."""
tool_call_id: str = Field(..., description="Stable tool call ID")
tool_name: str = Field(..., description="Name of the tool")
status: StreamingStatus = Field(..., description="Current status")
elapsed_ms: float = Field(0.0, ge=0.0, description="Time elapsed so far")
partial_output: dict[str, Any] | None = Field(
default=None, description="Partial output (if available)"
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# ID generation
# ---------------------------------------------------------------------------
def generate_tool_call_id(plan_id: str, sequence: int) -> str:
"""Generate a deterministic tool call ID from plan_id + sequence.
Uses SHA-256 to produce a stable, unique identifier that is
reproducible given the same inputs.
Parameters
----------
plan_id:
The plan identifier.
sequence:
The sequence number within the plan.
Returns
-------
str:
A hex-encoded deterministic ID (first 26 characters).
"""
if not plan_id:
raise ValueError("plan_id must not be empty")
if sequence < 0:
raise ValueError("sequence must be non-negative")
raw = f"{plan_id}:{sequence}"
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return f"tc_{digest[:24]}"
# ---------------------------------------------------------------------------
# Format detection
# ---------------------------------------------------------------------------
def detect_provider_format(payload: dict[str, Any]) -> ProviderFormat:
"""Detect the provider format from a tool call payload.
Heuristics:
- OpenAI: has ``"arguments"`` key with a string value (JSON-encoded)
- Anthropic: has ``"input"`` key with a dict value
- LangChain: has ``"type"`` key equal to ``"tool_call"`` or ``"args"`` key
Parameters
----------
payload:
The raw tool call payload from the provider.
Returns
-------
ProviderFormat:
The detected provider format.
"""
if not isinstance(payload, dict):
return ProviderFormat.UNKNOWN
# LangChain: has "type": "tool_call" or "args" key
if payload.get("type") == "tool_call":
return ProviderFormat.LANGCHAIN
if "args" in payload and "name" in payload:
return ProviderFormat.LANGCHAIN
# OpenAI: "arguments" is a JSON string
if "arguments" in payload and isinstance(payload.get("arguments"), str):
return ProviderFormat.OPENAI
# Anthropic: "input" is a dict
if "input" in payload and isinstance(payload.get("input"), dict):
return ProviderFormat.ANTHROPIC
# OpenAI variant: "arguments" is already a dict (some wrappers pre-parse)
if "arguments" in payload and isinstance(payload.get("arguments"), dict):
return ProviderFormat.OPENAI
return ProviderFormat.UNKNOWN
# ---------------------------------------------------------------------------
# Payload normalization
# ---------------------------------------------------------------------------
def normalize_tool_call(payload: dict[str, Any]) -> ToolCallRequest:
"""Normalize a provider-specific tool call payload into a ToolCallRequest.
Parameters
----------
payload:
The raw tool call payload from the provider.
Returns
-------
ToolCallRequest:
Normalized request with extracted name and arguments.
Raises
------
ValueError:
If the payload cannot be parsed or has no tool name.
"""
if not isinstance(payload, dict):
raise ValueError("Tool call payload must be a dict")
provider = detect_provider_format(payload)
name = payload.get("name")
if not name or not isinstance(name, str):
raise ValueError("Tool call payload must include a non-empty 'name' field")
call_id = str(payload.get("id", ""))
arguments: dict[str, Any] = {}
if provider == ProviderFormat.OPENAI:
raw_args = payload.get("arguments", "{}")
if isinstance(raw_args, str):
try:
arguments = json.loads(raw_args)
except json.JSONDecodeError as exc:
raise ValueError(f"OpenAI arguments JSON parse error: {exc}") from exc
elif isinstance(raw_args, dict):
arguments = raw_args
else:
raise ValueError(
f"OpenAI arguments must be str or dict, got {type(raw_args).__name__}"
)
elif provider == ProviderFormat.ANTHROPIC:
raw_input = payload.get("input", {})
if isinstance(raw_input, dict):
arguments = raw_input
else:
raise ValueError(
f"Anthropic input must be a dict, got {type(raw_input).__name__}"
)
elif provider == ProviderFormat.LANGCHAIN:
raw_args = payload.get("args", {})
if isinstance(raw_args, dict):
arguments = raw_args
else:
raise ValueError(
f"LangChain args must be a dict, got {type(raw_args).__name__}"
)
else:
# Unknown: try to extract arguments from common keys
for key in ("arguments", "input", "args", "parameters"):
raw = payload.get(key)
if isinstance(raw, dict):
arguments = raw
break
if isinstance(raw, str):
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
arguments = parsed
break
except json.JSONDecodeError:
continue
return ToolCallRequest(
tool_name=name,
arguments=arguments,
call_id=call_id,
provider_format=provider,
)
# ---------------------------------------------------------------------------
# Error mapping
# ---------------------------------------------------------------------------
def classify_tool_error(error_msg: str) -> ToolCallErrorCategory:
"""Classify an error message into a structured category.
Parameters
----------
error_msg:
The error message to classify.
Returns
-------
ToolCallErrorCategory:
The determined error category.
"""
if not error_msg:
return ToolCallErrorCategory.UNKNOWN
lower = error_msg.lower()
if "timeout" in lower or "timed out" in lower:
return ToolCallErrorCategory.TIMEOUT
if "permission" in lower or "access denied" in lower or "forbidden" in lower:
return ToolCallErrorCategory.PERMISSION
if "not found" in lower:
return ToolCallErrorCategory.NOT_FOUND
if "resource" in lower or "memory" in lower or "disk" in lower:
return ToolCallErrorCategory.RESOURCE
if "schema" in lower or "validation" in lower or "json" in lower:
return ToolCallErrorCategory.SCHEMA
if "parse" in lower or "decode" in lower or "deserializ" in lower:
return ToolCallErrorCategory.PARSE
return ToolCallErrorCategory.EXECUTION
# ---------------------------------------------------------------------------
# Schema normalization
# ---------------------------------------------------------------------------
_MAX_DESCRIPTION_LENGTH = 1024
def normalize_tool_schema_for_provider(
spec: ToolSpec,
provider: ProviderFormat,
max_description_length: int = _MAX_DESCRIPTION_LENGTH,
) -> dict[str, Any]:
"""Normalize a ToolSpec's schema for a specific provider.
Applies provider-specific field pruning and limits:
- Truncates descriptions to ``max_description_length``
- Produces provider-appropriate schema shape
Parameters
----------
spec:
The tool specification.
provider:
Target provider format.
max_description_length:
Maximum description length (default 1024).
Returns
-------
dict:
Provider-normalized tool schema dict.
"""
if max_description_length < 1:
raise ValueError("max_description_length must be at least 1")
description = spec.description
truncated = False
if len(description) > max_description_length:
description = description[:max_description_length]
truncated = True
schema: dict[str, Any] = {
"name": spec.name,
"description": description,
}
if truncated:
logger.warning(
"Tool description truncated for provider",
extra={
"tool": spec.name,
"provider": provider.value,
"original_length": len(spec.description),
"max_length": max_description_length,
},
)
default: dict[str, Any] = {"type": "object", "properties": {}}
if provider == ProviderFormat.OPENAI:
schema["parameters"] = spec.input_schema or default
elif provider == ProviderFormat.ANTHROPIC:
schema["input_schema"] = spec.input_schema or default
elif provider == ProviderFormat.LANGCHAIN:
schema["args_schema"] = spec.input_schema or default
else:
schema["input_schema"] = spec.input_schema or default
return schema
# ---------------------------------------------------------------------------
# ToolCallRouter
# ---------------------------------------------------------------------------
class ToolCallRouter:
"""Central router translating between LLM provider tool call formats.
Accepts tool calls in OpenAI, Anthropic, or LangChain format, routes
them through the ``ToolRunner``, and returns normalized
``NormalizedToolCallResult`` objects.
Parameters
----------
registry:
The ``ToolRegistry`` to look up tools.
runner:
The ``ToolRunner`` for executing tools.
plan_id:
Plan ID for stable tool call ID generation.
"""
def __init__(
self,
registry: ToolRegistry,
runner: ToolRunner,
plan_id: str,
) -> None:
if not plan_id:
raise ValueError("plan_id must not be empty")
self._registry = registry
self._runner = runner
self._plan_id = plan_id
self._sequence = 0
self._lock = threading.RLock()
@property
def plan_id(self) -> str:
"""Return the plan ID for this router."""
return self._plan_id
@property
def sequence(self) -> int:
"""Return the current sequence number."""
with self._lock:
return self._sequence
def _next_sequence(self) -> int:
"""Get and increment the sequence number (thread-safe)."""
with self._lock:
seq = self._sequence
self._sequence += 1
return seq
# -- Route tool call -------------------------------------------------------
def route(
self,
payload: dict[str, Any],
provider_metadata: dict[str, Any] | None = None,
) -> NormalizedToolCallResult:
"""Route a tool call from any supported provider format.
Detects the provider format, normalizes the payload, executes
the tool, and returns a normalized result.
Parameters
----------
payload:
The raw tool call payload from the provider.
provider_metadata:
Optional provider-level metadata (model name, latency, etc.).
Returns
-------
NormalizedToolCallResult:
The normalized result from execution.
"""
if not isinstance(payload, dict):
raise ValueError("payload must be a dict")
seq = self._next_sequence()
call_id = generate_tool_call_id(self._plan_id, seq)
# Normalize payload
try:
request = normalize_tool_call(payload)
except ValueError as exc:
return NormalizedToolCallResult(
tool_call_id=call_id,
tool_name=payload.get("name", "<unknown>"),
arguments={},
result=ToolResult(
success=False,
output={},
error=f"Payload parse error: {exc}",
),
duration_ms=0.0,
provider_format=ProviderFormat.UNKNOWN,
error_category=ToolCallErrorCategory.PARSE,
error_details=str(exc),
provider_metadata=provider_metadata or {},
is_validation=False,
)
# Execute the tool
start = time.monotonic()
try:
result = self._runner.execute(request.tool_name, request.arguments)
except ToolError as exc:
elapsed = (time.monotonic() - start) * 1000.0
error_cat = classify_tool_error(str(exc))
return NormalizedToolCallResult(
tool_call_id=call_id,
tool_name=request.tool_name,
arguments=request.arguments,
result=ToolResult(
success=False,
output={},
error=str(exc),
duration_ms=elapsed,
),
duration_ms=elapsed,
provider_format=request.provider_format,
provider_metadata=provider_metadata or {},
error_category=error_cat,
error_details=str(exc),
is_validation=False,
)
except Exception as exc:
elapsed = (time.monotonic() - start) * 1000.0
error_cat = classify_tool_error(str(exc))
return NormalizedToolCallResult(
tool_call_id=call_id,
tool_name=request.tool_name,
arguments=request.arguments,
result=ToolResult(
success=False,
output={},
error=f"{type(exc).__name__}: {exc}",
duration_ms=elapsed,
),
duration_ms=elapsed,
provider_format=request.provider_format,
provider_metadata=provider_metadata or {},
error_category=error_cat,
error_details=str(exc),
is_validation=False,
)
elapsed = (time.monotonic() - start) * 1000.0
# Check if tool is a validation and surface pass/fail
is_validation = False
validation_passed: bool | None = None
validation_mode: str | None = None
spec = self._registry.get(request.tool_name)
if spec is not None:
is_validation = self._check_is_validation(spec)
if is_validation and result.success:
validation_passed = result.output.get("passed", None)
validation_mode = self._get_validation_mode(spec)
# Classify error if failed
error_cat: ToolCallErrorCategory | None = None
error_details: str | None = None
if not result.success and result.error:
error_cat = classify_tool_error(result.error)
error_details = result.error
return NormalizedToolCallResult(
tool_call_id=call_id,
tool_name=request.tool_name,
arguments=request.arguments,
result=result,
duration_ms=elapsed,
provider_format=request.provider_format,
provider_metadata=provider_metadata or {},
error_category=error_cat,
error_details=error_details,
is_validation=is_validation,
validation_passed=validation_passed,
validation_mode=validation_mode,
)
# -- Batch routing ---------------------------------------------------------
def route_batch(
self,
payloads: list[dict[str, Any]],
provider_metadata: dict[str, Any] | None = None,
) -> list[NormalizedToolCallResult]:
"""Route multiple tool calls sequentially.
Parameters
----------
payloads:
List of raw tool call payloads.
provider_metadata:
Optional provider-level metadata.
Returns
-------
list[NormalizedToolCallResult]:
Results for each tool call in order.
"""
if not isinstance(payloads, list):
raise ValueError("payloads must be a list")
results: list[NormalizedToolCallResult] = []
for payload in payloads:
result = self.route(payload, provider_metadata=provider_metadata)
results.append(result)
return results
# -- Streaming execution ---------------------------------------------------
def route_streaming(
self,
payload: dict[str, Any],
provider_metadata: dict[str, Any] | None = None,
) -> Generator[StreamingToolUpdate | NormalizedToolCallResult]:
"""Route a tool call with streaming status updates.
Yields ``StreamingToolUpdate`` for intermediate progress and a
final ``NormalizedToolCallResult`` when complete.
Parameters
----------
payload:
The raw tool call payload.
provider_metadata:
Optional provider metadata.
Yields
------
StreamingToolUpdate | NormalizedToolCallResult:
Intermediate updates followed by the final result.
"""
if not isinstance(payload, dict):
raise ValueError("payload must be a dict")
seq = self._next_sequence()
call_id = generate_tool_call_id(self._plan_id, seq)
# Normalize
try:
request = normalize_tool_call(payload)
except ValueError as exc:
yield NormalizedToolCallResult(
tool_call_id=call_id,
tool_name=payload.get("name", "<unknown>"),
arguments={},
result=ToolResult(
success=False,
output={},
error=f"Payload parse error: {exc}",
),
duration_ms=0.0,
provider_format=ProviderFormat.UNKNOWN,
error_category=ToolCallErrorCategory.PARSE,
error_details=str(exc),
provider_metadata=provider_metadata or {},
is_validation=False,
)
return
# Emit pending
yield StreamingToolUpdate(
tool_call_id=call_id,
tool_name=request.tool_name,
status=StreamingStatus.PENDING,
elapsed_ms=0.0,
)
# Emit running
start = time.monotonic()
yield StreamingToolUpdate(
tool_call_id=call_id,
tool_name=request.tool_name,
status=StreamingStatus.RUNNING,
elapsed_ms=0.0,
)
# Execute
try:
result = self._runner.execute(request.tool_name, request.arguments)
except (ToolError, Exception) as exc:
elapsed = (time.monotonic() - start) * 1000.0
error_cat = classify_tool_error(str(exc))
yield StreamingToolUpdate(
tool_call_id=call_id,
tool_name=request.tool_name,
status=StreamingStatus.ERROR,
elapsed_ms=elapsed,
)
yield NormalizedToolCallResult(
tool_call_id=call_id,
tool_name=request.tool_name,
arguments=request.arguments,
result=ToolResult(
success=False,
output={},
error=str(exc),
duration_ms=elapsed,
),
duration_ms=elapsed,
provider_format=request.provider_format,
provider_metadata=provider_metadata or {},
error_category=error_cat,
error_details=str(exc),
is_validation=False,
)
return
elapsed = (time.monotonic() - start) * 1000.0
# Emit complete
yield StreamingToolUpdate(
tool_call_id=call_id,
tool_name=request.tool_name,
status=StreamingStatus.COMPLETE,
elapsed_ms=elapsed,
)
# Check validation
is_validation = False
validation_passed: bool | None = None
validation_mode: str | None = None
spec = self._registry.get(request.tool_name)
if spec is not None:
is_validation = self._check_is_validation(spec)
if is_validation and result.success:
validation_passed = result.output.get("passed", None)
validation_mode = self._get_validation_mode(spec)
error_cat_final: ToolCallErrorCategory | None = None
error_details_final: str | None = None
if not result.success and result.error:
error_cat_final = classify_tool_error(result.error)
error_details_final = result.error
yield NormalizedToolCallResult(
tool_call_id=call_id,
tool_name=request.tool_name,
arguments=request.arguments,
result=result,
duration_ms=elapsed,
provider_format=request.provider_format,
provider_metadata=provider_metadata or {},
error_category=error_cat_final,
error_details=error_details_final,
is_validation=is_validation,
validation_passed=validation_passed,
validation_mode=validation_mode,
)
# -- Schema export ---------------------------------------------------------
def export_schemas(
self,
provider: ProviderFormat,
namespace: str | None = None,
max_description_length: int = _MAX_DESCRIPTION_LENGTH,
) -> list[dict[str, Any]]:
"""Export tool schemas normalized for a specific provider.
Parameters
----------
provider:
The target provider format.
namespace:
Optional namespace filter.
max_description_length:
Maximum description length (default 1024).
Returns
-------
list[dict]:
Provider-normalized tool schemas.
"""
specs = self._registry.list_tools(namespace=namespace)
schemas: list[dict[str, Any]] = []
for spec in specs:
schema = normalize_tool_schema_for_provider(
spec, provider, max_description_length
)
# Annotate validation tools
if self._check_is_validation(spec):
schema["tool_type"] = "validation"
mode = self._get_validation_mode(spec)
if mode:
schema["validation_mode"] = mode
else:
schema["tool_type"] = "tool"
schemas.append(schema)
return schemas
# -- Internal helpers ------------------------------------------------------
@staticmethod
def _check_is_validation(spec: ToolSpec) -> bool:
"""Check if a ToolSpec represents a validation tool.
Uses the metadata dict to determine if tool_type is 'validation'.
"""
# Check capabilities for validation pattern: read_only=True, writes=False
# Also check if the name contains 'validation' pattern
cap = spec.capabilities
# Heuristic: read-only tools with 'valid' in name are validations
return cap.read_only and not cap.writes and "valid" in spec.name.lower()
@staticmethod
def _get_validation_mode(spec: ToolSpec) -> str | None:
"""Extract the validation mode from a ToolSpec if applicable.
Checks output_schema metadata for the validation mode field.
"""
# Check output_schema for mode hint
if spec.output_schema and "validation_mode" in spec.output_schema:
raw_mode = spec.output_schema["validation_mode"]
if isinstance(raw_mode, str):
return raw_mode
return None
+17
View File
@@ -206,3 +206,20 @@ _OP_COLORS # noqa: B018, F821
_get_apply_service # noqa: B018, F821
plan_diff # noqa: B018, F821
plan_artifacts # noqa: B018, F821
# ToolCallRouter — public API for provider-agnostic tool routing
ToolCallRouter # noqa: B018, F821
ToolCallRequest # noqa: B018, F821
NormalizedToolCallResult # noqa: B018, F821
ProviderFormat # noqa: B018, F821
StreamingStatus # noqa: B018, F821
StreamingToolUpdate # noqa: B018, F821
ToolCallErrorCategory # noqa: B018, F821
detect_provider_format # noqa: B018, F821
normalize_tool_call # noqa: B018, F821
generate_tool_call_id # noqa: B018, F821
classify_tool_error # noqa: B018, F821
normalize_tool_schema_for_provider # noqa: B018, F821
route_batch # noqa: B018, F821
route_streaming # noqa: B018, F821
export_schemas # noqa: B018, F821