File size: 7,304 Bytes
6b62834 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """Parser for ReAct outputs — extracts Thought, Action, and Final Answer."""
import json
import re
from dataclasses import dataclass
from typing import Optional
@dataclass
class ReActStep:
"""A single step of the ReAct loop."""
thought: str = ""
action: str = ""
action_input: dict = None
is_final: bool = False
final_answer: str = ""
raw_text: str = ""
def __post_init__(self):
if self.action_input is None:
self.action_input = {}
def parse_react_output(text: str, tool_names: list[str] | None = None) -> ReActStep:
"""Parse the LLM output into a ReAct step.
Handles these patterns:
- Thought: ...
- Action: tool_name
- Action Input: {...}
OR
- Thought: ...
- Final Answer: ...
"""
# Truncate repetitive output — if the model loops on the same sentence,
# cut at the first repetition to keep only useful content.
text = _trim_repetition(text)
# Strip any residual reasoning (<think> blocks) — reasoning text may
# contain rehearsed "Thought:/Action:" lines that must NOT be parsed as
# real actions. Providers normally strip this already; this is a safety net.
from agentic_rag.services.llm.base import strip_reasoning
text = strip_reasoning(text)
result = ReActStep(raw_text=text)
# Extract Thought
thought_match = re.search(r'Thought:\s*(.+?)(?=\n(?:Action|Final Answer)|$)', text, re.DOTALL)
if thought_match:
result.thought = thought_match.group(1).strip()
# Check for Final Answer first
final_match = re.search(r'Final Answer:\s*(.+)', text, re.DOTALL)
if final_match:
result.is_final = True
result.final_answer = final_match.group(1).strip()
return result
# Extract Action — handle Chinese-descriptive Action lines like:
# Action: 使用 rag_search 搜索...
# Action: 调用 mcp__tavily-mcp__tavily_search 查询...
action_match = re.search(r'Action:\s*(.+?)(?:\n|$)', text)
if action_match:
action_text = action_match.group(1).strip()
result.action = _resolve_tool_name(action_text, tool_names or [])
# Re-parse with stricter matching if failed
if not result.action:
action_match2 = re.search(r'Action:\s*(\S+)', text)
if action_match2:
result.action = _resolve_tool_name(action_match2.group(1).strip(), tool_names or [])
# Extract Action Input (try JSON first, then key=value)
action_input_match = re.search(r'Action Input:\s*(\{.+?\}|.+)', text, re.DOTALL)
if action_input_match:
input_str = action_input_match.group(1).strip()
result.action_input = parse_action_input(input_str)
return result
def _trim_repetition(text: str) -> str:
"""Detect and cut repetitive LLM output at the first repetition point.
When a model loops — e.g. "I will output the answer... I will output the answer..."
— truncate everything after the first occurrence of the repeated line.
"""
lines = text.split("\n")
seen: set[str] = set()
clean_lines: list[str] = []
for line in lines:
stripped = line.strip()
# Skip empty lines in repetition check
if not stripped:
clean_lines.append(line)
continue
# Normalize for comparison
norm = stripped.lower().rstrip(".。!!??,,")
if norm in seen:
# Found repetition — stop here
break
if len(norm) > 15: # only track meaningful lines
seen.add(norm)
clean_lines.append(line)
return "\n".join(clean_lines)
def _resolve_tool_name(action_text: str, tool_names: list[str]) -> str:
"""Extract the actual tool name from an Action line that may contain Chinese description.
Example inputs → outputs:
"rag_search" → "rag_search"
"使用 rag_search 搜索" → "rag_search"
"调用 mcp__tavily-mcp__tavily_search 查询" → "mcp__tavily-mcp__tavily_search"
"搜索文档" → "" (no tool found)
"""
# Already a clean single identifier
if re.match(r'^[a-zA-Z_][a-zA-Z0-9_\-/]*$', action_text):
if tool_names:
if action_text in tool_names:
return action_text
# Fuzzy: unique tool ending with the model's text
# ("tavily_search" → "mcp__tavily-mcp__tavily_search")
matches = [t for t in tool_names if t.endswith(action_text)]
if len(matches) == 1:
return matches[0]
return ""
return action_text
# Find tool-like patterns in the text: lowercase_with_underscores, possibly with __ or /
candidates = re.findall(r'[a-zA-Z_][a-zA-Z0-9_\-/]{2,}', action_text)
for c in candidates:
# Must contain underscore (real tools look like rag_search, mcp__xxx__yyy)
if '_' in c:
if tool_names:
if c in tool_names:
return c
else:
return c
# Last resort: pick the first ascii word
for c in candidates:
if tool_names:
if c in tool_names:
return c
else:
return c
# Fuzzy match: unique tool whose name contains the model's text.
# Handles cases like "tavily_search" → "mcp__tavily-mcp__tavily_search".
# The model's text must be a real suffix/substring, and only ONE tool may
# match (otherwise it's ambiguous and we refuse to guess).
if tool_names and len(action_text) >= 4 and '_' in action_text:
matches = [t for t in tool_names if t.endswith(action_text) or t.split('__')[-1] == action_text]
if len(matches) == 1:
return matches[0]
return ""
def parse_action_input(input_str: str) -> dict:
"""Parse action input string into a dict. Tries JSON first, then key=value."""
# Try JSON
try:
return json.loads(input_str)
except json.JSONDecodeError:
pass
# Try to extract JSON from within the string
json_match = re.search(r'\{[^{}]*\}', input_str)
if json_match:
try:
return json.loads(json_match.group(0))
except json.JSONDecodeError:
pass
# Fallback: treat as raw string
if input_str:
return {"query": input_str}
return {}
def extract_final_answer(text: str) -> Optional[str]:
"""Extract the final answer from text if present."""
from agentic_rag.services.llm.base import strip_reasoning
text = strip_reasoning(text)
match = re.search(r'Final Answer:\s*(.+)', text, re.DOTALL)
if match:
return match.group(1).strip()
return None
def is_final_answer(text: str) -> bool:
"""Check if the text contains a Final Answer marker."""
return "Final Answer:" in text
def format_observation(tool_name: str, result: str, error: Optional[str] = None) -> str:
"""Format a tool execution result as an Observation."""
if error:
return f"Observation: Error executing '{tool_name}': {error}"
# Truncate very long results — keeps the ReAct prompt compact so the
# model has less material to over-analyze on the next turn.
if len(result) > 1500:
result = result[:1500] + "... (truncated)"
return f"Observation: {result}"
|