gabejavitt commited on
Commit
d5d54b8
·
verified ·
1 Parent(s): 836bf02

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +335 -239
app.py CHANGED
@@ -5,12 +5,12 @@ import inspect
5
  import pandas as pd
6
  import io
7
  import contextlib
8
- from typing import TypedDict, Annotated
 
9
  import torch
10
- import json # For robust tool call parsing/generation if needed
11
- import re # For finding JSON
12
- import uuid # For generating tool call IDs
13
- import traceback
14
 
15
  # --- Multimodal & Web Tool Imports ---
16
  from transformers import pipeline
@@ -20,21 +20,18 @@ from bs4 import BeautifulSoup
20
 
21
  # --- LangChain & LangGraph Imports ---
22
  from langgraph.graph.message import add_messages
23
- # Make sure to import ToolCall
24
- from langchain_core.messages import AnyMessage, HumanMessage, AIMessage, ToolMessage, SystemMessage, ToolCall
25
  from langgraph.prebuilt import ToolNode
26
  from langgraph.graph import START, END, StateGraph
27
- # Removed tools_condition, we'll use a custom one
28
  from langchain_community.tools import DuckDuckGoSearchRun
29
  from langchain_core.tools import tool, BaseTool
30
- # --- ADD GROQ IMPORT ---
31
  from langchain_groq import ChatGroq
32
 
33
- # (Keep Constants as is)
34
  # --- Constants ---
35
- DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space" # This URL is currently not working
 
36
 
37
- # --- Initialize ASR Pipeline (Moved back to Global Scope) ---
38
  asr_pipeline = None
39
  try:
40
  print("Loading ASR (Whisper) pipeline globally...")
@@ -50,26 +47,43 @@ try:
50
  print("✅ ASR (Whisper) pipeline loaded successfully.")
51
  except Exception as e:
52
  print(f"⚠️ Warning: Could not load ASR pipeline globally. Error: {e}")
53
- import traceback
54
  traceback.print_exc()
55
  asr_pipeline = None
56
- # ====================================================
57
 
 
58
  # --- Tool Definitions (Standalone Functions) ---
59
-
60
  @tool
61
  def search_tool(query: str) -> str:
62
  """Calls DuckDuckGo search and returns the results. Use this for recent information or general web searches."""
 
 
 
 
63
  print(f"--- Calling Search Tool with query: {query} ---")
64
  try:
65
  search = DuckDuckGoSearchRun()
66
  return search.run(query)
67
  except Exception as e:
68
- return f"Error running search: {e}"
 
 
 
69
 
70
  @tool
71
  def code_interpreter(code: str) -> str:
72
- """Executes Python code..."""
 
 
 
 
 
 
 
 
 
 
 
 
73
  print(f"--- Calling Code Interpreter with code:\n{code}\n---")
74
  output_stream = io.StringIO()
75
  error_stream = io.StringIO()
@@ -80,140 +94,186 @@ def code_interpreter(code: str) -> str:
80
  "__builtins__": __builtins__
81
  }
82
  exec(code, safe_globals, {})
83
-
84
  stdout = output_stream.getvalue(); stderr = error_stream.getvalue()
85
  if stderr: return f"Error: {stderr}\nStdout: {stdout}"
86
  if stdout: return f"Success:\n{stdout}"
87
  return "Success: Code executed without error and produced no stdout."
88
-
89
  except Exception as e:
90
- # --- THIS IS THE IMPROVEMENT ---
91
- # Get the full traceback string
92
  tb_str = traceback.format_exc()
93
  print(f"--- Code Interpreter FAILED ---\n{tb_str}\n---")
94
  return f"Execution failed with error:\n{tb_str}"
95
- # --- END IMPROVEMENT ---
96
-
97
  @tool
98
  def read_file(path: str) -> str:
99
- """Reads the content of a file at the specified path. Use this to examine files provided in the question."""
 
 
 
 
100
  print(f"--- Calling Read File Tool at path: {path} ---")
101
  try:
 
102
  script_dir = os.getcwd()
103
  print(f"Base directory for reading: {script_dir}")
104
- full_path = os.path.join(script_dir, path)
 
105
  print(f"Attempting to read relative path: {full_path}")
106
  if not os.path.exists(full_path):
107
- full_path = path
108
  print(f"Attempting to read direct/absolute path: {full_path}")
109
  if not os.path.exists(full_path):
110
- base_path = os.path.basename(path)
111
  cwd_base_path = os.path.join(os.getcwd(), base_path)
112
  print(f"Attempting to read basename path in CWD: {cwd_base_path}")
113
- if os.path.exists(cwd_base_path): full_path = cwd_base_path
 
114
  else:
 
115
  try: cwd_files = os.listdir(".")
116
  except Exception as list_e: cwd_files = [f"Error listing CWD: {list_e}"]
117
  return (f"Error: File not found.\n"
118
- f"Tried relative: '{os.path.join(script_dir, path)}'\n"
119
- f"Tried direct/absolute: '{path}'\n"
120
  f"Tried basename in CWD: '{cwd_base_path}'\n"
121
  f"Files in CWD (.): {cwd_files}")
122
  print(f"Reading file: {full_path}")
123
- with open(full_path, 'r', encoding='utf-8') as f: return f.read()
124
- except Exception as e: return f"Error reading file {path}: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
 
 
126
  @tool
127
  def write_file(path: str, content: str) -> str:
128
  """Writes the given content to a file at the specified path relative to the app's current directory. Creates directories if they don't exist."""
 
 
129
  print(f"--- Calling Write File Tool at path: {path} ---")
130
  try:
131
- base_dir = os.getcwd()
132
- full_path = os.path.join(base_dir, path)
133
- print(f"Writing file to: {full_path}")
134
- os.makedirs(os.path.dirname(full_path), exist_ok=True)
135
  with open(full_path, 'w', encoding='utf-8') as f: f.write(content)
136
  return f"Successfully wrote to file {path} (relative to CWD)."
137
- except Exception as e: return f"Error writing to file {path}: {str(e)}"
 
138
 
139
  @tool
140
  def list_directory(path: str = ".") -> str:
141
  """Lists the contents (files and directories) of a directory at the specified path relative to the app's current directory."""
 
142
  print(f"--- Calling List Directory Tool at path: {path} ---")
143
  try:
144
- base_dir = os.getcwd()
145
- full_path = os.path.join(base_dir, path)
146
  print(f"Listing directory: {full_path}")
147
- if not os.path.isdir(full_path): return f"Error: '{path}' is not a valid directory."
148
  files = os.listdir(full_path); return "\n".join(files) if files else "Directory is empty."
149
- except Exception as e: return f"Error listing directory {path}: {str(e)}"
 
 
150
 
151
  @tool
