mirror of
https://github.com/csunny/DB-GPT.git
synced 2026-07-17 10:16:49 +00:00
fix(agent): tolerate multi-step ReAct output (#3074)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user