Raj commited on
Commit
058e8a6
·
verified ·
1 Parent(s): 81917a3

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +17 -2
  2. agent.py +112 -0
  3. app.py +80 -99
  4. requirements.txt +14 -2
  5. tools.py +450 -0
README.md CHANGED
@@ -8,8 +8,23 @@ sdk_version: 5.25.2
8
  app_file: app.py
9
  pinned: false
10
  hf_oauth: true
11
- # optional, default duration is 8 hours/480 minutes. Max duration is 30 days/43200 minutes.
12
  hf_oauth_expiration_minutes: 480
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  app_file: app.py
9
  pinned: false
10
  hf_oauth: true
 
11
  hf_oauth_expiration_minutes: 480
12
  ---
13
 
14
+ # GAIA Agent — Final Assignment
15
+
16
+ A `smolagents` CodeAgent built to answer GAIA Level 1 questions for the HF Agents
17
+ course final assignment. Uses HF Inference Providers as the LLM backend with
18
+ Serper for web search, plus tools for browsing, Wikipedia, YouTube transcripts,
19
+ GAIA file attachments, audio transcription, and image analysis.
20
+
21
+ ## Required Space secrets
22
+
23
+ | Secret | Purpose |
24
+ | ---------------- | -------------------------------------------------------- |
25
+ | `HF_TOKEN` | HF Inference Providers (uses your Pro quota) |
26
+ | `SERPER_API_KEY` | Web search via serper.dev |
27
+
28
+ Optional overrides:
29
+ - `AGENT_MODEL_ID` — default `meta-llama/Llama-3.3-70B-Instruct`
30
+ - `AGENT_PROVIDER` — default `auto` (HF Inference Providers routing)
agent.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GAIA agent: smolagents CodeAgent + HF Inference Providers backend."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from typing import Optional
7
+
8
+ from smolagents import CodeAgent, InferenceClientModel
9
+
10
+ from tools import (
11
+ analyze_image,
12
+ download_task_file,
13
+ read_table,
14
+ read_webpage,
15
+ transcribe_audio,
16
+ web_search,
17
+ wikipedia_search,
18
+ youtube_transcript,
19
+ )
20
+
21
+ DEFAULT_MODEL_ID = "meta-llama/Llama-3.3-70B-Instruct"
22
+ DEFAULT_PROVIDER = "auto"
23
+
24
+ SYSTEM_PROMPT = """You are a careful research agent solving GAIA benchmark questions.
25
+
26
+ Hard rules for the FINAL ANSWER:
27
+ - Answers are graded by EXACT string match after light normalization. Be precise.
28
+ - Output ONLY the answer — no prefixes, no units unless the question asks, no
29
+ trailing punctuation, no quotes.
30
+ - Numbers: digits only unless the question wants words. No commas, no $, no %.
31
+ - Strings: lowercase only if the question wants lowercase; otherwise match the
32
+ natural casing of the entity. Do not add articles (a, an, the).
33
+ - Lists: comma + single space between items, in the order requested.
34
+
35
+ Workflow:
36
+ 1. If the question references "the attached file", "this Excel/CSV/audio/image",
37
+ etc., call `download_task_file(task_id)` FIRST. The task_id is given to you.
38
+ 2. Pick the right tool: `read_table` for spreadsheets, `transcribe_audio` for
39
+ audio, `analyze_image` for images, `read_webpage` after `web_search` for the
40
+ open web, `wikipedia_search` for encyclopedic facts, `youtube_transcript`
41
+ for YouTube. Use Python freely for math, parsing, and logic.
42
+ 3. Cross-check facts when an answer feels uncertain. Don't fabricate.
43
+ 4. End with `final_answer("<the exact answer>")`.
44
+ """
45
+
46
+
47
+ class GaiaAgent:
48
+ """Stateless wrapper around a smolagents CodeAgent."""
49
+
50
+ def __init__(self, model_id: Optional[str] = None, provider: Optional[str] = None):
51
+ token = os.getenv("HF_TOKEN")
52
+ if not token:
53
+ raise RuntimeError(
54
+ "HF_TOKEN is not set. Add it as a Space secret to use HF Inference."
55
+ )
56
+ self.model = InferenceClientModel(
57
+ model_id=model_id or os.getenv("AGENT_MODEL_ID", DEFAULT_MODEL_ID),
58
+ provider=provider or os.getenv("AGENT_PROVIDER", DEFAULT_PROVIDER),
59
+ token=token,
60
+ max_tokens=2048,
61
+ )
62
+ self.tools = [
63
+ web_search,
64
+ read_webpage,
65
+ wikipedia_search,
66
+ youtube_transcript,
67
+ download_task_file,
68
+ read_table,
69
+ transcribe_audio,
70
+ analyze_image,
71
+ ]
72
+
73
+ def _build_agent(self) -> CodeAgent:
74
+ # Fresh agent per question — keeps memory clean between tasks.
75
+ return CodeAgent(
76
+ tools=self.tools,
77
+ model=self.model,
78
+ max_steps=8,
79
+ additional_authorized_imports=[
80
+ "pandas", "numpy", "json", "re", "math", "statistics",
81
+ "itertools", "collections", "datetime", "csv", "io",
82
+ "pathlib", "string", "base64",
83
+ ],
84
+ )
85
+
86
+ def __call__(self, question: str, task_id: Optional[str] = None) -> str:
87
+ agent = self._build_agent()
88
+ prompt = SYSTEM_PROMPT
89
+ if task_id:
90
+ prompt += f"\nThe current task_id is: {task_id}\n"
91
+ prompt += f"\nQuestion:\n{question}\n"
92
+ try:
93
+ raw = agent.run(prompt)
94
+ except Exception as e:
95
+ return f"AGENT ERROR: {e}"
96
+ return self._normalize(str(raw))
97
+
98
+ @staticmethod
99
+ def _normalize(text: str) -> str:
100
+ """Clean common LLM cruft so the grader's exact match has a chance."""
101
+ if not text:
102
+ return text
103
+ t = text.strip()
104
+ # Strip "FINAL ANSWER:" prefix if the model left it in.
105
+ t = re.sub(r"^\s*final answer\s*[:\-]\s*", "", t, flags=re.IGNORECASE)
106
+ # Strip wrapping quotes / backticks.
107
+ if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'`":
108
+ t = t[1:-1].strip()
109
+ # Drop trailing sentence-ending punctuation unless the answer is a sentence.
110
+ if not re.search(r"\s", t) or t.count(" ") < 6:
111
+ t = t.rstrip(".")
112
+ return t.strip()
app.py CHANGED
@@ -1,34 +1,22 @@
1
  import os
 
 
