Skip to content

Commit 3331bac

Browse files
fix: tolerate malformed tool output schemas at agent startup (#983)
1 parent 3338b18 commit 3331bac

10 files changed

Lines changed: 439 additions & 14 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.14.7"
3+
version = "0.14.8"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import inspect
2+
import logging
23
import sys
34
from types import ModuleType
45
from typing import Any, Type, cast
@@ -8,6 +9,15 @@
89

910
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
1011

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+
1121
# Shared pseudo-module for all dynamically created types
1222
# This allows get_type_hints() to resolve forward references
1323
_DYNAMIC_MODULE_NAME = "jsonschema_pydantic_converter._dynamic"
@@ -60,3 +70,96 @@ def create_model(
6070
model.__module__ = _DYNAMIC_MODULE_NAME
6171

6272
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)

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727
get_execution_folder_path,
2828
)
2929
from uipath_langchain._utils.durable_interrupt import durable_interrupt
30-
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
30+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
31+
create_model,
32+
create_output_model,
33+
)
3134
from uipath_langchain.agent.tools.structured_tool_with_argument_properties import (
3235
StructuredToolWithArgumentProperties,
3336
)
@@ -267,7 +270,7 @@ def create_escalation_tool(
267270
channel: EscalationChannel = _resolve_channel(resource)
268271

269272
input_model: Any = create_model(channel.input_schema)
270-
output_model: Any = create_model(channel.output_schema)
273+
output_model: Any = create_output_model(channel.output_schema, resource.name)
271274

272275
class EscalationToolOutput(BaseModel):
273276
action: Literal["approve", "reject"]

src/uipath_langchain/agent/tools/integration_tool.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
AgentStartupErrorCode,
2424
raise_for_enriched,
2525
)
26-
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
26+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
27+
create_model,
28+
create_output_model,
29+
)
2730

2831
from .schema_editing import strip_enum
2932
from .structured_tool_with_argument_properties import (
@@ -301,7 +304,9 @@ def create_integration_tool(
301304
input_model = create_model(cleaned_input_schema)
302305
# note: IS tools output schemas were recently added and are most likely not present in all resources
303306
output_model: Any = (
304-
create_model(remove_asterisk_from_properties(resource.output_schema))
307+
create_output_model(
308+
remove_asterisk_from_properties(resource.output_schema), resource.name
309+
)
305310
if resource.output_schema
306311
else create_model({"type": "object", "properties": {}})
307312
)

src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@
3939
build_file_content_blocks_for,
4040
)
4141
from uipath_langchain.agent.react.job_attachments import raise_for_job_attachment_error
42-
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
42+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
43+
create_model,
44+
create_output_model,
45+
)
4346
from uipath_langchain.agent.tools.internal_tools.pii_masker import (
4447
PiiMasker,
4548
masked_name_for,
@@ -247,7 +250,7 @@ def create_analyze_file_tool(
247250

248251
tool_name = sanitize_tool_name(resource.name)
249252
input_model = create_model(resource.input_schema)
250-
output_model = create_model(resource.output_schema)
253+
output_model = create_output_model(resource.output_schema, resource.name)
251254

252255
# Explicitly disable streaming - for conversational, no streaming is needed as this
253256
# internal tool-call does not produce streamed conversation events.

src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
durable_interrupt,
2727
)
2828
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
29-
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
29+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
30+
create_model,
31+
create_output_model,
32+
)
3033
from uipath_langchain.agent.tools.internal_tools.schema_utils import (
3134
add_query_field_to_schema,
3235
)
@@ -85,7 +88,7 @@ def create_deeprag_tool(
8588
)
8689

8790
input_model = create_model(input_schema)
88-
output_model = create_model(resource.output_schema)
91+
output_model = create_output_model(resource.output_schema, resource.name)
8992

9093
async def deeprag_tool_fn(**kwargs: Any) -> dict[str, Any]:
9194
query = kwargs.get("query") if not is_query_static else static_query

src/uipath_langchain/agent/tools/process_tool.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
from uipath_langchain._utils.durable_interrupt import durable_interrupt
1717
from uipath_langchain.agent.exceptions import raise_for_enriched
1818
from uipath_langchain.agent.react.job_attachments import get_job_attachments
19-
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
19+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
20+
create_model,
21+
create_output_model,
22+
)
2023
from uipath_langchain.agent.tools.structured_tool_with_argument_properties import (
2124
StructuredToolWithArgumentProperties,
2225
)
@@ -52,7 +55,7 @@ def create_process_tool(
5255
folder_path = get_execution_folder_path()
5356

5457
input_model: Any = create_model(resource.input_schema)
55-
output_model: Any = create_model(resource.output_schema)
58+
output_model: Any = create_output_model(resource.output_schema, resource.name)
5659

5760
_span_context: dict[str, Any] = {}
5861
_bts_context: dict[str, Any] = {}

0 commit comments

Comments
 (0)