| |
|
|
| """ |
| 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) |
|
|
|
|
| |
|
|
| 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": "<your reasoning about the current state and what to do next>", |
| "action": "<web_search | execute_code | analyze_csv | final_answer>", |
| "action_input": "<string argument for the tool, OR your final answer text>" |
| }} |
| |
| ## 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": "<one sentence>", "suggestion": "<if not progressing, what should the agent try instead?>"}} |
| |
| 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": "<analysis of why the agent got stuck and a different approach to try>", |
| "action": "<web_search | execute_code | analyze_csv | final_answer>", |
| "action_input": "<new approach argument>" |
| }} |
| """ |
|
|
| |
| 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) |
|
|
| |
| 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. |
| """ |
|
|
| |
| 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) |
|
|
| |
| 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}" |
| |
|
|
| |
|
|
| 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}") |
|
|
| |
| if max_iterations is None and max_cost is not None: |
| estimated_cost_per_call = 0.0001 |
| 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): |
| |
| 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])})") |
|
|
| |
| 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 |
|
|
| |
| observation = dispatch_tool(action, str(action_input)) |
| log(f"Observation: {observation[:300]}") |
|
|
| |
| 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()}") |
|
|
| |
| 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])}) ") |
|
|
| |
| 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 |
|
|