# ReAct Agent - Reasoning + Acting; planning loop with reflection and replanning. """ Loop structure per iteration: 1. Think - llm reasons about current state, picks a tool plus args 2. Act - Execute the chosen tool 3. Observe - Collect tool output 4. Reflect - Ask LLM "Am I making progress? (use a lightweight check prompt) if stuck 2+ times in a row, trigger replan 5. Repeat or return Final Answer """ from dataclasses import dataclass, field import json import os from typing import Any, Optional from openai import OpenAI import tiktoken from dotenv import load_dotenv from budget import BudgetExceededError, BudgetState from tools.code_executor import execute_code from tools.csv_analyzer import analyze_csv from tools.web_search import web_search load_dotenv() MODEL_NAME = "gpt-4o-mini" MAX_OUTPUT_TOKENS = 500 @dataclass class Step: iteration: int thought: str action: str action_input: Any observation: str was_replanned: bool = False made_progress: Optional[bool] = None @dataclass class AgentState: task: str history: list[Step] = field(default_factory=list) budget: BudgetState = field(default_factory=BudgetState) stuck_count : int = 0 final_answer: Optional[str] = None stopped_reason: Optional[str] = None def log(msg: str): print(msg) # System prompts for ReAct, Reflection and Replanning REACT_SYSTEM_PROMPT = """You are a precise, efficient AI agent with a strict resource budget. ## Your budget (CRITICAL): - You have at most {max_calls} LLM calls total for this task (including this one). - You have at most ${max_cost:.2f} total cost. - Calls used so far: {calls_used}. Remaining: {remaining_calls}. ## Available tools: 1. web_search(query: str, max_results: int = 5) — Search the web via DuckDuckGo. Use for facts, current events, lookups. 2. execute_code(code: str) — Run Python code in a sandbox. Use for calculations, data processing. 3. analyze_csv(filepath: str) — Analyze a CSV file. Returns statistics, column info, sample rows. ## ReAct format — you MUST respond in this exact JSON format: {{ "thought": "", "action": "", "action_input": "" }} ## Rules: - ALWAYS output valid JSON. Nothing outside the JSON block. - If you have enough information to answer, use action="final_answer". - Be concise — you have limited calls. Don't search for things you already know. - If a tool returns an error or empty result, try a DIFFERENT approach, not the same call again. - action_input for execute_code must be valid Python source code as a string. - action_input for web_search must be a search query string. - action_input for analyze_csv must be a file path string. - action_input for final_answer must be your complete answer as a string. ## Current conversation history: {history} """ REFLECT_SYSTEM_PROMPT = """You are evaluating whether an AI agent is making progress on a task. Task: {task} Last action taken: {action} with input: {action_input} Result: {observation} Previous stuck count: {stuck_count} Respond ONLY with valid JSON: {{"made_progress": true/false, "reason": "", "suggestion": ""}} made_progress = false if: - The tool returned an error and retrying the same thing won't help - The result was empty and a different query is needed - The agent is clearly going in circles - The tool result is irrelevant to the task """ REPLAN_SYSTEM_PROMPT = """You are helping an AI agent replan after getting stuck. Task: {task} Stuck count: {stuck_count} History so far: {history} The agent has been stuck — its last {stuck_count} steps made no progress. Suggest a completely DIFFERENT strategy. Respond in this exact JSON format: {{ "thought": "", "action": "", "action_input": "" }} """ # history formatting def format_history(steps: list[Step]) -> str: if not steps: return "No steps taken yet." lines = [] for s in steps: lines.append(f"[Step {s.iteration}]") lines.append(f" Thought: {s.thought}") lines.append(f" Action: {s.action}({repr(s.action_input)})") lines.append(f" Observation: {s.observation[:400]}") if s.was_replanned: lines.append(" *** REPLANNED ***") lines.append("") return "\n".join(lines) # LLM call wrapper def call_llm(client: OpenAI, system: str, user: str, budget: BudgetState, step_summary: str = "") -> dict: """ Make one OpenAI chat completion call. Records token usage into BudgetState — raises BudgetExceededError if limits hit. Returns parsed JSON dict from the assistant's message. """ # calculate estimated cost of this call encoding = tiktoken.encoding_for_model(MODEL_NAME) input_tokens = len(encoding.encode(user+system)) estimated_cost = (input_tokens / 1000) * budget.cost_per_1k_input + (MAX_OUTPUT_TOKENS / 1000) * budget.cost_per_1k_output log(f"Estimated cost of this call: ${estimated_cost:.6f}") budget.estimated_cost = estimated_cost if budget.calls_used >= budget.max_calls: log(f"Budget exceeded: {budget.calls_used}/{budget.max_calls} calls used") raise BudgetExceededError( reason=f"Call count limit exceeded ({budget.calls_used}/{budget.max_calls})", calls_used=budget.calls_used, cost_used=budget.cost_used, estimated_cost=estimated_cost, completed_steps=budget.completed_steps ) if (budget.max_cost - budget.cost_used - estimated_cost) < 0: reason = f"Budget expected to exceed: Used: ${budget.cost_used:.6f} + Expected: ${estimated_cost:.6f} = Total: ${budget.cost_used + estimated_cost:.6f}/ Max: ${budget.max_cost:.6f} cost used." raise BudgetExceededError( reason=reason, calls_used=budget.calls_used, cost_used=budget.cost_used, estimated_cost=estimated_cost, completed_steps=budget.completed_steps ) response = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": system}, {"role": "user", "content": user}, ], temperature=0, response_format={"type": "json_object"}, max_tokens=1000, ) usage = response.usage budget.record_call( input_tokens=usage.prompt_tokens, output_tokens=usage.completion_tokens, estimated_cost=estimated_cost, step_summary=step_summary or f"LLM call ({usage.prompt_tokens}in/{usage.completion_tokens}out tokens)", ) log(f"LLM call used {usage.prompt_tokens} input tokens and {usage.completion_tokens} output tokens. Total cost so far: ${budget.cost_used:.6f}") content = response.choices[0].message.content return json.loads(content) # tool dispatcher def dispatch_tool(action: str, action_input: str) -> str: """Run the named tool and return a string observation.""" if action == "web_search": result = web_search(action_input) if not result["success"]: return f"ERROR: {result['error']}" if not result["results"]: return "No results found for this query." lines = [] for i, r in enumerate(result["results"], 1): lines.append(f"{i}. {r['title']}\n {r['href']}\n {r['body']}") return "\n\n".join(lines) elif action == "execute_code": result = execute_code(action_input) if not result["success"]: out = f"ERROR (exit {result['exit_code']}): {result['error']}" if result["stderr"]: out += f"\nStderr:\n{result['stderr']}" return out output = result["stdout"] or "(no output)" if result["stderr"]: output += f"\nStderr: {result['stderr']}" return output elif action == "analyze_csv": result = analyze_csv(action_input) if not result["success"]: return f"ERROR: {result['error']}" return json.dumps(result, indent=2) else: return f"Unknown tool: {action}" # Main agent MAX_ITERATIONS = 10 def run_agent(task: str, max_iterations: int = MAX_ITERATIONS, max_cost: float = None) -> AgentState: """ Run the ReAct agent on a task. Returns AgentState with complete history, budget usage, and final answer. BudgetExceededError is caught here and stored in state.stopped_reason. """ client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) state = None log(f"\n{'='*60}") log(f"TASK: {task}") log(f"{'='*60}") # using larger max iterations if max_cost is set just to showcase budget stop if max_iterations is None and max_cost is not None: estimated_cost_per_call = 0.0001 # rough estimate max_iterations = int(max_cost / estimated_cost_per_call) elif max_iterations is None and max_cost is None: max_iterations = MAX_ITERATIONS max_cost = 0.20 if max_cost is None: max_cost = 0.20 state = AgentState(task=task, budget=BudgetState(max_cost=max_cost, max_calls=max_iterations)) print(f"Starting agent with max_iterations={max_iterations} and max_cost={max_cost}") print(f"Initial budget state: {state.budget.summary()}") try: for iteration in range(1, max_iterations + 1): # get thought and action from LLM system_prompt = REACT_SYSTEM_PROMPT.format( max_calls=state.budget.max_calls, max_cost=state.budget.max_cost, calls_used=state.budget.calls_used, remaining_calls=state.budget.remaining_calls(), history=format_history(state.history) ) react_response = call_llm( client=client, system=system_prompt, user=f"Task: {task}\n\n What is your next action?", budget=state.budget, step_summary=f"Step {iteration}: Think" ) log(f"\n[Iteration {iteration}] | ReAct | {state.budget.summary()}") thought = react_response.get("thought", "") action = react_response.get("action", "") action_input = react_response.get("action_input", "") log(f"Thought: {thought}") log(f"Action: {action}({repr(str(action_input)[:100])})") # check final answer if action == "final_answer": state.final_answer = str(action_input) state.stopped_reason = "answer" state.history.append(Step( iteration=iteration, thought=thought, action=action, action_input=action_input, observation="[Task complete]", made_progress=True )) log(f"\nFINAL ANSWER: {state.final_answer}") break # dispatch the tool observation = dispatch_tool(action, str(action_input)) log(f"Observation: {observation[:300]}") # reflect, did we make progress? reflect_system_prompt = REFLECT_SYSTEM_PROMPT.format( task=task, action=action, action_input=str(action_input)[:200], observation=observation[:400], stuck_count=state.stuck_count ) reflect_response = call_llm( client=client, system=reflect_system_prompt, user="Evaluate progress.", budget=state.budget, step_summary=f"Step {iteration}: Reflect" ) log(f"\n[Iteration {iteration}] | Reflection | {state.budget.summary()}") made_progress = reflect_response.get("made_progress", True) reflect_reason = reflect_response.get("reason", "") reflect_suggestion = reflect_response.get("suggestion", "") log(f"Progress: {made_progress} | Reason: {reflect_reason} | Suggestion: {reflect_suggestion}") was_replanned = False if not made_progress: state.stuck_count += 1 log(f"Agent is stuck. Stuck count: {state.stuck_count}") log(f"Agent was stuck because: {reflect_reason}") log(f"Reflection suggestion: {reflect_suggestion}") if state.stuck_count >= 1: log(f"Triggering replanning due to agent.") replan_system_prompt = REPLAN_SYSTEM_PROMPT.format( task=task, stuck_count=state.stuck_count, history=format_history(state.history) ) replan_response = call_llm( client=client, system=replan_system_prompt, user="Provide a new plan.", budget=state.budget, step_summary=f"Step {iteration}: Replan" ) log(f"\n[Iteration {iteration}] | Replanning | {state.budget.summary()}") # overwrite thought/action/input with the new plan thought = replan_response.get("thought", thought) action = replan_response.get("action", action) action_input = replan_response.get("action_input", action_input) was_replanned = True state.stuck_count = 0 log(f"Replanned -> {action}({repr(str(action_input)[:100])}) ") # executer replanned action if action == "final_answer": state.final_answer = str(action_input) state.stopped_reason = "answer" state.history.append(Step( iteration=iteration, thought=thought, action=action, action_input=action_input, observation="[Task complete]", was_replanned=was_replanned, made_progress=True )) log(f"\nFINAL ANSWER: {state.final_answer}") break observation = dispatch_tool(action, str(action_input)) log(f"Replanned Observation: {observation[:300]}") else: state.stuck_count = 0 state.history.append(Step( iteration=iteration, thought=thought, action=action, action_input=action_input, observation=observation, was_replanned=was_replanned, made_progress=made_progress )) else: state.stopped_reason = "max_iterations" log(f"\nStopped: reached max iterations ({max_iterations}) without a final answer.") except BudgetExceededError as bee: state.stopped_reason = "budget_exceeded" state.final_answer = None log(f"\n{bee}") log(f"Completed steps before budget exceeded: {bee.completed_steps}") log(f"\n{'='*60}") log(f"DONE | Reason: {state.stopped_reason} | {state.budget.summary()}") log(f"{'='*60}\n") return state