react_agent.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. """ReAct Agent实现 - 推理与行动结合的智能体"""
  2. import re
  3. from typing import Optional, List, Tuple, Callable, Dict, Any
  4. from core.agent import Agent
  5. from core.llm import HelloAgentsLLM
  6. from core.config import Config
  7. from core.message import Message
  8. from tools.registry import ToolRegistry
  9. from utils.cli_ui import Spinner, c, PRIMARY, ACCENT, INFO, hr, log_tool_event, clamp_text
  10. # 默认ReAct提示词模板
  11. DEFAULT_REACT_PROMPT = """你是一个具备推理和行动能力的AI助手。你可以通过思考分析问题,然后调用合适的工具来获取信息,最终给出准确的答案。
  12. ## 可用工具
  13. {tools}
  14. ## 工作流程
  15. 请严格按照以下格式进行回应,每次只能执行一个步骤:
  16. **Thought:** 分析当前问题,思考需要什么信息或采取什么行动。
  17. **Action:** 选择一个行动,格式必须是以下之一:
  18. - `{{tool_name}}[{{tool_input}}]` - 调用指定工具
  19. - `Finish[最终答案]` - 当你有足够信息给出最终答案时
  20. ## 重要提醒
  21. 1. 每次回应必须包含Thought和Action两部分
  22. 2. 工具调用的格式必须严格遵循:工具名[参数]
  23. 3. 只有当你确信有足够信息回答问题时,才使用Finish
  24. 4. 如果工具返回的信息不够,继续使用其他工具或相同工具的不同参数
  25. ## 当前任务
  26. **Question:** {question}
  27. ## 执行历史
  28. {history}
  29. 现在开始你的推理和行动:"""
  30. class ReActAgent(Agent):
  31. """
  32. ReAct (Reasoning and Acting) Agent
  33. 结合推理和行动的智能体,能够:
  34. 1. 分析问题并制定行动计划
  35. 2. 调用外部工具获取信息
  36. 3. 基于观察结果进行推理
  37. 4. 迭代执行直到得出最终答案
  38. 这是一个经典的Agent范式,特别适合需要外部信息的任务。
  39. """
  40. def __init__(
  41. self,
  42. name: str,
  43. llm: HelloAgentsLLM,
  44. tool_registry: Optional[ToolRegistry] = None,
  45. system_prompt: Optional[str] = None,
  46. config: Optional[Config] = None,
  47. max_steps: int = 5,
  48. custom_prompt: Optional[str] = None,
  49. observation_summarizer: Optional[Callable[[str, str, str], str]] = None,
  50. summarize_threshold_chars: int = 2000,
  51. finalize_on_max_steps: bool = True,
  52. early_stop_on_repeat: bool = True,
  53. repeat_action_threshold: int = 2,
  54. ):
  55. """
  56. 初始化ReActAgent
  57. Args:
  58. name: Agent名称
  59. llm: LLM实例
  60. tool_registry: 工具注册表(可选,如果不提供则创建空的工具注册表)
  61. system_prompt: 系统提示词
  62. config: 配置对象
  63. max_steps: 最大执行步数
  64. custom_prompt: 自定义提示词模板
  65. """
  66. super().__init__(name, llm, system_prompt, config)
  67. # 如果没有提供tool_registry,创建一个空的
  68. if tool_registry is None:
  69. self.tool_registry = ToolRegistry()
  70. else:
  71. self.tool_registry = tool_registry
  72. self.max_steps = max_steps
  73. self.current_history: List[str] = []
  74. self.last_trace: List[Dict[str, Any]] = []
  75. self.observation_summarizer = observation_summarizer
  76. self.summarize_threshold_chars = summarize_threshold_chars
  77. self.finalize_on_max_steps = finalize_on_max_steps
  78. self.early_stop_on_repeat = early_stop_on_repeat
  79. self.repeat_action_threshold = repeat_action_threshold
  80. # 设置提示词模板:用户自定义优先,否则使用默认模板
  81. self.prompt_template = custom_prompt if custom_prompt else DEFAULT_REACT_PROMPT
  82. def add_tool(self, tool):
  83. """
  84. 添加工具到工具注册表
  85. 支持MCP工具的自动展开
  86. Args:
  87. tool: 工具实例(可以是普通Tool或MCPTool)
  88. """
  89. # 检查是否是MCP工具
  90. if hasattr(tool, 'auto_expand') and tool.auto_expand:
  91. # MCP工具会自动展开为多个工具
  92. if hasattr(tool, '_available_tools') and tool._available_tools:
  93. for mcp_tool in tool._available_tools:
  94. # 创建包装工具
  95. from tools.base import Tool
  96. wrapped_tool = Tool(
  97. name=f"{tool.name}_{mcp_tool['name']}",
  98. description=mcp_tool.get('description', ''),
  99. func=lambda input_text, t=tool, tn=mcp_tool['name']: t.run({
  100. "action": "call_tool",
  101. "tool_name": tn,
  102. "arguments": {"input": input_text}
  103. })
  104. )
  105. self.tool_registry.register_tool(wrapped_tool)
  106. print(f"✅ MCP工具 '{tool.name}' 已展开为 {len(tool._available_tools)} 个独立工具")
  107. else:
  108. self.tool_registry.register_tool(tool)
  109. else:
  110. self.tool_registry.register_tool(tool)
  111. def run(self, input_text: str, **kwargs) -> str:
  112. """
  113. 运行ReAct Agent
  114. Args:
  115. input_text: 用户问题
  116. **kwargs: 其他参数
  117. Returns:
  118. 最终答案
  119. """
  120. self.current_history = []
  121. self.last_trace = []
  122. current_step = 0
  123. # Avoid dumping huge stitched prompts to console (CLI UX)
  124. preview = input_text.replace("\n", " ")
  125. if len(preview) > 160:
  126. preview = preview[:160] + "..."
  127. print("\n" + hr("=", 80))
  128. print(c(f"🤖 {self.name}", PRIMARY) + " " + c(f"{preview}", INFO))
  129. print(hr("=", 80))
  130. repeat_count = 0
  131. last_action_sig: Optional[str] = None
  132. while current_step < self.max_steps:
  133. current_step += 1
  134. print(c(f"\n--- Step {current_step}/{self.max_steps} ---", ACCENT))
  135. # 构建提示词
  136. tools_desc = self.tool_registry.get_tools_description()
  137. history_str = "\n".join(self.current_history)
  138. prompt = self.prompt_template.format(
  139. tools=tools_desc,
  140. question=input_text,
  141. history=history_str
  142. )
  143. # 调用LLM
  144. messages = [{"role": "user", "content": prompt}]
  145. spinner = Spinner("Thinking…")
  146. spinner.start()
  147. response_text = self.llm.invoke(messages, **kwargs)
  148. spinner.stop()
  149. if not response_text:
  150. print("❌ 错误:LLM未能返回有效响应。")
  151. break
  152. # 解析输出
  153. thought, action = self._parse_output(response_text)
  154. if thought:
  155. print(c("Thought:", INFO), thought)
  156. if not action:
  157. # One forced retry: ask model to rewrite in strict format (helps for greetings / bilingual models)
  158. try:
  159. repair_sys = (
  160. "You MUST output exactly two lines:\n"
  161. "Thought: ...\n"
  162. "Action: tool_name[tool_input] OR Finish[final answer]\n"
  163. "No extra text. No markdown headers."
  164. )
  165. repair_user = f"Rewrite the following into the required two-line format:\n\n{response_text}"
  166. spinner = Spinner("Repairing format…")
  167. spinner.start()
  168. repaired = self.llm.invoke(
  169. [{"role": "system", "content": repair_sys}, {"role": "user", "content": repair_user}],
  170. max_tokens=200,
  171. )
  172. spinner.stop()
  173. thought, action = self._parse_output(repaired or "")
  174. except Exception:
  175. pass
  176. if not action:
  177. print("⚠️ 警告:未能解析出有效的Action,流程终止。")
  178. break
  179. # 检查是否完成
  180. if action.startswith("Finish"):
  181. final_answer = self._parse_action_input(action)
  182. print(c("Finish:", PRIMARY))
  183. print(final_answer)
  184. # 保存到历史记录
  185. self.add_message(Message(input_text, "user"))
  186. self.add_message(Message(final_answer, "assistant"))
  187. return final_answer
  188. # 执行工具调用
  189. tool_name, tool_input = self._parse_action(action)
  190. if not tool_name or tool_input is None:
  191. self.current_history.append("Observation: 无效的Action格式,请检查。")
  192. continue
  193. log_tool_event(tool_name, tool_input)
  194. # 调用工具
  195. observation = self.tool_registry.execute_tool(tool_name, tool_input)
  196. observation_full = observation
  197. observation_summary = None
  198. if (
  199. self.observation_summarizer is not None
  200. and isinstance(observation, str)
  201. and len(observation) > self.summarize_threshold_chars
  202. ):
  203. try:
  204. observation_summary = self.observation_summarizer(tool_name, tool_input, observation)
  205. if observation_summary and isinstance(observation_summary, str):
  206. observation = observation_summary.strip() + "\n...truncated...\n"
  207. except Exception:
  208. # fall back to raw observation
  209. pass
  210. log_tool_event(f"{tool_name} result", clamp_text(str(observation), limit=6000))
  211. # 提前终止:重复相同 action 且无明显进展
  212. action_sig = f"{tool_name}|{tool_input}".strip()
  213. if self.early_stop_on_repeat:
  214. if last_action_sig == action_sig:
  215. repeat_count += 1
  216. else:
  217. repeat_count = 0
  218. last_action_sig = action_sig
  219. if repeat_count >= self.repeat_action_threshold:
  220. self.current_history.append("Observation: 已检测到重复行动,建议停止继续工具调用并给出当前能提供的结论/下一步。")
  221. break
  222. # 更新历史
  223. self.current_history.append(f"Action: {action}")
  224. self.current_history.append(f"Observation: {observation}")
  225. self.last_trace.append(
  226. {
  227. "action": action,
  228. "tool_name": tool_name,
  229. "tool_input": tool_input,
  230. "observation_full_len": len(observation_full) if isinstance(observation_full, str) else None,
  231. "observation_summary": observation_summary,
  232. }
  233. )
  234. # 未在循环内 Finish:进行兜底收敛
  235. if self.finalize_on_max_steps:
  236. try:
  237. tools_desc = self.tool_registry.get_tools_description()
  238. history_str = "\n".join(self.current_history[-24:])
  239. finalize_prompt = (
  240. "你是一个 ReAct 代理的最终收敛器。现在工具调用阶段结束了。"
  241. "请基于已有的 Thought/Action/Observation 历史,给出一个尽可能有用的最终回答。"
  242. "要求:\n"
  243. "1) 不要再调用工具\n"
  244. "2) 明确已完成的证据/发现\n"
  245. "3) 如果信息不足,说清楚缺少什么,并给出下一步最小化建议(1-3条)\n"
  246. )
  247. messages = [
  248. {"role": "system", "content": finalize_prompt},
  249. {"role": "user", "content": f"Question:\n{input_text}\n\nTools:\n{tools_desc}\n\nTrace:\n{history_str}"},
  250. ]
  251. final_answer = self.llm.invoke(messages, max_tokens=600)
  252. if final_answer:
  253. self.add_message(Message(input_text, "user"))
  254. self.add_message(Message(final_answer, "assistant"))
  255. return final_answer
  256. except Exception:
  257. pass
  258. print("⏰ 已达到最大步数,流程终止。")
  259. final_answer = "抱歉,我无法在限定步数内完成这个任务。你可以缩小范围或指定目标文件/模块。"
  260. # 保存到历史记录
  261. self.add_message(Message(input_text, "user"))
  262. self.add_message(Message(final_answer, "assistant"))
  263. return final_answer
  264. def _parse_output(self, text: str) -> Tuple[Optional[str], Optional[str]]:
  265. """解析LLM输出,提取思考和行动。
  266. 兼容常见变体:
  267. - Thought/Action 的全角冒号(:)
  268. - 中文标签:思考/行动
  269. - Markdown 强调:**Thought:** / **Action:**
  270. """
  271. # Normalize to make regex easier
  272. t = (text or "").strip()
  273. # Primary: strict 2-line format, allow markdown markers and fullwidth colon
  274. m = re.search(
  275. r"(?:\*\*)?(Thought|思考)(?:\*\*)?\s*[::]\s*(.*?)\n(?:\*\*)?(Action|行动)(?:\*\*)?\s*[::]\s*(.*)\s*$",
  276. t,
  277. flags=re.DOTALL,
  278. )
  279. if m:
  280. thought = m.group(2).strip()
  281. action = m.group(4).strip()
  282. return thought or None, action or None
  283. # Fallback: find first Thought-like line and first Action-like line anywhere
  284. thought_match = re.search(r"(?:\*\*)?(Thought|思考)(?:\*\*)?\s*[::]\s*(.*)", t)
  285. action_match = re.search(r"(?:\*\*)?(Action|行动)(?:\*\*)?\s*[::]\s*(.*)", t)
  286. thought = thought_match.group(2).strip() if thought_match else None
  287. action_raw = action_match.group(2).strip() if action_match else None
  288. # 关键修复:如果 action 中包含另一个 Thought/Action/Observation,截断到该位置
  289. # 防止模型一次输出多个 Thought/Action 循环时,把后续内容都当作第一个 Action 的输入
  290. if action_raw:
  291. stop_patterns = [
  292. r"\nThought:", r"\n思考:", r"\nAction:", r"\n行动:",
  293. r"\nObservation:", r"\n观察:", r"\n\*\*Thought", r"\n\*\*Action",
  294. ]
  295. earliest_stop = len(action_raw)
  296. for pat in stop_patterns:
  297. m = re.search(pat, action_raw, re.IGNORECASE)
  298. if m and m.start() < earliest_stop:
  299. earliest_stop = m.start()
  300. action_raw = action_raw[:earliest_stop].strip()
  301. return thought, action_raw
  302. def _parse_action(self, action_text: str) -> Tuple[Optional[str], Optional[str]]:
  303. """解析行动文本,提取工具名称和输入
  304. 使用括号匹配算法而非贪婪正则,正确处理嵌套 JSON。
  305. """
  306. # 先找工具名
  307. name_match = re.match(r"(\w+)\[", action_text)
  308. if not name_match:
  309. return None, None
  310. tool_name = name_match.group(1)
  311. start = name_match.end() - 1 # '[' 的位置
  312. # 使用括号匹配找到对应的 ']'
  313. depth = 0
  314. in_string = False
  315. escape = False
  316. end_pos = None
  317. for i, c in enumerate(action_text[start:], start):
  318. if escape:
  319. escape = False
  320. continue
  321. if c == '\\' and in_string:
  322. escape = True
  323. continue
  324. if c == '"' and not escape:
  325. in_string = not in_string
  326. continue
  327. if in_string:
  328. continue
  329. if c == '[':
  330. depth += 1
  331. elif c == ']':
  332. depth -= 1
  333. if depth == 0:
  334. end_pos = i
  335. break
  336. if end_pos is not None:
  337. tool_input = action_text[start + 1:end_pos]
  338. return tool_name, tool_input
  339. # fallback: 如果括号不匹配,尝试简单正则(不跨行)
  340. # 注意:不使用 re.DOTALL,这样 . 不会匹配换行符
  341. match = re.match(r"(\w+)\[([^\n]*)\]", action_text)
  342. if match:
  343. return match.group(1), match.group(2)
  344. return None, None
  345. def _parse_action_input(self, action_text: str) -> str:
  346. """解析行动输入
  347. 兼容多种 Finish 书写:
  348. - Finish[...]
  349. - Finish:... / Finish: ...(无方括号)
  350. - Finish\n<content>(换行后直接给内容/补丁)
  351. """
  352. # 规范格式:Finish[...]
  353. match = re.match(r"\w+\[(.*)\]\s*$", action_text, flags=re.DOTALL)
  354. if match:
  355. return match.group(1)
  356. # 宽松格式:Finish: ... 或 Finish:...
  357. m2 = re.match(r"finish\s*[::]\s*(.*)", action_text, flags=re.IGNORECASE | re.DOTALL)
  358. if m2:
  359. return m2.group(1)
  360. # 再宽松:去掉前缀 "Finish" 后的剩余内容
  361. if action_text.lower().startswith("finish"):
  362. return action_text[len("finish"):].strip()
  363. return ""