Skip to content

Commit d824332

Browse files
committed
improve input normalization
Signed-off-by: Yuchen Zhang <[email protected]>
1 parent 5821089 commit d824332

File tree

4 files changed

+296
-26
lines changed

4 files changed

+296
-26
lines changed

docs/source/workflows/about/react-agent.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ functions:
9191

9292
* `pass_tool_call_errors_to_agent`: Defaults to `True`. If set to `True`, the agent will pass tool call errors to the agent. If set to `False`, the agent will raise an exception.
9393

94+
* `replace_single_quotes_with_double_quotes_in_tool_input`: Defaults to `True`. If set to `True`, the agent will replace single quotes with double quotes in the tool input. This is useful for tools that expect structured json input.
95+
9496
* `description`: Defaults to `"ReAct Agent Workflow"`. When the ReAct agent is configured as a function, this config option allows us to control the tool description (for example, when used as a tool within another agent).
9597

9698
* `system_prompt`: Optional. Allows us to override the system prompt for the ReAct agent.

src/nat/agent/react_agent/agent.py

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ def __init__(self,
7777
retry_agent_response_parsing_errors: bool = True,
7878
parse_agent_response_max_retries: int = 1,
7979
tool_call_max_retries: int = 1,
80-
pass_tool_call_errors_to_agent: bool = True):
80+
pass_tool_call_errors_to_agent: bool = True,
81+
replace_single_quotes_with_double_quotes_in_tool_input: bool = True):
8182
super().__init__(llm=llm,
8283
tools=tools,
8384
callbacks=callbacks,
@@ -87,6 +88,8 @@ def __init__(self,
8788
if retry_agent_response_parsing_errors else 1)
8889
self.tool_call_max_retries = tool_call_max_retries
8990
self.pass_tool_call_errors_to_agent = pass_tool_call_errors_to_agent
91+
self.replace_single_quotes_with_double_quotes_in_tool_input = \
92+
replace_single_quotes_with_double_quotes_in_tool_input
9093
logger.debug(
9194
"%s Filling the prompt variables 'tools' and 'tool_names', using the tools provided in the config.",
9295
AGENT_LOG_PREFIX)
@@ -286,35 +289,45 @@ async def tool_node(self, state: ReActGraphState):
286289
agent_thoughts.tool_input)
287290

288291
# Run the tool. Try to use structured input, if possible.
292+
tool_input_str = agent_thoughts.tool_input.strip()
293+
289294
try:
290-
tool_input_str = str(agent_thoughts.tool_input).strip().replace("'", '"')
291-
tool_input_dict = json.loads(tool_input_str) if tool_input_str != 'None' else tool_input_str
295+
tool_input = json.loads(tool_input_str) if tool_input_str != 'None' else tool_input_str
292296
logger.debug("%s Successfully parsed structured tool input from Action Input", AGENT_LOG_PREFIX)
293297

294-
tool_response = await self._call_tool(requested_tool,
295-
tool_input_dict,
296-
RunnableConfig(callbacks=self.callbacks),
297-
max_retries=self.tool_call_max_retries)
298-
299-
if self.detailed_logs:
300-
self._log_tool_response(requested_tool.name, tool_input_dict, str(tool_response.content))
301-
302-
except JSONDecodeError as ex:
303-
logger.debug(
304-
"%s Unable to parse structured tool input from Action Input. Using Action Input as is."
305-
"\nParsing error: %s",
306-
AGENT_LOG_PREFIX,
307-
ex,
308-
exc_info=True)
309-
tool_input_str = str(agent_thoughts.tool_input)
310-
311-
tool_response = await self._call_tool(requested_tool,
312-
tool_input_str,
313-
RunnableConfig(callbacks=self.callbacks),
314-
max_retries=self.tool_call_max_retries)
298+
except JSONDecodeError as original_ex:
299+
if self.replace_single_quotes_with_double_quotes_in_tool_input:
300+
# If initial JSON parsing fails, try with quote normalization as a fallback
301+
normalized_str = tool_input_str.replace("'", '"')
302+
try:
303+
tool_input = json.loads(normalized_str)
304+
logger.debug("%s Successfully parsed structured tool input after quote normalization",
305+
AGENT_LOG_PREFIX)
306+
except JSONDecodeError:
307+
# the quote normalization failed, use raw string input
308+
logger.debug(
309+
"%s Unable to parse structured tool input after quote normalization. Using Action Input as is."
310+
"\nParsing error: %s",
311+
AGENT_LOG_PREFIX,
312+
original_ex)
313+
tool_input = tool_input_str
314+
else:
315+
# use raw string input
316+
logger.debug(
317+
"%s Unable to parse structured tool input from Action Input. Using Action Input as is."
318+
"\nParsing error: %s",
319+
AGENT_LOG_PREFIX,
320+
original_ex)
321+
tool_input = tool_input_str
322+
323+
# Call tool once with the determined input (either parsed dict or raw string)
324+
tool_response = await self._call_tool(requested_tool,
325+
tool_input,
326+
RunnableConfig(callbacks=self.callbacks),
327+
max_retries=self.tool_call_max_retries)
315328