2
  import gradio as gr
3
- import requests
4
- import inspect
5
  import pandas as pd
 
 
 
6
 
7
- # (Keep Constants as is)
8
  # --- Constants ---
9
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
10
 
11
- # --- Basic Agent Definition ---
12
- # ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
13
- class BasicAgent:
14
- def __init__(self):
15
- print("BasicAgent initialized.")
16
- def __call__(self, question: str) -> str:
17
- print(f"Agent received question (first 50 chars): {question[:50]}...")
18
- fixed_answer = "This is a default answer."
19
- print(f"Agent returning fixed answer: {fixed_answer}")
20
- return fixed_answer
21
-
22
- def run_and_submit_all( profile: gr.OAuthProfile | None):
23
- """
24
- Fetches all questions, runs the BasicAgent on them, submits all answers,
25
- and displays the results.
26
- """
27
- # --- Determine HF Space Runtime URL and Repo URL ---
28
- space_id = os.getenv("SPACE_ID") # Get the SPACE_ID for sending link to the code
29
 
30
  if profile:
31
- username= f"{profile.username}"
32
  print(f"User logged in: {username}")
33
  else:
34
  print("User not logged in.")
@@ -38,80 +26,93 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
38
  questions_url = f"{api_url}/questions"
39
  submit_url = f"{api_url}/submit"
40
 
41
- # 1. Instantiate Agent ( modify this part to create your agent)
42
  try:
43
- agent = BasicAgent()
44
  except Exception as e:
45
  print(f"Error instantiating agent: {e}")
46
  return f"Error initializing agent: {e}", None
47
- # In the case of an app running as a hugging Face space, this link points toward your codebase ( usefull for others so please keep it public)
48
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
49
  print(agent_code)
50
 
51
- # 2. Fetch Questions
52
  print(f"Fetching questions from: {questions_url}")
53
  try:
54
- response = requests.get(questions_url, timeout=15)
55
  response.raise_for_status()
56
  questions_data = response.json()
57
  if not questions_data:
58
- print("Fetched questions list is empty.")
59
- return "Fetched questions list is empty or invalid format.", None
60
  print(f"Fetched {len(questions_data)} questions.")
61
  except requests.exceptions.RequestException as e:
62
- print(f"Error fetching questions: {e}")
63
  return f"Error fetching questions: {e}", None
64
  except requests.exceptions.JSONDecodeError as e:
65
- print(f"Error decoding JSON response from questions endpoint: {e}")
66
- print(f"Response text: {response.text[:500]}")
67
- return f"Error decoding server response for questions: {e}", None
68
  except Exception as e:
69
- print(f"An unexpected error occurred fetching questions: {e}")
70
  return f"An unexpected error occurred fetching questions: {e}", None
71
 
72
- # 3. Run your Agent
73
  results_log = []
74
  answers_payload = []
75
  print(f"Running agent on {len(questions_data)} questions...")
76
- for item in questions_data:
77
  task_id = item.get("task_id")
78
  question_text = item.get("question")
79
  if not task_id or question_text is None:
80
  print(f"Skipping item with missing task_id or question: {item}")
81
  continue
 
82
  try:
