fix(agent): tolerate multi-step ReAct output (#3074)

This commit is contained in:
Artoriasn
2026-05-21 09:57:16 +08:00
committed by GitHub
parent 9470ea03ff
commit cc47cf0cc9
6 changed files with 293 additions and 43 deletions

View File

@@ -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:

View File

@@ -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 = (

View File

@@ -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."

View File

@@ -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 = (

View File

@@ -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

View File

@@ -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