316329
if self.detailed_logs:
317-
self._log_tool_response(requested_tool.name, tool_input_str, str(tool_response.content))
330+
self._log_tool_response(requested_tool.name, tool_input, str(tool_response.content))
318331

319332
if not self.pass_tool_call_errors_to_agent:
320333
if tool_response.status == "error":

src/nat/agent/react_agent/register.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ class ReActAgentWorkflowConfig(FunctionBaseConfig, name="react_agent"):
6262
include_tool_input_schema_in_tool_description: bool = Field(
6363
default=True, description="Specify inclusion of tool input schemas in the prompt.")
6464
description: str = Field(default="ReAct Agent Workflow", description="The description of this functions use.")
65+
replace_single_quotes_with_double_quotes_in_tool_input: bool = Field(
66+
default=True,
67+
description="Whether to replace single quotes with double quotes in the tool input. "
68+
"This is useful for tools that expect structured json input.")
6569
system_prompt: str | None = Field(
6670
default=None,
6771
description="Provides the SYSTEM_PROMPT to use with the agent") # defaults to SYSTEM_PROMPT in prompt.py
@@ -107,7 +111,9 @@ async def react_agent_workflow(config: ReActAgentWorkflowConfig, builder: Builde
107111
retry_agent_response_parsing_errors=config.retry_agent_response_parsing_errors,
108112
parse_agent_response_max_retries=config.parse_agent_response_max_retries,
109113
tool_call_max_retries=config.tool_call_max_retries,
110-
pass_tool_call_errors_to_agent=config.pass_tool_call_errors_to_agent).build_graph()
114+
pass_tool_call_errors_to_agent=config.pass_tool_call_errors_to_agent,
115+
replace_single_quotes_with_double_quotes_in_tool_input=config.
116+
replace_single_quotes_with_double_quotes_in_tool_input).build_graph()
111117

112118
async def _response_fn(input_message: ChatRequest) -> ChatResponse:
113119
try:

tests/nat/agent/test_react.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,3 +590,252 @@ def test_config_mixed_alias_usage():
590590
assert config.parse_agent_response_max_retries == 12
591591
assert config.max_tool_calls == 28
592592
assert config.tool_call_max_retries == 1 # default value
593+
594+
595+
# Tests for quote normalization in tool input parsing
596+
async def test_tool_node_json_input_with_double_quotes(mock_react_agent):
597+
"""Test that valid JSON with double quotes is parsed correctly."""
598+
tool_input = '{"query": "search term", "limit": 5}'
599+
mock_state = ReActGraphState(agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input, log='test')])
600+
601+
response = await mock_react_agent.tool_node(mock_state)
602+
response = response.tool_responses[-1]
603+
604+
assert isinstance(response, ToolMessage)
605+
assert response.name == "Tool A"
606+
# When JSON is successfully parsed, the mock tool receives a dict and LangChain extracts the "query" value
607+
assert response.content == "search term" # The mock tool extracts the query field value
608+
609+
610+
async def test_tool_node_json_input_with_single_quotes_normalization_enabled(mock_react_agent):
611+
"""Test that JSON with single quotes is normalized to double quotes when normalization is enabled."""
612+
# Agent should have normalization enabled by default
613+
assert mock_react_agent.replace_single_quotes_with_double_quotes_in_tool_input is True
614+
615+
tool_input_single_quotes = "{'query': 'search term', 'limit': 5}"
616+
mock_state = ReActGraphState(
617+
agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input_single_quotes, log='test')])
618+
619+
response = await mock_react_agent.tool_node(mock_state)
620+
response = response.tool_responses[-1]
621+
622+
assert isinstance(response, ToolMessage)
623+
assert response.name == "Tool A"
624+
# With quote normalization enabled, single quotes get normalized and JSON is parsed successfully
625+
# The mock tool then receives a dict and LangChain extracts the "query" value
626+
assert response.content == "search term"
627+
628+
629+
async def test_tool_node_json_input_with_single_quotes_normalization_disabled(mock_config_react_agent,
630+
mock_llm,
631+
mock_tool):
632+
"""Test that JSON with single quotes is NOT normalized when normalization is disabled."""
633+
tools = [mock_tool('Tool A'), mock_tool('Tool B')]
634+
prompt = create_react_agent_prompt(mock_config_react_agent)
635+
636+
# Create agent with quote normalization disabled
637+
agent = ReActAgentGraph(llm=mock_llm,
638+
prompt=prompt,
639+
tools=tools,
640+
detailed_logs=mock_config_react_agent.verbose,
641+
replace_single_quotes_with_double_quotes_in_tool_input=False)
642+
643+
assert agent.replace_single_quotes_with_double_quotes_in_tool_input is False
644+
645+
tool_input_single_quotes = "{'query': 'search term', 'limit': 5}"
646+
mock_state = ReActGraphState(
647+
agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input_single_quotes, log='test')])
648+
649+
response = await agent.tool_node(mock_state)
650+
response = response.tool_responses[-1]
651+
652+
assert isinstance(response, ToolMessage)
653+
assert response.name == "Tool A"
654+
# Should use the raw string input since JSON parsing fails and normalization is disabled
655+
assert response.content == tool_input_single_quotes
656+
657+
658+
async def test_tool_node_invalid_json_fallback_to_string(mock_react_agent):
659+
"""Test that invalid JSON falls back to using the raw string input."""
660+
# Invalid JSON that cannot be fixed by quote normalization
661+
tool_input_invalid = "{'query': 'search term', 'limit': }"
662+
mock_state = ReActGraphState(
663+
agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input_invalid, log='test')])
664+
665+
response = await mock_react_agent.tool_node(mock_state)
666+
response = response.tool_responses[-1]
667+
668+
assert isinstance(response, ToolMessage)
669+
assert response.name == "Tool A"
670+
# Should fall back to using the raw string
671+
assert response.content == tool_input_invalid
672+
673+
674+
async def test_tool_node_string_input_no_json_parsing(mock_react_agent):
675+
"""Test that plain string input is used as-is without attempting JSON parsing."""
676+
tool_input_string = "simple string input"
677+
mock_state = ReActGraphState(
678+
agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input_string, log='test')])
679+
680+
response = await mock_react_agent.tool_node(mock_state)
681+
response = response.tool_responses[-1]
682+
683+
assert isinstance(response, ToolMessage)
684+
assert response.name == "Tool A"
685+
assert response.content == tool_input_string
686+
687+
688+
async def test_tool_node_none_input(mock_react_agent):
689+
"""Test that 'None' input is handled correctly."""
690+
tool_input_none = "None"
691+
mock_state = ReActGraphState(agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input_none, log='test')])
692+
693+
response = await mock_react_agent.tool_node(mock_state)
694+
response = response.tool_responses[-1]
695+
696+
assert isinstance(response, ToolMessage)
697+
assert response.name == "Tool A"
698+
assert response.content == tool_input_none
699+
700+
701+
async def test_tool_node_nested_json_with_single_quotes(mock_react_agent):
702+
"""Test that complex nested JSON with single quotes is normalized correctly."""
703+
# Complex nested JSON with single quotes - doesn't have a "query" field so would return the full dict
704+
tool_input_nested = \
705+
"{'user': {'name': 'John', 'preferences': {'theme': 'dark', 'notifications': True}}, 'action': 'update'}"
706+
mock_state = ReActGraphState(
707+
agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input_nested, log='test')])
708+
709+
response = await mock_react_agent.tool_node(mock_state)
710+
response = response.tool_responses[-1]
711+
712+
assert isinstance(response, ToolMessage)
713+
assert response.name == "Tool A"
714+
# Since this JSON doesn't have a "query" field, the mock tool receives the full dict
715+
# and LangChain can't extract a "query" parameter, so it falls back to default behavior
716+
assert "John" in str(response.content) or isinstance(response.content, dict)
717+
718+
719+
async def test_tool_node_mixed_quotes_in_json(mock_config_react_agent, mock_llm, mock_tool):
720+
"""Test that JSON with mixed quotes is handled appropriately."""
721+
# This creates a scenario with mixed quotes that might be challenging to normalize
722+
tools = [mock_tool('Tool A')]
723+
prompt = create_react_agent_prompt(mock_config_react_agent)
724+
725+
agent = ReActAgentGraph(llm=mock_llm, prompt=prompt, tools=tools, detailed_logs=False)
726+
727+
# Mixed quotes - this is challenging JSON to normalize
728+
tool_input_mixed = '''{'outer': "inner string with 'nested quotes'", 'number': 42}'''
729+
mock_state = ReActGraphState(agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input_mixed, log='test')])
730+
731+
response = await agent.tool_node(mock_state)
732+
response = response.tool_responses[-1]
733+
734+
assert isinstance(response, ToolMessage)
735+
assert response.name == "Tool A"
736+
# Mixed quotes are complex to normalize, so it likely falls back to raw string input
737+
assert response.content == tool_input_mixed
738+
739+
740+
async def test_tool_node_whitespace_handling(mock_react_agent):
741+
"""Test that whitespace in tool input is handled correctly."""
742+
# Tool input with leading/trailing whitespace
743+
tool_input_whitespace = " {'query': 'search term'} "
744+
mock_state = ReActGraphState(
745+
agent_scratchpad=[AgentAction(tool='Tool A', tool_input=tool_input_whitespace, log='test')])
746+
747+
response = await mock_react_agent.tool_node(mock_state)
748+
response = response.tool_responses[-1]
749+
750+
assert isinstance(response, ToolMessage)
751+
assert response.name == "Tool A"
752+
# With whitespace trimmed and quote normalization, JSON is parsed and "query" value is extracted
753+
assert response.content == "search term"
754+
755+
756+
def test_config_replace_single_quotes_default():
757+
"""Test that replace_single_quotes_with_double_quotes_in_tool_input defaults to True."""
758+
config = ReActAgentWorkflowConfig(tool_names=['test'], llm_name='test')
759+
assert config.replace_single_quotes_with_double_quotes_in_tool_input is True
760+
761+
762+
def test_config_replace_single_quotes_explicit_false():
763+
"""Test that replace_single_quotes_with_double_quotes_in_tool_input can be set to False."""
764+
config = ReActAgentWorkflowConfig(tool_names=['test'],
765+
llm_name='test',
766+
replace_single_quotes_with_double_quotes_in_tool_input=False)
767+
assert config.replace_single_quotes_with_double_quotes_in_tool_input is False
768+
769+
770+
def test_react_agent_init_with_quote_normalization_param(mock_config_react_agent, mock_llm, mock_tool):
771+
"""Test that ReActAgentGraph initialization respects the quote normalization parameter."""
772+
tools = [mock_tool('Tool A'), mock_tool('Tool B')]
773+
prompt = create_react_agent_prompt(mock_config_react_agent)
774+
775+
# Test with normalization enabled
776+
agent_enabled = ReActAgentGraph(llm=mock_llm,
777+
prompt=prompt,
778+
tools=tools,
779+
detailed_logs=False,
780+
replace_single_quotes_with_double_quotes_in_tool_input=True)
781+
assert agent_enabled.replace_single_quotes_with_double_quotes_in_tool_input is True
782+
783+
# Test with normalization disabled
784+
agent_disabled = ReActAgentGraph(llm=mock_llm,
785+
prompt=prompt,
786+
tools=tools,
787+
detailed_logs=False,
788+
replace_single_quotes_with_double_quotes_in_tool_input=False)
789+
assert agent_disabled.replace_single_quotes_with_double_quotes_in_tool_input is False
790+
791+
792+
# Additional test to specifically verify the JSON parsing logic with quote normalization
793+
async def test_quote_normalization_json_parsing_logic(mock_config_react_agent, mock_llm):
794+
"""Test the specific quote normalization logic in JSON parsing."""
795+
from langchain_core.tools import BaseTool
796+
797+
# Create a custom tool that returns the exact input it receives
798+
class ExactInputTool(BaseTool):
799+
name: str = "ExactInputTool"
800+
description: str = "Returns exactly what it receives"
801+
802+
async def _arun(self, query, **kwargs):
803+
return f"Received: {query} (type: {type(query).__name__})"
804+
805+
def _run(self, query, **kwargs):
806+
return f"Received: {query} (type: {type(query).__name__})"
807+
808+
tools = [ExactInputTool()]
809+
prompt = create_react_agent_prompt(mock_config_react_agent)
810+
811+
# Test with quote normalization enabled
812+
agent_enabled = ReActAgentGraph(llm=mock_llm,
813+
prompt=prompt,
814+
tools=tools,
815+
detailed_logs=False,
816+
replace_single_quotes_with_double_quotes_in_tool_input=True)
817+
818+
# Test with single quotes - should be normalized and parsed as JSON
819+
tool_input_single = "{'query': 'test', 'count': 42}"
820+
mock_state = ReActGraphState(
821+
agent_scratchpad=[AgentAction(tool='ExactInputTool', tool_input=tool_input_single, log='test')])
822+
response = await agent_enabled.tool_node(mock_state)
823+
response_content = response.tool_responses[-1].content
824+
825+
# Should receive the "query" field value from the parsed JSON dict
826+
# This proves that quote normalization worked and JSON was successfully parsed
827+
assert "Received: test (type: str)" in response_content
828+
829+
# Test with quote normalization disabled
830+
agent_disabled = ReActAgentGraph(llm=mock_llm,
831+
prompt=prompt,
832+
tools=tools,
833+
detailed_logs=False,
834+
replace_single_quotes_with_double_quotes_in_tool_input=False)
835+
836+
response = await agent_disabled.tool_node(mock_state)
837+
response_content = response.tool_responses[-1].content
838+
839+
# Should receive the raw string (JSON parsing failed due to no normalization)
840+
# The full JSON string should be passed as the query parameter
841+
assert tool_input_single in response_content and "type: str" in response_content

0 commit comments

Comments
 (0)