152
  def audio_transcription_tool(file_path: str) -> str:
153
  """Transcribes an audio file (like .mp3 or .wav) and returns the text content."""
 
154
  print(f"--- Calling Audio Transcription: {file_path} ---")
155
  if asr_pipeline is None: return "Error: ASR pipeline unavailable."
156
  try:
157
- script_dir = os.getcwd()
158
- full_path = os.path.join(script_dir, file_path)
159
  if not os.path.exists(full_path):
160
  full_path = file_path
161
  if not os.path.exists(full_path):
162
  base_path = os.path.basename(file_path)
163
  cwd_base_path = os.path.join(os.getcwd(), base_path)
164
  if os.path.exists(cwd_base_path): full_path = cwd_base_path
165
- else: return f"Error: Audio file not found."
 
166
  transcription = asr_pipeline(full_path)
167
- return transcription.get("text", "Error: Transcription failed.")
168
- except Exception as e: import traceback; traceback.print_exc(); return f"Error transcribing: {e}"
 
 
169
 
170
  @tool
171
  def get_youtube_transcript(video_url: str) -> str:
172
- """Fetches YouTube transcript."""
 
173
  print(f"--- Calling YouTube Transcript: {video_url} ---")
174
  try:
175
  video_id = None
176
  if "watch?v=" in video_url: video_id = video_url.split("v=")[1].split("&")[0]
177
  elif "youtu.be/" in video_url: video_id = video_url.split("youtu.be/")[1].split("?")[0]
178
- if not video_id: return f"Error: Invalid YouTube URL."
179
  transcript_list = YouTubeTranscriptApi.get_transcript(video_id)
 
180
  full_transcript = " ".join([item["text"] for item in transcript_list])
181
- return full_transcript[:8000]
182
- except Exception as e: return f"Error getting transcript: {e}"
183
 
184
  @tool
185
  def scrape_web_page(url: str) -> str:
186
- """Fetches primary text content of a webpage."""
 
 
 
187
  print(f"--- Calling Web Scraper: {url} ---")
188
  try:
189
- headers = {'User-Agent': 'Mozilla/5.0'}
190
- response = requests.get(url, headers=headers, timeout=15); response.raise_for_status()
191
- if 'html' not in response.headers.get('Content-Type', '').lower(): return f"Error: Not HTML."
 
192
  soup = BeautifulSoup(response.text, 'html.parser')
193
- for tag in soup(["script", "style", "nav", "footer", "aside", "header", "form", "button", "input"]): tag.extract()
194
- main_content = soup.find('main') or soup.find('article') or soup.find('div', role='main') or soup.body or soup
 
 
195
  text = main_content.get_text(separator='\n', strip=True)
196
  lines = (line.strip() for line in text.splitlines()); chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
197
  text = '\n'.join(chunk for chunk in chunks if chunk)
198
- return text[:8000]
199
- except Exception as e: return f"Error scraping {url}: {e}"
 
 
 
 
200
 
201
- # +++++++++++++++++++ NEW FINAL ANSWER TOOL +++++++++++++++++++
202
  @tool
203
  def final_answer_tool(answer: str) -> str:
204
  """
205
- Call this tool *only* when you have the final, definitive answer to the user's question.
206
- The 'answer' argument should be the single, concise, factual answer, formatted exactly as requested by the user's prompt.
207
  """
 
 
 
 
 
 
208
  print(f"--- AGENT CALLING FINAL ANSWER TOOL ---")
209
- return answer
210
- # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
211
 
212
- # --- Helper Function for Cleaning Fences ---
213
  def remove_fences_simple(text):
214
- """Removes triple backtick fences and optional language identifiers."""
215
- original_text = text
216
- text = text.strip()
217
  if text.startswith("```") and text.endswith("```"):
218
  text = text[3:-3].strip()
219
  if '\n' in text:
@@ -222,7 +282,6 @@ def remove_fences_simple(text):
222
  text = rest.strip()
223
  return text
224
  return original_text
225
- # --- End Helper ---
226
 
227
  # List of standalone tool functions
