Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion agentflow/core/graph/tool_node/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@

- ToolNode
- HAS_FASTMCP, HAS_MCP
- UnsupportedToolParameterError
"""

from agentflow.core.state.tool_result import ToolResult

from .base import ToolNode
from .deps import HAS_FASTMCP, HAS_MCP
from .schema import UnsupportedToolParameterError


__all__ = ["HAS_FASTMCP", "HAS_MCP", "ToolNode", "ToolResult"]
__all__ = [
"HAS_FASTMCP",
"HAS_MCP",
"ToolNode",
"ToolResult",
"UnsupportedToolParameterError",
]
60 changes: 59 additions & 1 deletion agentflow/core/graph/tool_node/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,65 @@

from __future__ import annotations

import datetime as dt
import decimal
import enum
import json
import pathlib
import typing as t
import uuid


_STATUS_OK: set[str] = {"completed", "success", "ok", "done", "true", "1"}
_STATUS_FAIL: set[str] = {"failed", "failure", "error", "false", "0"}
_ERROR_TRUE: set[str] = {"true", "1", "yes", "error", "failed", "failure"}


def _stable_members(obj: set | frozenset) -> list:
"""Order a set so the same result serializes identically on every run.

Set iteration order is not stable across processes, which would make tool output
flap between runs and tests flaky.
"""
try:
return sorted(obj)
except TypeError:
# Mixed or uncomparable members: order is unavoidably arbitrary here, but
# losing the values entirely would be worse.
return list(obj)


# Rendering rules for the scalar types a tool can return but JSON cannot hold. Ordered:
# the first matching entry wins. Decimal renders as str, not float, so a money value
# does not lose precision on the way to the model.
_JSON_ENCODERS: tuple[tuple[t.Any, t.Callable[[t.Any], t.Any]], ...] = (
((dt.datetime, dt.date, dt.time), lambda o: o.isoformat()),
((uuid.UUID, pathlib.PurePath), str),
(decimal.Decimal, str),
(enum.Enum, lambda o: o.value),
((set, frozenset), _stable_members),
((bytes, bytearray), lambda o: o.decode("utf-8", errors="replace")),
)


def _json_default(obj: t.Any) -> t.Any:
"""Render the scalar types a tool can return but JSON cannot hold.

Mirrors the scalars ``schema.py`` accepts on the way in, so a value the model sent
as ``"2026-01-15T09:30:00"`` comes back in that same textual form rather than as a
Python repr.

Raises:
TypeError: For anything else, so ``json.dumps`` propagates and the caller falls
through to its existing repr fallback. Unknown objects must not be silently
stringified here, or a genuinely unserializable payload would look clean.
"""
for types_, encode in _JSON_ENCODERS:
if isinstance(obj, types_):
return encode(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")


def _safe_serialize(obj: t.Any) -> dict[str, t.Any]:
try:
json.dumps(obj)
Expand All @@ -24,7 +74,15 @@ def _safe_serialize(obj: t.Any) -> dict[str, t.Any]:
resource["uri"] = str(resource["uri"])
dumped["resource"] = resource
return dumped
return {"content": str(obj), "type": "fallback"}

# Retry with the scalar renderer so a container keeps its shape and only the
# offending leaves become text. Without this a single datetime collapses the
# whole return value into one repr string.
try:
normalized = json.loads(json.dumps(obj, default=_json_default))
except (TypeError, OverflowError, ValueError):
return {"content": str(obj), "type": "fallback"}
return normalized if isinstance(normalized, dict) else {"content": normalized}


def _as_bool(val: t.Any, truthy_set: set[str]) -> bool:
Expand Down
177 changes: 177 additions & 0 deletions agentflow/core/graph/tool_node/coercion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Coercion of raw LLM tool arguments into the types a tool function declares.

A provider hands back plain JSON, so a parameter annotated with a pydantic model, a
dataclass, or an enum arrives as a ``dict`` / ``str``. Passing that through untouched
means the tool body receives a ``dict`` where it expects a model instance, or the raw
string ``"active"`` where it expects ``Status.ACTIVE``. Both fail silently.

Validation goes through pydantic in both cases. Notably, a dataclass must **not** be
built with ``Cls(**value)``: a dataclass constructor performs no validation and no
conversion, so enum fields stay strings, nested dataclass fields stay dicts, and int
fields keep whatever the model sent, all without raising. ``TypeAdapter`` handles the
whole tree correctly and reports a precise error when the payload is wrong.

The same applies to the scalar stdlib types. JSON has no date or UUID, so ``schema.py``
advertises ``datetime`` as ``{"type": "string", "format": "date-time"}`` and the model
can only answer with a string. Leaving it as one means a body that does ``when.year``
raises ``AttributeError`` on a ``str``, so every type ``schema.py`` lists in ``_SCALARS``
must be coerced back here. That list is imported rather than restated: a type advertised
as a formatted string in one module and unknown to the other is exactly the asymmetry
this module exists to prevent.

Coercion is applied only when the annotation actually contains a structured or scalar
type. Plain primitives (``int``, ``str``, ``float``, ``bool``), ``dict``, and
``list[str]`` are passed through unchanged so existing tools see no behaviour change.
"""

from __future__ import annotations

import dataclasses
import enum
import inspect
import json
import typing as t
from functools import lru_cache

from pydantic import BaseModel, TypeAdapter, ValidationError

from .schema import _SCALARS


_EMPTY = inspect._empty
_MAX_DEPTH = 8

