4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
160 lines
5.4 KiB
Python
160 lines
5.4 KiB
Python
"""JSON schema inference and value-validation helpers for registered tools.
|
|
|
|
Inference: build a JSON Schema object from a Python function signature
|
|
(``infer_input_schema``) or a Pydantic model (``model_to_json_schema``).
|
|
|
|
Validation: check that a runtime value satisfies a JSON Schema fragment
|
|
(``_value_matches_schema``). Used by ``RegisteredTool.validate_public_input``
|
|
to reject bad planner payloads before they reach ``run()``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from collections.abc import Callable
|
|
from types import NoneType, UnionType
|
|
from typing import Any, Union, get_args, get_origin, get_type_hints
|
|
|
|
from pydantic import BaseModel
|
|
|
|
__all__ = [
|
|
"infer_input_schema",
|
|
"model_to_json_schema",
|
|
]
|
|
|
|
|
|
def _strip_optional(annotation: Any) -> tuple[Any, bool]:
|
|
origin = get_origin(annotation)
|
|
if origin is None:
|
|
return annotation, False
|
|
|
|
args = tuple(arg for arg in get_args(annotation) if arg is not NoneType)
|
|
if len(args) != len(get_args(annotation)):
|
|
if len(args) == 1:
|
|
return args[0], True
|
|
return args, True
|
|
|
|
return annotation, False
|
|
|
|
|
|
def _annotation_to_json_schema(annotation: Any) -> dict[str, Any]:
|
|
base_annotation, is_optional = _strip_optional(annotation)
|
|
origin = get_origin(base_annotation)
|
|
|
|
if base_annotation in (inspect.Signature.empty, Any):
|
|
schema: dict[str, Any] = {}
|
|
elif base_annotation is str:
|
|
schema = {"type": "string"}
|
|
elif base_annotation is int:
|
|
schema = {"type": "integer"}
|
|
elif base_annotation is float:
|
|
schema = {"type": "number"}
|
|
elif base_annotation is bool:
|
|
schema = {"type": "boolean"}
|
|
elif base_annotation is dict or origin is dict:
|
|
schema = {"type": "object"}
|
|
elif base_annotation is list or origin in (list, set, tuple):
|
|
schema = {"type": "array"}
|
|
elif origin is Union or isinstance(base_annotation, UnionType): # noqa: UP007
|
|
# Non-optional union (e.g. int | str): emit oneOf over each member.
|
|
schema = {"oneOf": [_annotation_to_json_schema(a) for a in get_args(base_annotation)]}
|
|
elif isinstance(base_annotation, tuple):
|
|
# Residual tuple produced by _strip_optional for X | Y | None with >1 non-None arm.
|
|
schema = {"oneOf": [_annotation_to_json_schema(a) for a in base_annotation]}
|
|
else:
|
|
schema = {"type": "string"}
|
|
|
|
if is_optional:
|
|
schema["nullable"] = True
|
|
return schema
|
|
|
|
|
|
def infer_input_schema(func: Callable[..., Any]) -> dict[str, Any]:
|
|
"""Infer a minimal JSON schema from a function signature."""
|
|
properties: dict[str, Any] = {}
|
|
required: list[str] = []
|
|
type_hints = get_type_hints(func)
|
|
|
|
for param in inspect.signature(func).parameters.values():
|
|
if param.kind in (
|
|
inspect.Parameter.POSITIONAL_ONLY,
|
|
inspect.Parameter.VAR_POSITIONAL,
|
|
inspect.Parameter.VAR_KEYWORD,
|
|
):
|
|
continue
|
|
|
|
if param.name.startswith("_"):
|
|
continue
|
|
|
|
resolved_annotation = type_hints.get(param.name, param.annotation)
|
|
schema = _annotation_to_json_schema(resolved_annotation)
|
|
properties[param.name] = schema
|
|
|
|
_, is_optional = _strip_optional(resolved_annotation)
|
|
if param.default is inspect.Signature.empty and not is_optional:
|
|
required.append(param.name)
|
|
|
|
return {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required,
|
|
}
|
|
|
|
|
|
def model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]:
|
|
"""Convert a Pydantic model to a JSON object schema for tools."""
|
|
schema = model.model_json_schema()
|
|
if not isinstance(schema, dict):
|
|
return {"type": "object", "properties": {}, "required": [], "additionalProperties": False}
|
|
schema.setdefault("type", "object")
|
|
if schema.get("type") == "object":
|
|
schema.setdefault("properties", {})
|
|
schema.setdefault("required", [])
|
|
schema.setdefault("additionalProperties", False)
|
|
return schema
|
|
|
|
|
|
def _json_type_matches(value: Any, schema_type: str) -> bool:
|
|
if schema_type == "string":
|
|
return isinstance(value, str)
|
|
if schema_type == "integer":
|
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
if schema_type == "number":
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
if schema_type == "boolean":
|
|
return isinstance(value, bool)
|
|
if schema_type == "array":
|
|
return isinstance(value, list)
|
|
if schema_type == "object":
|
|
return isinstance(value, dict)
|
|
return True
|
|
|
|
|
|
def _value_matches_schema(value: Any, schema: dict[str, Any]) -> bool:
|
|
if value is None and bool(schema.get("nullable")):
|
|
return True
|
|
|
|
if "enum" in schema and value not in schema.get("enum", []):
|
|
return False
|
|
|
|
one_of = schema.get("oneOf")
|
|
if isinstance(one_of, list) and one_of:
|
|
return any(
|
|
isinstance(option, dict) and _value_matches_schema(value, option) for option in one_of
|
|
)
|
|
|
|
any_of = schema.get("anyOf")
|
|
if isinstance(any_of, list) and any_of:
|
|
return any(
|
|
isinstance(option, dict) and _value_matches_schema(value, option) for option in any_of
|
|
)
|
|
|
|
schema_type = schema.get("type")
|
|
if isinstance(schema_type, str):
|
|
return _json_type_matches(value, schema_type)
|
|
if isinstance(schema_type, list):
|
|
return any(
|
|
isinstance(item, str) and _json_type_matches(value, item) for item in schema_type
|
|
)
|
|
return True
|