Skip to content

Commit 07bd007

Browse files
authored
fix(sdk): Use concise error message for tool validation errors (#2748)
1 parent 4ce4c0b commit 07bd007

File tree

2 files changed

+211
-6
lines changed

2 files changed

+211
-6
lines changed

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,7 @@ def _get_action_event(
845845

846846
# Validate arguments
847847
security_risk: risk.SecurityRisk = risk.SecurityRisk.UNKNOWN
848+
parsed_args: dict | None = None
848849
try:
849850
# Try parsing arguments as-is first. Raw newlines / tabs are
850851
# legal JSON whitespace and many models emit them between tokens
@@ -853,13 +854,14 @@ def _get_action_event(
853854
# Fall back to sanitization only when the raw string is invalid
854855
# (handles models that emit raw control chars *inside* strings).
855856
try:
856-
arguments = json.loads(tool_call.arguments)
857+
parsed_args = json.loads(tool_call.arguments)
857858
except json.JSONDecodeError:
858859
sanitized_args = sanitize_json_control_chars(tool_call.arguments)
859-
arguments = json.loads(sanitized_args)
860+
parsed_args = json.loads(sanitized_args)
860861

861862
# Fix malformed arguments (e.g., JSON strings for list/dict fields)
862-
arguments = fix_malformed_tool_arguments(arguments, tool.action_type)
863+
assert isinstance(parsed_args, dict)
864+
arguments = fix_malformed_tool_arguments(parsed_args, tool.action_type)
863865
security_risk = self._extract_security_risk(
864866
arguments,
865867
tool.name,
@@ -874,10 +876,14 @@ def _get_action_event(
874876

875877
action: Action = tool.action_from_arguments(arguments)
876878
except (json.JSONDecodeError, ValidationError, ValueError) as e:
877-
err = (
878-
f"Error validating args {tool_call.arguments} for tool "
879-
f"'{tool.name}': {e}"
879+
# Build concise error message with parameter names only (not values)
880+
keys = list(parsed_args.keys()) if isinstance(parsed_args, dict) else None
881+
params = (
882+
f"Parameters provided: {keys}"
883+
if keys is not None
884+
else "Arguments: unparseable JSON"
880885
)
886+
err = f"Error validating tool '{tool.name}': {e}. {params}"
881887
# Persist assistant function_call so next turn has matching call_id
882888
tc_event = ActionEvent(
883889
source="agent",
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Test that tool validation error messages are concise and don't include values."""
2+
3+
from collections.abc import Sequence
4+
from typing import TYPE_CHECKING, Self
5+
from unittest.mock import patch
6+
7+
from litellm import ChatCompletionMessageToolCall
8+
from litellm.types.utils import (
9+
Choices,
10+
Function,
11+
Message as LiteLLMMessage,
12+
ModelResponse,
13+
)
14+
from pydantic import SecretStr
15+
16+
from openhands.sdk.agent import Agent
17+
from openhands.sdk.conversation import Conversation
18+
from openhands.sdk.event import AgentErrorEvent
19+
from openhands.sdk.llm import LLM, Message, TextContent
20+
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
21+
from openhands.sdk.tool import Action, Observation, Tool, ToolExecutor, register_tool
22+
from openhands.sdk.tool.tool import ToolDefinition
23+
24+
25+
if TYPE_CHECKING:
26+
from openhands.sdk.conversation.state import ConversationState
27+
28+
29+
class ValidationTestAction(Action):
30+
"""Action for validation testing."""
31+
32+
command: str = ""
33+
path: str = ""
34+
old_str: str = ""
35+
36+
37+
class ValidationTestObservation(Observation):
38+
"""Observation for validation testing."""
39+
40+
result: str = ""
41+
42+
43+
class ValidationTestExecutor(
44+
ToolExecutor[ValidationTestAction, ValidationTestObservation]
45+
):
46+
"""Executor that just returns an observation."""
47+
48+
def __call__(
49+
self, action: ValidationTestAction, conversation=None
50+
) -> ValidationTestObservation:
51+
return ValidationTestObservation(result="ok")
52+
53+
54+
class ValidationTestTool(
55+
ToolDefinition[ValidationTestAction, ValidationTestObservation]
56+
):
57+
"""Tool for testing validation error messages."""
58+
59+
name = "validation_test_tool"
60+
61+
@classmethod
62+
def create(cls, conv_state: "ConversationState | None" = None) -> Sequence[Self]:
63+
return [
64+
cls(
65+
description="A tool for testing validation errors",
66+
action_type=ValidationTestAction,
67+
observation_type=ValidationTestObservation,
68+
executor=ValidationTestExecutor(),
69+
)
70+
]
71+
72+
73+
register_tool("ValidationTestTool", ValidationTestTool)
74+
75+
76+
def test_validation_error_shows_keys_not_values():
77+
"""Error message should show parameter keys, not large argument values."""
78+
llm = LLM(
79+
usage_id="test-llm",
80+
model="test-model",
81+
api_key=SecretStr("test-key"),
82+
base_url="http://test",
83+
)
84+
agent = Agent(llm=llm, tools=[Tool(name="ValidationTestTool")])
85+
86+
# Create tool call with large arguments but missing security_risk field
87+
large_value = "x" * 1000
88+
tool_args = f'{{"command": "view", "path": "/test", "old_str": "{large_value}"}}'
89+
90+
def mock_llm_response(messages, **kwargs):
91+
return ModelResponse(
92+
id="mock-1",
93+
choices=[
94+
Choices(
95+
index=0,
96+
message=LiteLLMMessage(
97+
role="assistant",
98+
content="I'll use the tool.",
99+
tool_calls=[
100+
ChatCompletionMessageToolCall(
101+
id="call_1",
102+
type="function",
103+
function=Function(
104+
name="validation_test_tool", arguments=tool_args
105+
),
106+
)
107+
],
108+
),
109+
finish_reason="tool_calls",
110+
)
111+
],
112+
created=0,
113+
model="test-model",
114+
object="chat.completion",
115+
)
116+
117+
collected_events = []
118+
conversation = Conversation(agent=agent, callbacks=[collected_events.append])
119+
conversation.set_security_analyzer(LLMSecurityAnalyzer())
120+
121+
with patch(
122+
"openhands.sdk.llm.llm.litellm_completion", side_effect=mock_llm_response
123+
):
124+
conversation.send_message(
125+
Message(role="user", content=[TextContent(text="Do something")])
126+
)
127+
agent.step(conversation, on_event=collected_events.append)
128+
129+
error_events = [e for e in collected_events if isinstance(e, AgentErrorEvent)]
130+
assert len(error_events) == 1
131+
132+
error_msg = error_events[0].error
133+
# Error should include tool name and parameter keys
134+
assert "validation_test_tool" in error_msg
135+
assert "Parameters provided:" in error_msg
136+
assert "command" in error_msg
137+
assert "path" in error_msg
138+
assert "old_str" in error_msg
139+
# Error should NOT include the large value (1000 x's)
140+
assert large_value not in error_msg
141+
142+
143+
def test_unparseable_json_error_message():
144+
"""Error message should indicate unparseable JSON when parsing fails."""
145+
llm = LLM(
146+
usage_id="test-llm",
147+
model="test-model",
148+
api_key=SecretStr("test-key"),
149+
base_url="http://test",
150+
)
151+
agent = Agent(llm=llm, tools=[Tool(name="ValidationTestTool")])
152+
153+
# Invalid JSON that cannot be parsed
154+
invalid_json = "{invalid json syntax"
155+
156+
def mock_llm_response(messages, **kwargs):
157+
return ModelResponse(
158+
id="mock-1",
159+
choices=[
160+
Choices(
161+
index=0,
162+
message=LiteLLMMessage(
163+
role="assistant",
164+
content="I'll use the tool.",
165+
tool_calls=[
166+
ChatCompletionMessageToolCall(
167+
id="call_1",
168+
type="function",
169+
function=Function(
170+
name="validation_test_tool", arguments=invalid_json
171+
),
172+
)
173+
],
174+
),
175+
finish_reason="tool_calls",
176+
)
177+
],
178+
created=0,
179+
model="test-model",
180+
object="chat.completion",
181+
)
182+
183+
collected_events = []
184+
conversation = Conversation(agent=agent, callbacks=[collected_events.append])
185+
186+
with patch(
187+
"openhands.sdk.llm.llm.litellm_completion", side_effect=mock_llm_response
188+
):
189+
conversation.send_message(
190+
Message(role="user", content=[TextContent(text="Do something")])
191+
)
192+
agent.step(conversation, on_event=collected_events.append)
193+
194+
error_events = [e for e in collected_events if isinstance(e, AgentErrorEvent)]
195+
assert len(error_events) == 1
196+
197+
error_msg = error_events[0].error
198+
assert "validation_test_tool" in error_msg
199+
assert "unparseable JSON" in error_msg

0 commit comments

Comments
 (0)