Coverage for src / dataknobs_bots / reasoning / react.py: 12%

73 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-16 10:13 -0700

1"""ReAct (Reasoning + Acting) reasoning strategy.""" 

2 

3import logging 

4from typing import Any 

5 

6from .base import ReasoningStrategy 

7 

8logger = logging.getLogger(__name__) 

9 

10 

11class ReActReasoning(ReasoningStrategy): 

12 """ReAct (Reasoning + Acting) strategy. 

13 

14 This strategy implements the ReAct pattern where the LLM: 

15 1. Reasons about what to do (Thought) 

16 2. Takes an action (using tools if needed) 

17 3. Observes the result 

18 4. Repeats until task is complete 

19 

20 This is useful for: 

21 - Multi-step problem solving 

22 - Tasks requiring tool use 

23 - Complex reasoning chains 

24 

25 Attributes: 

26 max_iterations: Maximum number of reasoning loops 

27 verbose: Whether to enable debug-level logging 

28 store_trace: Whether to store reasoning trace in conversation metadata 

29 

30 Example: 

31 ```python 

32 strategy = ReActReasoning( 

33 max_iterations=5, 

34 verbose=True, 

35 store_trace=True 

36 ) 

37 response = await strategy.generate( 

38 manager=conversation_manager, 

39 llm=llm_provider, 

40 tools=[search_tool, calculator_tool] 

41 ) 

42 ``` 

43 """ 

44 

45 def __init__( 

46 self, 

47 max_iterations: int = 5, 

48 verbose: bool = False, 

49 store_trace: bool = False, 

50 ): 

51 """Initialize ReAct reasoning strategy. 

52 

53 Args: 

54 max_iterations: Maximum reasoning/action iterations 

55 verbose: Enable debug-level logging for reasoning steps 

56 store_trace: Store reasoning trace in conversation metadata 

57 """ 

58 self.max_iterations = max_iterations 

59 self.verbose = verbose 

60 self.store_trace = store_trace 

61 

62 async def generate( 

63 self, 

64 manager: Any, 

65 llm: Any, 

66 tools: list[Any] | None = None, 

67 **kwargs: Any, 

68 ) -> Any: 

69 """Generate response using ReAct loop. 

70 

71 The ReAct loop: 

72 1. Generate response (may include tool calls) 

73 2. If tool calls present, execute them 

74 3. Add observations to conversation 

75 4. Repeat until no more tool calls or max iterations 

76 

77 Args: 

78 manager: ConversationManager instance 

79 llm: LLM provider instance 

80 tools: Optional list of available tools 

81 **kwargs: Generation parameters 

82 

83 Returns: 

84 Final LLM response 

85 """ 

86 if not tools: 

87 # No tools available, fall back to simple generation 

88 logger.info( 

89 "ReAct: No tools available, falling back to simple generation", 

90 extra={"conversation_id": manager.conversation_id}, 

91 ) 

92 return await manager.complete(**kwargs) 

93 

94 # Initialize trace if enabled 

95 trace = [] if self.store_trace else None 

96 

97 # Get log level based on verbose setting 

98 log_level = logging.DEBUG if self.verbose else logging.INFO 

99 

100 logger.log( 

101 log_level, 

102 "ReAct: Starting reasoning loop", 

103 extra={ 

104 "conversation_id": manager.conversation_id, 

105 "max_iterations": self.max_iterations, 

106 "tools_available": len(tools), 

107 }, 

108 ) 

109 

110 # ReAct loop 

111 for iteration in range(self.max_iterations): 

112 iteration_trace = { 

113 "iteration": iteration + 1, 

114 "tool_calls": [], 

115 } 

116 

117 logger.log( 

118 log_level, 

119 "ReAct: Starting iteration", 

120 extra={ 

121 "conversation_id": manager.conversation_id, 

122 "iteration": iteration + 1, 

123 "max_iterations": self.max_iterations, 

124 }, 

125 ) 

126 

127 # Generate response with tools 

128 response = await manager.complete(tools=tools, **kwargs) 

129 

130 # Check if we have tool calls 

131 if not hasattr(response, "tool_calls") or not response.tool_calls: 

132 # No tool calls, we're done 

133 logger.log( 

134 log_level, 

135 "ReAct: No tool calls in response, finishing", 

136 extra={ 

137 "conversation_id": manager.conversation_id, 

138 "iteration": iteration + 1, 

139 }, 

140 ) 

141 

142 if trace is not None: 

143 iteration_trace["status"] = "completed" 

144 trace.append(iteration_trace) 

145 await self._store_trace(manager, trace) 

146 

147 return response 

148 

149 num_tool_calls = len(response.tool_calls) 

