Skip to content

Commit 848f660

Browse files
fix: coerce stringified dict/list fields in analyze files tool input args (#686)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fc2a17e commit 848f660

File tree

5 files changed

+212
-4
lines changed

5 files changed

+212
-4
lines changed

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.8.18"
3+
version = "0.8.19"
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/json_utils.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import ast
2+
import json
13
import sys
24
from typing import Any, ForwardRef, Union, get_args, get_origin
35

@@ -212,3 +214,59 @@ def _json_key(field_name: str, field_info: Any) -> str:
212214

213215
def _is_pydantic_model(annotation: Any) -> bool:
214216
return isinstance(annotation, type) and issubclass(annotation, BaseModel)
217+
218+
219+
def _coerce_field(key: str, value: Any, schema: type[BaseModel] | None) -> Any:
220+
"""Coerce a single field value, skipping str-typed fields when schema is available."""
221+
if schema is None:
222+
return coerce_json_strings(value)
223+
224+
field_info = schema.model_fields.get(key)
225+
if field_info is None:
226+
return coerce_json_strings(value)
227+
228+
annotation = _unwrap_optional(field_info.annotation)
229+
230+
if annotation is str:
231+
return value
232+
233+
if _is_pydantic_model(annotation):
234+
return coerce_json_strings(value, annotation)
235+
236+
if get_origin(annotation) is list:
237+
item_args = get_args(annotation)
238+
item_schema = None
239+
if item_args and _is_pydantic_model(item_args[0]):
240+
item_schema = item_args[0]
241+
if isinstance(value, list):
242+
return [coerce_json_strings(item, item_schema) for item in value]
243+
244+
return coerce_json_strings(value)
245+
246+
247+
def coerce_json_strings(data: Any, schema: type[BaseModel] | None = None) -> Any:
248+
"""Parse stringified dicts/lists back into Python objects.
249+
250+
LLMs sometimes serialize nested objects as strings instead of dicts,
251+
either as JSON (double quotes) or Python repr (single quotes).
252+
When a schema is provided, str-typed fields are left untouched.
253+
"""
254+
if isinstance(data, dict):
255+
return {k: _coerce_field(k, v, schema) for k, v in data.items()}
256+
if isinstance(data, list):
257+
return [coerce_json_strings(item) for item in data]
258+
if isinstance(data, str):
259+
try:
260+
parsed = json.loads(data)
261+
if isinstance(parsed, (dict, list)):
262+
return parsed
263+
except (json.JSONDecodeError, TypeError):
264+
pass
265+
# LLMs sometimes emit Python repr (single quotes) instead of JSON
266+
try:
267+
parsed = ast.literal_eval(data)
268+
if isinstance(parsed, (dict, list)):
269+
return parsed
270+
except (ValueError, SyntaxError):
271+
pass
272+
return data

src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
get_job_attachments,
1212
replace_job_attachment_ids,
1313
)
14+
from uipath_langchain.agent.react.json_utils import coerce_json_strings
1415
from uipath_langchain.agent.react.types import AgentGraphState
1516
from uipath_langchain.agent.tools.tool_node import AsyncToolWrapperWithState
1617

