atakan
fix: Enforce parameter provenance, fix rendering/citations, add tools, harden agent loop
48ee375
Raw
History Blame Contribute Delete
6.45 kB
"""ControlAI Agent: Typed tool registry with JSON Schema validation and verifier execution."""
from __future__ import annotations
import json
import math
import re
from collections.abc import Callable
from typing import Any
import jsonschema
# Every tool computes internally at full double precision -- this only
# affects what gets reported back. 6 significant figures is well past any
# real sensor/actuator precision, so nothing engineering-relevant is lost,
# while `K = [1.7416573867739407, 0.6719633404417155]` in a chat answer
# clearly is: raw float64 repr in prose reads as noise, not rigor.
RESULT_SIGNIFICANT_FIGURES = 6
def _round_significant(x: float, sig: int = RESULT_SIGNIFICANT_FIGURES) -> float:
if x == 0 or not math.isfinite(x):
return x
digits = sig - int(math.floor(math.log10(abs(x)))) - 1
return round(x, digits)
def _round_floats(obj: Any, sig: int = RESULT_SIGNIFICANT_FIGURES) -> Any:
"""Recursively round every float in a tool result to `sig` significant
figures, leaving ints, bools, strings, and structure untouched."""
if isinstance(obj, bool):
return obj
if isinstance(obj, float):
return _round_significant(obj, sig)
if isinstance(obj, dict):
return {k: _round_floats(v, sig) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return type(obj)(_round_floats(v, sig) for v in obj)
return obj
def _parse_stringified_array(raw: str) -> Any:
"""Best-effort parse of a numeric array the model wrote as a JSON string.
The model sometimes emits `"numerator": "[10]"` or even
`"denominator": "[1 6 5 0]"` -- a string containing array-shaped text,
including MATLAB/Numpy space-separated form, instead of an actual JSON
array. Schema validation correctly rejects that as type "string" where
"array" is required, and a perfectly usable numeric tool call is lost
over pure formatting. Recover the intended array where unambiguous.
"""
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
pass
stripped = raw.strip()
if stripped.startswith("[") and stripped.endswith("]"):
spaced = re.sub(r"(?<=[\d\.\]])\s+(?=[\-\d\.\[])", ", ", stripped)
try:
return json.loads(spaced)
except json.JSONDecodeError:
pass
return raw
def _coerce_array_arguments(arguments: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
"""Recursively repair string-typed values against `"type": "array"` schema
properties (including nested arrays, e.g. matrix parameters) before
validation, so a stringified array no longer fails a tool call outright.
"""
def _coerce(value: Any, node: dict[str, Any]) -> Any:
node_type = node.get("type")
if node_type == "array" and isinstance(value, str):
value = _parse_stringified_array(value)
if node_type == "array" and isinstance(value, list) and "items" in node:
return [_coerce(v, node["items"]) for v in value]
return value
props = schema.get("properties", {})
return {
key: (_coerce(val, props[key]) if key in props else val)
for key, val in arguments.items()
}
class ToolRegistry:
"""Registry for deterministic mathematical control tools with strict JSON Schema validation."""
def __init__(self) -> None:
self._tools: dict[str, Callable[..., dict[str, Any]]] = {}
self._schemas: dict[str, dict[str, Any]] = {}
self._param_schemas: dict[str, dict[str, Any]] = {}
self._descriptions: dict[str, str] = {}
def register(
self,
name: str,
description: str,
parameters_schema: dict[str, Any],
) -> Callable:
def decorator(func: Callable[..., dict[str, Any]]) -> Callable:
self._tools[name] = func
self._descriptions[name] = description
self._param_schemas[name] = parameters_schema
self._schemas[name] = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters_schema,
},
}
return func
return decorator
def get_tool_schemas(self) -> list[dict[str, Any]]:
return list(self._schemas.values())
def get_callables(self, exclude: set[str] = frozenset()) -> dict[str, Callable[..., dict[str, Any]]]:
"""Name -> underlying function for every registered tool except `exclude`.
Used to expose the deterministic tools as plain callables inside the
execute_python_code sandbox, since the model naturally expects a tool
it knows by name (e.g. place_state_feedback) to be usable directly in
code it writes, not only through the separate tool-call protocol.
"""
return {name: fn for name, fn in self._tools.items() if name not in exclude}
def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if name not in self._tools:
return {
"status": "error",
"error": f"Tool '{name}' is not registered. Available tools: {sorted(self._tools.keys())}",
}
# 1. Strict JSON Schema Validation (after repairing stringified arrays)
param_schema = self._param_schemas[name]
arguments = _coerce_array_arguments(arguments, param_schema)
try:
jsonschema.validate(instance=arguments, schema=param_schema)
except jsonschema.ValidationError as schema_err:
return {
"status": "error",
"error_type": "SchemaValidationError",
"error": f"Invalid arguments for tool '{name}': {schema_err.message} (at path: {list(schema_err.path)})",
"expected_schema": param_schema,
}
# 2. Execution & Deterministic Calculation
try:
func = self._tools[name]
result = func(**arguments)
if "status" not in result:
result["status"] = "success"
return _round_floats(result)
except Exception as exc:
return {
"status": "error",
"error_type": type(exc).__name__,
"error": f"Execution error in '{name}': {str(exc)}",
}
registry = ToolRegistry()