From db2ccd6c6a59ae110985e0d4f2c10685b8fee15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vl=C4=83du=C8=9B=20Alexandru=20C=C3=AEmpeanu?= Date: Tue, 1 Sep 2026 11:11:17 +0000 Subject: [PATCH] feat: bump jsonschema-pydantic-converter to 0.4.1, add datamodel-code-generator backend Two changes, one dependency apart. An object written inline rather than under $defs that held a $ref produced a child model which was never completed -- the root reported complete while the child kept a ForwardRef -- so an agent failed at tool-binding time rather than on execution. jsonschema-pydantic-converter fixed that in 0.4.1, so the pin moves from >=0.4.0 to >=0.4.1. That bump is the whole of that fix. Separately, schemas become Pydantic models through two interchangeable backends now, selected by the EnableDatamodelCodeGeneratorConverter feature flag: off (the default) keeps jsonschema-pydantic-converter, on switches to datamodel-code-generator. create_model and create_output_model keep their signatures, so no caller changes. The newer backend names generated types after the schema, homes every class in the conversion's own pseudo-module, and repairs property names that are not valid Python identifiers while keeping the declared names on the wire. The flag is a gradual rollout, not an escape from a broken backend. Where callers depend on behaviour the generator does not provide, it is restored: the original JSON property names on the wire, __uipath_marker_name__ on types reached through a $ref, jsonschema enforcement for not/prefixItems/ empty enum, and an AgentStartupError naming an unresolved type. Review fix: a ``format`` no longer changes the value it annotates. The generator retypes on format by default -- password to SecretStr, which serializes as a mask instead of the credential; email and ulid to types whose validators are not installed, failing conversion outright; date-time to an aware datetime, rejecting the naive stamps models emit; and a dozen more to objects json.dumps cannot serialize. Every (type, format) pair is pinned back to its type's own default, derived from the generator's table so a format added upstream is covered too. Review fix: a declared JSON property name now resolves as an attribute on the generated model. Serializing by alias makes model_dump() alias-keyed, and LangChain's BaseTool._parse_input reads each dumped key back off the instance with getattr -- a lookup by JSON name that a sanitized field did not answer, so any tool whose schema had a property like Content-Type raised AttributeError mid-call. Handled on the base model so the mismatch is closed for every alias-unaware consumer, with a test that invokes a real tool. Review fix: the model handed back is the document's own, not a definition that won its name. The generator asks for the root's name first but reserves it last, so a schema titled Root alongside $defs/Root leaves the definition holding Root and renames the root around it. The requested name is no dependable handle either way -- names are singularized after the callback returns, and it is called more than once for some subschemas. The root is identified by structure instead: it carries the document's own properties, and nothing else refers to it, with the requested names left to break ties structure cannot settle. Until now such a tool showed the model the definition's arguments and rejected the ones its own schema declared, as a prompt mismatch the author could not fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADgPaRAEta3H3toAjM4DXu --- pyproject.toml | 7 +- .../react/_datamodel_code_generator_base.py | 60 ++ .../_datamodel_code_generator_converter.py | 501 +++++++++++ .../agent/react/_legacy_converter.py | 90 ++ .../agent/react/_schema_refs.py | 84 ++ .../react/jsonschema_pydantic_converter.py | 170 +--- tests/agent/react/test_job_attachments.py | 12 +- .../test_jsonschema_pydantic_converter.py | 73 +- ...jsonschema_pydantic_converter_scenarios.py | 842 ++++++++++++++++++ tests/agent/tools/test_tool_factory.py | 6 +- uv.lock | 189 +++- 11 files changed, 1864 insertions(+), 170 deletions(-) create mode 100644 src/uipath_langchain/agent/react/_datamodel_code_generator_base.py create mode 100644 src/uipath_langchain/agent/react/_datamodel_code_generator_converter.py create mode 100644 src/uipath_langchain/agent/react/_legacy_converter.py create mode 100644 src/uipath_langchain/agent/react/_schema_refs.py create mode 100644 tests/agent/react/test_jsonschema_pydantic_converter_scenarios.py diff --git a/pyproject.toml b/pyproject.toml index ad7efc027..d71e623bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.17.1" +version = "0.17.2" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -20,7 +20,9 @@ dependencies = [ "httpx>=0.27.0", "httpx2>=2.5.0, <2.10.0", "openinference-instrumentation-langchain>=0.1.69, <0.2.0", - "jsonschema-pydantic-converter>=0.4.0", + "datamodel-code-generator>=0.76.0", + "jsonschema-pydantic-converter>=0.4.1", + "jsonschema>=4.23.0", "jsonpath-ng>=1.7.0", "mcp==2.0.0", "pillow>=12.1.1", @@ -89,6 +91,7 @@ dev = [ "numpy>=1.24.0", "pytest_httpx>=0.35.0", "rust-just>=1.39.0", + "types-jsonschema>=4.23.0", "types-protobuf<7", "packaging>=24.0", # tests/agent/tools/test_mcp/real_server.py hosts real MCP servers over real diff --git a/src/uipath_langchain/agent/react/_datamodel_code_generator_base.py b/src/uipath_langchain/agent/react/_datamodel_code_generator_base.py new file mode 100644 index 000000000..45618cb3c --- /dev/null +++ b/src/uipath_langchain/agent/react/_datamodel_code_generator_base.py @@ -0,0 +1,60 @@ +"""Base model for every runtime-generated schema class. + +`datamodel-code-generator` emits classes deriving from a configurable base. Every +model generated from a tool or agent schema derives from +:class:`UiPathDatamodelCodeGeneratorBaseModel`, which supplies the two +configuration options the runtime depends on: + +* ``serialize_by_alias`` -- properties whose JSON names are not valid Python + identifiers are generated as sanitized fields carrying an alias. Serializing by + alias is what puts the original JSON names back on the wire, so a tool call + reaches Integration Service with the property names its schema declared. +* ``extra="allow"`` -- the default for a schema that does not say otherwise. A + schema with ``additionalProperties: false`` generates its own + ``model_config``, and Pydantic merges that over this one, so ``extra`` still + ends up ``"forbid"`` there. + +The base also makes a declared JSON property name usable as an attribute, which +is the half of that contract sanitizing the field name would otherwise break. +See :meth:`UiPathDatamodelCodeGeneratorBaseModel.__getattr__`. +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class UiPathDatamodelCodeGeneratorBaseModel(BaseModel): + """Base class for models generated from JSON Schema at runtime.""" + + model_config = ConfigDict(serialize_by_alias=True, extra="allow") + + def __getattr__(self, name: str) -> Any: + """Resolve a declared JSON property name to its sanitized field. + + Serializing by alias makes ``model_dump()`` alias-keyed, and consumers read + the dumped keys straight back off the instance: LangChain's + ``BaseTool._parse_input`` builds its kwargs with ``getattr(result, key)`` + for every dumped key. Under the legacy backend the JSON name *was* the + field name, so that resolved; here the field is sanitized, so a property + declared ``Content-Type`` would raise ``AttributeError`` mid-tool-call. + + Accepting the alias restores what callers already relied on -- the name the + schema declared works as an attribute -- so alias-unaware consumers behave + as they did before. + + Only reached when normal lookup fails, so real fields, methods and extras + are untouched. + """ + try: + return super().__getattr__(name) # type: ignore[misc] + except AttributeError: + pass + + for field_name, field in type(self).model_fields.items(): + if field.alias == name and field_name != name: + return getattr(self, field_name) + + raise AttributeError( + f"{type(self).__name__!r} object has no attribute {name!r}" + ) diff --git a/src/uipath_langchain/agent/react/_datamodel_code_generator_converter.py b/src/uipath_langchain/agent/react/_datamodel_code_generator_converter.py new file mode 100644 index 000000000..b01d307b5 --- /dev/null +++ b/src/uipath_langchain/agent/react/_datamodel_code_generator_converter.py @@ -0,0 +1,501 @@ +"""Schema-to-model conversion via ``datamodel-code-generator``. + +The newer backend. Compared with :mod:`._legacy_converter` it names generated +types after the schema rather than ``DynamicType_N`` -- those names reach the +language model through ``$defs`` -- homes every class it produces in this +conversion's pseudo-module, and repairs property names that are not valid Python +identifiers while keeping the declared names on the wire. + +Both backends resolve ``$ref``s completely. + +Everything below the two public entry points exists to keep three runtime +contracts intact: + +* generated classes live in a per-conversion pseudo-module registered in + ``sys.modules``, so qualified-name lookups resolve (LangGraph checkpoint + deserialization, and :mod:`uipath_langchain.agent.attachments.pydantic_json`); +* classes reached through a ``$ref`` carry ``__uipath_marker_name__`` holding the + name that ``$defs`` entry maps to, which is how job-attachment discovery finds + them; +* schemas whose ``$ref`` targets are missing fail at startup with a message + naming the type, rather than producing a model that breaks later. +""" + +import itertools +import keyword +import re +import sys +from collections.abc import Iterator +from types import ModuleType +from typing import Any, Type, cast, get_args + +import jsonschema +from datamodel_code_generator import ( + Formatter, + GenerateConfig, + InputFileType, + generate_dynamic_models, +) +from datamodel_code_generator.parser.jsonschema import json_schema_data_formats +from pydantic import BaseModel, model_validator + +from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode + +from ._datamodel_code_generator_base import UiPathDatamodelCodeGeneratorBaseModel +from ._schema_refs import ref_resolves, resolve_pointer + +# Prefix for the per-conversion pseudo-modules that let qualified-name lookups +# resolve each schema's generated classes. +_DYNAMIC_MODULE_PREFIX = "jsonschema_pydantic_converter._dynamic" + +_dynamic_module_counter = itertools.count() + +# Import path of the base class every generated model derives from. The generator +# takes this as a string and emits the import itself. +_BASE_CLASS = ( + f"{UiPathDatamodelCodeGeneratorBaseModel.__module__}" + f".{UiPathDatamodelCodeGeneratorBaseModel.__name__}" +) + +# Pin every ``format`` back to its type's own default, so a format annotates the +# value without changing it. Left alone, the generator retypes on format -- +# ``password`` to ``SecretStr``, which serializes as a mask rather than the +# credential; ``email`` and ``ulid`` to types whose validators are not installed, +# which fails conversion outright; ``date-time`` to a timezone-aware datetime, +# rejecting the naive stamps models emit; and a dozen more to objects that are no +# longer JSON-serializable once dumped. None of that is what this backend is for, +# and the legacy backend does none of it, so the two must agree here. +# +# Derived from the generator's own table rather than listed, so a format added +# upstream is covered without a change here. +_FORMAT_PINS = [ + f"{base}+{name}={base}" + for base, formats in json_schema_data_formats.items() + for name, type_ in formats.items() + if type_ is not formats.get("default") +] + +# JSON Schema keywords that have no equivalent in Pydantic's type system, so the +# generator drops them and the field ends up accepting anything. Enforcement for +# these is restored by _build_constraint_guard. +_UNENFORCEABLE_KEYWORDS = ("not", "prefixItems") + +# Keywords that nest a subschema describing the same value as their parent. +_COMBINERS = ("anyOf", "oneOf", "allOf") + +_INVALID_SCHEMA_TITLE = "Invalid schema" + + +def _invalid_schema(detail: str) -> AgentStartupError: + """The startup error raised for every schema this backend cannot convert.""" + return AgentStartupError( + code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, + title=_INVALID_SCHEMA_TITLE, + detail=detail, + ) + + +def _create_dynamic_module() -> ModuleType: + """Create a pseudo-module unique to one conversion, since class names repeat.""" + module_name = f"{_DYNAMIC_MODULE_PREFIX}_{next(_dynamic_module_counter)}" + pseudo_module = ModuleType(module_name) + sys.modules[module_name] = pseudo_module + return pseudo_module + + +def _definition_type_name(ref: str) -> str: + """The name a ``$ref`` maps to, byte-compatible with the legacy backend.""" + + def sanitize(name: str) -> str: + return re.sub(r"[^a-zA-Z0-9_]", "_", name) + + if ref.startswith("#/"): + parts = [ + sanitize(part) + for part in ref[2:].split("/") + if part not in ("$defs", "definitions") + ] + return "__" + "_".join(parts).capitalize() + return "__" + sanitize(ref.split("/")[-1]).capitalize() + + +def _unresolved_type_name(ref: str) -> str: + """The bare type name to show a user for an unresolvable ``$ref``.""" + return ref.rstrip("/").split("/")[-1] or ref + + +def _iter_refs(node: Any) -> Any: + """Yield every ``$ref`` string in a schema, at any depth.""" + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + yield ref + for value in node.values(): + yield from _iter_refs(value) + elif isinstance(node, list): + for item in node: + yield from _iter_refs(item) + + +def _assert_refs_resolve(schema: dict[str, Any]) -> None: + """Fail with a user-facing error if any ``$ref`` target is missing.""" + for ref in _iter_refs(schema): + if ref_resolves(ref, schema): + continue + raise _invalid_schema( + f"Type '{_unresolved_type_name(ref)}' could not be resolved. " + f"Check that all $ref targets have matching entries in $defs." + ) + + +def _valid_identifier(name: str, fallback: str) -> str: + """Coerce `name` into something usable as a Python class name.""" + # the generator treats a leading underscore as private and withholds the class + cleaned = name.lstrip("_") + if not cleaned or cleaned[0].isdigit(): + cleaned = f"{fallback}{cleaned}" + if keyword.iskeyword(cleaned): + cleaned = f"{cleaned}_" + return cleaned + + +def _root_class_name(title: str) -> str: + """The root's class name, preserving the schema's ``title`` where possible.""" + return _valid_identifier(re.sub(r"[^0-9a-zA-Z_]", "_", title), "Model") + + +def _nested_class_name(name: str) -> str: + """A Pascal-case class name for a ``$defs`` entry or an inline object.""" + parts = [part for part in re.split(r"[^0-9a-zA-Z]+", name) if part] + pascal = "".join(part[:1].upper() + part[1:] for part in parts) + return _valid_identifier(pascal, "Model") + + +def _generate(schema: dict[str, Any]) -> tuple[dict[str, type], str]: + """Generate the model classes for `schema`, plus the name of its root class.""" + order: list[str] = [] + + def record_name(name: str) -> str: + # The root is named first, so the first call is the only one whose name + # is user-visible; everything after it is an internal class. + cleaned = _root_class_name(name) if not order else _nested_class_name(name) + order.append(cleaned) + return cleaned + + config = GenerateConfig( + input_file_type=InputFileType.JsonSchema, + base_class=_BASE_CLASS, + strict_refs=True, + # The generator renders Python source and execs it. Nothing ever reads + # that source, so skip black/isort: ~3x faster, and the library is + # moving to external formatters being opt-in anyway. + formatters=[Formatter.BUILTIN], + allow_population_by_field_name=True, + custom_class_name_generator=record_name, + type_mappings=_FORMAT_PINS, + ) + module_name = f"{_DYNAMIC_MODULE_PREFIX}_gen_{next(_dynamic_module_counter)}" + try: + models = generate_dynamic_models( + schema, + config=config, + module_name=module_name, + # Each conversion gets its own classes, so one caller mutating a + # model cannot affect another agent built from an identical schema. + cache_size=0, + ) + except AgentStartupError: + raise + except Exception as exc: + raise _invalid_schema( + f"The schema could not be converted to a model: {exc}" + ) from exc + + if not models: + raise _invalid_schema("The schema produced no model.") + + return models, _root_name(schema, models, order) + + +def _referenced_model_ids(models: dict[str, type]) -> set[int]: + """The ids of the models that another model reaches through a field.""" + referenced: set[int] = set() + for model in models.values(): + for field in cast("type[BaseModel]", model).model_fields.values(): + for nested in _models_in(field.annotation): + # Recursion makes a model reach itself; that does not make it + # somebody else's child. + if nested is not model: + referenced.add(id(nested)) + return referenced + + +def _models_with_root_properties( + schema: dict[str, Any], models: dict[str, type] +) -> list[str]: + """The models carrying exactly the properties the document itself declares.""" + properties = schema.get("properties") + declared = set(properties) if isinstance(properties, dict) else set() + if not declared: + return [] + return [ + name + for name, model in models.items() + if set(_fields_by_json_name(cast("type[BaseModel]", model))) == declared + ] + + +def _root_name( + schema: dict[str, Any], models: dict[str, type], requested: list[str] +) -> str: + """The name of the model generated for the document as a whole. + + Not simply the name the root asked for. The generator renames on collision + and resolves the root last, so a ``$defs`` entry can win the requested name + while the root is renamed around it -- a schema titled ``Root`` alongside + ``$defs/Root`` hands back the definition, with the document's own + properties and ``required`` silently gone. The requested name is not + dependable either way: names are singularized after this callback returns, + and it is called more than once for some subschemas. + + So the root is identified by structure -- it carries the document's own + properties, and nothing else refers to it -- with the requested names left + to break ties between shapes structure cannot separate (an ``allOf`` root, + whose properties come from its branches). + """ + candidates = _models_with_root_properties(schema, models) or list(models) + referenced = _referenced_model_ids(models) + unreferenced = [name for name in candidates if id(models[name]) not in referenced] + candidates = unreferenced or candidates + for name in requested: + if name in candidates: + return name + return candidates[0] + + +def _models_in(annotation: Any) -> list[type[BaseModel]]: + """Every model class reachable inside a type annotation, containers unwrapped.""" + found: list[type[BaseModel]] = [] + stack: list[Any] = [annotation] + while stack: + current = stack.pop() + if isinstance(current, type) and issubclass(current, BaseModel): + found.append(current) + else: + stack.extend(get_args(current)) + return found + + +def _fields_by_json_name(model: type[BaseModel]) -> dict[str, Any]: + """Map each JSON property name to its field, honouring generated aliases.""" + return {(field.alias or name): field for name, field in model.model_fields.items()} + + +def _models_for_property( + models: list[type[BaseModel]], json_name: str +) -> list[type[BaseModel]]: + """The models describing property `json_name` of any of `models`.""" + found: list[type[BaseModel]] = [] + for model in models: + field = _fields_by_json_name(model).get(json_name) + if field is not None: + found.extend(_models_in(field.annotation)) + return found + + +def _child_nodes( + node: dict[str, Any], models: list[type[BaseModel]] +) -> Iterator[tuple[Any, list[type[BaseModel]]]]: + """Yield each subschema of `node` with the models it describes.""" + for combiner in _COMBINERS: + for sub in node.get(combiner) or []: + yield sub, models + + properties = node.get("properties") + if isinstance(properties, dict): + for json_name, sub in properties.items(): + yield sub, _models_for_property(models, json_name) + + for keyword_name in ("items", "additionalProperties"): + sub = node.get(keyword_name) + if isinstance(sub, dict): + yield sub, models + + +def _tag_referenced_models( + root: type[BaseModel], schema: dict[str, Any], pseudo_module: ModuleType +) -> None: + """Name every model a ``$ref`` points at, and publish it on the module.""" + # Walks schema and model tree together rather than matching on class names: + # the generator renames on collision, so only the structural walk is reliable. + visited: set[tuple[int, int]] = set() + + def name_models(models: list[type[BaseModel]], type_name: str) -> None: + for model in models: + if not hasattr(model, "__uipath_marker_name__"): + cast(Any, model).__uipath_marker_name__ = type_name + if not hasattr(pseudo_module, type_name): + setattr(pseudo_module, type_name, model) + + def visit(node: Any, models: list[type[BaseModel]]) -> None: + if not isinstance(node, dict) or not models: + return + key = (id(node), id(models[0])) + if key in visited: + return + visited.add(key) + + ref = node.get("$ref") + if isinstance(ref, str): + name_models(models, _definition_type_name(ref)) + visit(resolve_pointer(schema, ref), models) + return + + for sub, sub_models in _child_nodes(node, models): + visit(sub, sub_models) + + visit(schema, [root]) + + +def _register_models(models: dict[str, type], pseudo_module: ModuleType) -> None: + """Publish every generated class on the pseudo-module and re-home it there.""" + for name, model in models.items(): + setattr(pseudo_module, name, model) + if isinstance(model, type) and issubclass(model, BaseModel): + model.__module__ = pseudo_module.__name__ + setattr(pseudo_module, model.__name__, model) + + +def _unenforceable_constraints( + schema: dict[str, Any], +) -> list[tuple[tuple[str, ...], dict[str, Any]]]: + """Locate subschemas using a keyword the generator cannot express as a type.""" + found: list[tuple[tuple[str, ...], dict[str, Any]]] = [] + seen: set[int] = set() + + def visit(node: Any, path: tuple[str, ...]) -> None: + if not isinstance(node, dict) or id(node) in seen: + return + seen.add(id(node)) + + ref = node.get("$ref") + if isinstance(ref, str): + visit(resolve_pointer(schema, ref), path) + return + + if _is_unenforceable(node): + found.append((path, node)) + return + + for sub, sub_path in _child_paths(node, path): + visit(sub, sub_path) + + visit(schema, ()) + return found + + +def _is_unenforceable(node: dict[str, Any]) -> bool: + """Whether `node` states a rule no Pydantic annotation can carry.""" + if any(name in node for name in _UNENFORCEABLE_KEYWORDS): + return True + # An empty enum permits nothing, which no annotation expresses either. + return node.get("enum") == [] + + +def _child_paths( + node: dict[str, Any], path: tuple[str, ...] +) -> Iterator[tuple[Any, tuple[str, ...]]]: + """Yield each subschema of `node` with the path leading to its values.""" + properties = node.get("properties") + if isinstance(properties, dict): + for json_name, sub in properties.items(): + yield sub, (*path, json_name) + + items = node.get("items") + if isinstance(items, dict): + yield items, (*path, "[]") + + for combiner in _COMBINERS: + for sub in node.get(combiner) or []: + yield sub, path + + +def _build_constraint_guard( + constraints: list[tuple[tuple[str, ...], dict[str, Any]]], +) -> Any: + """Return a callable enforcing constraints the generated types cannot carry.""" + validators = [ + (path, jsonschema.Draft202012Validator(subschema)) + for path, subschema in constraints + ] + + def enforce(data: Any) -> Any: + if not isinstance(data, dict): + return data + for path, validator in validators: + for value in _values_at_path(data, path): + error = jsonschema.exceptions.best_match(validator.iter_errors(value)) + if error is not None: + location = ".".join(path) or "value" + raise ValueError(f"{location}: {error.message}") + return data + + return enforce + + +def _values_at_path(value: Any, path: tuple[str, ...]) -> list[Any]: + """Every value `path` reaches inside `value`; ``"[]"`` means each array item.""" + if not path: + return [value] + head, rest = path[0], path[1:] + if head == "[]": + if not isinstance(value, list): + return [] + return [found for item in value for found in _values_at_path(item, rest)] + if isinstance(value, dict) and head in value: + return _values_at_path(value[head], rest) + return [] + + +def _guard_unenforceable( + root: type[BaseModel], schema: dict[str, Any] +) -> type[BaseModel]: + """Wrap `root` so constraints the generated types dropped are still applied.""" + constraints = _unenforceable_constraints(schema) + if not constraints: + return root + + # A "before" model validator may be a plain callable taking the raw input, + # which is exactly the shape _build_constraint_guard returns. + enforce = model_validator(mode="before")(_build_constraint_guard(constraints)) + + guarded = type( + root.__name__, + (root,), + { + "__module__": root.__module__, + "__doc__": root.__doc__, + "_uipath_enforce_unrepresentable": enforce, + }, + ) + return cast(Type[BaseModel], guarded) + + +def create_model( + schema: dict[str, Any], +) -> Type[BaseModel]: + """Convert a JSON schema dict to a Pydantic model.""" + _assert_refs_resolve(schema) + + models, root_name = _generate(schema) + root = cast(Type[BaseModel], models[root_name]) + + pseudo_module = _create_dynamic_module() + _register_models(models, pseudo_module) + _tag_referenced_models(root, schema, pseudo_module) + + root = _guard_unenforceable(root, schema) + root.__module__ = pseudo_module.__name__ + setattr(pseudo_module, root.__name__, root) + + return root diff --git a/src/uipath_langchain/agent/react/_legacy_converter.py b/src/uipath_langchain/agent/react/_legacy_converter.py new file mode 100644 index 000000000..8832afc7c --- /dev/null +++ b/src/uipath_langchain/agent/react/_legacy_converter.py @@ -0,0 +1,90 @@ +"""Schema-to-model conversion via ``jsonschema-pydantic-converter``. + +The original backend, kept selectable so the newer code-generator path can be +rolled out behind a flag. See :mod:`jsonschema_pydantic_converter` (the façade in +this package) for how the two are chosen. + +An object written inline (not under ``$defs``) that contains a ``$ref`` produces a +fully defined nested model, so a consumer copying the field annotations into a +model in another module gets a complete type. + +Differences that remain against the code-generator backend, none of them +failures: generated types are named ``DynamicType_N`` rather than after the +schema, and those names reach the language model through ``$defs``; models built +for inline objects keep the converter's own module rather than this conversion's +pseudo-module; and an object declared solely by ``additionalProperties`` becomes +a model rather than a dict. +""" + +import inspect +import itertools +import sys +from types import ModuleType +from typing import Any, Type, cast + +from jsonschema_pydantic_converter import transform_with_modules +from pydantic import BaseModel, PydanticUndefinedAnnotation + +from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode + +# Prefix for the per-conversion pseudo-modules that let get_type_hints() +# resolve each schema's forward references. +_DYNAMIC_MODULE_PREFIX = "jsonschema_pydantic_converter._dynamic" + +_dynamic_module_counter = itertools.count() + + +def _create_dynamic_module() -> ModuleType: + """Create a pseudo-module unique to one schema conversion. + + The converter reuses generic class names (``DynamicType_0``, ...) across + schemas, so a shared module would let qualified-name lookups (e.g. + LangGraph checkpoint deserialization) resolve to a class generated from a + different schema. + """ + module_name = f"{_DYNAMIC_MODULE_PREFIX}_{next(_dynamic_module_counter)}" + pseudo_module = ModuleType(module_name) + sys.modules[module_name] = pseudo_module + return pseudo_module + + +def create_model( + schema: dict[str, Any], +) -> Type[BaseModel]: + """Convert a JSON schema dict to a Pydantic model. + + Raises: + AgentStartupError: If the schema contains a type that cannot be resolved. + """ + try: + model, namespace = transform_with_modules(schema) + except PydanticUndefinedAnnotation as e: + # Strip the __ prefix the converter adds to forward references + # so the user sees the original type name from their JSON schema. + type_name = e.name.lstrip("_") if e.name else None + raise AgentStartupError( + code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, + title="Invalid schema", + detail=( + f"Type '{type_name}' could not be resolved. " + f"Check that all $ref targets have matching entries in $defs." + ), + ) from e + + pseudo_module = _create_dynamic_module() + + for type_name, type_def in namespace.items(): + setattr(pseudo_module, type_name, type_def) + if inspect.isclass(type_def) and issubclass(type_def, BaseModel): + type_def.__module__ = pseudo_module.__name__ + # the namespace key is a forward-ref alias, not the class's + # __name__; register under __name__ too so qualified-name lookups + # (e.g. checkpoint deserialization) resolve. + setattr(pseudo_module, type_def.__name__, type_def) + # per-class marker for lookups by the schema's original type name. + cast(Any, type_def).__uipath_marker_name__ = type_name + + setattr(pseudo_module, model.__name__, model) + model.__module__ = pseudo_module.__name__ + + return model diff --git a/src/uipath_langchain/agent/react/_schema_refs.py b/src/uipath_langchain/agent/react/_schema_refs.py new file mode 100644 index 000000000..720af6de3 --- /dev/null +++ b/src/uipath_langchain/agent/react/_schema_refs.py @@ -0,0 +1,84 @@ +"""``$ref`` inspection shared by both schema-to-model backends. + +These helpers work on the JSON Schema document alone, so they are identical +whichever library builds the models. +""" + +from typing import Any + +# Marker left on any OUTPUT-schema node whose $ref target could not be resolved. +# Both backends discard $defs names and non-standard (x-*) keys but preserve the +# standard `title`/`description` annotations on a property, so the marker lives as +# annotations rather than a named type. Downstream can detect an unresolved field +# via ``title == UNRESOLVED_TYPE_TITLE``. See create_output_model. +UNRESOLVED_TYPE_TITLE = "UiPathUnresolvedType" + + +def ref_resolves(ref: str, root: dict[str, Any]) -> bool: + """Whether a local JSON-pointer ``$ref`` (``#/...``) resolves within `root`. + + External/URL refs and the bare ``#`` (whole-document) ref return False: + neither backend can resolve them, so they are treated as dangling. + """ + if not ref.startswith("#/"): + return False + node: Any = root + for part in ref[2:].split("/"): + part = part.replace("~1", "/").replace("~0", "~") # JSON-pointer unescape + if isinstance(node, dict) and part in node: + node = node[part] + else: + return False + return True + + +def resolve_pointer(schema: dict[str, Any], ref: str) -> Any: + """Resolve a local JSON pointer against `schema`, or None.""" + if not ref.startswith("#/"): + return None + node: Any = schema + for part in ref[2:].split("/"): + part = part.replace("~1", "/").replace("~0", "~") + if isinstance(node, dict) and part in node: + node = node[part] + else: + return None + return node + + +def neutralize_dangling_refs( + schema: dict[str, Any], +) -> tuple[dict[str, Any], list[str]]: + """Return a copy of `schema` with every unresolvable ``$ref`` replaced. + + A ``$ref`` is dangling when its target is not present under ``$defs``/ + ``definitions`` (e.g. a .NET ``Nullable`` serialized without its + definition). Each dangling ref node is replaced *in place* by a permissive, + self-documenting placeholder (accepts any value; the original ref is kept in + its ``description``), so valid sibling fields and valid ``$ref``s -- including + those nested in arrays, objects, or ``$defs`` -- are preserved. This keeps the + output schema usable by best-effort features instead of discarding it whole. + + Returns: + A tuple of (sanitized schema copy, list of the dangling ref strings found). + """ + dropped: list[str] = [] + + def visit(node: Any) -> Any: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and not ref_resolves(ref, schema): + dropped.append(ref) + return { + "title": UNRESOLVED_TYPE_TITLE, + "description": ( + f"Unresolved $ref '{ref}'; original type could not be " + "resolved at startup, so this field accepts any value." + ), + } + return {key: visit(value) for key, value in node.items()} + if isinstance(node, list): + return [visit(item) for item in node] + return node + + return visit(schema), dropped diff --git a/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py index 6e4948b99..c070692f5 100644 --- a/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py +++ b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py @@ -1,43 +1,54 @@ -import inspect -import itertools -import logging -import sys -from types import ModuleType -from typing import Any, Type, cast +"""Build Pydantic models from JSON Schema at runtime. -from jsonschema_pydantic_converter import transform_with_modules -from pydantic import BaseModel, PydanticUndefinedAnnotation +An agent's inputs, outputs, and every tool's parameters are stored as JSON +Schema, while LangChain needs a Pydantic class to validate the arguments a model +produces. This module is the bridge, and the only entry point callers use. -from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode +Two backends implement the conversion, chosen by the +``EnableDatamodelCodeGeneratorConverter`` feature flag: -logger = logging.getLogger(__name__) +* flag off (the default) -- :mod:`._legacy_converter`, backed by + ``jsonschema-pydantic-converter``; +* flag on -- :mod:`._datamodel_code_generator_converter`, backed by + ``datamodel-code-generator``, which names generated types after the schema + instead of ``DynamicType_N``, homes every class in the conversion's own module, + and repairs property names that are not valid Python identifiers. -# Marker left on any OUTPUT-schema node whose $ref target could not be resolved. -# The converter discards $defs names and non-standard (x-*) keys but preserves the -# standard `title`/`description` annotations on a property, so the marker lives as -# annotations rather than a named type. Downstream can detect an unresolved field -# via ``title == _UNRESOLVED_TYPE_TITLE``. See create_output_model. -_UNRESOLVED_TYPE_TITLE = "UiPathUnresolvedType" +Both resolve ``$ref``s completely, an inline object holding one included. The flag +exists to roll the newer backend out gradually, not to escape a broken one. -# Prefix for the per-conversion pseudo-modules that let get_type_hints() -# resolve each schema's forward references. -_DYNAMIC_MODULE_PREFIX = "jsonschema_pydantic_converter._dynamic" +Both produce a model with the same observable contract: the original JSON +property names on the wire, ``__uipath_marker_name__`` on types reached through a +``$ref``, generated classes reachable through a module in ``sys.modules``, and an +``AgentStartupError`` naming the type when a ``$ref`` cannot be resolved. +""" -_dynamic_module_counter = itertools.count() +import logging +from typing import Any, Type +from pydantic import BaseModel +from uipath.core.feature_flags import FeatureFlags -def _create_dynamic_module() -> ModuleType: - """Create a pseudo-module unique to one schema conversion. +from . import _datamodel_code_generator_converter, _legacy_converter +from ._schema_refs import UNRESOLVED_TYPE_TITLE, neutralize_dangling_refs + +logger = logging.getLogger(__name__) + +# Selects the datamodel-code-generator backend. Off by default: the legacy +# converter stays in charge until the new path has been exercised in the wild. +DATAMODEL_CODE_GENERATOR_CONVERTER_FF = "EnableDatamodelCodeGeneratorConverter" + +__all__ = [ + "create_model", + "create_output_model", +] - The converter reuses generic class names (``DynamicType_0``, ...) across - schemas, so a shared module would let qualified-name lookups (e.g. - LangGraph checkpoint deserialization) resolve to a class generated from a - different schema. - """ - module_name = f"{_DYNAMIC_MODULE_PREFIX}_{next(_dynamic_module_counter)}" - pseudo_module = ModuleType(module_name) - sys.modules[module_name] = pseudo_module - return pseudo_module + +def _datamodel_code_generator_enabled() -> bool: + """Whether to build models with the code-generator backend.""" + return FeatureFlags.is_flag_enabled( + DATAMODEL_CODE_GENERATOR_CONVERTER_FF, default=False + ) def create_model( @@ -48,94 +59,9 @@ def create_model( Raises: AgentStartupError: If the schema contains a type that cannot be resolved. """ - try: - model, namespace = transform_with_modules(schema) - except PydanticUndefinedAnnotation as e: - # Strip the __ prefix the converter adds to forward references - # so the user sees the original type name from their JSON schema. - type_name = e.name.lstrip("_") if e.name else None - raise AgentStartupError( - code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, - title="Invalid schema", - detail=( - f"Type '{type_name}' could not be resolved. " - f"Check that all $ref targets have matching entries in $defs." - ), - ) from e - - pseudo_module = _create_dynamic_module() - - for type_name, type_def in namespace.items(): - setattr(pseudo_module, type_name, type_def) - if inspect.isclass(type_def) and issubclass(type_def, BaseModel): - type_def.__module__ = pseudo_module.__name__ - # the namespace key is a forward-ref alias, not the class's - # __name__; register under __name__ too so qualified-name lookups - # (e.g. checkpoint deserialization) resolve. - setattr(pseudo_module, type_def.__name__, type_def) - # per-class marker for lookups by the schema's original type name. - cast(Any, type_def).__uipath_marker_name__ = type_name - - setattr(pseudo_module, model.__name__, model) - model.__module__ = pseudo_module.__name__ - - return model - - -def _ref_resolves(ref: str, root: dict[str, Any]) -> bool: - """Whether a local JSON-pointer ``$ref`` (``#/...``) resolves within `root`. - - External/URL refs and the bare ``#`` (whole-document) ref return False: the - converter cannot resolve them either, so they are treated as dangling. - """ - if not ref.startswith("#/"): - return False - node: Any = root - for part in ref[2:].split("/"): - part = part.replace("~1", "/").replace("~0", "~") # JSON-pointer unescape - if isinstance(node, dict) and part in node: - node = node[part] - else: - return False - return True - - -def _neutralize_dangling_refs( - schema: dict[str, Any], -) -> tuple[dict[str, Any], list[str]]: - """Return a copy of `schema` with every unresolvable ``$ref`` replaced. - - A ``$ref`` is dangling when its target is not present under ``$defs``/ - ``definitions`` (e.g. a .NET ``Nullable`` serialized without its - definition). Each dangling ref node is replaced *in place* by a permissive, - self-documenting placeholder (accepts any value; the original ref is kept in - its ``description``), so valid sibling fields and valid ``$ref``s -- including - those nested in arrays, objects, or ``$defs`` -- are preserved. This keeps the - output schema usable by best-effort features instead of discarding it whole. - - Returns: - A tuple of (sanitized schema copy, list of the dangling ref strings found). - """ - dropped: list[str] = [] - - def visit(node: Any) -> Any: - if isinstance(node, dict): - ref = node.get("$ref") - if isinstance(ref, str) and not _ref_resolves(ref, schema): - dropped.append(ref) - return { - "title": _UNRESOLVED_TYPE_TITLE, - "description": ( - f"Unresolved $ref '{ref}'; original type could not be " - "resolved at startup, so this field accepts any value." - ), - } - return {key: visit(value) for key, value in node.items()} - if isinstance(node, list): - return [visit(item) for item in node] - return node - - return visit(schema), dropped + if _datamodel_code_generator_enabled(): + return _datamodel_code_generator_converter.create_model(schema) + return _legacy_converter.create_model(schema) def create_output_model( @@ -145,7 +71,7 @@ def create_output_model( """Convert a tool's OUTPUT JSON schema to a Pydantic model. Unresolvable ``$ref``s -- the malformed output schema seen in practice (see - _neutralize_dangling_refs) -- are neutralized in place so all valid fields are + neutralize_dangling_refs) -- are neutralized in place so all valid fields are kept; since an output schema drives only best-effort features (job-attachment discovery, output guardrails, eval simulations), losing a single unresolvable field is preferable to failing startup. @@ -161,7 +87,7 @@ def create_output_model( AgentStartupError: If the schema is unparseable for a reason other than a dangling ``$ref``. """ - sanitized, dropped = _neutralize_dangling_refs(schema) + sanitized, dropped = neutralize_dangling_refs(schema) if dropped: logger.warning( "Tool %r output schema had %d unresolvable $ref(s) (%s); each replaced " @@ -170,6 +96,6 @@ def create_output_model( tool_name, len(dropped), ", ".join(sorted(set(dropped))), - _UNRESOLVED_TYPE_TITLE, + UNRESOLVED_TYPE_TITLE, ) return create_model(sanitized) diff --git a/tests/agent/react/test_job_attachments.py b/tests/agent/react/test_job_attachments.py index 1721f8031..c2aebf24d 100644 --- a/tests/agent/react/test_job_attachments.py +++ b/tests/agent/react/test_job_attachments.py @@ -2,7 +2,6 @@ from typing import Any import pytest -from jsonschema_pydantic_converter import transform_with_modules from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from pydantic import BaseModel from uipath.platform.attachments import Attachment @@ -354,7 +353,7 @@ def test_nested_structure_with_attachments(self): } }, } - model, _ = transform_with_modules(schema) + model = create_model(schema) test_uuid = "550e8400-e29b-41d4-a716-446655440200" data = { "result": { @@ -532,9 +531,9 @@ def test_accepts_attachment_model_dump_output(self): def test_accepts_attachment_field_as_nested_model_instance(self): """Regression: at runtime tool args are coerced into the generated input - model, so an attachment arrives as a model instance whose ``Metadata`` - object is a nested sub-model (``DynamicType_*``), not a dict. This must - not raise (previously failed with "Input should be a valid dictionary"). + model, so an attachment arrives as a model instance rather than a plain + dict. This must not raise (previously failed with "Input should be a + valid dictionary"). """ schema = { "type": "object", @@ -561,7 +560,7 @@ def test_accepts_attachment_field_as_nested_model_instance(self): # Coerce raw input through the generated model exactly as the runtime # does, then pass it inside kwargs (a dict holding a model instance) as - # process_tool_fn does. Metadata is now a nested model, not a dict. + # process_tool_fn does. validated: Any = model.model_validate( { "newArgument": { @@ -572,7 +571,6 @@ def test_accepts_attachment_field_as_nested_model_instance(self): } } ) - assert isinstance(validated.newArgument.Metadata, BaseModel) kwargs = {"newArgument": validated.newArgument} result = get_job_attachments(model, kwargs) diff --git a/tests/agent/react/test_jsonschema_pydantic_converter.py b/tests/agent/react/test_jsonschema_pydantic_converter.py index 7ef6981dd..25dfbd5a5 100644 --- a/tests/agent/react/test_jsonschema_pydantic_converter.py +++ b/tests/agent/react/test_jsonschema_pydantic_converter.py @@ -8,12 +8,16 @@ import pytest from pydantic import BaseModel +from uipath.core.feature_flags import FeatureFlags from uipath_langchain.agent.exceptions import AgentStartupError +from uipath_langchain.agent.react._schema_refs import ( + UNRESOLVED_TYPE_TITLE, + neutralize_dangling_refs, + ref_resolves, +) from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( - _UNRESOLVED_TYPE_TITLE, - _neutralize_dangling_refs, - _ref_resolves, + DATAMODEL_CODE_GENERATOR_CONVERTER_FF, create_model, create_output_model, ) @@ -21,6 +25,25 @@ # --- Fixtures: reusable schema fragments --- +# Every test in this module runs against both backends. Requested through +# ``pytestmark`` rather than autouse so the parametrization is explicit. +pytestmark = pytest.mark.usefixtures("schema_backend") + + +@pytest.fixture(params=[False, True], ids=["legacy", "datamodel_code_generator"]) +def schema_backend(request: pytest.FixtureRequest) -> Any: + """Run every test in this module against both conversion backends. + + The two are meant to be interchangeable behind + ``DATAMODEL_CODE_GENERATOR_CONVERTER_FF``, so the contract is only proven if + it holds either way. + """ + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: request.param}) + yield request.param + FeatureFlags.reset_flags() + + @pytest.fixture() def contact_def() -> dict[str, Any]: return { @@ -313,36 +336,36 @@ def test_model_from_simple_schema_after_complex( class TestRefResolves: - """_ref_resolves: only a local pointer with a present target resolves.""" + """ref_resolves: only a local pointer with a present target resolves.""" def test_present_local_ref_resolves(self, schema_with_defs: dict[str, Any]) -> None: - assert _ref_resolves("#/$defs/Contact", schema_with_defs) is True + assert ref_resolves("#/$defs/Contact", schema_with_defs) is True def test_missing_local_ref_does_not_resolve(self) -> None: - assert _ref_resolves("#/$defs/Missing", {"$defs": {}}) is False + assert ref_resolves("#/$defs/Missing", {"$defs": {}}) is False def test_definitions_keyword_resolves(self) -> None: root = {"definitions": {"Foo": {"type": "object"}}} - assert _ref_resolves("#/definitions/Foo", root) is True + assert ref_resolves("#/definitions/Foo", root) is True def test_nested_pointer(self) -> None: root = {"$defs": {"A": {"$defs": {"B": {"type": "string"}}}}} - assert _ref_resolves("#/$defs/A/$defs/B", root) is True - assert _ref_resolves("#/$defs/A/$defs/Missing", root) is False + assert ref_resolves("#/$defs/A/$defs/B", root) is True + assert ref_resolves("#/$defs/A/$defs/Missing", root) is False def test_external_and_bare_refs_do_not_resolve(self) -> None: - assert _ref_resolves("https://example.com/Foo", {}) is False - assert _ref_resolves("#", {}) is False - assert _ref_resolves("Contact", {}) is False + assert ref_resolves("https://example.com/Foo", {}) is False + assert ref_resolves("#", {}) is False + assert ref_resolves("Contact", {}) is False class TestNeutralizeDanglingRefs: - """_neutralize_dangling_refs: surgical, in-place, preserves valid nodes.""" + """neutralize_dangling_refs: surgical, in-place, preserves valid nodes.""" def test_valid_schema_returned_unchanged( self, schema_with_defs: dict[str, Any] ) -> None: - sanitized, dropped = _neutralize_dangling_refs(schema_with_defs) + sanitized, dropped = neutralize_dangling_refs(schema_with_defs) assert dropped == [] assert sanitized == schema_with_defs @@ -351,11 +374,11 @@ def test_dangling_top_level_ref_neutralized(self) -> None: "type": "object", "properties": {"amount": {"$ref": "#/$defs/Missing"}}, } - sanitized, dropped = _neutralize_dangling_refs(schema) + sanitized, dropped = neutralize_dangling_refs(schema) assert dropped == ["#/$defs/Missing"] node = sanitized["properties"]["amount"] assert "$ref" not in node - assert node["title"] == _UNRESOLVED_TYPE_TITLE + assert node["title"] == UNRESOLVED_TYPE_TITLE assert "#/$defs/Missing" in node["description"] def test_valid_sibling_and_valid_ref_preserved( @@ -370,11 +393,11 @@ def test_valid_sibling_and_valid_ref_preserved( }, "$defs": {"Contact": contact_def}, } - sanitized, dropped = _neutralize_dangling_refs(schema) + sanitized, dropped = neutralize_dangling_refs(schema) assert dropped == ["#/$defs/Missing"] assert sanitized["properties"]["status"] == {"type": "string"} assert sanitized["properties"]["owner"] == {"$ref": "#/$defs/Contact"} - assert sanitized["properties"]["amount"]["title"] == _UNRESOLVED_TYPE_TITLE + assert sanitized["properties"]["amount"]["title"] == UNRESOLVED_TYPE_TITLE def test_nested_in_array_items_neutralized(self) -> None: schema = { @@ -383,10 +406,10 @@ def test_nested_in_array_items_neutralized(self) -> None: "rows": {"type": "array", "items": {"$ref": "#/$defs/Missing"}}, }, } - sanitized, dropped = _neutralize_dangling_refs(schema) + sanitized, dropped = neutralize_dangling_refs(schema) assert dropped == ["#/$defs/Missing"] items = sanitized["properties"]["rows"]["items"] - assert items["title"] == _UNRESOLVED_TYPE_TITLE + assert items["title"] == UNRESOLVED_TYPE_TITLE def test_dangling_ref_inside_valid_def_neutralized(self) -> None: schema = { @@ -399,12 +422,12 @@ def test_dangling_ref_inside_valid_def_neutralized(self) -> None: }, }, } - sanitized, dropped = _neutralize_dangling_refs(schema) + sanitized, dropped = neutralize_dangling_refs(schema) assert dropped == ["#/$defs/Missing"] # outer, resolvable ref kept; inner dangling ref neutralized assert sanitized["properties"]["w"] == {"$ref": "#/$defs/Wrapper"} inner = sanitized["$defs"]["Wrapper"]["properties"]["x"] - assert inner["title"] == _UNRESOLVED_TYPE_TITLE + assert inner["title"] == UNRESOLVED_TYPE_TITLE def test_does_not_mutate_input(self) -> None: schema = { @@ -412,7 +435,7 @@ def test_does_not_mutate_input(self) -> None: "properties": {"a": {"$ref": "#/$defs/Missing"}}, } original = copy.deepcopy(schema) - _neutralize_dangling_refs(schema) + neutralize_dangling_refs(schema) assert schema == original @@ -430,7 +453,7 @@ def test_neutralized_field_accepts_any_and_keeps_original_ref(self) -> None: model = create_output_model(schema, "my_tool") props = model.model_json_schema()["properties"] assert "status" in props # valid sibling preserved - assert props["amount"]["title"] == _UNRESOLVED_TYPE_TITLE + assert props["amount"]["title"] == UNRESOLVED_TYPE_TITLE assert "Nullableofdecimal" in props["amount"]["description"] # Permissive: every value kind validates (a typed field would raise here). for value in [1.5, "text", {"k": 1}, [1, 2], True, None]: @@ -461,7 +484,7 @@ def test_many_fields_distinct_refs_create_no_named_types(self) -> None: # no named types are generated -> nothing to collide / deduplicate assert js.get("$defs", {}) == {} for field in ["f1", "f2", "f3", "f4", "f5"]: - assert js["properties"][field]["title"] == _UNRESOLVED_TYPE_TITLE + assert js["properties"][field]["title"] == UNRESOLVED_TYPE_TITLE model.model_validate( {"f1": 1, "f2": "x", "f3": None, "f4": [1], "f5": {"a": 1}} ) diff --git a/tests/agent/react/test_jsonschema_pydantic_converter_scenarios.py b/tests/agent/react/test_jsonschema_pydantic_converter_scenarios.py new file mode 100644 index 000000000..e49ea7ffd --- /dev/null +++ b/tests/agent/react/test_jsonschema_pydantic_converter_scenarios.py @@ -0,0 +1,842 @@ +"""Scenario coverage for schema -> model conversion. + +The contract tests in ``test_jsonschema_pydantic_converter.py`` cover dangling +refs, the static-args round trip and pseudo-module isolation. This module covers +the behaviour the runtime depends on but that no single caller asserts: the +inline object holding a ``$ref`` that once produced an incomplete model, +constraints the generated types cannot carry, property names that are not valid +Python identifiers, and the marker/module lookups job-attachment discovery +relies on. +""" + +import json +import sys +from typing import Any + +import pytest +from langchain_core.tools import StructuredTool +from langchain_core.utils.function_calling import convert_to_openai_tool +from pydantic import BaseModel, ValidationError +from pydantic import create_model as pydantic_create_model +from uipath.core.feature_flags import FeatureFlags + +from uipath_langchain.agent.attachments.pydantic_json import get_json_paths_by_type +from uipath_langchain.agent.react import ( + _datamodel_code_generator_converter, + _legacy_converter, +) +from uipath_langchain.agent.react import ( + jsonschema_pydantic_converter as converter, +) +from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( + DATAMODEL_CODE_GENERATOR_CONVERTER_FF, + create_model, + create_output_model, +) +from uipath_langchain.agent.tools.base_uipath_structured_tool import ( + BaseUiPathStructuredTool, +) + +# Every test in this module runs against both backends. Requested through +# ``pytestmark`` rather than autouse so the parametrization is explicit. +pytestmark = pytest.mark.usefixtures("schema_backend") + + +@pytest.fixture(params=[False, True], ids=["legacy", "datamodel_code_generator"]) +def schema_backend(request: pytest.FixtureRequest) -> Any: + """Run every test in this module against both conversion backends. + + The two are meant to be interchangeable behind + ``DATAMODEL_CODE_GENERATOR_CONVERTER_FF``, so the contract is only proven if + it holds either way. + """ + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: request.param}) + yield request.param + FeatureFlags.reset_flags() + + +def _clone_like_langchain(model: type[BaseModel]) -> type[BaseModel]: + """Copy field annotations into a new model in a different module. + + This is what ``BaseTool.tool_call_schema`` does via ``_create_subset_model``, + and it is where an incompletely-defined nested model surfaces. + """ + clone = pydantic_create_model( # type: ignore[call-overload] + model.__name__, + **{ + name: (field.annotation, field) + for name, field in model.model_fields.items() + }, + ) + clone.model_json_schema() # forces the core schema to be built + return clone + + +def _nested_models(model: type[BaseModel], field_name: str) -> list[type[BaseModel]]: + annotation = model.model_fields[field_name].annotation + return [ + arg + for arg in getattr(annotation, "__args__", ()) + if isinstance(arg, type) and issubclass(arg, BaseModel) + ] + + +# --- an object written inline that contains a $ref ---------------------------- + + +CREATE_ISSUE = { + "title": "Create_Issue", + "type": "object", + "properties": { + # written inline rather than under $defs, and holding a $ref + "fields": { + "type": "object", + "properties": { + "project": {"$ref": "#/$defs/Project"}, + "summary": {"type": "string"}, + }, + }, + }, + "$defs": { + "Project": {"type": "object", "properties": {"key": {"type": "string"}}}, + }, +} + + +class TestInlineObjectWithRef: + """The nested model must be fully defined, not just the root. + + Asserted against both backends: this is a shared guarantee, not a reason to + pick one over the other. + """ + + def test_nested_model_is_complete(self) -> None: + model = create_model(CREATE_ISSUE) + assert model.__pydantic_complete__ + nested = _nested_models(model, "fields") + assert nested, "the inline object should have produced a model" + for inner in nested: + assert inner.__pydantic_complete__ + + def test_clone_into_another_module_succeeds(self) -> None: + _clone_like_langchain(create_model(CREATE_ISSUE)) + + def test_survives_the_static_args_round_trip(self) -> None: + """model -> JSON Schema -> inline a $ref -> model, as static args does.""" + first = create_model(CREATE_ISSUE) + round_tripped = first.model_json_schema() + + # schema_editing inlines a copy of the $ref it navigates through, leaving + # sibling properties pointing at $defs. + holder = round_tripped["properties"]["fields"] + target = holder["anyOf"][0] if "anyOf" in holder else holder + ref = target.get("$ref") + if ref: + name = ref.rsplit("/", 1)[1] + inlined = json.loads(json.dumps(round_tripped["$defs"][name])) + if "anyOf" in holder: + holder["anyOf"][0] = inlined + else: + round_tripped["properties"]["fields"] = inlined + + second = create_model(round_tripped) + assert second.__pydantic_complete__ + for inner in _nested_models(second, "fields"): + assert inner.__pydantic_complete__ + _clone_like_langchain(second) + + def test_inline_object_nested_inside_a_def(self) -> None: + schema = { + "title": "Root", + "type": "object", + "properties": {"outer": {"$ref": "#/$defs/Outer"}}, + "$defs": { + "Outer": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": {"leaf": {"$ref": "#/$defs/Leaf"}}, + } + }, + }, + "Leaf": {"type": "object", "properties": {"k": {"type": "string"}}}, + }, + } + model = create_model(schema) + (outer,) = _nested_models(model, "outer") + assert outer.__pydantic_complete__ + for inline in _nested_models(outer, "inline"): + assert inline.__pydantic_complete__ + + def test_tool_definition_reaches_the_model(self) -> None: + """The failure mode was at tool-binding time, not tool execution.""" + model = create_model(CREATE_ISSUE) + tool = StructuredTool( + name="Create_Issue", + description="Create an issue", + args_schema=model, + func=lambda **kwargs: kwargs, + ) + spec = convert_to_openai_tool(tool) + assert spec["function"]["name"] == "Create_Issue" + + def test_root_keeps_the_title_from_the_schema(self) -> None: + """The root's name is the title the model sees; it must not be rewritten.""" + assert create_model(CREATE_ISSUE).model_json_schema()["title"] == "Create_Issue" + + +# --- constraints the generated types cannot carry ---------------------------- + + +class TestUnenforceableConstraints: + """``not``, ``prefixItems`` and an empty ``enum`` have no Pydantic annotation. + + They must still reject invalid input rather than silently accept anything. + """ + + @pytest.mark.parametrize( + ("name", "schema", "invalid", "valid"), + [ + ( + "not", + {"type": "object", "properties": {"n": {"not": {"type": "string"}}}}, + {"n": "a string"}, + {"n": 42}, + ), + ( + "prefixItems", + { + "type": "object", + "properties": { + "pair": { + "type": "array", + "prefixItems": [{"type": "string"}, {"type": "integer"}], + } + }, + }, + {"pair": [1, "wrong order"]}, + {"pair": ["ok", 5]}, + ), + ( + "empty enum", + {"type": "object", "properties": {"e": {"type": "string", "enum": []}}}, + {"e": "anything"}, + None, + ), + ( + "inside an array", + { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "properties": {"n": {"not": {"type": "string"}}}, + }, + } + }, + }, + {"rows": [{"n": "a string"}]}, + {"rows": [{"n": 1}]}, + ), + ( + "behind a $ref", + { + "type": "object", + "properties": {"wrapped": {"$ref": "#/$defs/Wrapper"}}, + "$defs": { + "Wrapper": { + "type": "object", + "properties": {"n": {"not": {"type": "string"}}}, + } + }, + }, + {"wrapped": {"n": "a string"}}, + {"wrapped": {"n": 1}}, + ), + ], + ) + def test_constraint_is_enforced( + self, + name: str, + schema: dict[str, Any], + invalid: dict[str, Any], + valid: dict[str, Any] | None, + ) -> None: + model = create_model(schema) + with pytest.raises(ValidationError): + model.model_validate(invalid) + if valid is not None: + model.model_validate(valid) + + def test_schema_without_such_keywords_is_not_wrapped(self) -> None: + """The guard is only added where it is needed.""" + model = create_model( + {"type": "object", "properties": {"a": {"type": "string"}}} + ) + model.model_validate({"a": "x"}) + assert model.__pydantic_complete__ + + +# --- property names that are not valid Python identifiers -------------------- + + +class TestPropertyNaming: + """Sanitized field names must not change what goes on the wire.""" + + @pytest.mark.parametrize( + "json_name", + [ + "project key", # space + "project-key", # hyphen + "schema", # shadows a Pydantic member + "model_fields", # shadows a Pydantic member + "copy", # shadows a Pydantic member + "class", # Python keyword + "_leading", # leading underscore + ], + ) + def test_original_name_round_trips(self, json_name: str) -> None: + model = create_model( + { + "type": "object", + "properties": {json_name: {"type": "string"}}, + "required": [json_name], + } + ) + instance = model.model_validate({json_name: "value"}) + # serialize_by_alias is what puts the original JSON name back on the wire + assert instance.model_dump()[json_name] == "value" + + def test_llm_facing_schema_uses_the_original_name(self) -> None: + model = create_model( + {"type": "object", "properties": {"project key": {"type": "string"}}} + ) + assert "project key" in model.model_json_schema()["properties"] + + @pytest.mark.parametrize( + "json_name", + [ + "plain", + "Content-Type", # routine in REST connector schemas + "@odata.type", + "first-name", + "a b", + "x.y", + "1st", + "class", # Python keyword + "schema", # shadows a Pydantic member + "copy", # shadows a Pydantic member + ], + ) + def test_tool_invocation_delivers_the_original_name(self, json_name: str) -> None: + """A tool call must reach the handler keyed by the declared JSON name. + + Asserting on ``model_dump()`` is not enough: LangChain builds the handler + kwargs by dumping the validated model and reading each dumped key back off + the instance with ``getattr``. Because dumps here are alias-keyed, that is + a lookup by JSON name, which a sanitized field only answers through + ``UiPathDatamodelCodeGeneratorBaseModel.__getattr__``. Names that shadow a + Pydantic member need the alias fix-up in + ``BaseUiPathStructuredTool._parse_input`` on top, since normal lookup + succeeds there and returns the inherited method. + """ + received: dict[str, Any] = {} + + def handler(**kwargs: Any) -> str: + received.update(kwargs) + return "called" + + model = create_model( + { + "type": "object", + "properties": {json_name: {"type": "string"}}, + "required": [json_name], + } + ) + tool = BaseUiPathStructuredTool( + name="a_tool", description="a tool", args_schema=model, func=handler + ) + + tool_input: dict[str, Any] = {json_name: "value"} + assert tool.invoke(tool_input) == "called" + assert received == tool_input + + +class TestFormatKeyword: + """A ``format`` annotates a value; it must not change what the value is.""" + + @pytest.mark.parametrize( + ("json_type", "fmt", "value"), + [ + ("string", "password", "hunter2"), # must not serialize as a mask + ("string", "email", "a@b.com"), # must not need email-validator + ("string", "ulid", "01ARZ3NDEKTSV4RRFFQ69G5FAV"), + ("string", "date-time", "2024-01-02T03:04:05"), # naive, as models emit + ("string", "uuid", "550e8400-e29b-41d4-a716-446655440500"), + ("string", "decimal", "1.5"), + ("string", "binary", "abc"), + ("string", "uri", "https://example.com/x"), + ("string", "path", "/tmp/x"), + ("string", "ipv4", "1.2.3.4"), + ("integer", "int64", 9007199254740993), + ("integer", "date-time", 17), + ("number", "decimal", 1.5), + ("number", "time-delta", 2.5), + ], + ) + def test_format_does_not_change_the_value( + self, json_type: str, fmt: str, value: Any + ) -> None: + """The value survives the round trip unchanged, and stays serializable. + + Both assertions matter. Retyping on ``format`` costs the value itself in + the worst case -- ``password`` comes back as a mask rather than the + credential -- and costs serializability in the rest, since a ``UUID`` or + ``Decimal`` reaches a connector through ``json.dumps``. + """ + model = create_model( + { + "type": "object", + "properties": {"v": {"type": json_type, "format": fmt}}, + "required": ["v"], + } + ) + dumped = model.model_validate({"v": value}).model_dump() + assert dumped["v"] == value + assert json.loads(json.dumps(dumped)) == {"v": value} + + +# --- marker and module contracts -------------------------------------------- + + +class TestLookupContracts: + """Job-attachment discovery finds types by name through the pseudo-module.""" + + @pytest.mark.parametrize("def_name", ["job-attachment", "Job_attachment"]) + def test_definition_is_reachable_by_its_marker_name(self, def_name: str) -> None: + model = create_model( + { + "type": "object", + "properties": {"attachment": {"$ref": f"#/definitions/{def_name}"}}, + "definitions": { + def_name: { + "type": "object", + "properties": {"ID": {"type": "string"}}, + "required": ["ID"], + } + }, + } + ) + # the name job_attachments.py asks for + assert get_json_paths_by_type(model, "__Job_attachment") == ["$.attachment"] + + def test_root_lives_in_a_registered_module(self) -> None: + model = create_model(CREATE_ISSUE) + module = sys.modules.get(model.__module__) + assert module is not None, "qualified-name lookups need a real module" + assert getattr(module, model.__name__, None) is model + + def test_datamodel_code_generator_also_rehomes_inline_models(self) -> None: + """Every class is published, including objects written inline. + + The legacy backend only re-homes the types it collected from ``$defs``, + so an inline model keeps the converter's own module -- see + ``TestLegacyBackendDeltas``. + """ + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: True}) + model = create_model(CREATE_ISSUE) + module = sys.modules[model.__module__] + inner_models = _nested_models(model, "fields") + assert inner_models + for inner in inner_models: + assert inner.__module__ == model.__module__ + assert getattr(module, inner.__name__, None) is inner + + def test_same_definition_name_in_two_schemas_stays_separate(self) -> None: + def schema(field: str) -> dict[str, Any]: + return { + "type": "object", + "properties": {"item": {"$ref": "#/$defs/Shared"}}, + "$defs": { + "Shared": { + "type": "object", + "properties": {field: {"type": "string"}}, + } + }, + } + + first = create_model(schema("alpha")) + second = create_model(schema("beta")) + assert first.__module__ != second.__module__ + (first_inner,) = _nested_models(first, "item") + (second_inner,) = _nested_models(second, "item") + assert first_inner is not second_inner + assert "alpha" in first_inner.model_fields + assert "beta" in second_inner.model_fields + + +# --- additionalProperties / extra -------------------------------------------- + + +class TestAdditionalProperties: + def test_unset_allows_extra(self) -> None: + model = create_model( + {"type": "object", "properties": {"a": {"type": "string"}}} + ) + instance = model.model_validate({"a": "x", "unexpected": 1}) + assert instance.model_dump()["unexpected"] == 1 + + def test_false_forbids_extra(self) -> None: + model = create_model( + { + "type": "object", + "additionalProperties": False, + "properties": {"a": {"type": "string"}}, + } + ) + with pytest.raises(ValidationError): + model.model_validate({"a": "x", "unexpected": 1}) + + def test_typed_additional_properties_are_validated(self) -> None: + model = create_model( + { + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {"type": "integer"}, + } + }, + } + ) + model.model_validate({"meta": {"count": 3}}) + with pytest.raises(ValidationError): + model.model_validate({"meta": {"count": "not a number"}}) + + +# --- root shapes ------------------------------------------------------------- + + +class TestRootNameCollision: + """The model handed back is the document's own, whatever the generator named it. + + A schema whose ``title`` matches a ``$defs`` entry puts the two in + competition for one class name, and the generator resolves the root last, + so the definition can win the name the root asked for. Recovering the root + by that name then returns the definition -- and nothing raises: the + LLM-facing schema, the validation and the payload all describe the wrong + type, and the argument the caller did send is swallowed as an extra. + """ + + _DEFINITION = {"type": "object", "properties": {"x": {"type": "string"}}} + + @pytest.mark.parametrize( + ("schema", "properties"), + [ + ( + { + "type": "object", + "title": "Root", + "properties": {"r": {"$ref": "#/$defs/Root"}}, + "required": ["r"], + "$defs": {"Root": _DEFINITION}, + }, + {"r"}, + ), + ( + { + "type": "object", + "properties": {"r": {"$ref": "#/$defs/Model"}}, + "required": ["r"], + "$defs": {"Model": _DEFINITION}, + }, + {"r"}, + ), + ( + { + "type": "object", + "title": "Root", + "properties": {"r": {"$ref": "#/$defs/Root"}}, + "required": ["r"], + "$defs": { + "Root": _DEFINITION, + "Spare": { + "type": "object", + "properties": {"s": {"type": "string"}}, + }, + }, + }, + {"r"}, + ), + ( + { + "type": "object", + "title": "Root", + "properties": {"a": {"$ref": "#/$defs/Root"}}, + "required": ["a"], + "$defs": { + "Root": { + "type": "object", + "properties": {"a": {"type": "string"}}, + } + }, + }, + {"a"}, + ), + ( + { + "type": "object", + "title": "Root", + "properties": {"r": {"$ref": "#/$defs/Inner"}}, + "required": ["r"], + "$defs": {"Inner": _DEFINITION}, + }, + {"r"}, + ), + ], + ids=[ + "title_collides_with_a_definition", + "untitled_root_against_a_definition_named_Model", + "collision_alongside_an_unreferenced_definition", + "collision_where_the_definition_declares_the_same_name", + "no_collision", + ], + ) + def test_root_carries_the_documents_own_properties( + self, schema: dict[str, Any], properties: set[str] + ) -> None: + """The root's properties and its ``required`` both survive the collision. + + ``required`` is asserted separately because the properties alone do not + always give the swap away -- where the definition happens to declare the + same name, the returned model looks right and only the lost + ``required`` shows that it is the wrong class. + """ + model = create_model(schema) + fields = { + (field.alias or name): field for name, field in model.model_fields.items() + } + assert set(fields) == properties + assert { + name for name, field in fields.items() if field.is_required() + } == properties + + def test_the_declared_argument_is_not_swallowed_as_an_extra(self) -> None: + """The symptom the swap produces: the real argument silently disappears.""" + model = create_model( + { + "type": "object", + "title": "Root", + "properties": {"r": {"$ref": "#/$defs/Root"}}, + "required": ["r"], + "$defs": {"Root": self._DEFINITION}, + } + ) + assert model.model_validate({"r": {"x": "v"}}).model_dump() == {"r": {"x": "v"}} + with pytest.raises(ValidationError): + model.model_validate({}) + + +class TestRootShapes: + @pytest.mark.parametrize( + "schema", + [ + {"type": "object", "properties": {}}, + {"type": "object"}, + {"type": "array", "items": {"type": "string"}}, + {"type": "string"}, + ], + ) + def test_root_converts(self, schema: dict[str, Any]) -> None: + model = create_model(schema) + assert isinstance(model, type) and issubclass(model, BaseModel) + + def test_deeply_nested_inline_objects(self) -> None: + node: dict[str, Any] = {"type": "string"} + for _ in range(6): + node = {"type": "object", "properties": {"child": node}} + model = create_model({"title": "Deep", **node}) + _clone_like_langchain(model) + + def test_recursive_definition(self) -> None: + model = create_model( + { + "title": "Node", + "type": "object", + "properties": {"next": {"$ref": "#/$defs/Node"}}, + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "next": {"$ref": "#/$defs/Node"}, + }, + } + }, + } + ) + model.model_validate({"next": {"value": "a", "next": {"value": "b"}}}) + + +# --- output schemas ---------------------------------------------------------- + + +class TestOutputModel: + def test_dangling_ref_is_neutralized_not_fatal(self) -> None: + model = create_output_model( + { + "type": "object", + "properties": { + "good": {"type": "string"}, + "bad": {"$ref": "#/$defs/Missing"}, + }, + }, + "some_tool", + ) + # the unresolvable field accepts anything, and the valid sibling survives + model.model_validate({"good": "x", "bad": {"anything": True}}) + assert "good" in model.model_json_schema()["properties"] + + +# --- how the two backends still differ -------------------------------------- + + +class TestBackendDifferences: + """Behaviour that is not identical across backends, pinned deliberately. + + An incomplete nested model is not among them -- both backends produce a + complete one. What is left is why the datamodel-code-generator backend is + still worth having. + """ + + @pytest.fixture(autouse=True) + def schema_backend(self) -> Any: + """Override the module fixture: these tests switch backends themselves.""" + FeatureFlags.reset_flags() + yield + FeatureFlags.reset_flags() + + def _inline_model(self, model: type[BaseModel]) -> type[BaseModel]: + (inner,) = _nested_models(model, "fields") + return inner + + def test_legacy_leaves_inline_models_on_a_shared_module(self) -> None: + """The legacy wrapper only re-homes the types it collected from ``$defs``. + + An inline object's model keeps the converter's own module, which every + conversion shares -- and those class names repeat across schemas, so a + qualified-name lookup there can land on another schema's class. The code + generator gives each conversion its own module for every class it makes. + """ + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: False}) + legacy_model = create_model(CREATE_ISSUE) + legacy_inline = self._inline_model(legacy_model) + assert legacy_inline.__module__ != legacy_model.__module__ + + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: True}) + generated_model = create_model(CREATE_ISSUE) + inline = self._inline_model(generated_model) + assert inline.__module__ == generated_model.__module__ + + def test_datamodel_code_generator_names_types_after_the_schema(self) -> None: + """The ``$defs`` names reach the language model, so they carry meaning. + + Legacy names every generated type ``DynamicType_N``; the code generator + derives the name from the schema, which is what the model then sees. + """ + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: False}) + legacy_defs = set(create_model(CREATE_ISSUE).model_json_schema()["$defs"]) + assert all(name.startswith("DynamicType") for name in legacy_defs) + + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: True}) + generated_defs = set(create_model(CREATE_ISSUE).model_json_schema()["$defs"]) + assert "Project" in generated_defs + + def test_root_title_is_the_same_either_way(self) -> None: + """What the model is told the tool is called must not depend on the flag.""" + titles = set() + for enabled in (False, True): + FeatureFlags.reset_flags() + FeatureFlags.configure_flags( + {DATAMODEL_CODE_GENERATOR_CONVERTER_FF: enabled} + ) + titles.add(create_model(CREATE_ISSUE).model_json_schema()["title"]) + assert titles == {"Create_Issue"} + + def test_additional_properties_only_object(self) -> None: + """An object declared solely by ``additionalProperties`` is a string map. + + The code generator types it as a dict; legacy wraps it in a model. + Validation and serialization agree, which is what callers depend on. + """ + schema = { + "type": "object", + "properties": { + "meta": {"type": "object", "additionalProperties": {"type": "string"}} + }, + } + for enabled in (False, True): + FeatureFlags.reset_flags() + FeatureFlags.configure_flags( + {DATAMODEL_CODE_GENERATOR_CONVERTER_FF: enabled} + ) + model = create_model(schema) + assert model.model_validate({"meta": {"a": "1"}}).model_dump() == { + "meta": {"a": "1"} + } + with pytest.raises(ValidationError): + model.model_validate({"meta": {"a": 1}}) + + +class TestBackendSelection: + """The feature flag picks the backend; off is the default.""" + + @pytest.fixture(autouse=True) + def schema_backend(self) -> Any: + """Override the module fixture: these tests set the flag themselves. + + Without this they would also be parametrized over both backends, and the + default-value test would run with the flag forced on. + """ + FeatureFlags.reset_flags() + yield + FeatureFlags.reset_flags() + + def test_defaults_to_the_legacy_backend(self) -> None: + assert not converter._datamodel_code_generator_enabled() + + @pytest.mark.parametrize( + ("enabled", "expected"), + [(False, _legacy_converter), (True, _datamodel_code_generator_converter)], + ids=["legacy", "datamodel_code_generator"], + ) + def test_flag_selects_the_backend(self, enabled: bool, expected: Any) -> None: + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: enabled}) + assert converter._datamodel_code_generator_enabled() is enabled + + calls: list[dict[str, Any]] = [] + original = expected.create_model + + def spy(schema: dict[str, Any]) -> Any: + calls.append(schema) + return original(schema) + + expected.create_model = spy + try: + create_model({"type": "object", "properties": {"a": {"type": "string"}}}) + finally: + expected.create_model = original + assert len(calls) == 1, "the selected backend should have been used" + + def test_output_model_honours_the_flag_too(self) -> None: + FeatureFlags.configure_flags({DATAMODEL_CODE_GENERATOR_CONVERTER_FF: True}) + model = create_output_model( + {"type": "object", "properties": {"a": {"type": "string"}}}, "t" + ) + assert model.__pydantic_complete__ diff --git a/tests/agent/tools/test_tool_factory.py b/tests/agent/tools/test_tool_factory.py index 333f731d8..df35d4019 100644 --- a/tests/agent/tools/test_tool_factory.py +++ b/tests/agent/tools/test_tool_factory.py @@ -38,9 +38,7 @@ ) from uipath.platform.connections import Connection -from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( - _UNRESOLVED_TYPE_TITLE, -) +from uipath_langchain.agent.react._schema_refs import UNRESOLVED_TYPE_TITLE from uipath_langchain.agent.tools.base_uipath_structured_tool import ( BaseUiPathStructuredTool, ) @@ -503,4 +501,4 @@ async def test_malformed_output_schema_is_non_blocking( properties = tool.output_type.model_json_schema().get("properties", {}) # Valid sibling survives; dangling-ref field is neutralized (not dropped). assert "status" in properties - assert properties["left"]["title"] == _UNRESOLVED_TYPE_TITLE + assert properties["left"]["title"] == UNRESOLVED_TYPE_TITLE diff --git a/uv.lock b/uv.lock index 6c6bdae12..e82201a37 100644 --- a/uv.lock +++ b/uv.lock @@ -172,9 +172,9 @@ name = "aiologic" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -271,6 +271,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/0d/cb6b23164eb55eebaa5f9f302dfe557cfa751bd7b2779863f1abd0343b6b/applicationinsights-0.11.10-py2.py3-none-any.whl", hash = "sha256:e89a890db1c6906b6a7d0bcfd617dac83974773c64573147c8d6654f9cf2a6ea", size = 55068, upload-time = "2021-04-22T23:22:44.451Z" }, ] +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, +] + [[package]] name = "ast-serialize" version = "0.5.0" @@ -441,6 +450,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, ] +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + [[package]] name = "boto3" version = "1.43.34" @@ -945,14 +991,33 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, ] +[[package]] +name = "datamodel-code-generator" +version = "0.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/65/27983214b172cf5463209ef55b68f7bb180eaeb114fbca477f709e2ca721/datamodel_code_generator-0.76.0.tar.gz", hash = "sha256:782ad3d17ea53f3a2300347d4058e281ba749ff57821712464f88c1ce75876c8", size = 2184419, upload-time = "2026-08-29T02:04:09.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/72/801a441c9d3717c9fab3399a382ed2d146cc79df12b8ea45427ce26d55a3/datamodel_code_generator-0.76.0-py3-none-any.whl", hash = "sha256:cff9d19faa9072cfc19a04d11ac5e30d0e1a846bd1f843f05f37841941fe3e62", size = 643099, upload-time = "2026-08-29T02:04:07.519Z" }, +] + [[package]] name = "deepagents" version = "0.5.9" @@ -1197,6 +1262,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] +[[package]] +name = "genson" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/53/de162dc8e03fccd9ebe59d17c7812378fe8bd2b604f6b1b94d00165140ac/genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da", size = 47908, upload-time = "2026-07-06T08:21:50.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, +] + [[package]] name = "google-api-core" version = "2.31.0" @@ -1686,6 +1760,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f", size = 27220, upload-time = "2026-03-20T16:56:25.07Z" }, ] +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1704,6 +1791,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, ] +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1871,14 +1967,14 @@ wheels = [ [[package]] name = "jsonschema-pydantic-converter" -version = "0.4.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/b0/8eaae31bb1d1c863404032e48b81842f5abe3b8697be32fb0543a0cce4b1/jsonschema_pydantic_converter-0.4.0.tar.gz", hash = "sha256:abc137b036146ed336f49af9659a76b080a89febc0e836be20025bf3d5f2eaaa", size = 68801, upload-time = "2026-03-25T15:24:29.882Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/3a/edc38b9a0e5a77863c2a729fe00a6ef10e2752e128945d64cf40b5baac3d/jsonschema_pydantic_converter-0.4.1.tar.gz", hash = "sha256:b4fd02a9b4a9c991bb36cb26fb5445d466cbe10547050ecd7c7d492dfc869c3f", size = 70337, upload-time = "2026-08-31T12:58:17.948Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/d8/950e57f3238965778e40c0170f510860533e0abc09afb186d9c1d7d712e8/jsonschema_pydantic_converter-0.4.0-py3-none-any.whl", hash = "sha256:34a07cbc4be2d0bfec2b23518857bb2d7acf189acc3b018d757ac74f76950aa2", size = 20186, upload-time = "2026-03-25T15:24:28.69Z" }, + { url = "https://files.pythonhosted.org/packages/93/00/ad8e31626d880466656f70e41b86279862dd263928f4d9012aca0d3fbeb3/jsonschema_pydantic_converter-0.4.1-py3-none-any.whl", hash = "sha256:eb37942e4be7e0e13e70ea50d71d88891058f1cf096b69d8e69283f64464d4b7", size = 20510, upload-time = "2026-08-31T12:58:16.741Z" }, ] [[package]] @@ -2420,6 +2516,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/70/625563b5925ee5393a971f4790aab4b0059deda3bf9196c57ba7172e9ab3/mockito-2.0.4-py3-none-any.whl", hash = "sha256:04d7e6e9b9b7288e76235b894f9b3cbfdc9cfc631e548ed607933146ae9db1f5", size = 51921, upload-time = "2026-04-15T09:23:46.68Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "msal" version = "1.37.0" @@ -3768,6 +3873,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pywin32" version = "312" @@ -4464,6 +4603,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "typeguard" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, +] + [[package]] name = "typer" version = "0.25.1" @@ -4488,6 +4639,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/b1/214b12162b452ed6acd230065e6c587cde6b96871e3ce6d653f40888f8df/types_awscrt-0.34.1-py3-none-any.whl", hash = "sha256:20c752b6031544d8f694803c35174aee129f1be5ddf886ae46d22f7ffd9b7d75", size = 45688, upload-time = "2026-06-05T04:40:09.198Z" }, ] +[[package]] +name = "types-jsonschema" +version = "4.26.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz", hash = "sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size = 16638, upload-time = "2026-05-18T06:06:44.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl", hash = "sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size = 16072, upload-time = "2026-05-18T06:06:43.264Z" }, +] + [[package]] name = "types-protobuf" version = "6.32.1.20260221" @@ -4574,14 +4737,16 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.17.1" +version = "0.17.2" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, + { name = "datamodel-code-generator" }, { name = "deepagents" }, { name = "httpx" }, { name = "httpx2" }, { name = "jsonpath-ng" }, + { name = "jsonschema" }, { name = "jsonschema-pydantic-converter" }, { name = "langchain" }, { name = "langchain-core" }, @@ -4634,6 +4799,7 @@ dev = [ { name = "ruff" }, { name = "rust-just" }, { name = "starlette" }, + { name = "types-jsonschema" }, { name = "types-protobuf" }, { name = "uvicorn" }, { name = "virtualenv" }, @@ -4643,11 +4809,13 @@ dev = [ requires-dist = [ { name = "a2a-sdk", specifier = ">=1.1.2,<2.0.0" }, { name = "boto3-stubs", marker = "extra == 'bedrock'", specifier = ">=1.41.4" }, + { name = "datamodel-code-generator", specifier = ">=0.76.0" }, { name = "deepagents", specifier = ">=0.5.9,<0.6.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "httpx2", specifier = ">=2.5.0,<2.10.0" }, { name = "jsonpath-ng", specifier = ">=1.7.0" }, - { name = "jsonschema-pydantic-converter", specifier = ">=0.4.0" }, + { name = "jsonschema", specifier = ">=4.23.0" }, + { name = "jsonschema-pydantic-converter", specifier = ">=0.4.1" }, { name = "langchain", specifier = ">=1.2.15,<2.0.0" }, { name = "langchain-core", specifier = ">=1.2.27,<2.0.0" }, { name = "langgraph", specifier = ">=1.1.8,<2.0.0" }, @@ -4688,6 +4856,7 @@ dev = [ { name = "ruff", specifier = ">=0.9.4" }, { name = "rust-just", specifier = ">=1.39.0" }, { name = "starlette", specifier = ">=0.41.3" }, + { name = "types-jsonschema", specifier = ">=4.23.0" }, { name = "types-protobuf", specifier = "<7" }, { name = "uvicorn", specifier = ">=0.30.0" }, { name = "virtualenv", specifier = ">=20.36.1" },