forked from cleveragents/cleveragents-core
feat(change): add tool router for providers
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user