From cc47cf0cc93310eada8d4f7fae36ca9f2bd1e3bb Mon Sep 17 00:00:00 2001 From: Artoriasn <48850755+Artoriasn@users.noreply.github.com> Date: Thu, 21 May 2026 09:57:16 +0800 Subject: [PATCH] fix(agent): tolerate multi-step ReAct output (#3074) --- .../agent/expand/actions/react_action.py | 4 +- .../src/dbgpt/agent/expand/data_agent.py | 2 +- .../dbgpt/agent/expand/data_analysis_agent.py | 2 +- .../src/dbgpt/agent/expand/react_agent.py | 12 +- .../src/dbgpt/agent/util/react_parser.py | 210 ++++++++++++++---- .../agent/util/tests/test_react_parser.py | 106 +++++++++ 6 files changed, 293 insertions(+), 43 deletions(-) diff --git a/packages/dbgpt-core/src/dbgpt/agent/expand/actions/react_action.py b/packages/dbgpt-core/src/dbgpt/agent/expand/actions/react_action.py index f51a10851..7088f3e45 100644 --- a/packages/dbgpt-core/src/dbgpt/agent/expand/actions/react_action.py +++ b/packages/dbgpt-core/src/dbgpt/agent/expand/actions/react_action.py @@ -58,7 +58,7 @@ class Terminate(Action[None], BaseTool): parser = kwargs["parser"] else: parser = ReActOutputParser() - steps = parser.parse(ai_message) + steps = parser.parse_current_step(ai_message) if len(steps) == 0: return None if len(steps) > 1: @@ -145,7 +145,7 @@ class ReActAction(ToolAction): parser = kwargs["parser"] else: parser = ReActOutputParser() - steps = parser.parse(ai_message) + steps = parser.parse_current_step(ai_message) if len(steps) == 0: raise ValueError("No valid ReAct step found in model output.") if len(steps) > 1: diff --git a/packages/dbgpt-core/src/dbgpt/agent/expand/data_agent.py b/packages/dbgpt-core/src/dbgpt/agent/expand/data_agent.py index 8a174a680..a0ed4b079 100644 --- a/packages/dbgpt-core/src/dbgpt/agent/expand/data_agent.py +++ b/packages/dbgpt-core/src/dbgpt/agent/expand/data_agent.py @@ -290,7 +290,7 @@ class DataAnalysisPlanningAgent(ConversableAgent): raise ValueError("The response is empty.") try: - steps = self.parser.parse(message_content) + steps = self.parser.parse_current_step(message_content) err_msg = None if not steps: err_msg = ( diff --git a/packages/dbgpt-core/src/dbgpt/agent/expand/data_analysis_agent.py b/packages/dbgpt-core/src/dbgpt/agent/expand/data_analysis_agent.py index d83d12034..211874798 100644 --- a/packages/dbgpt-core/src/dbgpt/agent/expand/data_analysis_agent.py +++ b/packages/dbgpt-core/src/dbgpt/agent/expand/data_analysis_agent.py @@ -65,7 +65,7 @@ class DataAnalysisAgent(ConversableAgent): raise ValueError("The response is empty.") try: - steps = self.parser.parse(message_content) + steps = self.parser.parse_current_step(message_content) if not steps or len(steps) != 1: return ActionOutput( is_exe_success=False, content="Invalid response format." diff --git a/packages/dbgpt-core/src/dbgpt/agent/expand/react_agent.py b/packages/dbgpt-core/src/dbgpt/agent/expand/react_agent.py index a4b90ffa0..2ffdfddf8 100644 --- a/packages/dbgpt-core/src/dbgpt/agent/expand/react_agent.py +++ b/packages/dbgpt-core/src/dbgpt/agent/expand/react_agent.py @@ -38,6 +38,16 @@ of steps you can take is {{ max_steps }}. Do not output an empty string! {{ action_space }} # RESPONSE FORMAT # +IMPORTANT: +- You must never answer directly outside the ReAct format. +- Every response must contain exactly one Action and one Action Input. +- If the task is complete, use exactly: +Thought: ... +Phase: 返回最终结果 +Action: terminate +Action Input: {"result": "final answer"} +- Do not put the final answer as plain markdown outside Action Input. + For each task input, your response should contain: 1. One analysis of the task and the current environment, reasoning to determine the \ next action (prefix "Thought: "). @@ -232,7 +242,7 @@ class ReActAgent(ConversableAgent): if not message_content: raise ValueError("The response is empty.") try: - steps = self.parser.parse(message_content) + steps = self.parser.parse_current_step(message_content) err_msg = None if not steps: err_msg = ( diff --git a/packages/dbgpt-core/src/dbgpt/agent/util/react_parser.py b/packages/dbgpt-core/src/dbgpt/agent/util/react_parser.py index 7dd02165f..d713f43da 100644 --- a/packages/dbgpt-core/src/dbgpt/agent/util/react_parser.py +++ b/packages/dbgpt-core/src/dbgpt/agent/util/react_parser.py @@ -47,6 +47,10 @@ class ReActOutputParser: Args: thought_prefix: Prefix string that indicates the start of a thought. phase_prefix: Prefix string that indicates the start of a phase. + action_intention_prefix: Prefix string that indicates the start of + an action intention. + action_reason_prefix: Prefix string that indicates the start of an + action reason. action_prefix: Prefix string that indicates the start of an action. action_input_prefix: Prefix string that indicates the start of action input. observation_prefix: Prefix string that indicates the start of an @@ -71,11 +75,84 @@ class ReActOutputParser: self.action_input_prefix_escaped = re.escape(action_input_prefix) self.observation_prefix_escaped = re.escape(observation_prefix) - def _strip_leading_vis_thinking_block(self, text: str) -> str: - """Remove the leading vis-thinking wrapper produced by VisThinking.""" + def _prefix_line_pattern(self, escaped_prefix: str) -> str: + """Build a regex for a ReAct prefix at the start of a logical line.""" + return rf"^[ \t]*{escaped_prefix}\s*" + + def _markdown_fence_spans(self, text: str) -> List[tuple[int, int]]: + """Return markdown fenced-code spans so ReAct labels inside are ignored.""" + fence_pattern = re.compile( + r"^[ \t]*(```+|~~~+)[^\n]*\n.*?^[ \t]*\1[ \t]*$", + re.DOTALL | re.MULTILINE, + ) + return [match.span() for match in fence_pattern.finditer(text)] + + @staticmethod + def _is_in_spans(pos: int, spans: List[tuple[int, int]]) -> bool: + return any(start <= pos < end for start, end in spans) + + def _find_prefix_matches(self, text: str, escaped_prefix: str) -> List[re.Match]: + """Find line-start ReAct prefix matches outside markdown code fences.""" + pattern = re.compile( + self._prefix_line_pattern(escaped_prefix), re.MULTILINE + ) + fence_spans = self._markdown_fence_spans(text) + return [ + match + for match in pattern.finditer(text) + if not self._is_in_spans(match.start(), fence_spans) + ] + + def _mask_prefixes_in_fences(self, text: str) -> str: + """Mask ReAct labels inside code fences while preserving string offsets.""" + chars = list(text) + escaped_prefixes = ( + self.thought_prefix_escaped, + self.phase_prefix_escaped, + self.action_intention_prefix_escaped, + self.action_reason_prefix_escaped, + self.action_prefix_escaped, + self.action_input_prefix_escaped, + self.observation_prefix_escaped, + ) + for start, end in self._markdown_fence_spans(text): + fenced_text = text[start:end] + for escaped_prefix in escaped_prefixes: + pattern = re.compile( + self._prefix_line_pattern(escaped_prefix), re.MULTILINE + ) + for match in pattern.finditer(fenced_text): + prefix_start = start + match.start() + while prefix_start < end and chars[prefix_start] in (" ", "\t"): + prefix_start += 1 + if prefix_start < end: + chars[prefix_start] = "_" + return "".join(chars) + + def _strip_vis_thinking_blocks(self, text: str) -> str: + """Remove vis-thinking wrappers produced by reasoning model output.""" + fence = "`" * 6 + pattern = ( + rf"{re.escape(fence)}{re.escape(VisThinking.vis_tag())}" + rf"\s*\n.*?\n{re.escape(fence)}\s*" + ) + return re.sub(pattern, "", text, flags=re.DOTALL) + + def _strip_markdown_code_fence(self, text: str) -> str: + """Remove a markdown fence that wraps the whole ReAct response.""" + stripped = text.strip() + match = re.fullmatch(r"```[a-zA-Z0-9_-]*\s*\n(.*?)\n```", stripped, re.DOTALL) + if match: + return match.group(1).strip() + return text + + def _normalize_react_text(self, text: str) -> str: + """Normalize common wrappers before ReAct parsing.""" if not text: return text + text = self._strip_vis_thinking_blocks(text) + text = self._strip_markdown_code_fence(text) stripped = text.lstrip() fence = "`" * 6 opening = f"{fence}{VisThinking.vis_tag()}" @@ -99,8 +176,7 @@ class ReActOutputParser: if closing_index is None: return text - stripped_content = "\n".join(lines[closing_index + 1 :]).lstrip() - return stripped_content + return "\n".join(lines[closing_index + 1 :]).lstrip() def parse(self, text: str) -> List[ReActStep]: """ @@ -117,10 +193,12 @@ class ReActOutputParser: steps = [] # Remove any leading/trailing whitespace - text = self._strip_leading_vis_thinking_block(text).strip() + text = self._normalize_react_text(text).strip() - # Find all instances of the thought prefix - thought_matches = list(re.finditer(rf"{self.thought_prefix_escaped}\s*", text)) + # Find all line-start instances of the thought prefix outside code fences. + thought_matches = self._find_prefix_matches( + text, self.thought_prefix_escaped + ) if not thought_matches: return [] @@ -145,6 +223,22 @@ class ReActOutputParser: return steps + def parse_current_step(self, text: str) -> List[ReActStep]: + """Parse the single step that should be executed in the current round. + + Some reasoning models incorrectly emit a whole ReAct trajectory in one + response. DB-GPT executes one action per round, so callers that are about + to run tools should use only the first actionable step while preserving + ``parse()`` for history and diagnostics. + """ + steps = self.parse(text) + if len(steps) <= 1: + return steps + for step in steps: + if step.action: + return [step] + return [steps[0]] + def _parse_step(self, step_text: str) -> Optional[ReActStep]: """ Parse a single step of the ReAct format. @@ -159,70 +253,103 @@ class ReActOutputParser: """ # Initialize the result thought = None + phase = None + action_intention = None + action_reason = None action = None action_input = None observation = None is_terminal = False + match_text = self._mask_prefixes_in_fences(step_text) # Extract thought + thought_line = self._prefix_line_pattern(self.thought_prefix_escaped) + phase_line = self._prefix_line_pattern(self.phase_prefix_escaped) + action_intention_line = self._prefix_line_pattern( + self.action_intention_prefix_escaped + ) + action_reason_line = self._prefix_line_pattern( + self.action_reason_prefix_escaped + ) + action_line = self._prefix_line_pattern(self.action_prefix_escaped) + action_input_line = self._prefix_line_pattern( + self.action_input_prefix_escaped + ) + observation_line = self._prefix_line_pattern( + self.observation_prefix_escaped + ) + thought_match = re.search( - rf"{self.thought_prefix_escaped}\s*(.*?)(?={self.phase_prefix_escaped}|{self.action_intention_prefix_escaped}|{self.action_reason_prefix_escaped}|{self.action_prefix_escaped}|{self.observation_prefix_escaped}|$)", - step_text, - re.DOTALL, + rf"{thought_line}(.*?)(?={phase_line}|{action_intention_line}|" + rf"{action_reason_line}|{action_line}|{observation_line}|\Z)", + match_text, + re.DOTALL | re.MULTILINE, ) if thought_match: - thought = thought_match.group(1).strip() + thought = step_text[thought_match.start(1) : thought_match.end(1)].strip() # Extract phase (optional, between thought and action) - phase = None phase_match = re.search( - rf"{self.phase_prefix_escaped}\s*(.*?)(?={self.action_intention_prefix_escaped}|{self.action_reason_prefix_escaped}|{self.action_prefix_escaped}|{self.action_input_prefix_escaped}|{self.observation_prefix_escaped}|$)", - step_text, - re.DOTALL, + rf"{phase_line}(.*?)(?={action_intention_line}|{action_reason_line}|" + rf"{action_line}|{action_input_line}|{observation_line}|\Z)", + match_text, + re.DOTALL | re.MULTILINE, ) if phase_match: - phase = phase_match.group(1).strip() or None + phase = step_text[phase_match.start(1) : phase_match.end(1)].strip() or None # Extract action intention (optional, short user-facing intent) - action_intention = None action_intention_match = re.search( - rf"{self.action_intention_prefix_escaped}\s*(.*?)(?={self.action_reason_prefix_escaped}|{self.action_prefix_escaped}|{self.action_input_prefix_escaped}|{self.observation_prefix_escaped}|$)", - step_text, - re.DOTALL, + rf"{action_intention_line}(.*?)(?={action_reason_line}|{action_line}|" + rf"{action_input_line}|{observation_line}|\Z)", + match_text, + re.DOTALL | re.MULTILINE, ) if action_intention_match: - action_intention = action_intention_match.group(1).strip() or None + action_intention = ( + step_text[ + action_intention_match.start(1) : action_intention_match.end(1) + ].strip() + or None + ) # Extract action reason (optional, short user-facing reason) - action_reason = None action_reason_match = re.search( - rf"{self.action_reason_prefix_escaped}\s*(.*?)(?={self.action_intention_prefix_escaped}|{self.action_prefix_escaped}|{self.action_input_prefix_escaped}|{self.observation_prefix_escaped}|$)", - step_text, - re.DOTALL, + rf"{action_reason_line}(.*?)(?={action_intention_line}|{action_line}|" + rf"{action_input_line}|{observation_line}|\Z)", + match_text, + re.DOTALL | re.MULTILINE, ) if action_reason_match: - action_reason = action_reason_match.group(1).strip() or None + action_reason = ( + step_text[ + action_reason_match.start(1) : action_reason_match.end(1) + ].strip() + or None + ) # Extract action action_match = re.search( - rf"{self.action_prefix_escaped}\s*(.*?)(?={self.action_input_prefix_escaped}|{self.observation_prefix_escaped}|$)", - step_text, - re.DOTALL, + rf"{action_line}(.*?)(?={action_input_line}|{observation_line}|\Z)", + match_text, + re.DOTALL | re.MULTILINE, ) if action_match: - action = action_match.group(1).strip() + action = step_text[action_match.start(1) : action_match.end(1)].strip() # Check if this is a terminate action is_terminal = action.lower() == self.terminate_action.lower() # Extract action input action_input_match = re.search( - rf"{self.action_input_prefix_escaped}\s*(.*?)(?={self.observation_prefix_escaped}|{self.thought_prefix_escaped}|$)", - step_text, - re.DOTALL, + rf"{action_input_line}(.*?)(?={observation_line}|{thought_line}|\Z)", + match_text, + re.DOTALL | re.MULTILINE, ) if action_input_match: - action_input_text = action_input_match.group(1).strip() + action_input_text = step_text[ + action_input_match.start(1) : action_input_match.end(1) + ].strip() # Try to parse action input as JSON if it looks like JSON if ( @@ -239,12 +366,14 @@ class ReActOutputParser: # Extract observation observation_match = re.search( - rf"{self.observation_prefix_escaped}\s*(.*?)(?={self.thought_prefix_escaped}|$)", - step_text, - re.DOTALL, + rf"{observation_line}(.*?)(?={thought_line}|\Z)", + match_text, + re.DOTALL | re.MULTILINE, ) if observation_match: - observation_text = observation_match.group(1).strip() + observation_text = step_text[ + observation_match.start(1) : observation_match.end(1) + ].strip() # Try to parse observation as JSON if it looks like JSON if ( @@ -283,6 +412,11 @@ class ReActOutputParser: """ for step in reversed(steps): # Look from the end if step.is_terminal and step.action == self.terminate_action: + if ( + isinstance(step.action_input, dict) + and "result" in step.action_input + ): + return step.action_input["result"] if ( isinstance(step.action_input, dict) and "output" in step.action_input diff --git a/packages/dbgpt-core/src/dbgpt/agent/util/tests/test_react_parser.py b/packages/dbgpt-core/src/dbgpt/agent/util/tests/test_react_parser.py index 718e2ea51..c4b3af10a 100644 --- a/packages/dbgpt-core/src/dbgpt/agent/util/tests/test_react_parser.py +++ b/packages/dbgpt-core/src/dbgpt/agent/util/tests/test_react_parser.py @@ -41,6 +41,29 @@ Observation: 4""" assert steps[0].observation == "4" assert steps[0].is_terminal is False + def test_parsing_with_action_intention_and_reason(self): + """Test parsing with optional action intention and reason fields.""" + parser = ReActOutputParser() + text = """Thought: I need to inspect the database schema. +Action Intention: Query the available asset categories. +Action Reason: The user asked about assets by major type. +Action: query_db +Action Input: {"sql": "select distinct major_type from assets"}""" + + steps = parser.parse(text) + + assert len(steps) == 1 + assert steps[0].thought == "I need to inspect the database schema." + assert steps[0].action_intention == "Query the available asset categories." + assert ( + steps[0].action_reason + == "The user asked about assets by major type." + ) + assert steps[0].action == "query_db" + assert steps[0].action_input == { + "sql": "select distinct major_type from assets" + } + def test_terminal_action(self): """Test parsing of a terminal action.""" parser = ReActOutputParser() @@ -60,6 +83,17 @@ Action Input: {"output": "The answer is 4"}""" final_output = parser.get_final_output(steps) assert final_output == "The answer is 4" + def test_terminal_action_with_result_key(self): + """Test parsing of the agentic terminal action format.""" + parser = ReActOutputParser() + text = """Thought: I've finished the calculation. +Action: terminate +Action Input: {"result": "The answer is 4"}""" + + steps = parser.parse(text) + + assert parser.get_final_output(steps) == "The answer is 4" + def test_multi_step_parsing(self): """Test parsing of multiple steps.""" parser = ReActOutputParser() @@ -402,3 +436,75 @@ print("Hello, world!") ) assert steps[0].observation is None assert steps[0].is_terminal is False + + def test_parsing_with_leading_vis_thinking_block(self): + """Test parsing when reasoning UI markup precedes the ReAct response.""" + parser = ReActOutputParser() + text = """``````vis-thinking +I should decide which tool to use first. +`````` +Thought: I should calculate 2+2. +Action: calculator +Action Input: {"operation": "add", "a": 2, "b": 2}""" + + steps = parser.parse(text) + + assert len(steps) == 1 + assert steps[0].thought == "I should calculate 2+2." + assert steps[0].action == "calculator" + + def test_parsing_with_markdown_code_fence_wrapper(self): + """Test parsing when the model wraps the whole response in a code fence.""" + parser = ReActOutputParser() + text = """```text +Thought: I should calculate 2+2. +Action: calculator +Action Input: {"operation": "add", "a": 2, "b": 2} +```""" + + steps = parser.parse(text) + + assert len(steps) == 1 + assert steps[0].thought == "I should calculate 2+2." + assert steps[0].action == "calculator" + + def test_parse_current_step_uses_first_action_for_multi_round_output(self): + """Use the first action when a model emits multiple ReAct rounds at once.""" + parser = ReActOutputParser() + text = """Thought: I need to query the database first. +Action: query_db +Action Input: {"sql": "select count(*) from assets"} +Observation: 30 + +Thought: I already know the answer. +Action: terminate +Action Input: {"result": "网络设备资产共 30 条"}""" + + all_steps = parser.parse(text) + current_steps = parser.parse_current_step(text) + + assert len(all_steps) == 2 + assert len(current_steps) == 1 + assert current_steps[0].action == "query_db" + assert current_steps[0].action_input == { + "sql": "select count(*) from assets" + } + + def test_react_markers_inside_action_input_code_fence_are_not_steps(self): + """Do not split Action Input code fences that mention ReAct labels.""" + parser = ReActOutputParser() + text = """Thought: I need to create a prompt file. +Action: CreateFile +Action Input: CreateFile(filepath="prompt.txt"): +``` +Thought: this is documentation, not a new ReAct step. +Action: example_tool +Action Input: {"demo": true} +```""" + + steps = parser.parse(text) + + assert len(steps) == 1 + assert steps[0].action == "CreateFile" + assert "Thought: this is documentation" in steps[0].action_input + assert "Action: example_tool" in steps[0].action_input