|
1 | 1 | import inspect |
| 2 | +import logging |
2 | 3 | import sys |
3 | 4 | from types import ModuleType |
4 | 5 | from typing import Any, Type, cast |
|
8 | 9 |
|
9 | 10 | from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode |
10 | 11 |
|
| 12 | +logger = logging.getLogger(__name__) |
| 13 | + |
| 14 | +# Marker left on any OUTPUT-schema node whose $ref target could not be resolved. |
| 15 | +# The converter discards $defs names and non-standard (x-*) keys but preserves the |
| 16 | +# standard `title`/`description` annotations on a property, so the marker lives as |
| 17 | +# annotations rather than a named type. Downstream can detect an unresolved field |
| 18 | +# via ``title == _UNRESOLVED_TYPE_TITLE``. See create_output_model. |
| 19 | +_UNRESOLVED_TYPE_TITLE = "UiPathUnresolvedType" |
| 20 | + |
11 | 21 | # Shared pseudo-module for all dynamically created types |
12 | 22 | # This allows get_type_hints() to resolve forward references |
13 | 23 | _DYNAMIC_MODULE_NAME = "jsonschema_pydantic_converter._dynamic" |
@@ -60,3 +70,96 @@ def create_model( |
60 | 70 | model.__module__ = _DYNAMIC_MODULE_NAME |
61 | 71 |
|
62 | 72 | return model |
| 73 | + |
| 74 | + |
| 75 | +def _ref_resolves(ref: str, root: dict[str, Any]) -> bool: |
| 76 | + """Whether a local JSON-pointer ``$ref`` (``#/...``) resolves within `root`. |
| 77 | +
|
| 78 | + External/URL refs and the bare ``#`` (whole-document) ref return False: the |
| 79 | + converter cannot resolve them either, so they are treated as dangling. |
| 80 | + """ |
| 81 | + if not ref.startswith("#/"): |
| 82 | + return False |
| 83 | + node: Any = root |
| 84 | + for part in ref[2:].split("/"): |
| 85 | + part = part.replace("~1", "/").replace("~0", "~") # JSON-pointer unescape |
| 86 | + if isinstance(node, dict) and part in node: |
| 87 | + node = node[part] |
| 88 | + else: |
| 89 | + return False |
| 90 | + return True |
| 91 | + |
| 92 | + |
| 93 | +def _neutralize_dangling_refs( |
| 94 | + schema: dict[str, Any], |
| 95 | +) -> tuple[dict[str, Any], list[str]]: |
| 96 | + """Return a copy of `schema` with every unresolvable ``$ref`` replaced. |
| 97 | +
|
| 98 | + A ``$ref`` is dangling when its target is not present under ``$defs``/ |
| 99 | + ``definitions`` (e.g. a .NET ``Nullable<decimal>`` serialized without its |
| 100 | + definition). Each dangling ref node is replaced *in place* by a permissive, |
| 101 | + self-documenting placeholder (accepts any value; the original ref is kept in |
| 102 | + its ``description``), so valid sibling fields and valid ``$ref``s -- including |
| 103 | + those nested in arrays, objects, or ``$defs`` -- are preserved. This keeps the |
| 104 | + output schema usable by best-effort features instead of discarding it whole. |
| 105 | +
|
| 106 | + Returns: |
| 107 | + A tuple of (sanitized schema copy, list of the dangling ref strings found). |
| 108 | + """ |
| 109 | + dropped: list[str] = [] |
| 110 | + |
| 111 | + def visit(node: Any) -> Any: |
| 112 | + if isinstance(node, dict): |
| 113 | + ref = node.get("$ref") |
| 114 | + if isinstance(ref, str) and not _ref_resolves(ref, schema): |
| 115 | + dropped.append(ref) |
| 116 | + return { |
| 117 | + "title": _UNRESOLVED_TYPE_TITLE, |
| 118 | + "description": ( |
| 119 | + f"Unresolved $ref '{ref}'; original type could not be " |
| 120 | + "resolved at startup, so this field accepts any value." |
| 121 | + ), |
| 122 | + } |
| 123 | + return {key: visit(value) for key, value in node.items()} |
| 124 | + if isinstance(node, list): |
| 125 | + return [visit(item) for item in node] |
| 126 | + return node |
| 127 | + |
| 128 | + return visit(schema), dropped |
| 129 | + |
| 130 | + |
| 131 | +def create_output_model( |
| 132 | + schema: dict[str, Any], |
| 133 | + tool_name: str, |
| 134 | +) -> Type[BaseModel]: |
| 135 | + """Convert a tool's OUTPUT JSON schema to a Pydantic model. |
| 136 | +
|
| 137 | + Unresolvable ``$ref``s -- the malformed output schema seen in practice (see |
| 138 | + _neutralize_dangling_refs) -- are neutralized in place so all valid fields are |
| 139 | + kept; since an output schema drives only best-effort features (job-attachment |
| 140 | + discovery, output guardrails, eval simulations), losing a single unresolvable |
| 141 | + field is preferable to failing startup. |
| 142 | +
|
| 143 | + Any *other* conversion failure is deliberately left fatal: we would rather fail |
| 144 | + loudly at startup than swallow an unexpected malformation into a degraded model |
| 145 | + that fails obscurely at runtime. |
| 146 | +
|
| 147 | + Returns: |
| 148 | + The converted model, with dangling refs neutralized. |
| 149 | +
|
| 150 | + Raises: |
| 151 | + AgentStartupError: If the schema is unparseable for a reason other than a |
| 152 | + dangling ``$ref``. |
| 153 | + """ |
| 154 | + sanitized, dropped = _neutralize_dangling_refs(schema) |
| 155 | + if dropped: |
| 156 | + logger.warning( |
| 157 | + "Tool %r output schema had %d unresolvable $ref(s) (%s); each replaced " |
| 158 | + "with a permissive %r placeholder. Output schema does not affect the " |
| 159 | + "core tool call, so agent startup is not blocked.", |
| 160 | + tool_name, |
| 161 | + len(dropped), |
| 162 | + ", ".join(sorted(set(dropped))), |
| 163 | + _UNRESOLVED_TYPE_TITLE, |
| 164 | + ) |
| 165 | + return create_model(sanitized) |
0 commit comments