228
  defined_tools = [
@@ -234,221 +293,258 @@ defined_tools = [
234
  audio_transcription_tool,
235
  get_youtube_transcript,
236
  scrape_web_page,
237
- final_answer_tool # Add the new tool to the list
238
- ]
239
 
240
  # --- LangGraph Agent State ---
241
  class AgentState(TypedDict):
242
- messages: Annotated[list[AnyMessage], add_messages]
 
243
 
 
244
  def should_continue(state: AgentState):
245
  """
246
- Custom logic to decide whether to continue or end.
247
- This now allows for a "reasoning loop".
248
  """
249
  last_message = state['messages'][-1]
250
-
251
- if isinstance(last_message, AIMessage):
252
- if last_message.tool_calls:
253
- # Check for the final answer tool
254
- if last_message.tool_calls[0].get("name") == "final_answer_tool":
255
- print("--- Condition: Saw final_answer_tool, ending graph. ---")
256
- return END
257
- else:
258
- # Any other tool call goes to the tools node
259
- print("--- Condition: Saw other tools, calling tools node. ---")
260
- return "tools"
261
-
262
- # --- THIS IS THE KEY CHANGE ---
263
- # If the last message is from the AI and has NO tool calls (i.e., it's plain text),
264
- # loop back to the agent node to let it "think" again.
265
- print("--- Condition: No tool call. Looping back to agent (reasoning loop). ---")
 
 
 
 
 
 
266
  return "agent"
267
-
268
  # --- Basic Agent Definition ---
269
- # ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
270
  class BasicAgent:
271
-
272
  def __init__(self):
273
  print("BasicAgent (LangGraph) initializing...")
274
-
275
  GROQ_API_KEY = os.getenv("GROQ_API_KEY")
276
  if not GROQ_API_KEY: raise ValueError("GROQ_API_KEY secret is not set!")
277
- HUGGINGFACEHUB_API_TOKEN = os.getenv("HUGGINGFACEHUB_API_TOKEN")
278
- if not HUGGINGFACEHUB_API_TOKEN: print("⚠️ Warning: HUGGINGFACEHUB_API_TOKEN secret not set.")
279
 
280
  self.tools = defined_tools
281
  tool_descriptions = "\n".join([
282
  f"- {tool.name}: {tool.description}" if tool.name != 'code_interpreter' else
283
- (f"- {tool.name}: Executes Python code. Use for calculations, data manipulation, or logic puzzles. "
284
- "**When solving logic puzzles, write out your reasoning steps as comments in the code.** "
285
- "'pandas' (as pd) is available.")
 
 
 
 
286
  for tool in self.tools
287
  ])
288
 
289
- # ==================== MODIFIED SYSTEM PROMPT ====================
290
  self.system_prompt = f"""You are a highly intelligent and meticulous AI assistant for the GAIA benchmark.
291
  Your goal is to provide the concise, factual answer by strictly following a step-by-step reasoning process.
292
 
293
  **CRITICAL PROTOCOL: YOU MUST FOLLOW THIS PROCESS**
294
 
295
  1. **ANALYZE:** Read the question and all messages in the history.
296
- 2. **PLAN:** Your *first* response on any new task MUST be a step-by-step plan as plain text. Do NOT call a tool on your first turn. Write down your logic, what you need to find, and which tool you *plan* to use.
297
- 3. **EXECUTE:** After you submit your plan, you will run again. Now, execute the *first* step of your plan by calling the *one* appropriate tool.
298
- 4. **ANALYZE TOOL OUTPUT:** You will receive a [Tool Output] message. You MUST read it.
299
  5. **REPEAT or FINISH:**
300
- * **If more steps are needed:** Go back to step 2 (PLAN). Write an *updated* plan as plain text (e.g., "Step 1 was successful. My new step 2 is...").
301
- * **If the [Tool Output] contains the final answer:** You MUST call the `final_answer_tool`. Your answer *must* be derived *only* from the [Tool Output], not your own knowledge.
302
 
303
  **RULES:**
304
- * **NEVER** call a tool on the same turn you write a plan.
305
- * **NEVER** use your pre-trained "leaked" knowledge for the final answer. The answer *must* come from a [Tool Output] (e.g., from `code_interpreter`'s print() or `search_tool`).
306
- * **NEVER** answer a logic puzzle from memory. You *must* use `code_interpreter`, **print the result**, and then use that printed result for your final answer.
307
- * **NEVER** call `final_answer_tool` until a tool has given you the answer.
308
- * **Error Handling:** If a tool call fails, your next step (Step 2) must be to write a plan that analyzes the error and tries a *different* approach.
309
 
310
  **TOOLS:**
311
  {tool_descriptions}
312
 
313
- - code_interpreter: Executes Python code.
314
- **CODE INTERPRETER RULES:**
315
- 1. **ALWAYS** use a `print()` statement to output your final result. The tool only returns what you print.
316
- 2. **NEVER** write a complex, multi-step script in one go.
317
- 3. **ALWAYS** break the problem down. Call the tool with a *simple* script to get one piece of information (e.g., `print(df.head())`).
318
- 4. Then, use that output (in your "think" step) to plan your *next* simple script (e.g., `print(df['column'].value_counts())`).
319
- 5. **ALWAYS** write your logical plan as Python comments (`#`) inside the code block *before* you write the code itself.
320
-
321
- **REASONING PROCESS & STOPPING CONDITION:**
322
- 1. **PLAN:** First, respond with your step-by-step plan in plain text. Do not call a tool yet.
323
- 2. **(Graph will loop)**
324
- 3. **EXECUTE:** Now, call the *one* tool needed for the first step of your plan.
325
- 4. **ANALYZE:** You will get a [Tool Output].
326
- 5. **REPEAT:** Go back to step 1. Write an updated plan (e.g., "Step 1 was successful and gave me [data]. My step 2 is...").
327
- 6. **STOP:** Only call `final_answer_tool` when a [Tool Output] has given you the final, exact answer.
328
  **TOOL FORMAT (JSON ONLY):**
329
- ```json
330
- {{
331
- "tool": "tool_name",
332
- "tool_input": {{ "arg_name1": "value1", ... }}
333
- }}
334
- ```
 
335
  * Replace `tool_name` with the tool's name. Provide arguments in `tool_input`. Match names/types precisely.
336
  * Do not add any text before or after the JSON block.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
- **REASONING PROCESS & STOPPING CONDITION:**
339
- 1. **Analyze:** Read the question. Break it down into logical steps.
340
- 2. **DECIDE:** Do you have enough information to call a tool, or do you need to write down your plan first?
341
- 3. **ACT (Two Options):**
342
- a. **Write Plan (Chain of Thought):** If you are not ready to call a tool, or if the problem is a complex logic puzzle, respond with your step-by-step reasoning plan as **plain text**. This allows you to "think" and add your plan to memory before your next step.
343
- b. **Call Tool:** If you are ready, call **one** tool using the JSON format.
344
- 4. **Analyze Output:** After a tool is called, you will receive its output.
345
- 5. **GOTO 1:** Repeat the process. Analyze the new information and decide your next step (think, or call another tool).
346
- 6. **STOPPING:** The *only* way to provide the final answer is by calling `final_answer_tool`.
347
- 7. **FINAL OUTPUT:** The graph will stop *only* when you call `final_answer_tool`. Do not provide the answer in any other way."""
348
- # =============================================================
349
-
350
- print("Initializing Groq LLM Endpoint...")
351
- try:
352
- chat_llm = ChatGroq(
353
- temperature=0.01,
354
- groq_api_key=GROQ_API_KEY,
355
- model_name="openai/gpt-oss-120b" # <-- Your change is here
356
- )
357
- print("✅ Groq LLM Endpoint initialized with llama-3.1-8b-instant.")
358
- except Exception as e: print(f"Error initializing Groq: {e}"); raise
359
-
360
- self.llm_with_tools = chat_llm.bind_tools(self.tools)
361
- print("✅ Tools bound to LLM (using bind_tools).")
362
-
363
- def agent_node(state: AgentState):
364
- print("--- Running Agent Node ---")
365
- ai_message: AIMessage = self.llm_with_tools.invoke(state["messages"])
366
- print(f"AI Message Raw Content: {ai_message.content}")
367
- if ai_message.tool_calls: print(f"AI tool calls via bind_tools: {ai_message.tool_calls}")
368
- elif ai_message.invalid_tool_calls: print(f"AI INVALID tool calls via bind_tools: {ai_message.invalid_tool_calls}")
369
- else: print(f"AI content (no calls): {ai_message.pretty_repr()}")
370
- return {"messages": [ai_message]}
371
-
372
- tool_node = ToolNode(self.tools)
373
-
374
- print("Building agent graph...")
375
- graph_builder = StateGraph(AgentState)
376
- graph_builder.add_node("agent", agent_node)
377
- graph_builder.add_node("tools", tool_node)
378
- graph_builder.add_edge(START, "agent")
379
- graph_builder.add_edge("tools", "agent") # This edge is correct
380
- # --- REPLACE your old add_conditional_edges ---
381
- graph_builder.add_conditional_edges(
382
- "agent",
383
- should_continue,
384
- {
385
- "tools": "tools", # If tools are called
386
- "agent": "agent", # If text is generated (the new loop)
387
- END: END # If final_answer is called
388
- })
389
- self.graph = graph_builder.compile()
390
- print("✅ Graph compiled.")
391
-
392
- # ++++++++++++++++++++ __call__ METHOD ++++++++++++++++++++
393
- def __call__(self, question: str) -> str:
394
- print(f"\n--- Starting Agent Run for Question ---")
395
- print(f"Agent received question (first 100 chars): {question[:100]}...")
396
-
397
- graph_input = {"messages": [
398
  SystemMessage(content=self.system_prompt),
399
  HumanMessage(content=question)
400
- ]}
401
-
402
- final_answer = "AGENT FAILED TO PRODUCE ANSWER" # Default answer
 
 
 
 
 
403
 
404
- try:
405
- for event in self.graph.stream(graph_input, stream_mode="values", config={"recursion_limit": 25}):
406
- last_message = event["messages"][-1]
407
-
408
- if isinstance(last_message, AIMessage) and last_message.tool_calls:
409
- for tool_call in last_message.tool_calls:
410
- if tool_call.get("name") == "final_answer_tool":
411
- final_answer = tool_call['args'].get('answer', "ERROR: FINAL_ANSWER_TOOL CALLED WITHOUT ANSWER")
412
- print(f"--- Final Answer Captured from tool call: '{final_answer}' ---")
413
- break
414
-
415
- elif isinstance(last_message, ToolMessage):
416
- print(f"Tool Result ({last_message.tool_call_id}): {last_message.content[:500]}...")
417
- elif isinstance(last_message, AIMessage) and not last_message.tool_calls:
418
- # This might be an error or the agent failing to call final_answer_tool
419
- print(f"AI Message (no tool call): {last_message.content[:500]}...")
420
- # We store this in case the graph ends here, but it's not the ideal path
421
- if isinstance(last_message.content, str) and last_message.content.strip():
422
- final_answer = last_message.content # Fallback
423
-
424
- # --- Cleaning step (for the final answer, wherever it came from) ---
425
- cleaned_answer = str(final_answer).strip() # Ensure it's a string
426
-
427
- prefixes_to_remove = ["The answer is:", "Here is the answer:", "Based on the information:", "Final Answer:", "Answer:"]
428
- original_cleaned = cleaned_answer
429
- for prefix in prefixes_to_remove:
430
- if cleaned_answer.lower().startswith(prefix.lower()):
431
- potential_answer = cleaned_answer[len(prefix):].strip()
432
- if potential_answer: cleaned_answer = potential_answer; break
433
- if cleaned_answer == original_cleaned and any(cleaned_answer.lower().startswith(p.lower()) for p in prefixes_to_remove):
434
- print(f"Warning: Prefix found but not stripped: '{original_cleaned[:100]}...'")
435
-
436
- looks_like_code = any(kw in cleaned_answer for kw in ["def ", "import ", "print(", "for ", "while ", "if ", "class ", "=>", "dict(", "list["]) or cleaned_answer.count('\n') > 3 or (cleaned_answer.startswith('[') and cleaned_answer.endswith(']')) or (cleaned_answer.startswith('{') and cleaned_answer.endswith('}'))
437
 
438
- if not looks_like_code:
439
- # ++++++++++++++++ USING remove_fences_simple ++++++++++++++++
440
- cleaned_answer = remove_fences_simple(cleaned_answer) # Use the helper function
441
- # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
442
- if cleaned_answer.startswith("`") and cleaned_answer.endswith("`"):
443
- cleaned_answer = cleaned_answer[1:-1].strip()
444
-
445
- print(f"Agent returning final answer (cleaned): '{cleaned_answer}'")
446
- return cleaned_answer # Return the cleaned answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
 
448
- except Exception as e:
449
- print(f"Error running agent graph: {e}")
450
- import traceback; traceback.print_exc()
451
- return f"AGENT GRAPH ERROR: {e}"
 
 
 
 
452
  # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
453
 
454
 
 
5
  import pandas as pd
6
  import io
7
  import contextlib
8
+ import traceback # <-- Added for detailed errors
9
+ from typing import TypedDict, Annotated, List # <-- Added List
10
  import torch
11
+ import json
12
+ import re # <-- Added for robust parsing
13
+ import uuid # <-- Added for robust parsing
 
14
 
15
  # --- Multimodal & Web Tool Imports ---
16
  from transformers import pipeline
 
20
 
21
  # --- LangChain & LangGraph Imports ---
22
  from langgraph.graph.message import add_messages
23
+ from langchain_core.messages import AnyMessage, HumanMessage, AIMessage, ToolMessage, SystemMessage, ToolCall # <-- Ensure ToolCall is imported
 
24
  from langgraph.prebuilt import ToolNode
25
  from langgraph.graph import START, END, StateGraph
 
26
  from langchain_community.tools import DuckDuckGoSearchRun
27
  from langchain_core.tools import tool, BaseTool
 
28
  from langchain_groq import ChatGroq
29
 
 
30
  # --- Constants ---
31
+ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space" # Still not working
32
+ MAX_TURNS = 15 # <-- Added turn limit
33
 
34
+ # --- Initialize ASR Pipeline (Keep as is) ---
35
  asr_pipeline = None
36
  try:
37
  print("Loading ASR (Whisper) pipeline globally...")
 
47
  print("✅ ASR (Whisper) pipeline loaded successfully.")
48
  except Exception as e:
49
  print(f"⚠️ Warning: Could not load ASR pipeline globally. Error: {e}")
 
50
  traceback.print_exc()
51
  asr_pipeline = None
 
52
 
53
+ # ====================================================
54
  # --- Tool Definitions (Standalone Functions) ---
 
55
  @tool
56
  def search_tool(query: str) -> str:
57
  """Calls DuckDuckGo search and returns the results. Use this for recent information or general web searches."""
58
+ # --- Input Validation ---
59
+ if not isinstance(query, str) or not query.strip():
60
+ return "Error: Invalid input. 'query' must be a non-empty string."
61
+ # --- End Validation ---
62
  print(f"--- Calling Search Tool with query: {query} ---")
63
  try:
64
  search = DuckDuckGoSearchRun()
65
  return search.run(query)
66
  except Exception as e:
67
+ # --- Granular Error ---
68
+ tb_str = traceback.format_exc()
69
+ print(f"--- Search Tool FAILED ---\n{tb_str}\n---")
70
+ return f"Error running search for '{query}': {str(e)}\nTraceback:\n{tb_str}"
71
 
72
  @tool
73
  def code_interpreter(code: str) -> str:
74
+ """
75
+ Executes a string of Python code and returns its stdout, stderr, and any error.
76
+ Use for calculations, data manipulation (pandas), logic puzzles.
77
+ RULES:
78
+ 1. ALWAYS use print() for final results.
79
+ 2. Write simple, single-step scripts. Use plan text output to plan next steps.
80
+ 3. Write reasoning as Python comments (#) before code.
81
+ 'pandas' (as pd) is available.
82
+ """
83
+ # --- Input Validation ---
84
+ if not isinstance(code, str): # Basic check, could add more (e.g., length)
85
+ return "Error: Invalid input. 'code' must be a string."
86
+ # --- End Validation ---
87
  print(f"--- Calling Code Interpreter with code:\n{code}\n---")
88
  output_stream = io.StringIO()
89
  error_stream = io.StringIO()
 
94
  "__builtins__": __builtins__
95
  }