83
- submitted_answer = agent(question_text)
84
- answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
85
- results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
86
  except Exception as e:
87
- print(f"Error running agent on task {task_id}: {e}")
88
- results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": f"AGENT ERROR: {e}"})
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  if not answers_payload:
91
- print("Agent did not produce any answers to submit.")
92
- return "Agent did not produce any answers to submit.", pd.DataFrame(results_log)
 
 
93
 
94
- # 4. Prepare Submission
95
- submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
96
- status_update = f"Agent finished. Submitting {len(answers_payload)} answers for user '{username}'..."
97
- print(status_update)
 
 
 
 
 
 
98
 
99
  # 5. Submit
100
  print(f"Submitting {len(answers_payload)} answers to: {submit_url}")
101
  try:
102
- response = requests.post(submit_url, json=submission_data, timeout=60)
103
  response.raise_for_status()
104
  result_data = response.json()
105
  final_status = (
106
  f"Submission Successful!\n"
107
  f"User: {result_data.get('username')}\n"
108
  f"Overall Score: {result_data.get('score', 'N/A')}% "
109
- f"({result_data.get('correct_count', '?')}/{result_data.get('total_attempted', '?')} correct)\n"
 
110
  f"Message: {result_data.get('message', 'No message received.')}"
111
  )
112
  print("Submission successful.")
113
- results_df = pd.DataFrame(results_log)
114
- return final_status, results_df
115
  except requests.exceptions.HTTPError as e:
116
  error_detail = f"Server responded with status {e.response.status_code}."
117
  try:
@@ -119,42 +120,28 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
119
  error_detail += f" Detail: {error_json.get('detail', e.response.text)}"
120
  except requests.exceptions.JSONDecodeError:
121
  error_detail += f" Response: {e.response.text[:500]}"
122
- status_message = f"Submission Failed: {error_detail}"
123
- print(status_message)
124
- results_df = pd.DataFrame(results_log)
125
- return status_message, results_df
126
  except requests.exceptions.Timeout:
127
- status_message = "Submission Failed: The request timed out."
128
- print(status_message)
129
- results_df = pd.DataFrame(results_log)
130
- return status_message, results_df
131
  except requests.exceptions.RequestException as e:
132
- status_message = f"Submission Failed: Network error - {e}"
133
- print(status_message)
134
- results_df = pd.DataFrame(results_log)
135
- return status_message, results_df
136
  except Exception as e:
137
- status_message = f"An unexpected error occurred during submission: {e}"
138
- print(status_message)
139
- results_df = pd.DataFrame(results_log)
140
- return status_message, results_df
141
 
142
 
143
  # --- Build Gradio Interface using Blocks ---
144
  with gr.Blocks() as demo:
145
- gr.Markdown("# Basic Agent Evaluation Runner")
146
  gr.Markdown(
147
  """
148
  **Instructions:**
149
-
150
- 1. Please clone this space, then modify the code to define your agent's logic, the tools, the necessary packages, etc ...
151
- 2. Log in to your Hugging Face account using the button below. This uses your HF username for submission.
152
- 3. Click 'Run Evaluation & Submit All Answers' to fetch questions, run your agent, submit answers, and see the score.
153
-
154
- ---
155
- **Disclaimers:**
156
- Once clicking on the "submit button, it can take quite some time ( this is the time for the agent to go through all the questions).
157
- This space provides a basic setup and is intentionally sub-optimal to encourage you to develop your own, more robust solution. For instance for the delay process of the submit button, a solution could be to cache the answers and submit in a seperate action or even to answer the questions in async.
158
  """
159
  )
160
 
@@ -162,35 +149,29 @@ with gr.Blocks() as demo:
162
 
163
  run_button = gr.Button("Run Evaluation & Submit All Answers")
164
 
165
- status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False)
166
- # Removed max_rows=10 from DataFrame constructor
 
167
  results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
168
 
169
- run_button.click(
170
- fn=run_and_submit_all,
171
- outputs=[status_output, results_table]
172
- )
173
 
174
  if __name__ == "__main__":
175
- print("\n" + "-"*30 + " App Starting " + "-"*30)
176
- # Check for SPACE_HOST and SPACE_ID at startup for information
177
  space_host_startup = os.getenv("SPACE_HOST")
178
- space_id_startup = os.getenv("SPACE_ID") # Get SPACE_ID at startup
179
 
180
  if space_host_startup:
181
- print(f"✅ SPACE_HOST found: {space_host_startup}")
182
- print(f" Runtime URL should be: https://{space_host_startup}.hf.space")
183
- else:
184
- print("ℹ️ SPACE_HOST environment variable not found (running locally?).")
185
-
186
- if space_id_startup: # Print repo URLs if SPACE_ID is found
187
- print(f"✅ SPACE_ID found: {space_id_startup}")
188
- print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
189
- print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main")
190
  else:
191
- print("ℹ️ SPACE_ID environment variable not found (running locally?). Repo URL cannot be determined.")
192
 
