Skip to content

Commit 915a95a

Browse files
fix(sdk): Use concise error message for tool validation errors
When tool validation fails, the error message now includes only parameter names (not values) to avoid wasting LLM context window on large payloads like file_editor's old_str/new_str. Before: Error validating args {"command": "view", "old_str": "<huge value>"...} for tool 'file_editor': ... After: Error validating tool 'file_editor': ... Parameters provided: ['command', 'path', 'old_str'] For unparseable JSON, the message indicates: Error validating tool 'file_editor': ... Arguments: unparseable JSON Fixes #2741 Co-authored-by: openhands <openhands@all-hands.dev>
1 parent b619eae commit 915a95a

File tree

2 files changed

+211
-7
lines changed

2 files changed

+211
-7
lines changed

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

Lines changed: 12 additions & 7 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,13 @@ 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}"
880-
)
879+
# Build concise error message with parameter names only (not values)
880+
# to avoid wasting LLM context on large payloads
881+
if isinstance(parsed_args, dict):
882+
params_info = f"Parameters provided: {list(parsed_args.keys())}"
883+
else:
884+
params_info = "Arguments: unparseable JSON"
885+
err = f"Error validating tool '{tool.name}': {e}. {params_info}"
881886
# Persist assistant function_call so next turn has matching call_id
882887
tc_event = ActionEvent(
883888
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)