96
  exec(code, safe_globals, {})
 
97
  stdout = output_stream.getvalue(); stderr = error_stream.getvalue()
98
  if stderr: return f"Error: {stderr}\nStdout: {stdout}"
99
  if stdout: return f"Success:\n{stdout}"
100
  return "Success: Code executed without error and produced no stdout."
 
101
  except Exception as e:
102
+ # --- Granular Error with Traceback ---
 
103
  tb_str = traceback.format_exc()
104
  print(f"--- Code Interpreter FAILED ---\n{tb_str}\n---")
105
  return f"Execution failed with error:\n{tb_str}"
106
+
 
107
  @tool
108
  def read_file(path: str) -> str:
109
+ """Reads the content of a file at the specified path relative to the app's CWD. Use this to examine files provided."""
110
+ # --- 1. Stricter Input Validation ---
111
+ if not isinstance(path, str) or not path.strip():
112
+ return "Error: Invalid input. 'path' must be a non-empty string."
113
+ # --- End Validation ---
114
  print(f"--- Calling Read File Tool at path: {path} ---")
115
  try:
116
+ # --- Path Finding Logic ---
117
  script_dir = os.getcwd()
118
  print(f"Base directory for reading: {script_dir}")
119
+ safe_path = os.path.normpath(path) # Normalize path
120
+ full_path = os.path.join(script_dir, safe_path)
121
  print(f"Attempting to read relative path: {full_path}")