193
- print("-"*(60 + len(" App Starting ")) + "\n")
 
 
194
 
195
- print("Launching Gradio Interface for Basic Agent Evaluation...")
196
- demo.launch(debug=True, share=False)
 
 
1
  import os
2
+ import traceback
3
+
4
  import gradio as gr
 
 
5
  import pandas as pd
6
+ import requests
7
+
8
+ from agent import GaiaAgent
9
 
 
10
  # --- Constants ---
11
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
12
 
13
+
14
+ def run_and_submit_all(profile: gr.OAuthProfile | None):
15
+ """Fetch GAIA questions, run the agent, submit answers, render results."""
16
+ space_id = os.getenv("SPACE_ID")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  if profile:
19
+ username = f"{profile.username}"
20
  print(f"User logged in: {username}")
21
  else:
22
  print("User not logged in.")
 
26
  questions_url = f"{api_url}/questions"
27
  submit_url = f"{api_url}/submit"
28
 
29
+ # 1. Instantiate agent
30
  try:
31
+ agent = GaiaAgent()
32
  except Exception as e:
33
  print(f"Error instantiating agent: {e}")
34
  return f"Error initializing agent: {e}", None
35
+
36
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
37
  print(agent_code)
38
 
39
+ # 2. Fetch questions
40
  print(f"Fetching questions from: {questions_url}")
41
  try:
42
+ response = requests.get(questions_url, timeout=30)
43
  response.raise_for_status()
44
  questions_data = response.json()
45
  if not questions_data:
46
+ return "Fetched questions list is empty or invalid format.", None
 
47
  print(f"Fetched {len(questions_data)} questions.")
48
  except requests.exceptions.RequestException as e:
 
49
  return f"Error fetching questions: {e}", None
50
  except requests.exceptions.JSONDecodeError as e:
51
+ return f"Error decoding server response for questions: {e}", None
 
 
52
  except Exception as e:
 
53
  return f"An unexpected error occurred fetching questions: {e}", None
54
 
55
+ # 3. Run agent over all questions
56
  results_log = []
57
  answers_payload = []
58
  print(f"Running agent on {len(questions_data)} questions...")
59
+ for i, item in enumerate(questions_data, 1):
60
  task_id = item.get("task_id")
61
  question_text = item.get("question")
62
  if not task_id or question_text is None:
63
  print(f"Skipping item with missing task_id or question: {item}")
64
  continue
65
+ print(f"\n=== [{i}/{len(questions_data)}] task {task_id} ===")
66
  try:
67
+ submitted_answer = agent(question_text, task_id=task_id)
 
 
68
  except Exception as e:
69
+ traceback.print_exc()
70
+ submitted_answer = f"AGENT ERROR: {e}"
71
+ print(f" -> {submitted_answer!r}")
72
+ answers_payload.append(
73
+ {"task_id": task_id, "submitted_answer": submitted_answer}
74
+ )
75
+ results_log.append(
76
+ {
77
+ "Task ID": task_id,
78
+ "Question": question_text,
79
+ "Submitted Answer": submitted_answer,
80
+ }
81
+ )
82
 
83
  if not answers_payload:
84
+ return (
85
+ "Agent did not produce any answers to submit.",
86
+ pd.DataFrame(results_log),
87
+ )
88
 
89
+ # 4. Prepare submission
90
+ submission_data = {
91
+ "username": username.strip(),
92
+ "agent_code": agent_code,
93
+ "answers": answers_payload,
94
+ }
95
+ print(
96
+ f"Agent finished. Submitting {len(answers_payload)} answers for "
97
+ f"user '{username}'..."
98
+ )
99
 
100
  # 5. Submit
101
  print(f"Submitting {len(answers_payload)} answers to: {submit_url}")
102
  try:
103
+ response = requests.post(submit_url, json=submission_data, timeout=120)
104
  response.raise_for_status()
105
  result_data = response.json()
106
  final_status = (
107
  f"Submission Successful!\n"
108
  f"User: {result_data.get('username')}\n"
109
  f"Overall Score: {result_data.get('score', 'N/A')}% "
110
+ f"({result_data.get('correct_count', '?')}/"
111
+ f"{result_data.get('total_attempted', '?')} correct)\n"
112
  f"Message: {result_data.get('message', 'No message received.')}"
113
  )
114
  print("Submission successful.")
115
+ return final_status, pd.DataFrame(results_log)
 
116
  except requests.exceptions.HTTPError as e:
117
  error_detail = f"Server responded with status {e.response.status_code}."
118
  try:
 
120
  error_detail += f" Detail: {error_json.get('detail', e.response.text)}"
121
  except requests.exceptions.JSONDecodeError:
122
  error_detail += f" Response: {e.response.text[:500]}"
123
+ return f"Submission Failed: {error_detail}", pd.DataFrame(results_log)
 
 
 
124
  except requests.exceptions.Timeout:
125
+ return "Submission Failed: The request timed out.", pd.DataFrame(results_log)
 
 
 
126
  except requests.exceptions.RequestException as e:
127
+ return f"Submission Failed: Network error - {e}", pd.DataFrame(results_log)
 
 
 
128
  except Exception as e:
129
+ return (
130
+ f"An unexpected error occurred during submission: {e}",
131
+ pd.DataFrame(results_log),
132
+ )
133
 
134
 
135
  # --- Build Gradio Interface using Blocks ---
136
  with gr.Blocks() as demo:
137
+ gr.Markdown("# GAIA Agent — Final Assignment Runner")
138
  gr.Markdown(
139
  """
140
  **Instructions:**
141
+ 1. Add `HF_TOKEN` and `SERPER_API_KEY` as Space secrets.
142
+ 2. Log in to Hugging Face with the button below.
143
+ 3. Click **Run Evaluation & Submit All Answers**. Running 20 questions
144
+ can take 10–20 minutes; stay on the tab.
 
 
 
 
 
145
  """
146
  )
147
 
 
149
 
150
  run_button = gr.Button("Run Evaluation & Submit All Answers")
151
 
152
+ status_output = gr.Textbox(
153
+ label="Run Status / Submission Result", lines=5, interactive=False
154
+ )
155
  results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
156
 
157
+ run_button.click(fn=run_and_submit_all, outputs=[status_output, results_table])
158
+
 
 
159
 
160
  if __name__ == "__main__":
161
+ print("\n" + "-" * 30 + " App Starting " + "-" * 30)
 
162
  space_host_startup = os.getenv("SPACE_HOST")
163
+ space_id_startup = os.getenv("SPACE_ID")
164
 
165
  if space_host_startup:
166
+ print(f"SPACE_HOST: {space_host_startup}")
167
+ print(f" Runtime URL: https://{space_host_startup}.hf.space")
 
 
 
 
 
 
 
168
  else:
169
+ print("SPACE_HOST not set (running locally?).")
170
 
171
+ if space_id_startup:
172
+ print(f"SPACE_ID: {space_id_startup}")
173
+ print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
174
 