150 logger.log( 

151 log_level, 

152 "ReAct: Executing tool calls", 

153 extra={ 

154 "conversation_id": manager.conversation_id, 

155 "iteration": iteration + 1, 

156 "num_tools": num_tool_calls, 

157 "tools": [tc.name for tc in response.tool_calls], 

158 }, 

159 ) 

160 

161 # Execute all tool calls 

162 for tool_call in response.tool_calls: 

163 tool_trace = { 

164 "name": tool_call.name, 

165 "parameters": tool_call.parameters, 

166 } 

167 

168 try: 

169 # Find the tool 

170 tool = self._find_tool(tool_call.name, tools) 

171 if not tool: 

172 observation = f"Error: Tool '{tool_call.name}' not found" 

173 tool_trace["status"] = "error" 

174 tool_trace["error"] = "Tool not found" 

175 

176 logger.warning( 

177 "ReAct: Tool not found", 

178 extra={ 

179 "conversation_id": manager.conversation_id, 

180 "iteration": iteration + 1, 

181 "tool_name": tool_call.name, 

182 }, 

183 ) 

184 else: 

185 # Execute the tool 

186 result = await tool.execute(**tool_call.parameters) 

187 observation = f"Tool result: {result}" 

188 tool_trace["status"] = "success" 

189 tool_trace["result"] = str(result) 

190 

191 logger.log( 

192 log_level, 

193 "ReAct: Tool executed successfully", 

194 extra={ 

195 "conversation_id": manager.conversation_id, 

196 "iteration": iteration + 1, 

197 "tool_name": tool_call.name, 

198 "result_length": len(str(result)), 

199 }, 

200 ) 

201 

202 # Add observation to conversation 

203 await manager.add_message( 

204 content=f"Observation from {tool_call.name}: {observation}", 

205 role="system", 

206 ) 

207 

208 except Exception as e: 

209 # Handle tool execution errors 

210 error_msg = f"Error executing tool {tool_call.name}: {e!s}" 

211 tool_trace["status"] = "error" 

212 tool_trace["error"] = str(e) 

213 

214 logger.error( 

215 "ReAct: Tool execution failed", 

216 extra={ 

217 "conversation_id": manager.conversation_id, 

218 "iteration": iteration + 1, 

219 "tool_name": tool_call.name, 

220 "error": str(e), 

221 }, 

222 exc_info=True, 

223 ) 

224 

225 await manager.add_message(content=error_msg, role="system") 

226 

227 if trace is not None: 

228 iteration_trace["tool_calls"].append(tool_trace) 

229 

230 if trace is not None: 

231 iteration_trace["status"] = "continued" 

232 trace.append(iteration_trace) 

233 

234 # Max iterations reached, generate final response without tools 

235 logger.log( 

236 log_level, 

237 "ReAct: Max iterations reached, generating final response", 

238 extra={ 

239 "conversation_id": manager.conversation_id, 

240 "iterations_used": self.max_iterations, 

241 }, 

242 ) 

243 

244 if trace is not None: 

245 trace.append({"status": "max_iterations_reached"}) 

246 await self._store_trace(manager, trace) 

247 

248 return await manager.complete(**kwargs) 

249 

250 async def _store_trace(self, manager: Any, trace: list[dict[str, Any]]) -> None: 

251 """Store reasoning trace in conversation metadata. 

252 

253 Args: 

254 manager: ConversationManager instance 

255 trace: Reasoning trace data 

256 """ 

257 try: 

258 # Get existing metadata 

259 metadata = manager.conversation.metadata or {} 

260 

261 # Add trace to metadata 

262 metadata["reasoning_trace"] = trace 

263 

264 # Update conversation metadata 

265 await manager.storage.update_metadata( 

266 conversation_id=manager.conversation_id, 

267 metadata=metadata, 

268 ) 

269 

270 logger.debug( 

271 "ReAct: Stored reasoning trace in conversation metadata", 

272 extra={ 

273 "conversation_id": manager.conversation_id, 

274 "trace_items": len(trace), 

275 }, 

276 ) 

277 except Exception as e: 

278 logger.warning( 

279 "ReAct: Failed to store reasoning trace", 

280 extra={ 

281 "conversation_id": manager.conversation_id, 

282 "error": str(e), 

283 }, 

284 ) 

285 

286 def _find_tool(self, tool_name: str, tools: list[Any]) -> Any | None: 

287 """Find a tool by name. 

288 

289 Args: 

290 tool_name: Name of the tool to find 

291 tools: List of available tools 

292 

293 Returns: 

294 Tool instance or None if not found 

295 """ 

296 for tool in tools: 

297 if tool.name == tool_name: 

298 return tool 

299 return None