@@ -73,18 +74,21 @@ async def job_attachment_wrapper(
7374
input_args = call["args"]
7475
modified_input_args = input_args
7576

77+
schema = None
7678
if isinstance(tool.args_schema, type) and issubclass(
7779
tool.args_schema, BaseModel
7880
):
81+
schema = tool.args_schema
7982
errors: list[str] = []
80-
paths = get_job_attachment_paths(tool.args_schema)
83+
paths = get_job_attachment_paths(schema)
8184
modified_input_args = replace_job_attachment_ids(
8285
paths, input_args, state.inner_state.job_attachments, errors
8386
)
8487

8588
if errors:
8689
return {"error": "\n".join(errors)}
87-
call["args"] = modified_input_args
90+
91+
call["args"] = coerce_json_strings(modified_input_args, schema)
8892
tool_result = await tool.ainvoke(call)
8993
job_attachments_dict = {}
9094
if output_type is not None:

tests/agent/react/test_json_utils.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pydantic import BaseModel, RootModel
44

55
from uipath_langchain.agent.react.json_utils import (
6+
coerce_json_strings,
67
extract_values_by_paths,
78
get_json_paths_by_type,
89
)
@@ -323,3 +324,148 @@ def test_underscore_field_list_jsonpath(self) -> None:
323324
model = create_model(schema)
324325
paths = get_json_paths_by_type(model, "__Job_attachment")
325326
assert paths == ["$._files[*]"]
327+
328+
329+
# -- coerce_json_strings: no schema (blind coercion) --------------------------
330+
331+
332+
class TestCoerceJsonStringsNoSchema:
333+
"""Without a schema, all parseable strings are coerced."""
334+
335+
def test_no_coercion_needed(self) -> None:
336+
data = {"name": "test", "count": 42}
337+
assert coerce_json_strings(data) == data
338+
339+
def test_json_object_string(self) -> None:
340+
data = {"metadata": '{"size": "99353"}'}
341+
assert coerce_json_strings(data) == {"metadata": {"size": "99353"}}
342+
343+
def test_python_repr_string(self) -> None:
344+
data = {"metadata": "{'size': '99353'}"}
345+
assert coerce_json_strings(data) == {"metadata": {"size": "99353"}}
346+
347+
def test_nested_in_dict(self) -> None:
348+
data = {"attachment": {"metadata": '{"size": 1024}', "name": "file.pdf"}}
349+
assert coerce_json_strings(data) == {
350+
"attachment": {"metadata": {"size": 1024}, "name": "file.pdf"}
351+
}
352+
353+
def test_in_list_items(self) -> None:
354+
data = {
355+
"items": [
356+
{"metadata": '{"size": 100}', "name": "a.pdf"},
357+
{"metadata": {"size": 200}, "name": "b.pdf"},
358+
]
359+
}
360+
assert coerce_json_strings(data) == {
361+
"items": [
362+
{"metadata": {"size": 100}, "name": "a.pdf"},
363+
{"metadata": {"size": 200}, "name": "b.pdf"},
364+
]
365+
}
366+
367+
def test_invalid_string_unchanged(self) -> None:
368+
data = {"metadata": "not valid json"}
369+
assert coerce_json_strings(data) == data
370+
371+
def test_json_array_string(self) -> None:
372+
data = {"tags": "[1, 2, 3]"}
373+
assert coerce_json_strings(data) == {"tags": [1, 2, 3]}
374+
375+
def test_plain_string_unchanged(self) -> None:
376+
data = {"name": "hello world"}
377+
assert coerce_json_strings(data) == data
378+
379+
def test_empty_dict(self) -> None:
380+
assert coerce_json_strings({}) == {}
381+
382+
def test_dict_value_unchanged(self) -> None:
383+
data = {"metadata": {"already": "a dict"}}
384+
assert coerce_json_strings(data) == data
385+
386+
def test_json_primitives_unchanged(self) -> None:
387+
"""JSON primitives (numbers, booleans) stay as strings."""
388+
data = {"value": "42", "flag": "true"}
389+
assert coerce_json_strings(data) == data
390+
391+
def test_non_dict_passthrough(self) -> None:
392+
assert coerce_json_strings(42) == 42
393+
assert coerce_json_strings(None) is None
394+
assert coerce_json_strings(True) is True
395+
396+
397+
# -- coerce_json_strings: with schema -----------------------------------------
398+
399+
400+
class TestCoerceJsonStringsWithSchema:
401+
"""With a schema, str-typed fields are protected from coercion."""
402+
403+
def test_str_field_preserved_dict_field_coerced(self) -> None:
404+
"""The real-world Analyze_Files scenario."""
405+
406+
class AttachmentInput(BaseModel):
407+
ID: str
408+
FullName: str
409+
MimeType: str
410+
Metadata: dict[str, Any] | None = None
411+
412+
class AnalyzeFilesInput(BaseModel):
413+
analysisTask: str
414+
attachments: list[AttachmentInput]
415+
416+
data = {
417+
"analysisTask": '{"instruction": "summarize the document"}',
418+
"attachments": [
419+
{
420+
"ID": "550e8400-e29b-41d4-a716-446655440000",
421+
"FullName": "report.pdf",
422+
"MimeType": "application/pdf",
423+
"Metadata": '{"size": "99353"}',
424+
}
425+
],
426+
}
427+
result = coerce_json_strings(data, AnalyzeFilesInput)
428+
429+
assert result["attachments"][0]["Metadata"] == {"size": "99353"}
430+
assert isinstance(result["analysisTask"], str)
431+
assert result["analysisTask"] == '{"instruction": "summarize the document"}'
432+
433+
def test_python_repr_with_schema(self) -> None:
434+
"""Single-quoted Python repr is coerced for dict fields."""
435+
436+
class Inner(BaseModel):
437+
Metadata: dict[str, Any] | None = None
438+
439+
class Outer(BaseModel):
440+
item: Inner
441+
442+
data = {"item": {"Metadata": "{'size': '99353'}"}}
443+
result = coerce_json_strings(data, Outer)
444+
assert result["item"]["Metadata"] == {"size": "99353"}
445+
446+
def test_unknown_field_coerced(self) -> None:
447+
"""Fields not in the schema fall back to blind coercion."""
448+
449+
class Schema(BaseModel):
450+
name: str
451+
452+
data = {"name": "test", "extra": '{"a": 1}'}
453+
result = coerce_json_strings(data, Schema)
454+
assert result["name"] == "test"
455+
assert result["extra"] == {"a": 1}
456+
457+
def test_nested_model_field_recurses(self) -> None:
458+
"""BaseModel-typed fields recurse with child schema."""
459+
460+
class Child(BaseModel):
461+
value: str
462+
data: dict[str, Any] | None = None
463+
464+
class Parent(BaseModel):
465+
child: Child
466+
467+
result = coerce_json_strings(
468+
{"child": {"value": '{"x": 1}', "data": '{"y": 2}'}}, Parent
469+
)
470+
assert result["child"]["value"] == '{"x": 1}'
471+
assert result["child"]["data"] == {"y": 2}

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)