122
  if not os.path.exists(full_path):
123
+ full_path = safe_path # Try direct/absolute
124
  print(f"Attempting to read direct/absolute path: {full_path}")
125
  if not os.path.exists(full_path):
126
+ base_path = os.path.basename(safe_path)
127
  cwd_base_path = os.path.join(os.getcwd(), base_path)
128
  print(f"Attempting to read basename path in CWD: {cwd_base_path}")
129
+ if os.path.exists(cwd_base_path):
130
+ full_path = cwd_base_path
131
  else:
132
+ # --- 2a. Granular Error: File Not Found ---
133
  try: cwd_files = os.listdir(".")
134
  except Exception as list_e: cwd_files = [f"Error listing CWD: {list_e}"]
135
  return (f"Error: File not found.\n"
136
+ f"Tried relative: '{os.path.join(script_dir, safe_path)}'\n"
137
+ f"Tried direct/absolute: '{safe_path}'\n"
138
  f"Tried basename in CWD: '{cwd_base_path}'\n"
139
  f"Files in CWD (.): {cwd_files}")
140
  print(f"Reading file: {full_path}")
141
+ # --- File Reading Logic with Specific Error Handling ---
142
+ try:
143
+ with open(full_path, 'r', encoding='utf-8') as f:
144
+ return f.read()
145
+ # --- 2b. Granular Errors during file open/read ---
146
+ except FileNotFoundError:
147
+ return f"Error: File not found at final path '{full_path}'."
148
+ except PermissionError:
149
+ return f"Error: Permission denied when trying to read file '{full_path}'."
150
+ except IsADirectoryError:
151
+ return f"Error: Specified path '{full_path}' is a directory, not a file."
152
+ except UnicodeDecodeError:
153
+ return f"Error: Could not decode file '{full_path}' as UTF-8. It might be binary or have a different encoding."
154
+ except Exception as read_e:
155
+ tb_str = traceback.format_exc()
156
+ return f"Error reading file content from {full_path}: {str(read_e)}\nTraceback:\n{tb_str}"
157
+ except Exception as e:
158
+ # --- 2c. Fallback for Unexpected Errors ---
159
+ tb_str = traceback.format_exc()
160
+ print(f"--- Read File Tool FAILED UNEXPECTEDLY ---\n{tb_str}\n---")
161
+ return f"Unexpected error setting up file read for '{path}': {str(e)}\nTraceback:\n{tb_str}"
162
 
163
+ # --- (Keep write_file, list_directory, audio_transcription_tool, get_youtube_transcript, scrape_web_page as they were,
164
+ # but consider adding similar input validation and granular errors to them too) ---
165
  @tool
166
  def write_file(path: str, content: str) -> str:
167
  """Writes the given content to a file at the specified path relative to the app's current directory. Creates directories if they don't exist."""
168
+ if not isinstance(path, str) or not path.strip(): return "Error: Invalid input. 'path' must be a non-empty string."
169
+ if not isinstance(content, str): return "Error: Invalid input. 'content' must be a string."
170
  print(f"--- Calling Write File Tool at path: {path} ---")
171
  try:
172
+ base_dir = os.getcwd(); full_path = os.path.join(base_dir, path)
173
+ print(f"Writing file to: {full_path}"); os.makedirs(os.path.dirname(full_path), exist_ok=True)
 
 
174
  with open(full_path, 'w', encoding='utf-8') as f: f.write(content)
175
  return f"Successfully wrote to file {path} (relative to CWD)."
176
+ except PermissionError: return f"Error: Permission denied writing to file '{full_path}'."
177
+ except Exception as e: tb_str = traceback.format_exc(); return f"Error writing to file {path}: {str(e)}\nTraceback:\n{tb_str}"
178
 
179
  @tool
180
  def list_directory(path: str = ".") -> str:
181
  """Lists the contents (files and directories) of a directory at the specified path relative to the app's current directory."""
182
+ if not isinstance(path, str): return "Error: Invalid input. 'path' must be a string (or empty for current directory)."
183
  print(f"--- Calling List Directory Tool at path: {path} ---")
184
  try:
185
+ base_dir = os.getcwd(); full_path = os.path.join(base_dir, path)
 
186
  print(f"Listing directory: {full_path}")
187
+ if not os.path.isdir(full_path): return f"Error: '{path}' is not a valid directory relative to CWD."
188
  files = os.listdir(full_path); return "\n".join(files) if files else "Directory is empty."
189
+ except FileNotFoundError: return f"Error: Directory not found at '{full_path}'."
190
+ except PermissionError: return f"Error: Permission denied listing directory '{full_path}'."
191
+ except Exception as e: tb_str = traceback.format_exc(); return f"Error listing directory {path}: {str(e)}\nTraceback:\n{tb_str}"
192
 
193
  @tool
