Skip to content

Commit 54d982f

Browse files
Add glm 4.6 bis - bis (#1046)
Co-authored-by: openhands <[email protected]>
1 parent a612c0a commit 54d982f

File tree

3 files changed

+376
-0
lines changed

3 files changed

+376
-0
lines changed

openhands-sdk/openhands/sdk/agent/agent.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import openhands.sdk.security.risk as risk
66
from openhands.sdk.agent.base import AgentBase
7+
from openhands.sdk.agent.utils import fix_malformed_tool_arguments
78
from openhands.sdk.context.view import View
89
from openhands.sdk.conversation import (
910
ConversationCallbackType,
@@ -351,6 +352,9 @@ def _get_action_event(
351352
try:
352353
arguments = json.loads(tool_call.arguments)
353354

355+
# Fix malformed arguments (e.g., JSON strings for list/dict fields)
356+
arguments = fix_malformed_tool_arguments(arguments, tool.action_type)
357+
354358
# if the tool has a security_risk field (when security analyzer is set),
355359
# pop it out as it's not part of the tool's action schema
356360
if (
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import json
2+
import types
3+
from typing import Annotated, Any, Union, get_args, get_origin
4+
5+
from openhands.sdk.tool import Action
6+
7+
8+
def fix_malformed_tool_arguments(
9+
arguments: dict[str, Any], action_type: type[Action]
10+
) -> dict[str, Any]:
11+
"""Fix malformed tool arguments by decoding JSON strings for list/dict fields.
12+
13+
This function handles cases where certain LLMs (such as GLM 4.6) incorrectly
14+
encode array/object parameters as JSON strings when using native function calling.
15+
16+
Example raw LLM output from GLM 4.6:
17+
{
18+
"role": "assistant",
19+
"content": "I'll view the file for you.",
20+
"tool_calls": [{
21+
"id": "call_ef8e",
22+
"type": "function",
23+
"function": {
24+
"name": "str_replace_editor",
25+
"arguments": '{
26+
"command": "view",
27+
"path": "/tmp/test.txt",
28+
"view_range": "[1, 5]"
29+
}'
30+
}
31+
}]
32+
}
33+
34+
Expected output: `"view_range" : [1, 5]`
35+
36+
Note: The arguments field is a JSON string. When decoded, view_range is
37+
incorrectly a string "[1, 5]" instead of the proper array [1, 5].
38+
This function automatically fixes this by detecting that view_range
39+
expects a list type and decoding the JSON string to get the actual array.
40+
41+
Args:
42+
arguments: The parsed arguments dict from json.loads(tool_call.arguments).
43+
action_type: The action type that defines the expected schema.
44+
45+
Returns:
46+
The arguments dict with JSON strings decoded where appropriate.
47+
"""
48+
if not isinstance(arguments, dict):
49+
return arguments
50+
51+
fixed_arguments = arguments.copy()
52+
53+
# Use model_fields to properly handle aliases and inherited fields
54+
for field_name, field_info in action_type.model_fields.items():
55+
# Check both the field name and its alias (if any)
56+
data_key = field_info.alias if field_info.alias else field_name
57+
if data_key not in fixed_arguments:
58+
continue
59+
60+
value = fixed_arguments[data_key]
61+
# Skip if value is not a string
62+
if not isinstance(value, str):
63+
continue
64+
65+
expected_type = field_info.annotation
66+
67+
# Unwrap Annotated types - only the first arg is the actual type
68+
if get_origin(expected_type) is Annotated:
69+
type_args = get_args(expected_type)
70+
expected_type = type_args[0] if type_args else expected_type
71+
72+
# Get the origin of the expected type (e.g., list from list[str])
73+
origin = get_origin(expected_type)
74+
75+
# For Union types, we need to check all union members
76+
if origin is Union or origin is types.UnionType:
77+
# For Union types, check each union member
78+
type_args = get_args(expected_type)
79+
expected_origins = [get_origin(arg) or arg for arg in type_args]
80+
else:
81+
# For non-Union types, just check the origin
82+
expected_origins = [origin or expected_type]
83+
84+
# Check if any of the expected types is list or dict
85+
if any(exp in (list, dict) for exp in expected_origins):
86+
# Try to parse the string as JSON
87+
try:
88+
parsed_value = json.loads(value)
89+
# json.loads() returns dict, list, str, int, float, bool, or None
90+
# Only use parsed value if it matches expected collection types
91+
if isinstance(parsed_value, (list, dict)):
92+
fixed_arguments[data_key] = parsed_value
93+
except (json.JSONDecodeError, ValueError):
94+
# If parsing fails, leave the original value
95+
# Pydantic will raise validation error if needed
96+
pass
97+
98+
return fixed_arguments
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Tests for fix_malformed_tool_arguments helper function.
2+
3+
This module tests the fix_malformed_tool_arguments helper that automatically
4+
decodes JSON strings for list/dict fields. This handles cases where LLMs
5+
(like GLM-4) return array/object values as JSON strings instead of native
6+
JSON arrays/objects.
7+
"""
8+
9+
from typing import Annotated
10+
11+
import pytest
12+
from pydantic import Field, ValidationError
13+
14+
from openhands.sdk.agent.utils import fix_malformed_tool_arguments
15+
from openhands.sdk.tool.schema import Action
16+
17+
18+
class JsonDecodingTestAction(Action):
19+
"""Test action with list and dict fields."""
20+
21+
items: list[str] = Field(description="A list of items")
22+
config: dict[str, int] = Field(description="Configuration dictionary")
23+
name: str = Field(description="A regular string field")
24+
25+
26+
class JsonDecodingAnnotatedAction(Action):
27+
"""Test action with Annotated types."""
28+
29+
items: Annotated[list[str], Field(description="A list of items")]
30+
config: Annotated[dict[str, int], Field(description="Configuration dictionary")]
31+
32+
33+
class JsonDecodingAliasAction(Action):
34+
"""Test action with field aliases."""
35+
36+
my_list: list[int] = Field(alias="myList", description="A list with alias")
37+
my_dict: dict[str, str] = Field(alias="myDict", description="A dict with alias")
38+
39+
40+
class JsonDecodingOptionalAction(Action):
41+
"""Test action with optional list/dict fields."""
42+
43+
items: list[str] | None = Field(default=None, description="Optional list")
44+
config: dict[str, int] | None = Field(default=None, description="Optional dict")
45+
46+
47+
def test_decode_json_string_list():
48+
"""Test that JSON string lists are decoded to native lists."""
49+
data = {
50+
"items": '["a", "b", "c"]',
51+
"config": '{"x": 1, "y": 2}',
52+
"name": "test",
53+
}
54+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
55+
action = JsonDecodingTestAction.model_validate(fixed_data)
56+
57+
assert action.items == ["a", "b", "c"]
58+
assert action.config == {"x": 1, "y": 2}
59+
assert action.name == "test"
60+
61+
62+
def test_decode_json_string_dict():
63+
"""Test that JSON string dicts are decoded to native dicts."""
64+
data = {
65+
"items": '["item1", "item2"]',
66+
"config": '{"key1": 10, "key2": 20}',
67+
"name": "dict_test",
68+
}
69+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
70+
action = JsonDecodingTestAction.model_validate(fixed_data)
71+
72+
assert action.items == ["item1", "item2"]
73+
assert action.config == {"key1": 10, "key2": 20}
74+
assert action.name == "dict_test"
75+
76+
77+
def test_native_list_dict_passthrough():
78+
"""Test that native lists and dicts pass through unchanged."""
79+
data = {
80+
"items": ["direct", "list"],
81+
"config": {"direct": 42},
82+
"name": "native_test",
83+
}
84+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
85+
action = JsonDecodingTestAction.model_validate(fixed_data)
86+
87+
assert action.items == ["direct", "list"]
88+
assert action.config == {"direct": 42}
89+
assert action.name == "native_test"
90+
91+
92+
def test_regular_string_not_decoded():
93+
"""Test that regular string fields are not affected by JSON decoding."""
94+
data = {
95+
"items": "[]",
96+
"config": "{}",
97+
"name": "this is not json but a regular string",
98+
}
99+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
100+
action = JsonDecodingTestAction.model_validate(fixed_data)
101+
102+
assert action.items == []
103+
assert action.config == {}
104+
# Regular string field should NOT be decoded
105+
assert action.name == "this is not json but a regular string"
106+
107+
108+
def test_annotated_types():
109+
"""Test that Annotated types are properly handled."""
110+
data = {
111+
"items": '["x", "y", "z"]',
112+
"config": '{"a": 1, "b": 2}',
113+
}
114+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingAnnotatedAction)
115+
action = JsonDecodingAnnotatedAction.model_validate(fixed_data)
116+
117+
assert action.items == ["x", "y", "z"]
118+
assert action.config == {"a": 1, "b": 2}
119+
120+
121+
def test_field_aliases():
122+
"""Test that field aliases are properly handled."""
123+
data = {
124+
"myList": "[1, 2, 3]",
125+
"myDict": '{"key": "value"}',
126+
}
127+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingAliasAction)
128+
action = JsonDecodingAliasAction.model_validate(fixed_data)
129+
130+
assert action.my_list == [1, 2, 3]
131+
assert action.my_dict == {"key": "value"}
132+
133+
134+
def test_optional_fields_with_json_strings():
135+
"""Test that optional list/dict fields work with JSON strings."""
136+
data = {
137+
"items": '["opt1", "opt2"]',
138+
"config": '{"opt": 99}',
139+
}
140+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingOptionalAction)
141+
action = JsonDecodingOptionalAction.model_validate(fixed_data)
142+
143+
assert action.items == ["opt1", "opt2"]
144+
assert action.config == {"opt": 99}
145+
146+
147+
def test_optional_fields_with_none():
148+
"""Test that optional fields can be None."""
149+
data = {}
150+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingOptionalAction)
151+
action = JsonDecodingOptionalAction.model_validate(fixed_data)
152+
153+
assert action.items is None
154+
assert action.config is None
155+
156+
157+
def test_optional_fields_with_native_values():
158+
"""Test that optional fields work with native values."""
159+
data = {
160+
"items": ["native1", "native2"],
161+
"config": {"native": 100},
162+
}
163+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingOptionalAction)
164+
action = JsonDecodingOptionalAction.model_validate(fixed_data)
165+
166+
assert action.items == ["native1", "native2"]
167+
assert action.config == {"native": 100}
168+
169+
170+
def test_invalid_json_string_rejected():
171+
"""Test that invalid JSON strings are rejected with validation error."""
172+
data = {
173+
"items": "not valid json",
174+
"config": "{}",
175+
"name": "test",
176+
}
177+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
178+
179+
with pytest.raises(ValidationError) as exc_info:
180+
JsonDecodingTestAction.model_validate(fixed_data)
181+
182+
# Should fail validation because "not valid json" can't be parsed as list
183+
assert "items" in str(exc_info.value)
184+
185+
186+
def test_json_string_with_wrong_type_rejected():
187+
"""Test that JSON strings with wrong types are rejected."""
188+
# Field expects list but JSON string contains dict
189+
data = {
190+
"items": '{"not": "a list"}',
191+
"config": "{}",
192+
"name": "test",
193+
}
194+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
195+
196+
with pytest.raises(ValidationError) as exc_info:
197+
JsonDecodingTestAction.model_validate(fixed_data)
198+
199+
assert "items" in str(exc_info.value)
200+
201+
202+
def test_nested_structures():
203+
"""Test that nested lists and dicts in JSON strings work."""
204+
205+
class NestedAction(Action):
206+
nested_list: list[list[int]] = Field(description="Nested list")
207+
nested_dict: dict[str, dict[str, str]] = Field(description="Nested dict")
208+
209+
data = {
210+
"nested_list": "[[1, 2], [3, 4]]",
211+
"nested_dict": '{"outer": {"inner": "value"}}',
212+
}
213+
fixed_data = fix_malformed_tool_arguments(data, NestedAction)
214+
action = NestedAction.model_validate(fixed_data)
215+
216+
assert action.nested_list == [[1, 2], [3, 4]]
217+
assert action.nested_dict == {"outer": {"inner": "value"}}
218+
219+
220+
def test_empty_collections():
221+
"""Test that empty lists and dicts work."""
222+
data = {
223+
"items": "[]",
224+
"config": "{}",
225+
"name": "empty",
226+
}
227+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
228+
action = JsonDecodingTestAction.model_validate(fixed_data)
229+
230+
assert action.items == []
231+
assert action.config == {}
232+
233+
234+
def test_mixed_native_and_json_strings():
235+
"""Test mixing native values and JSON strings in same model."""
236+
data = {
237+
"items": ["native", "list"], # Native list
238+
"config": '{"from": 1, "json": 2}', # JSON string
239+
"name": "mixed",
240+
}
241+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
242+
action = JsonDecodingTestAction.model_validate(fixed_data)
243+
244+
assert action.items == ["native", "list"]
245+
assert action.config == {"from": 1, "json": 2}
246+
assert action.name == "mixed"
247+
248+
249+
def test_unicode_in_json_strings():
250+
"""Test that unicode characters in JSON strings are handled correctly."""
251+
data = {
252+
"items": '["hello", "世界", "🌍"]',
253+
"config": '{"greeting": 1, "你好": 2}',
254+
"name": "unicode",
255+
}
256+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
257+
action = JsonDecodingTestAction.model_validate(fixed_data)
258+
259+
assert action.items == ["hello", "世界", "🌍"]
260+
assert action.config == {"greeting": 1, "你好": 2}
261+
262+
263+
def test_whitespace_in_json_strings():
264+
"""Test that JSON strings with extra whitespace work."""
265+
data = {
266+
"items": ' [ "a" , "b" , "c" ] ',
267+
"config": ' { "x" : 1 , "y" : 2 } ',
268+
"name": "whitespace",
269+
}
270+
fixed_data = fix_malformed_tool_arguments(data, JsonDecodingTestAction)
271+
action = JsonDecodingTestAction.model_validate(fixed_data)
272+
273+
assert action.items == ["a", "b", "c"]
274+
assert action.config == {"x": 1, "y": 2}

0 commit comments

Comments
 (0)