175
+ print("-" * (60 + len(" App Starting ")) + "\n")
176
+ print("Launching Gradio Interface for GAIA Agent Runner...")
177
+ demo.launch(debug=True, share=False)
requirements.txt CHANGED
@@ -1,2 +1,14 @@
1
- gradio
2
- requests
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=5.25.2
2
+ requests
3
+ pandas
4
+ smolagents>=1.14.0
5
+ huggingface_hub>=0.27.0
6
+ duckduckgo-search
7
+ wikipedia-api
8
+ youtube-transcript-api
9
+ beautifulsoup4
10
+ markdownify
11
+ openpyxl
12
+ python-dotenv
13
+ Pillow
14
+ pypdf
tools.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom tools for the GAIA agent.
2
+
3
+ Each tool is a @tool-decorated function that smolagents can call from a CodeAgent.
4
+ Keep tool docstrings precise — the LLM reads them to decide when to call.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import io
9
+ import os
10
+ import re
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Optional
14
+ from urllib.parse import urlparse
15
+
16
+ import requests
17
+ from smolagents import tool
18
+
19
+ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
20
+ USER_AGENT = (
21
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
22
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
23
+ )
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Web search
28
+ # ---------------------------------------------------------------------------
29
+ @tool
30
+ def web_search(query: str, num_results: int = 8) -> str:
31
+ """Search the web with Serper (Google results) and return the top hits.
32
+
33
+ Args:
34
+ query: The search query.
35
+ num_results: How many results to return (1-10).
36
+
37
+ Returns:
38
+ A text block of results: title, link, snippet. Use this to find URLs
39
+ worth reading with `read_webpage`.
40
+ """
41
+ api_key = os.getenv("SERPER_API_KEY")
42
+ num_results = max(1, min(int(num_results), 10))
43
+ if not api_key:
44
+ # Fallback to DuckDuckGo if no Serper key.
45
+ try:
46
+ from duckduckgo_search import DDGS
47
+
48
+ with DDGS() as ddgs:
49
+ hits = list(ddgs.text(query, max_results=num_results))
50
+ if not hits:
51
+ return "No results."
52
+ return "\n\n".join(
53
+ f"[{i + 1}] {h.get('title', '')}\n{h.get('href', '')}\n{h.get('body', '')}"
54
+ for i, h in enumerate(hits)
55
+ )
56
+ except Exception as e: # pragma: no cover
57
+ return f"Search failed (no SERPER_API_KEY, DDG fallback errored): {e}"
58
+
59
+ try:
60
+ resp = requests.post(
61
+ "https://google.serper.dev/search",
62
+ headers={"X-API-KEY": api_key, "Content-Type": "application/json"},
63
+ json={"q": query, "num": num_results},
64
+ timeout=20,
65
+ )
66
+ resp.raise_for_status()
67
+ data = resp.json()
68
+ except Exception as e:
69
+ return f"Serper search failed: {e}"
70
+
71
+ parts: list[str] = []
72
+ if "answerBox" in data:
73
+ ab = data["answerBox"]
74
+ parts.append(
75
+ "ANSWER BOX:\n"
76
+ + (ab.get("answer") or ab.get("snippet") or ab.get("title") or "").strip()
77
+ )
78
+ if "knowledgeGraph" in data:
79
+ kg = data["knowledgeGraph"]
80
+ parts.append(
81
+ f"KNOWLEDGE GRAPH: {kg.get('title', '')} — {kg.get('description', '')}"
82
+ )
83
+ for i, item in enumerate(data.get("organic", [])[:num_results], 1):
84
+ parts.append(
85
+ f"[{i}] {item.get('title', '')}\n{item.get('link', '')}\n"
86
+ f"{item.get('snippet', '')}"
87
+ )
88
+ return "\n\n".join(parts) if parts else "No results."
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Web page reader
93
+ # ---------------------------------------------------------------------------
94
+ @tool
95
+ def read_webpage(url: str, max_chars: int = 8000) -> str:
96
+ """Fetch a URL and return its main text content as Markdown.
97
+
98
+ Args:
99
+ url: The full URL to fetch (http or https).
100
+ max_chars: Maximum characters to return (truncated tail dropped).
101
+
102
+ Returns:
103
+ Markdown text. Use after `web_search` to actually read a page.
104
+ """
105
+ try:
106
+ from bs4 import BeautifulSoup
107
+ from markdownify import markdownify
108
+ except Exception as e: # pragma: no cover
109
+ return f"Missing deps: {e}"
110
+
111
+ if not url.startswith(("http://", "https://")):
112
+ return f"Invalid URL: {url}"
113
+
114
+ try:
115
+ resp = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=25)
116
+ resp.raise_for_status()
117
+ except Exception as e:
118
+ return f"Fetch failed for {url}: {e}"
119
+
120
+ ctype = resp.headers.get("Content-Type", "").lower()
121
+ if "pdf" in ctype or url.lower().endswith(".pdf"):
122
+ return _pdf_to_text(resp.content, max_chars)
123
+
124
+ soup = BeautifulSoup(resp.text, "html.parser")
125
+ for tag in soup(["script", "style", "noscript", "header", "footer", "nav"]):
126
+ tag.decompose()
127
+ md = markdownify(str(soup), heading_style="ATX")
128
+ md = re.sub(r"\n{3,}", "\n\n", md).strip()
129
+ if len(md) > max_chars:
130
+ md = md[:max_chars] + "\n\n[...truncated...]"
131
+ return md
132
+
133
+
134
+ def _pdf_to_text(data: bytes, max_chars: int) -> str:
135
+ try:
136
+ from pypdf import PdfReader
137
+ except Exception:
138
+ try:
139
+ from PyPDF2 import PdfReader # type: ignore
140
+ except Exception as e:
141
+ return f"PDF read failed (install pypdf): {e}"
142
+ try:
143
+ reader = PdfReader(io.BytesIO(data))
144
+ text = "\n\n".join((p.extract_text() or "") for p in reader.pages)
145
+ except Exception as e:
146
+ return f"PDF parse failed: {e}"
147
+ if len(text) > max_chars:
148
+ text = text[:max_chars] + "\n\n[...truncated...]"
149
+ return text
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Wikipedia
154
+ # ---------------------------------------------------------------------------
155
+ @tool
156
+ def wikipedia_search(query: str, sentences: int = 8) -> str:
157
+ """Look up a topic on English Wikipedia.
158
+
159
+ Args:
160
+ query: The page title or topic.
161
+ sentences: Sentences of summary to return.
162
+
163
+ Returns:
164
+ A summary block with the page URL, or an error message.
165
+ """
166
+ try:
167
+ import wikipediaapi
168
+ except Exception as e: # pragma: no cover
169
+ return f"Missing deps: {e}"
170
+
171
+ wiki = wikipediaapi.Wikipedia(user_agent=USER_AGENT, language="en")
172
+ page = wiki.page(query)
173
+ if not page.exists():
174
+ # Try a search-then-fetch with the search API.
175
+ try:
176
+ resp = requests.get(
177
+ "https://en.wikipedia.org/w/api.php",
178
+ params={
179
+ "action": "query",
180
+ "list": "search",
181
+ "srsearch": query,
182
+ "format": "json",
183
+ "srlimit": 1,
184
+ },
185
+ headers={"User-Agent": USER_AGENT},
186
+ timeout=15,
187
+ )
188
+ hits = resp.json().get("query", {}).get("search", [])
189
+ if not hits:
190
+ return f"No Wikipedia page found for: {query}"
191
+ page = wiki.page(hits[0]["title"])
192
+ except Exception as e:
193
+ return f"Wikipedia lookup failed: {e}"
194
+ if not page.exists():
195
+ return f"No Wikipedia page found for: {query}"
196
+
197
+ summary = page.summary
198
+ parts = re.split(r"(?<=[.!?])\s+", summary)
199
+ out = " ".join(parts[: max(1, int(sentences))])
200
+ return f"{page.title}\n{page.fullurl}\n\n{out}"
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # YouTube transcript
205
+ # ---------------------------------------------------------------------------
206
+ @tool
207
+ def youtube_transcript(url_or_id: str) -> str:
208
+ """Fetch the transcript of a YouTube video.
209
+
210
+ Args:
211
+ url_or_id: A full YouTube URL or just the 11-char video ID.
212
+
213
+ Returns:
214
+ Plain text transcript, or an error message.
215
+ """
216
+ vid = _yt_id(url_or_id)
217
+ if not vid:
218
+ return f"Could not parse YouTube id from: {url_or_id}"
219
+ try:
220
+ from youtube_transcript_api import YouTubeTranscriptApi
221
+ except Exception as e: # pragma: no cover
222
+ return f"Missing deps: {e}"
223
+ try:
224
+ chunks = YouTubeTranscriptApi.get_transcript(vid)
225
+ except Exception as e:
226
+ return f"Transcript fetch failed: {e}"
227
+ return " ".join(c["text"] for c in chunks)
228
+
229
+
230
+ def _yt_id(s: str) -> Optional[str]:
231
+ s = s.strip()
232
+ if re.fullmatch(r"[A-Za-z0-9_-]{11}", s):
233
+ return s
234
+ try:
235
+ u = urlparse(s)
236
+ except Exception:
237
+ return None
238
+ if u.hostname in ("youtu.be",):
239
+ return u.path.lstrip("/")[:11] or None
240
+ if u.hostname and "youtube" in u.hostname:
241
+ from urllib.parse import parse_qs
242
+
243
+ qs = parse_qs(u.query)
244
+ v = qs.get("v", [None])[0]
245
+ if v:
246
+ return v[:11]
247
+ m = re.search(r"/(embed|shorts)/([A-Za-z0-9_-]{11})", u.path)
248
+ if m:
249
+ return m.group(2)
250
+ m = re.search(r"([A-Za-z0-9_-]{11})", s)
251
+ return m.group(1) if m else None
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # GAIA file attachment
256
+ # ---------------------------------------------------------------------------
257
+ @tool
258
+ def download_task_file(task_id: str) -> str:
259
+ """Download the file attachment for a GAIA task (if one exists).
260
+
261
+ Args:
262
+ task_id: The task id of the current question.
263
+
264
+ Returns:
265
+ Absolute local path of the downloaded file, or a message saying
266
+ no file is attached. Read the file with normal Python after.
267
+ """
268
+ base = os.getenv("GAIA_API_URL", DEFAULT_API_URL).rstrip("/")
269
+ url = f"{base}/files/{task_id}"
270
+ try:
271
+ resp = requests.get(url, timeout=30)
272
+ except Exception as e:
273
+ return f"Download error: {e}"
274
+ if resp.status_code == 404:
275
+ return "NO_FILE: this task has no attachment."
276
+ if resp.status_code != 200:
277
+ return f"Download failed: HTTP {resp.status_code}"
278
+
279
+ name = _filename_from_response(resp, task_id)
280
+ out_dir = Path(tempfile.gettempdir()) / "gaia_files"
281
+ out_dir.mkdir(parents=True, exist_ok=True)
282
+ path = out_dir / name
283
+ path.write_bytes(resp.content)
284
+ return str(path.resolve())
285
+
286
+
287
+ def _filename_from_response(resp: requests.Response, task_id: str) -> str:
288
+ cd = resp.headers.get("Content-Disposition", "")
289
+ m = re.search(r'filename\*?=(?:UTF-\d\'\')?"?([^";]+)"?', cd)
290
+ if m:
291
+ return m.group(1).strip()
292
+ ctype = resp.headers.get("Content-Type", "").split(";")[0].strip()
293
+ ext = {
294
+ "text/plain": ".txt",
295
+ "text/csv": ".csv",
296
+ "application/pdf": ".pdf",
297
+ "application/json": ".json",
298
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
299
+ "application/vnd.ms-excel": ".xls",
300
+ "application/x-python": ".py",
301
+ "image/png": ".png",
302
+ "image/jpeg": ".jpg",
303
+ "audio/mpeg": ".mp3",
304
+ "audio/wav": ".wav",
305
+ "audio/x-wav": ".wav",
306
+ "audio/mp4": ".m4a",
307
+ "video/mp4": ".mp4",
308
+ }.get(ctype, "")
309
+ return f"{task_id}{ext}"
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Excel / CSV reader (deterministic helper so the LLM doesn't have to handcraft)
314
+ # ---------------------------------------------------------------------------
315
+ @tool
316
+ def read_table(file_path: str, sheet: Optional[str] = None, max_rows: int = 200) -> str:
317
+ """Read an Excel/CSV file and return a textual preview.
318
+
319
+ Args:
320
+ file_path: Absolute path to .xlsx / .xls / .csv / .tsv.
321
+ sheet: Optional sheet name (Excel only). Default: first sheet.
322
+ max_rows: Max rows to include in the preview.
323
+
324
+ Returns:
325
+ Column dtypes + a CSV-style preview. For deeper analysis, load it with
326
+ pandas yourself in a code block.
327
+ """
328
+ import pandas as pd
329
+
330
+ p = Path(file_path)
331
+ if not p.exists():
332
+ return f"File not found: {file_path}"
333
+ suffix = p.suffix.lower()
334
+ try:
335
+ if suffix in (".xlsx", ".xls"):
336
+ df = pd.read_excel(p, sheet_name=sheet or 0)
337
+ elif suffix == ".tsv":
338
+ df = pd.read_csv(p, sep="\t")
339
+ else:
340
+ df = pd.read_csv(p)
341
+ except Exception as e:
342
+ return f"Read failed: {e}"
343
+
344
+ head = df.head(max_rows)
345
+ info = [
346
+ f"shape: {df.shape}",
347
+ "dtypes:",
348
+ df.dtypes.astype(str).to_string(),
349
+ "",
350
+ "preview:",
351
+ head.to_csv(index=False),
352
+ ]
353
+ return "\n".join(info)
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Audio transcription via HF Inference (Whisper)
358
+ # ---------------------------------------------------------------------------
359
+ @tool
360
+ def transcribe_audio(file_path: str) -> str:
361
+ """Transcribe an audio file (mp3/wav/m4a) using Whisper via HF Inference.
362
+
363
+ Args:
364
+ file_path: Absolute path to the audio file.
365
+
366
+ Returns:
367
+ The transcript text, or an error message.
368
+ """
369
+ from huggingface_hub import InferenceClient
370
+
371
+ token = os.getenv("HF_TOKEN")
372
+ if not token:
373
+ return "Missing HF_TOKEN for HF Inference."
374
+ p = Path(file_path)
375
+ if not p.exists():
376
+ return f"File not found: {file_path}"
377
+ model_id = os.getenv("ASR_MODEL_ID", "openai/whisper-large-v3")
378
+ try:
379
+ client = InferenceClient(token=token)
380
+ out = client.automatic_speech_recognition(p.read_bytes(), model=model_id)
381
+ except Exception as e:
382
+ return f"ASR failed: {e}"
383
+ if isinstance(out, dict):
384
+ return out.get("text", "")
385
+ return getattr(out, "text", str(out))
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Image VQA via HF Inference
390
+ # ---------------------------------------------------------------------------
391
+ @tool
392
+ def analyze_image(file_path: str, question: str = "Describe this image in detail.") -> str:
393
+ """Ask a vision-language model about an image file.
394
+
395
+ Args:
396
+ file_path: Absolute path to a .png / .jpg / .jpeg / .webp file.
397
+ question: The question to ask about the image. Default: detailed description.
398
+
399
+ Returns:
400
+ The model's answer text.
401
+ """
402
+ import base64
403
+
404
+ from huggingface_hub import InferenceClient
405
+
406
+ token = os.getenv("HF_TOKEN")
407
+ if not token:
408
+ return "Missing HF_TOKEN for HF Inference."
409
+ p = Path(file_path)
410
+ if not p.exists():
411
+ return f"File not found: {file_path}"
412
+
413
+ model_id = os.getenv("VLM_MODEL_ID", "Qwen/Qwen2.5-VL-7B-Instruct")
414
+ provider = os.getenv("VLM_PROVIDER", "auto")
415
+
416
+ suffix = p.suffix.lower().lstrip(".")
417
+ mime = {"jpg": "jpeg"}.get(suffix, suffix) or "png"
418
+ b64 = base64.b64encode(p.read_bytes()).decode("ascii")
419
+ data_url = f"data:image/{mime};base64,{b64}"
420
+
421
+ try:
422
+ client = InferenceClient(token=token, provider=provider)
423
+ resp = client.chat.completions.create(
424
+ model=model_id,
425
+ messages=[
426
+ {
427
+ "role": "user",
428
+ "content": [
429
+ {"type": "text", "text": question},
430
+ {"type": "image_url", "image_url": {"url": data_url}},
431
+ ],
432
+ }
433
+ ],
434
+ max_tokens=512,
435
+ )
436
+ return resp.choices[0].message.content or ""
437
+ except Exception as e:
438
+ return f"VLM call failed: {e}"
439
+
440
+
441
+ __all__ = [
442
+ "web_search",
443
+ "read_webpage",
444
+ "wikipedia_search",
445
+ "youtube_transcript",
446
+ "download_task_file",
447
+ "read_table",
448
+ "transcribe_audio",
449
+ "analyze_image",
450
+ ]