194
  def audio_transcription_tool(file_path: str) -> str:
195
  """Transcribes an audio file (like .mp3 or .wav) and returns the text content."""
196
+ if not isinstance(file_path, str) or not file_path.strip(): return "Error: Invalid input. 'file_path' must be a non-empty string."
197
  print(f"--- Calling Audio Transcription: {file_path} ---")
198
  if asr_pipeline is None: return "Error: ASR pipeline unavailable."
199
  try:
200
+ # (Keep your existing path finding logic for audio files)
201
+ script_dir = os.getcwd(); full_path = os.path.join(script_dir, file_path)
202
  if not os.path.exists(full_path):
203
  full_path = file_path
204
  if not os.path.exists(full_path):
205
  base_path = os.path.basename(file_path)
206
  cwd_base_path = os.path.join(os.getcwd(), base_path)
207
  if os.path.exists(cwd_base_path): full_path = cwd_base_path
208
+ else: return f"Error: Audio file not found." # More specific error
209
+ print(f"Transcribing file: {full_path}")
210
  transcription = asr_pipeline(full_path)
211
+ result_text = transcription.get("text", "")
212
+ if not result_text: return "Error: Transcription failed or produced empty text."
213
+ return result_text
214
+ except Exception as e: tb_str = traceback.format_exc(); return f"Error transcribing '{file_path}': {str(e)}\nTraceback:\n{tb_str}"
215
 
216
  @tool
217
  def get_youtube_transcript(video_url: str) -> str:
218
+ """Fetches YouTube transcript for the given video URL."""
219
+ if not isinstance(video_url, str) or not video_url.strip(): return "Error: Invalid input. 'video_url' must be a non-empty string."
220
  print(f"--- Calling YouTube Transcript: {video_url} ---")
221
  try:
222
  video_id = None
223
  if "watch?v=" in video_url: video_id = video_url.split("v=")[1].split("&")[0]
224
  elif "youtu.be/" in video_url: video_id = video_url.split("youtu.be/")[1].split("?")[0]
225
+ if not video_id: return f"Error: Could not extract YouTube video ID from URL '{video_url}'."
226
  transcript_list = YouTubeTranscriptApi.get_transcript(video_id)
227
+ if not transcript_list: return "Error: No transcript found for this video."
228
  full_transcript = " ".join([item["text"] for item in transcript_list])
229
+ return full_transcript[:8000] # Keep length limit
230
+ except Exception as e: tb_str = traceback.format_exc(); return f"Error getting transcript for '{video_url}': {str(e)}\nTraceback:\n{tb_str}"
231
 
232
  @tool
233
  def scrape_web_page(url: str) -> str:
234
+ """Fetches primary text content of a webpage specified by URL."""
235
+ if not isinstance(url, str) or not url.strip(): return "Error: Invalid input. 'url' must be a non-empty string."
236
+ # Basic URL scheme check
237
+ if not url.lower().startswith(('http://', 'https://')): return f"Error: Invalid URL scheme. URL must start with http:// or https://. Received: '{url}'"
238
  print(f"--- Calling Web Scraper: {url} ---")
239
  try:
240
+ headers = {'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'} # Be a good bot
241
+ response = requests.get(url, headers=headers, timeout=20); response.raise_for_status()
242
+ content_type = response.headers.get('Content-Type', '').lower()
243
+ if 'html' not in content_type: return f"Error: Content type is '{content_type}', not HTML."
244
  soup = BeautifulSoup(response.text, 'html.parser')
245
+ # (Keep your existing tag extraction logic)
246
+ for tag in soup(["script", "style", "nav", "footer", "aside", "header", "form", "button", "input", "img", "link", "meta"]): tag.extract()
247
+ main_content = soup.find('main') or soup.find('article') or soup.find('div', role='main') or soup.body
248
+ if not main_content: return "Error: Could not find main body content."
249
  text = main_content.get_text(separator='\n', strip=True)
250
  lines = (line.strip() for line in text.splitlines()); chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
251
  text = '\n'.join(chunk for chunk in chunks if chunk)
252
+ if not text: return "Error: Scraped content was empty after cleaning."
253
+ return text[:8000] # Keep length limit
254
+ except requests.exceptions.RequestException as req_e:
255
+ return f"Error fetching URL {url}: {str(req_e)}"
256
+ except Exception as e: tb_str = traceback.format_exc(); return f"Error scraping {url}: {str(e)}\nTraceback:\n{tb_str}"
257
+
258
 
 
259
  @tool
260
  def final_answer_tool(answer: str) -> str:
261
  """
262
+ Call this tool ONLY when you have the final, definitive answer.
263
+ The 'answer' argument must be a string containing only the concise, factual answer.
264
  """
265
+ # --- Input Validation ---
266
+ if not isinstance(answer, str):
267
+ # Attempt conversion, or return error if not possible/sensible
268
+ try: answer = str(answer)
269
+ except: return "Error: Invalid input. 'answer' must be a string or convertible to a string."
270
+ # --- End Validation ---
271
  print(f"--- AGENT CALLING FINAL ANSWER TOOL ---")
272
+ return answer # The tool itself just returns the answer
 
273
 
274
+ # --- Helper Function for Cleaning Fences (Keep as is) ---
275
  def remove_fences_simple(text):
276
+ original_text = text; text = text.strip()
 
 
277
  if text.startswith("```") and text.endswith("```"):
278
  text = text[3:-3].strip()
279
  if '\n' in text:
 
282
  text = rest.strip()
283
  return text
284
  return original_text
 
285
 
286
  # List of standalone tool functions
287
  defined_tools = [
 
293
  audio_transcription_tool,
294
  get_youtube_transcript,
295
  scrape_web_page,
296
+ final_answer_tool
297
+ ] # Ensure remove_fences_simple is NOT here
298
 
299
  # --- LangGraph Agent State ---
300
  class AgentState(TypedDict):
301
+ messages: Annotated[List[AnyMessage], add_messages]
302
+ turn: int # <-- Added turn counter
303
 
304
+ # --- Custom Conditional Edge ---
305
  def should_continue(state: AgentState):
306
  """
307
+ Custom logic: loop for thoughts, route to tools, end on final_answer or limit.
 
308
  """
309
  last_message = state['messages'][-1]