# Types that reach the tool as a JSON string (or number) and must be parsed back into
# the annotated type. Sourced from the schema builder so the two cannot drift.
_SCALAR_TYPES: tuple[type, ...] = tuple(k for k in _SCALARS if isinstance(k, type))


def _is_structured(annotation: t.Any) -> bool:
"""Return True for the types that need pydantic validation to be usable."""
if not isinstance(annotation, type):
return False
return (
issubclass(annotation, BaseModel)
or issubclass(annotation, enum.Enum)
or dataclasses.is_dataclass(annotation)
or annotation in _SCALAR_TYPES
)


def _needs_coercion(annotation: t.Any, depth: int = 0) -> bool:
"""Return True when the annotation contains a structured type anywhere inside it."""
if depth > _MAX_DEPTH:
return False
if _is_structured(annotation):
return True
return any(_needs_coercion(arg, depth + 1) for arg in t.get_args(annotation))


@lru_cache(maxsize=512)
def _cached_needs_coercion(annotation: t.Any) -> bool:
return _needs_coercion(annotation)


@lru_cache(maxsize=512)
def _adapter(annotation: t.Any) -> TypeAdapter:
"""Build and cache a TypeAdapter. Construction is expensive; this runs per call."""
return TypeAdapter(annotation)


def _maybe_json(value: t.Any) -> t.Any:
"""Decode a JSON object/array string.

Weaker models, and any model whose prompt history was built against the old
``{"type": "string"}`` schema, hand-serialize nested objects into a single string.
Only strings that clearly look like a JSON object or array are decoded, so an
enum member such as ``"5"`` is never turned into an int.

This is a *fallback*, applied only after the raw value has already been rejected.
Decoding up front would corrupt any value the annotation would have accepted as
it stands, such as an enum member whose value is literally ``"{}"``.
"""
if not isinstance(value, str):
return value
stripped = value.strip()
if stripped[:1] not in ("{", "["):
return value
try:
return json.loads(stripped)
except ValueError:
return value


def _validate(annotation: t.Any, value: t.Any) -> t.Any:
"""Validate against the annotation, tolerating annotations the cache cannot key."""
try:
adapter = _adapter(annotation)
except TypeError:
# Unhashable annotation could not be cached; build the adapter directly.
adapter = TypeAdapter(annotation)
return adapter.validate_python(value)


def _readable_error(
exc: ValidationError,
tool_name: str,
param_name: str,
) -> TypeError:
"""Flatten pydantic field errors into a message that can go back to the model."""
details = "; ".join(
f"{'.'.join(str(p) for p in err['loc']) or param_name}: {err['msg']}"
for err in exc.errors()
)
return TypeError(f"Invalid argument {param_name!r} for tool {tool_name!r}: {details}")


def coerce_tool_argument(
value: t.Any,
annotation: t.Any,
*,
tool_name: str,
param_name: str,
) -> t.Any:
"""Coerce one raw tool argument into its declared type.

Args:
value: The raw argument value as provided by the model.
annotation: The resolved annotation of the target parameter.
tool_name: Tool name, used only for error messages.
param_name: Parameter name, used only for error messages.

Returns:
The coerced value, or the original value when no coercion applies.

Raises:
TypeError: If the value does not satisfy the annotation. The message carries
the pydantic field errors so it can be handed back to the model as a
retryable tool error.
"""
if annotation is _EMPTY or annotation is t.Any or annotation is None:
return value

try:
needs = _cached_needs_coercion(annotation)
except TypeError:
# Unhashable annotation; fall back to the uncached walk.
needs = _needs_coercion(annotation)

if not needs:
return value

try:
return _validate(annotation, value)
except ValidationError as raw_error:
# Only now consider that the model may have hand-serialized a nested object
# into a string. Trying this first would decode a value the annotation was
# willing to accept, and report a contradictory error about it.
decoded = _maybe_json(value)
if decoded is value:
raise _readable_error(raw_error, tool_name, param_name) from raw_error

try:
return _validate(annotation, decoded)
except ValidationError as decoded_error:
# The decoded payload is what the model meant, so its field errors are the
# actionable ones ("zip_code: Field required", not "expected a dict").
raise _readable_error(decoded_error, tool_name, param_name) from decoded_error
12 changes: 11 additions & 1 deletion agentflow/core/graph/tool_node/local_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
from agentflow.utils import CallbackContext, CallbackManager, InvocationType, call_sync_or_async

from ._helpers import _extract_block_meta, _safe_serialize
from .coercion import coerce_tool_argument
from .constants import INJECTABLE_PARAMS, has_injected_default
from .schema import _safe_type_hints


if t.TYPE_CHECKING:
Expand All @@ -39,6 +41,7 @@ def _prepare_input_data_tool(
default_data: dict,
) -> dict:
sig = inspect.signature(fn)
hints = _safe_type_hints(fn)
input_data = {}
for param_name, param in sig.parameters.items():
if param.kind in (
Expand All @@ -62,7 +65,14 @@ def _prepare_input_data_tool(
continue

if param_name in args:
input_data[param_name] = args[param_name]
# The provider returns plain JSON, so a model/dataclass/enum parameter
# arrives as a dict or str and must be validated into its real type.
input_data[param_name] = coerce_tool_argument(
args[param_name],
hints.get(param_name, param.annotation),
tool_name=name,
param_name=param_name,
)
elif param.default is inspect.Parameter.empty:
raise TypeError(f"Missing required parameter '{param_name}' for function '{name}'")

Expand Down
Loading
Loading