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
+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