310
+ current_turn = state.get('turn', 0)
311
+
312
+ # 1. Check for explicit end signal (final_answer_tool)
313
+ if isinstance(last_message, AIMessage) and last_message.tool_calls:
314
+ if last_message.tool_calls[0].get("name") == "final_answer_tool":
315
+ print("--- Condition: Saw final_answer_tool, ending graph. ---")
316
+ return END
317
+
318
+ # 2. Check turn limit *before* deciding to loop or call tools
319
+ if current_turn >= MAX_TURNS:
320
+ print(f"--- Condition: Reached max turns ({MAX_TURNS}). Forcing END. ---")
321
+ # Optional: Append an error message for clarity in final output
322
+ state['messages'].append(SystemMessage(content=f"SYSTEM: Agent reached maximum turn limit ({MAX_TURNS}). Ending execution."))
323
+ return END
324
+
325
+ # 3. If tools were called (and it wasn't final_answer), route to tools node
326
+ if isinstance(last_message, AIMessage) and last_message.tool_calls:
327
+ print("--- Condition: Saw other tools, calling tools node. ---")
328
+ return "tools"
329
+
330
+ # 4. If no tool call and not over limit, loop back to agent (reasoning loop)
331
+ print(f"--- Condition: No tool call (Turn {current_turn}). Looping back to agent. ---")
332
  return "agent"
333
+
334
  # --- Basic Agent Definition ---
 
335
  class BasicAgent:
 
336
  def __init__(self):
337
  print("BasicAgent (LangGraph) initializing...")
 
338
  GROQ_API_KEY = os.getenv("GROQ_API_KEY")
339
  if not GROQ_API_KEY: raise ValueError("GROQ_API_KEY secret is not set!")
 
 
340
 
341
  self.tools = defined_tools
342
  tool_descriptions = "\n".join([
343
  f"- {tool.name}: {tool.description}" if tool.name != 'code_interpreter' else
344
+ (f"- {tool.name}: Executes Python code. Use for calculations, data manipulation, or logic puzzles.\n"
345
+ f" **CODE INTERPRETER RULES:**\n"
346
+ f" 1. ALWAYS use `print()` for final results.\n"
347
+ f" 2. Write SIMPLE, single-step scripts.\n"
348
+ f" 3. PLAN your next script using plain text output first.\n"
349
+ f" 4. Write reasoning as Python comments (#) before code.\n"
350
+ f" 'pandas' (as pd) is available.")
351
  for tool in self.tools
352
  ])
353
 
