From f4a82194366da94fa9c23903b0aee5e3c2de19c6 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 7 Apr 2026 16:21:45 +0000 Subject: [PATCH 1/3] 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": ""...} 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-sdk/openhands/sdk/agent/agent.py | 19 +- .../test_tool_validation_error_message.py | 199 ++++++++++++++++++ 2 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 tests/sdk/agent/test_tool_validation_error_message.py diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index dbfda8196d..8dd2e1bf7d 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -845,6 +845,7 @@ def _get_action_event( # Validate arguments security_risk: risk.SecurityRisk = risk.SecurityRisk.UNKNOWN + parsed_args: dict | None = None try: # Try parsing arguments as-is first. Raw newlines / tabs are # legal JSON whitespace and many models emit them between tokens @@ -853,13 +854,14 @@ def _get_action_event( # Fall back to sanitization only when the raw string is invalid # (handles models that emit raw control chars *inside* strings). try: - arguments = json.loads(tool_call.arguments) + parsed_args = json.loads(tool_call.arguments) except json.JSONDecodeError: sanitized_args = sanitize_json_control_chars(tool_call.arguments) - arguments = json.loads(sanitized_args) + parsed_args = json.loads(sanitized_args) # Fix malformed arguments (e.g., JSON strings for list/dict fields) - arguments = fix_malformed_tool_arguments(arguments, tool.action_type) + assert isinstance(parsed_args, dict) + arguments = fix_malformed_tool_arguments(parsed_args, tool.action_type) security_risk = self._extract_security_risk( arguments, tool.name, @@ -874,10 +876,15 @@ def _get_action_event( action: Action = tool.action_from_arguments(arguments) except (json.JSONDecodeError, ValidationError, ValueError) as e: - err = ( - f"Error validating args {tool_call.arguments} for tool " - f"'{tool.name}': {e}" + # Build concise error message with parameter names only (not values) + # to avoid wasting LLM context on large payloads + keys = list(parsed_args.keys()) if isinstance(parsed_args, dict) else None + params = ( + f"Parameters provided: {keys}" + if keys + else "Arguments: unparseable JSON" ) + err = f"Error validating tool '{tool.name}': {e}. {params}" # Persist assistant function_call so next turn has matching call_id tc_event = ActionEvent( source="agent", diff --git a/tests/sdk/agent/test_tool_validation_error_message.py b/tests/sdk/agent/test_tool_validation_error_message.py new file mode 100644 index 0000000000..f2f5a29d21 --- /dev/null +++ b/tests/sdk/agent/test_tool_validation_error_message.py @@ -0,0 +1,199 @@ +"""Test that tool validation error messages are concise and don't include values.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Self +from unittest.mock import patch + +from litellm import ChatCompletionMessageToolCall +from litellm.types.utils import ( + Choices, + Function, + Message as LiteLLMMessage, + ModelResponse, +) +from pydantic import SecretStr + +from openhands.sdk.agent import Agent +from openhands.sdk.conversation import Conversation +from openhands.sdk.event import AgentErrorEvent +from openhands.sdk.llm import LLM, Message, TextContent +from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer +from openhands.sdk.tool import Action, Observation, Tool, ToolExecutor, register_tool +from openhands.sdk.tool.tool import ToolDefinition + + +if TYPE_CHECKING: + from openhands.sdk.conversation.state import ConversationState + + +class ValidationTestAction(Action): + """Action for validation testing.""" + + command: str = "" + path: str = "" + old_str: str = "" + + +class ValidationTestObservation(Observation): + """Observation for validation testing.""" + + result: str = "" + + +class ValidationTestExecutor( + ToolExecutor[ValidationTestAction, ValidationTestObservation] +): + """Executor that just returns an observation.""" + + def __call__( + self, action: ValidationTestAction, conversation=None + ) -> ValidationTestObservation: + return ValidationTestObservation(result="ok") + + +class ValidationTestTool( + ToolDefinition[ValidationTestAction, ValidationTestObservation] +): + """Tool for testing validation error messages.""" + + name = "validation_test_tool" + + @classmethod + def create(cls, conv_state: "ConversationState | None" = None) -> Sequence[Self]: + return [ + cls( + description="A tool for testing validation errors", + action_type=ValidationTestAction, + observation_type=ValidationTestObservation, + executor=ValidationTestExecutor(), + ) + ] + + +register_tool("ValidationTestTool", ValidationTestTool) + + +def test_validation_error_shows_keys_not_values(): + """Error message should show parameter keys, not large argument values.""" + llm = LLM( + usage_id="test-llm", + model="test-model", + api_key=SecretStr("test-key"), + base_url="http://test", + ) + agent = Agent(llm=llm, tools=[Tool(name="ValidationTestTool")]) + + # Create tool call with large arguments but missing security_risk field + large_value = "x" * 1000 + tool_args = f'{{"command": "view", "path": "/test", "old_str": "{large_value}"}}' + + def mock_llm_response(messages, **kwargs): + return ModelResponse( + id="mock-1", + choices=[ + Choices( + index=0, + message=LiteLLMMessage( + role="assistant", + content="I'll use the tool.", + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_1", + type="function", + function=Function( + name="validation_test_tool", arguments=tool_args + ), + ) + ], + ), + finish_reason="tool_calls", + ) + ], + created=0, + model="test-model", + object="chat.completion", + ) + + collected_events = [] + conversation = Conversation(agent=agent, callbacks=[collected_events.append]) + conversation.set_security_analyzer(LLMSecurityAnalyzer()) + + with patch( + "openhands.sdk.llm.llm.litellm_completion", side_effect=mock_llm_response + ): + conversation.send_message( + Message(role="user", content=[TextContent(text="Do something")]) + ) + agent.step(conversation, on_event=collected_events.append) + + error_events = [e for e in collected_events if isinstance(e, AgentErrorEvent)] + assert len(error_events) == 1 + + error_msg = error_events[0].error + # Error should include tool name and parameter keys + assert "validation_test_tool" in error_msg + assert "Parameters provided:" in error_msg + assert "command" in error_msg + assert "path" in error_msg + assert "old_str" in error_msg + # Error should NOT include the large value (1000 x's) + assert large_value not in error_msg + + +def test_unparseable_json_error_message(): + """Error message should indicate unparseable JSON when parsing fails.""" + llm = LLM( + usage_id="test-llm", + model="test-model", + api_key=SecretStr("test-key"), + base_url="http://test", + ) + agent = Agent(llm=llm, tools=[Tool(name="ValidationTestTool")]) + + # Invalid JSON that cannot be parsed + invalid_json = "{invalid json syntax" + + def mock_llm_response(messages, **kwargs): + return ModelResponse( + id="mock-1", + choices=[ + Choices( + index=0, + message=LiteLLMMessage( + role="assistant", + content="I'll use the tool.", + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_1", + type="function", + function=Function( + name="validation_test_tool", arguments=invalid_json + ), + ) + ], + ), + finish_reason="tool_calls", + ) + ], + created=0, + model="test-model", + object="chat.completion", + ) + + collected_events = [] + conversation = Conversation(agent=agent, callbacks=[collected_events.append]) + + with patch( + "openhands.sdk.llm.llm.litellm_completion", side_effect=mock_llm_response + ): + conversation.send_message( + Message(role="user", content=[TextContent(text="Do something")]) + ) + agent.step(conversation, on_event=collected_events.append) + + error_events = [e for e in collected_events if isinstance(e, AgentErrorEvent)] + assert len(error_events) == 1 + + error_msg = error_events[0].error + assert "validation_test_tool" in error_msg + assert "unparseable JSON" in error_msg From be1465f58e7725063e8e68e0010001bdc544efc0 Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:34:22 +0200 Subject: [PATCH 2/3] Update openhands-sdk/openhands/sdk/agent/agent.py Co-authored-by: OpenHands Bot --- openhands-sdk/openhands/sdk/agent/agent.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index 8dd2e1bf7d..d92c33c473 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -877,12 +877,12 @@ def _get_action_event( action: Action = tool.action_from_arguments(arguments) except (json.JSONDecodeError, ValidationError, ValueError) as e: # Build concise error message with parameter names only (not values) - # to avoid wasting LLM context on large payloads - keys = list(parsed_args.keys()) if isinstance(parsed_args, dict) else None - params = ( - f"Parameters provided: {keys}" - if keys - else "Arguments: unparseable JSON" +keys = list(parsed_args.keys()) if isinstance(parsed_args, dict) else None +params = ( + f"Parameters provided: {keys}" + if keys is not None + else "Arguments: unparseable JSON" +) ) err = f"Error validating tool '{tool.name}': {e}. {params}" # Persist assistant function_call so next turn has matching call_id From 2154ff143ad965bfb97168fa0a8b14f9477c40c2 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Tue, 7 Apr 2026 18:40:44 +0200 Subject: [PATCH 3/3] fix: correct indentation in except block for concise validation errors The previous commit introduced broken indentation in the except block, causing a SyntaxError (unmatched ')') that broke all CI checks. Co-Authored-By: Claude Opus 4.6 (1M context) --- openhands-sdk/openhands/sdk/agent/agent.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index d92c33c473..e3b0cd7142 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -877,12 +877,11 @@ def _get_action_event( action: Action = tool.action_from_arguments(arguments) except (json.JSONDecodeError, ValidationError, ValueError) as e: # Build concise error message with parameter names only (not values) -keys = list(parsed_args.keys()) if isinstance(parsed_args, dict) else None -params = ( - f"Parameters provided: {keys}" - if keys is not None - else "Arguments: unparseable JSON" -) + keys = list(parsed_args.keys()) if isinstance(parsed_args, dict) else None + params = ( + f"Parameters provided: {keys}" + if keys is not None + else "Arguments: unparseable JSON" ) err = f"Error validating tool '{tool.name}': {e}. {params}" # Persist assistant function_call so next turn has matching call_id