From d52de39f216717fa68098685bbd96056f7b1587c Mon Sep 17 00:00:00 2001 From: TINGyu123644 <2663180454@qq.com> Date: Sun, 1 Nov 2026 12:18:54 +0800 Subject: [PATCH] fix(agent): treat malformed ReAct action payloads as text instead of crashing CotAgentOutputParser.parse_action assumed the model's JSON payload is always a single action-shaped object. Four shapes violated that and each aborted the run with an uncaught exception (the error is not caught in CotAgentRunner.run): - a JSON list with more than one item (e.g. a direct answer that happens to be JSON data) or a non-object list ([] / [1]) raised AttributeError: 'list'/'int' object has no attribute 'items'; - a non-string action name ({"action": ["search"], ...}) raised a pydantic ValidationError from AgentScratchpadUnit.Action, as did a non-dict/str action_input; - an extra field after the action ({"action": "search", ..., "reason": "need data"}) silently changed the tool: the last non-input key won, so the agent called a tool named "need data". parse_action now: - runs the first action-shaped item of a list (the prompt allows exactly one action per turn) and keeps a list without one as text; - keeps a payload as text when the action name is not a string or the action input is not a dict/str, instead of raising; - takes the first non-input key as the action name so trailing extra keys cannot override the tool; - serializes non-action payloads with ensure_ascii=False so non-ASCII text is not escaped to \uXXXX. Adds regression tests for all of the above (verified RED without the fix); the existing 65 parser tests still pass. Fixes #45200. --- .../agent/output_parser/cot_output_parser.py | 57 ++++++++++++--- .../output_parser/test_cot_output_parser.py | 73 +++++++++++++++++++ 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/api/core/agent/output_parser/cot_output_parser.py b/api/core/agent/output_parser/cot_output_parser.py index 67e96da4f8527e..6399550b7223d3 100644 --- a/api/core/agent/output_parser/cot_output_parser.py +++ b/api/core/agent/output_parser/cot_output_parser.py @@ -12,9 +12,30 @@ class CotAgentOutputParser: def handle_react_stream_output( cls, llm_response: Generator[LLMResultChunk], usage_dict: dict[str, Any] ) -> Generator[str | AgentScratchpadUnit.Action]: - def parse_action(action) -> str | AgentScratchpadUnit.Action: + def _is_callable_action(action_name: Any, action_input: Any) -> bool: + # AgentScratchpadUnit.Action requires a str name and a dict/str + # input; anything else cannot be dispatched as a tool call. + return ( + action_name is not None + and isinstance(action_name, str) + and action_input is not None + and isinstance(action_input, dict | str) + ) + + def _split_action_fields(action: dict) -> tuple[Any, Any]: action_name = None action_input = None + for key, value in action.items(): + if "input" in key.lower(): + action_input = value + elif action_name is None: + # The first non-input key names the action. Extra keys + # added after it (e.g. a trailing "reason") must not + # override the tool the agent is about to call. + action_name = value + return action_name, action_input + + def parse_action(action) -> str | AgentScratchpadUnit.Action: if isinstance(action, str): try: action = json.loads(action, strict=False) @@ -22,22 +43,38 @@ def parse_action(action) -> str | AgentScratchpadUnit.Action: return action or "" # cohere always returns a list - if isinstance(action, list) and len(action) == 1: - action = action[0] + if isinstance(action, list): + # The prompt allows exactly one action per turn, so a list of + # actions runs its first action-shaped item. A list without + # one (a direct answer that happens to be JSON data, or + # non-object items like [1]) is not an action and is kept as + # text - iterating it raised AttributeError on .items(). + for item in action: + if not isinstance(item, dict): + continue + action_name, action_input = _split_action_fields(item) + if _is_callable_action(action_name, action_input): + return AgentScratchpadUnit.Action( + action_name=action_name, + action_input=action_input, + ) + return json.dumps(action, ensure_ascii=False) - for key, value in action.items(): - if "input" in key.lower(): - action_input = value - else: - action_name = value + if not isinstance(action, dict): + return json.dumps(action, ensure_ascii=False) - if action_name is not None and action_input is not None: + action_name, action_input = _split_action_fields(action) + if _is_callable_action(action_name, action_input): return AgentScratchpadUnit.Action( action_name=action_name, action_input=action_input, ) else: - return json.dumps(action) + # A non-string action name (e.g. {"action": ["search"]}) or a + # non-dict/str action input is not callable; keep the payload + # as text instead of raising a validation error from + # AgentScratchpadUnit.Action. + return json.dumps(action, ensure_ascii=False) def extra_json_from_code_block(code_block) -> list[list | dict]: blocks = re.findall(r"```[json]*\s*([\[{].*[]}])\s*```", code_block, re.DOTALL | re.IGNORECASE) diff --git a/api/tests/unit_tests/core/agent/output_parser/test_cot_output_parser.py b/api/tests/unit_tests/core/agent/output_parser/test_cot_output_parser.py index cfb8a3ec225fcf..82353fcbe38dd1 100644 --- a/api/tests/unit_tests/core/agent/output_parser/test_cot_output_parser.py +++ b/api/tests/unit_tests/core/agent/output_parser/test_cot_output_parser.py @@ -291,3 +291,76 @@ def test_edge_inputs(self, make_chunk, usage_dict, content) -> None: if content.lower() in {"action:", "thought:"}: assert "action:" not in joined.lower() assert "thought:" not in joined.lower() + + # -------------------------------------------------------- + # Malformed action payloads (issue #45200) + # -------------------------------------------------------- + + def test_multi_item_json_list_is_not_an_action(self, make_chunk, usage_dict) -> None: + # A JSON list with more than one item is not a single action. The + # direct answer it carries must reach the user as text instead of + # raising AttributeError ('list' object has no attribute 'items'). + content = '```json\n[{"name": "a"}, {"name": "b"}]\n```' + chunks = [make_chunk(content)] + result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)) + assert all(isinstance(item, str) for item in result) + assert json.loads("".join(result)) == [{"name": "a"}, {"name": "b"}] + + @pytest.mark.parametrize("payload", ["[]", "[1]"]) + def test_non_object_json_list_is_not_an_action(self, make_chunk, usage_dict, payload) -> None: + # An empty or non-object list used to raise AttributeError + # ('int' object has no attribute 'items'); it is plain text now. + content = f"```json\n{payload}\n```" + chunks = [make_chunk(content)] + result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)) + assert all(isinstance(item, str) for item in result) + assert json.loads("".join(result)) == json.loads(payload) + + def test_multi_action_list_runs_first_action(self, make_chunk, usage_dict) -> None: + # The prompt allows exactly one action per turn: a list of actions + # runs the first one instead of crashing or running all of them. + content = '```json\n[{"action": "search", "input": "q1"}, {"action": "lookup", "input": "q2"}]\n```' + chunks = [make_chunk(content)] + result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)) + assert [item for item in result if isinstance(item, AgentScratchpadUnit.Action)] == [ + AgentScratchpadUnit.Action(action_name="search", action_input="q1") + ] + + def test_non_string_action_name_is_text(self, make_chunk, usage_dict) -> None: + # A non-string action name used to raise a pydantic ValidationError + # from AgentScratchpadUnit.Action; it is treated as text now. + content = '{"action": ["search"], "action_input": {"q": "x"}}' + chunks = [make_chunk(content)] + result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)) + assert all(isinstance(item, str) for item in result) + assert json.loads("".join(result)) == {"action": ["search"], "action_input": {"q": "x"}} + + def test_extra_field_after_action_does_not_override_tool(self, make_chunk, usage_dict) -> None: + # Extra fields after the action used to override the tool name (the + # last non-input key won, so a trailing "reason" became the action). + content = '{"action": "search", "action_input": {"q": "x"}, "reason": "need data"}' + chunks = [make_chunk(content)] + result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)) + assert result == [AgentScratchpadUnit.Action(action_name="search", action_input={"q": "x"})] + + def test_non_action_json_preserves_non_ascii(self, make_chunk, usage_dict) -> None: + # json.dumps defaults escaped non-ASCII to \uXXXX sequences; keep the + # model's text as-is when a payload is not an action. + content = '{"foo": "中文"}' + chunks = [make_chunk(content)] + result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)) + assert result == ['{"foo": "中文"}'] + + @pytest.mark.parametrize( + "action_input", + [[1, 2], 5], + ) + def test_wrong_typed_action_input_is_text(self, make_chunk, usage_dict, action_input) -> None: + # A non-dict/str action input used to raise a pydantic ValidationError + # from AgentScratchpadUnit.Action; it is treated as text now. + payload = {"action": "search", "action_input": action_input} + content = json.dumps(payload) + chunks = [make_chunk(content)] + result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)) + assert all(isinstance(item, str) for item in result) + assert json.loads("".join(result)) == payload