354
+ # ==================== SYSTEM PROMPT V4 ====================
355
  self.system_prompt = f"""You are a highly intelligent and meticulous AI assistant for the GAIA benchmark.
356
  Your goal is to provide the concise, factual answer by strictly following a step-by-step reasoning process.
357
 
358
  **CRITICAL PROTOCOL: YOU MUST FOLLOW THIS PROCESS**
359
 
360
  1. **ANALYZE:** Read the question and all messages in the history.
361
+ 2. **MANDATORY FIRST STEP:** Your *first* response on *any* new task MUST be a plan in plain text. Do NOT call any tool on your first turn. Write down your logic, what you need, and which tool you *plan* to use next. Failure to provide a plan first will result in incorrect behavior.
362
+ 3. **EXECUTE:** After submitting your plan, you will run again. Now, execute the *next* step of your plan by calling the *one* appropriate tool using the correct JSON format.
363
+ 4. **ANALYZE TOOL OUTPUT:** You will receive a ToolMessage with the output. You MUST read it carefully.
364
  5. **REPEAT or FINISH:**
365
+ * **If more steps are needed:** Go back to step 1 (ANALYZE the new info & PLAN). Write an *updated* plan as plain text (e.g., "The search found X. My next step is to use code_interpreter to process X...").
366
+ * **If the ToolMessage contains the final answer:** You MUST call the `final_answer_tool`. Your answer *must* be derived *only* from the ToolMessage output, not your own knowledge.
367
 
368
  **RULES:**
369
+ * **NEVER** call a tool on the same turn you write a plan (plain text).
370
+ * **NEVER** use your pre-trained "leaked" knowledge for the final answer. The answer *must* come from a ToolMessage (e.g., from `code_interpreter`'s print() or `search_tool`).
371
+ * **NEVER** answer a logic puzzle from memory. You *must* use `code_interpreter`, ensure it `print()`s the result, analyze that output, and then use that printed result for `final_answer_tool`.
372
+ * **NEVER** call `final_answer_tool` until a tool has explicitly given you the answer in its output.
373
+ * **Error Handling:** If a tool call returns an Error, your next step (Step 1 PLAN) MUST analyze the error message and propose a *different* approach (different tool, different arguments, different logic). Do not retry the exact same failed call.
374
 
375
  **TOOLS:**
376
  {tool_descriptions}
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  **TOOL FORMAT (JSON ONLY):**
379
+ Respond ONLY with a single JSON block like this when calling a tool:
380
+ ```json
381
+ {{
382
+ "tool": "tool_name",
383
+ "tool_input": {{ "arg_name1": "value1", ... }}
384
+ }}
385
+ ```
386
  * Replace `tool_name` with the tool's name. Provide arguments in `tool_input`. Match names/types precisely.
387
  * Do not add any text before or after the JSON block.
388
+ Example for final_answer_tool:
389
+ ```json
390
+ {{
391
+ "tool": "final_answer_tool",
392
+ "tool_input": {{
393
+ "answer": "The final answer string here"
394
+ }}
395
+ }}
396
+ ```
397
+ NOTE: The value for "answer" MUST be a string enclosed in double quotes.
398
+ print("Initializing Groq LLM Endpoint...")
399
+ try:
400
+ chat_llm = ChatGroq(
401
+ temperature=0.01, # Low temperature for factual tasks
402
+ groq_api_key=GROQ_API_KEY,
403
+ model_name="openai/gpt-oss-120b" # <-- Switched Model
404
+ )
405
+ print("✅ Groq LLM Endpoint initialized with openai/gpt-oss-120b.")
406
+ except Exception as e: print(f"Error initializing Groq: {e}"); raise
407
+
408
+ self.llm_with_tools = chat_llm.bind_tools(self.tools)
409
+ print("✅ Tools bound to LLM (using bind_tools).")
410
+
411
+ # --- Agent Node with Robust Parsing Fallback ---
412
+ def agent_node(state: AgentState):
413
+ current_turn = state.get('turn', 0) + 1
414
+ print(f"--- Running Agent Node (Turn {current_turn}) ---")
415
+
416
+ # Ensure message history isn't excessively long (optional safety)
417
+ # if len(state['messages']) > 20:
418
+ # print("Warning: Pruning message history to prevent excessive length.")
419
+ # messages_to_send = [state['messages'][0]] + state['messages'][-19:] # Keep system + last N
420
+ # else:
421
+ # messages_to_send = state["messages"]
422
+
423
+ messages_to_send = state["messages"] # Keep all for now
424
+
425
+ ai_message: AIMessage = self.llm_with_tools.invoke(messages_to_send)
426
+
427
+ # --- Robust Parsing Fallback ---
428
+ if not ai_message.tool_calls and isinstance(ai_message.content, str) and ai_message.content.strip():
429
+ # Simple JSON block finder (might need refinement for complex cases)
430
+ json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```|(\{.*?\})", ai_message.content, re.DOTALL | re.IGNORECASE)
431
+ if json_match:
432
+ json_str = json_match.group(1) or json_match.group(2)
433
+ try:
434
+ parsed_json = json.loads(json_str)
435
+ # Basic validation for *our* tool format
436
+ if isinstance(parsed_json, dict) and "tool" in parsed_json and "tool_input" in parsed_json:
437
+ tool_name = parsed_json.get("tool")
438
+ tool_input = parsed_json.get("tool_input", {})
439
+ # Check if the tool name is actually one we defined
440
+ if any(t.name == tool_name for t in self.tools):
441
+ print(f"--- Fallback: Manually parsed tool call for '{tool_name}' from content ---")
442
+ tool_call = ToolCall(name=tool_name, args=tool_input, id=str(uuid.uuid4()))
443
+ ai_message.tool_calls = [tool_call]
444
+ ai_message.content = "" # Clear content as it's parsed
445
+ else:
446
+ print(f"--- Fallback Warning: Found JSON, but tool '{tool_name}' is not defined. ---")
447
+ else:
448
+ print("--- Fallback Warning: Found JSON, but not in expected tool format {tool:..., tool_input:...}. ---")
449
+ except json.JSONDecodeError as json_err:
450
+ print(f"--- Fallback Warning: Found text resembling JSON, but failed to parse: {json_err} ---")
451
+ # --- End Fallback ---
452
+
453
+ print(f"AI Message Raw Content: {ai_message.content}")
454
+ if ai_message.tool_calls: print(f"AI tool calls: {ai_message.tool_calls}")
455
+ elif ai_message.invalid_tool_calls: print(f"AI INVALID tool calls: {ai_message.invalid_tool_calls}")
456
+ else: print(f"AI content (no calls): {ai_message.pretty_repr()}")
457
+
458
+ return {"messages": [ai_message], "turn": current_turn}
459
+
460
+ tool_node = ToolNode(self.tools)
461
+ print("Building agent graph...")
462
+ graph_builder = StateGraph(AgentState)
463
+ graph_builder.add_node("agent", agent_node)
464
+ graph_builder.add_node("tools", tool_node)
465
+
466
+ graph_builder.add_edge(START, "agent")
467
+ graph_builder.add_edge("tools", "agent") # Always go back to agent after tools
468
+
469
+ # --- Updated Conditional Edges ---
470
+ graph_builder.add_conditional_edges(
471
+ "agent",
472
+ should_continue,
473
+ {
474
+ "tools": "tools", # If tools are called (and not final_answer)
475
+ "agent": "agent", # If text/plan is generated (reasoning loop)
476
+ END: END # If final_answer called or turn limit reached
477
+ }
478
+ )
479
+ self.graph = graph_builder.compile()
480
+ print("✅ Graph compiled.")
481
 
482
+ # --- __call__ Method (Keep mostly as is, just init turn) ---
483
+ def __call__(self, question: str) -> str:
484
+ print(f"\n--- Starting Agent Run for Question ---")
485
+ print(f"Agent received question (first 100 chars): {question[:100]}...")
486
+
487
+ # Initialize graph input with turn counter
488
+ graph_input = {
489
+ "messages": [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  SystemMessage(content=self.system_prompt),
491
  HumanMessage(content=question)
492
+ ],
493
+ "turn": 0
494
+ }
495
+
496
+ final_answer = "AGENT FAILED TO PRODUCE ANSWER"
497
+ try:
498
+ # Add config for recursion limit (LangGraph default is 25, but our turn limit is softer)
499
+ config = {"recursion_limit": MAX_TURNS + 5} # Allow slightly more graph steps than turns
500
 
501
+ for event in self.graph.stream(graph_input, stream_mode="values", config=config):
502
+ last_message = event["messages"][-1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
 
504
+ # Check for final answer extraction
505
+ if isinstance(last_message, AIMessage) and last_message.tool_calls:
506
+ if last_message.tool_calls[0].get("name") == "final_answer_tool":
507
+ final_answer = last_message.tool_calls[0]['args'].get('answer', "ERROR: FINAL_ANSWER_TOOL CALLED WITHOUT ANSWER")
508
+ print(f"--- Final Answer Captured from tool call: '{final_answer}' ---")
509
+ # We can break here since the graph condition should lead to END anyway
510
+ break
511
+
512
+ # Log other message types (optional but helpful)
513
+ elif isinstance(last_message, ToolMessage):
514
+ print(f"Tool Result ({last_message.tool_call_id}): {last_message.content[:500]}...")
515
+ elif isinstance(last_message, AIMessage) and not last_message.tool_calls:
516
+ # This is now expected (the "plan" or "think" step)
517
+ print(f"AI Message (Plan/Thought): {last_message.content[:500]}...")
518
+ # Don't set final_answer here anymore, only final_answer_tool counts
519
+
520
+ # --- Cleaning step (Keep as is) ---
521
+ cleaned_answer = str(final_answer).strip()
522
+ # ... (keep existing prefix removal and fence removal logic) ...
523
+ prefixes_to_remove = ["The answer is:", "Here is the answer:", "Based on the information:", "Final Answer:", "Answer:"]
524
+ original_cleaned = cleaned_answer
525
+ for prefix in prefixes_to_remove:
526
+ if cleaned_answer.lower().startswith(prefix.lower()):
527
+ potential_answer = cleaned_answer[len(prefix):].strip()
528
+ if potential_answer: cleaned_answer = potential_answer; break
529
+ if cleaned_answer == original_cleaned and any(cleaned_answer.lower().startswith(p.lower()) for p in prefixes_to_remove):
530
+ print(f"Warning: Prefix found but not stripped: '{original_cleaned[:100]}...'")
531
+
532
+ # Simple fence removal
533
+ cleaned_answer = remove_fences_simple(cleaned_answer)
534
+ if cleaned_answer.startswith("`") and cleaned_answer.endswith("`"):
535
+ cleaned_answer = cleaned_answer[1:-1].strip()
536
+
537
+ print(f"Agent returning final answer (cleaned): '{cleaned_answer}'")
538
+ return cleaned_answer
539
 
540
+ except Exception as e:
541
+ print(f"Error running agent graph: {e}")
542
+ tb_str = traceback.format_exc()
543
+ print(tb_str)
544
+ # Check if it was specifically our turn limit message
545
+ if isinstance(e, SystemMessage) and f"maximum turn limit ({MAX_TURNS})" in str(e.content):
546
+ return f"AGENT STOPPED: Reached maximum turn limit ({MAX_TURNS})."
547
+ return f"AGENT GRAPH ERROR: {e}"
548
  # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
549
 
550