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

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

2

3 import logging

4 from typing import Any

5

6 from .base import ReasoningStrategy

7

8 logger = logging.getLogger(__name__)

9

10

11 class 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