Raj commited on
Commit
08fe068
·
verified ·
1 Parent(s): 81917a3

Upload 6 files

Browse files
Files changed (6) hide show
  1. README.md +37 -2
  2. agent.py +267 -0
  3. app.py +82 -103
  4. config.py +66 -0
  5. requirements.txt +14 -2
  6. tools.py +451 -0
README.md CHANGED
@@ -8,8 +8,43 @@ 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
17
+ Agents course final assignment. Uses HF Inference Providers for the LLM and a
18
+ small toolset for browsing, files, audio, and images. Self-consistency +
19
+ verifier pass on top to maximize exact-match score.
20
+
21
+ ## Where things live
22
+
23
+ | File | Purpose |
24
+ | ------------- | -------------------------------------------------------- |
25
+ | `app.py` | Gradio UI + run-and-submit pipeline |
26
+ | `agent.py` | `GaiaAgent` class — self-consistency + verifier |
27
+ | `tools.py` | 8 tools (search, browse, wiki, YT, file, table, ASR, VLM)|
28
+ | `config.py` | **All non-secret settings** (model IDs, knobs) |
29
+ | `requirements.txt` | Python deps |
30
+
31
+ ## Secrets (Space → Settings → Variables and secrets)
32
+
33
+ | Secret | Purpose |
34
+ | ---------------- | -------------------------------------------------------- |
35
+ | `HF_TOKEN` | HF Inference Providers (uses your Pro quota) |
36
+ | `SERPER_API_KEY` | Web search via serper.dev |
37
+
38
+ That's it. No model IDs in secrets — edit `config.py` for those.
39
+
40
+ ## Tuning
41
+
42
+ Open `config.py` and change values directly:
43
+
44
+ - `AGENT_MODEL_ID` — the LLM brain. Default DeepSeek-V3.
45
+ - `SELF_CONSISTENCY_N` — independent attempts per question. 3 = balanced, 5 = max.
46
+ - `MAX_STEPS` — agent reasoning steps per attempt.
47
+ - `VLM_MODEL_ID` — vision model for image questions.
48
+
49
+ Any of the constants in `config.py` can also be overridden by a Space env var
50
+ of the same name, but that's optional.
agent.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GAIA agent: smolagents CodeAgent + HF Inference Providers backend.
2
+
3
+ Pipeline per question:
4
+ 1. Eager-download `/files/{task_id}` so attachments are never missed.
5
+ 2. Run the CodeAgent N times (self-consistency).
6
+ 3. Majority-vote across normalized candidate answers.
7
+ 4. Verifier LLM pass: re-asks the model to pick the best answer in the
8
+ canonical GAIA `FINAL ANSWER:` format given the candidates.
9
+ 5. Final string normalization for exact-match.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from collections import Counter
16
+ from typing import Optional
17
+
18
+ from huggingface_hub import InferenceClient
19
+ from smolagents import CodeAgent, InferenceClientModel
20
+
21
+ import config
22
+ from tools import (
23
+ analyze_image,
24
+ download_task_file,
25
+ read_table,
26
+ read_webpage,
27
+ transcribe_audio,
28
+ web_search,
29
+ wikipedia_search,
30
+ youtube_transcript,
31
+ )
32
+
33
+ SYSTEM_PROMPT = """You are a general AI assistant solving questions from the GAIA benchmark.
34
+
35
+ I will ask you a question. Think step by step using the tools available. When you
36
+ are confident, finish your answer with the following template:
37
+
38
+ FINAL ANSWER: [YOUR FINAL ANSWER]
39
+
40
+ YOUR FINAL ANSWER must obey these rules exactly:
41
+ - It is a number, OR as few words as possible, OR a comma separated list of
42
+ numbers and/or strings.
43
+ - If a number: digits only. No commas inside numbers. No units ($, %, kg, etc.)
44
+ unless the question explicitly asks for them.
45
+ - If a string: no articles (a, an, the), no abbreviations (write "Saint Louis"
46
+ not "St. Louis", "New York City" not "NYC"), and digits in plain text unless
47
+ the question asks for numerals.
48
+ - If a comma separated list: items separated by ", " (comma + single space),
49
+ in the order requested, each item following the number/string rules above.
50
+ - Do NOT include explanations, units, quotes, or trailing punctuation in the
51
+ FINAL ANSWER line itself.
52
+
53
+ Tool playbook:
54
+ - If the question mentions an attached file ("the attached", "this Excel /
55
+ CSV / audio / image / Python file", "the file"), call
56
+ `download_task_file(task_id)` FIRST to get a local path.
57
+ - Spreadsheets / CSV: `read_table(path)` then pandas in Python.
58
+ - Audio: `transcribe_audio(path)`.
59
+ - Image: `analyze_image(path, question)` — phrase the question precisely.
60
+ - YouTube link: `youtube_transcript(url)`.
61
+ - Open web: `web_search(query)` then `read_webpage(url)` on the best hit.
62
+ - Encyclopedic facts: `wikipedia_search(topic)`.
63
+ - Use Python freely for math, parsing, sorting, set operations, etc.
64
+
65
+ Self-check before answering:
66
+ - Did you answer the literal question asked? (Not a related one.)
67
+ - Does the format match the rules above?
68
+ - If unsure, do one more verification search.
69
+ """
70
+
71
+ VERIFIER_SYSTEM = """You are a strict GAIA answer formatter.
72
+
73
+ You will see a GAIA question and several candidate answers produced by an agent.
74
+ Pick the single best answer and reformat it to obey the GAIA answer format:
75
+
76
+ - A number, a short string, or a comma-separated list.
77
+ - Numbers: digits only, no commas, no units unless the question requires them.
78
+ - Strings: no articles, no abbreviations, plain-text digits unless asked.
79
+ - Comma-separated lists: ", " between items, in the order requested.
80
+ - No explanations. No quotes. No trailing punctuation.
81
+
82
+ Respond on a SINGLE LINE in exactly this template:
83
+ FINAL ANSWER: <the answer>
84
+ """
85
+
86
+
87
+ class GaiaAgent:
88
+ """CodeAgent + self-consistency + verifier."""
89
+
90
+ def __init__(self, model_id: Optional[str] = None, provider: Optional[str] = None):
91
+ token = os.getenv("HF_TOKEN")
92
+ if not token:
93
+ raise RuntimeError(
94
+ "HF_TOKEN is not set. Add it as a Space secret to use HF Inference."
95
+ )
96
+ self._token = token
97
+ self._model_id = model_id or config.AGENT_MODEL_ID
98
+ self._provider = provider or config.AGENT_PROVIDER
99
+ self.model = InferenceClientModel(
100
+ model_id=self._model_id,
101
+ provider=self._provider,
102
+ token=token,
103
+ max_tokens=config.AGENT_MAX_TOKENS,
104
+ )
105
+ self.tools = [
106
+ web_search,
107
+ read_webpage,
108
+ wikipedia_search,
109
+ youtube_transcript,
110
+ download_task_file,
111
+ read_table,
112
+ transcribe_audio,
113
+ analyze_image,
114
+ ]
115
+ self._verifier = InferenceClient(token=token, provider=self._provider)
116
+ self._n = config.SELF_CONSISTENCY_N
117
+
118
+ # -------------------- public --------------------
119
+ def __call__(self, question: str, task_id: Optional[str] = None) -> str:
120
+ # 1) Pre-download attachment.
121
+ attachment_line = ""
122
+ if task_id:
123
+ try:
124
+ path = download_task_file(task_id)
125
+ if path and not path.startswith(
126
+ ("NO_FILE", "Download error", "Download failed")
127
+ ):
128
+ attachment_line = (
129
+ f"An attached file for this task has already been "
130
+ f"downloaded to: {path}\nUse the right tool on this "
131
+ f"path (read_table for spreadsheets/CSV, "
132
+ f"transcribe_audio for audio, analyze_image for "
133
+ f"images, or open it with Python).\n"
134
+ )
135
+ except Exception as e:
136
+ print(f" pre-download skipped: {e}")
137
+
138
+ # 2) Self-consistency: N independent CodeAgent runs.
139
+ candidates: list[str] = []
140
+ for i in range(self._n):
141
+ try:
142
+ raw = self._single_run(question, task_id, attachment_line)
143
+ except Exception as e:
144
+ print(f" attempt {i + 1} errored: {e}")
145
+ continue
146
+ norm = self._normalize(str(raw))
147
+ print(f" attempt {i + 1}: {norm!r}")
148
+ if norm and not norm.startswith("AGENT ERROR"):
149
+ candidates.append(norm)
150
+
151
+ if not candidates:
152
+ return "AGENT ERROR: no successful attempts"
153
+
154
+ # 3) Majority vote on normalized strings (case-insensitive bucket).
155
+ voted = self._vote(candidates)
156
+
157
+ # 4) Verifier pass — pick + reformat using the LLM.
158
+ try:
159
+ verified = self._verify(question, candidates, voted)
160
+ except Exception as e:
161
+ print(f" verifier errored, falling back to vote: {e}")
162
+ verified = voted
163
+
164
+ return self._normalize(verified)
165
+
166
+ # -------------------- helpers --------------------
167
+ def _build_agent(self) -> CodeAgent:
168
+ kwargs = dict(
169
+ tools=self.tools,
170
+ model=self.model,
171
+ max_steps=config.MAX_STEPS,
172
+ additional_authorized_imports=[
173
+ "pandas", "numpy", "json", "re", "math", "statistics",
174
+ "itertools", "collections", "datetime", "csv", "io",
175
+ "pathlib", "string", "base64", "urllib", "unicodedata",
176
+ ],
177
+ )
178
+ if config.PLANNING_INTERVAL > 0:
179
+ try:
180
+ return CodeAgent(planning_interval=config.PLANNING_INTERVAL, **kwargs)
181
+ except TypeError:
182
+ pass
183
+ return CodeAgent(**kwargs)
184
+
185
+ def _single_run(
186
+ self,
187
+ question: str,
188
+ task_id: Optional[str],
189
+ attachment_line: str,
190
+ ) -> str:
191
+ agent = self._build_agent()
192
+ prompt = SYSTEM_PROMPT
193
+ if task_id:
194
+ prompt += f"\nThe current task_id is: {task_id}\n"
195
+ if attachment_line:
196
+ prompt += attachment_line
197
+ prompt += f"\nQuestion:\n{question}\n"
198
+ return agent.run(prompt)
199
+
200
+ @staticmethod
201
+ def _vote(candidates: list[str]) -> str:
202
+ """Majority-vote with case-insensitive bucketing; ties → first seen."""
203
+ buckets: dict[str, list[str]] = {}
204
+ for c in candidates:
205
+ key = c.lower().strip()
206
+ buckets.setdefault(key, []).append(c)
207
+ ranked = sorted(
208
+ buckets.items(),
209
+ key=lambda kv: (-len(kv[1]), candidates.index(kv[1][0])),
210
+ )
211
+ return ranked[0][1][0]
212
+
213
+ def _verify(
214
+ self,
215
+ question: str,
216
+ candidates: list[str],
217
+ voted: str,
218
+ ) -> str:
219
+ """Ask the LLM to choose + reformat the best candidate."""
220
+ cand_block = "\n".join(f"- {c}" for c in candidates)
221
+ user = (
222
+ f"Question:\n{question}\n\n"
223
+ f"Candidate answers from independent attempts:\n{cand_block}\n\n"
224
+ f"Most common candidate: {voted}\n\n"
225
+ f"Return the best final answer in the canonical GAIA format."
226
+ )
227
+ resp = self._verifier.chat.completions.create(
228
+ model=self._model_id,
229
+ messages=[
230
+ {"role": "system", "content": VERIFIER_SYSTEM},
231
+ {"role": "user", "content": user},
232
+ ],
233
+ max_tokens=256,
234
+ temperature=0.0,
235
+ )
236
+ text = (resp.choices[0].message.content or "").strip()
237
+ m = re.search(r"final answer\s*[:\-]\s*(.+)", text, flags=re.IGNORECASE)
238
+ return m.group(1).strip() if m else text
239
+
240
+ @staticmethod
241
+ def _normalize(text: str) -> str:
242
+ """Strip every common LLM cruft pattern so exact-match has a chance."""
243
+ if not text:
244
+ return text
245
+ t = str(text).strip()
246
+
247
+ # 1) If the model emitted "FINAL ANSWER: X" anywhere, keep only X.
248
+ m = re.search(r"final answer\s*[:\-]\s*(.+)", t, flags=re.IGNORECASE | re.DOTALL)
249
+ if m:
250
+ t = m.group(1).strip()
251
+ t = t.split("\n")[0].strip()
252
+
253
+ # 2) Strip wrapping markdown bold/italic/code.
254
+ t = re.sub(r"^[*_`]+|[*_`]+$", "", t).strip()
255
+
256
+ # 3) Strip wrapping quotes (incl. smart quotes).
257
+ if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'`“”‘’":
258
+ t = t[1:-1].strip()
259
+
260
+ # 4) Drop a single trailing period if the answer is short (not a sentence).
261
+ if t.count(" ") < 6:
262
+ t = t.rstrip(".")
263
+
264
+ # 5) Collapse internal whitespace.
265
+ t = re.sub(r"[ \t]+", " ", t).strip()
266
+
267
+ return t
app.py CHANGED
@@ -1,117 +1,116 @@
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.")
35
  return "Please Login to Hugging Face with the button.", None
36
 
37
- api_url = DEFAULT_API_URL
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 +118,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 +147,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
+ import config
9
+ from agent import GaiaAgent
10
+
11
 
12
+ def run_and_submit_all(profile: gr.OAuthProfile | None):
13
+ """Fetch GAIA questions, run the agent, submit answers, render results."""
14
+ space_id = os.getenv("SPACE_ID")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  if profile:
17
+ username = f"{profile.username}"
18
  print(f"User logged in: {username}")
19
  else:
20
  print("User not logged in.")
21
  return "Please Login to Hugging Face with the button.", None
22
 
23
+ api_url = config.GAIA_API_URL
24
  questions_url = f"{api_url}/questions"
25
  submit_url = f"{api_url}/submit"
26
 
27
+ # 1. Instantiate agent
28
  try:
29
+ agent = GaiaAgent()
30
  except Exception as e:
31
  print(f"Error instantiating agent: {e}")
32
  return f"Error initializing agent: {e}", None
33
+
34
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
35
  print(agent_code)
36
 
37
+ # 2. Fetch questions
38
  print(f"Fetching questions from: {questions_url}")
39
  try:
40
+ response = requests.get(questions_url, timeout=30)
41
  response.raise_for_status()
42
  questions_data = response.json()
43
  if not questions_data:
44
+ return "Fetched questions list is empty or invalid format.", None
 
45
  print(f"Fetched {len(questions_data)} questions.")
46
  except requests.exceptions.RequestException as e:
 
47
  return f"Error fetching questions: {e}", None
48
  except requests.exceptions.JSONDecodeError as e:
49
+ return f"Error decoding server response for questions: {e}", None
 
 
50
  except Exception as e:
 
51
  return f"An unexpected error occurred fetching questions: {e}", None
52
 
53
+ # 3. Run agent over all questions
54
  results_log = []
55
  answers_payload = []
56
  print(f"Running agent on {len(questions_data)} questions...")
57
+ for i, item in enumerate(questions_data, 1):
58
  task_id = item.get("task_id")
59
  question_text = item.get("question")
60
  if not task_id or question_text is None:
61
  print(f"Skipping item with missing task_id or question: {item}")
62
  continue
63
+ print(f"\n=== [{i}/{len(questions_data)}] task {task_id} ===")
64
  try:
65
+ submitted_answer = agent(question_text, task_id=task_id)
 
 
66
  except Exception as e:
67
+ traceback.print_exc()
68
+ submitted_answer = f"AGENT ERROR: {e}"
69
+ print(f" -> {submitted_answer!r}")
70
+ answers_payload.append(
71
+ {"task_id": task_id, "submitted_answer": submitted_answer}
72
+ )
73
+ results_log.append(
74
+ {
75
+ "Task ID": task_id,
76
+ "Question": question_text,
77
+ "Submitted Answer": submitted_answer,
78
+ }
79
+ )
80
 
81
  if not answers_payload:
82
+ return (
83
+ "Agent did not produce any answers to submit.",
84
+ pd.DataFrame(results_log),
85
+ )
86
 
87
+ # 4. Prepare submission
88
+ submission_data = {
89
+ "username": username.strip(),
90
+ "agent_code": agent_code,
91
+ "answers": answers_payload,
92
+ }
93
+ print(
94
+ f"Agent finished. Submitting {len(answers_payload)} answers for "
95
+ f"user '{username}'..."
96
+ )
97
 
98
  # 5. Submit
99
  print(f"Submitting {len(answers_payload)} answers to: {submit_url}")
100
  try:
101
+ response = requests.post(submit_url, json=submission_data, timeout=120)
102
  response.raise_for_status()
103
  result_data = response.json()
104
  final_status = (
105
  f"Submission Successful!\n"
106
  f"User: {result_data.get('username')}\n"
107
  f"Overall Score: {result_data.get('score', 'N/A')}% "
108
+ f"({result_data.get('correct_count', '?')}/"
109
+ f"{result_data.get('total_attempted', '?')} correct)\n"
110
  f"Message: {result_data.get('message', 'No message received.')}"
111
  )
112
  print("Submission successful.")
113
+ return final_status, pd.DataFrame(results_log)
 
114
  except requests.exceptions.HTTPError as e:
115
  error_detail = f"Server responded with status {e.response.status_code}."
116
  try:
 
118
  error_detail += f" Detail: {error_json.get('detail', e.response.text)}"
119
  except requests.exceptions.JSONDecodeError:
120
  error_detail += f" Response: {e.response.text[:500]}"
121
+ return f"Submission Failed: {error_detail}", pd.DataFrame(results_log)
 
 
 
122
  except requests.exceptions.Timeout:
123
+ return "Submission Failed: The request timed out.", pd.DataFrame(results_log)
 
 
 
124
  except requests.exceptions.RequestException as e:
125
+ return f"Submission Failed: Network error - {e}", pd.DataFrame(results_log)
 
 
 
126
  except Exception as e:
127
+ return (
128
+ f"An unexpected error occurred during submission: {e}",
129
+ pd.DataFrame(results_log),
130
+ )
131
 
132
 
133
  # --- Build Gradio Interface using Blocks ---
134
  with gr.Blocks() as demo:
135
+ gr.Markdown("# GAIA Agent — Final Assignment Runner")
136
  gr.Markdown(
137
  """
138
  **Instructions:**
139
+ 1. Add `HF_TOKEN` and `SERPER_API_KEY` as Space secrets.
140
+ 2. Log in to Hugging Face with the button below.
141
+ 3. Click **Run Evaluation & Submit All Answers**. Running 20 questions
142
+ can take 10–20 minutes; stay on the tab.
 
 
 
 
 
143
  """
144
  )
145
 
 
147
 
148
  run_button = gr.Button("Run Evaluation & Submit All Answers")
149
 
150
+ status_output = gr.Textbox(
151
+ label="Run Status / Submission Result", lines=5, interactive=False
152
+ )
153
  results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
154
 
155
+ run_button.click(fn=run_and_submit_all, outputs=[status_output, results_table])
156
+
 
 
157
 
158
  if __name__ == "__main__":
159
+ print("\n" + "-" * 30 + " App Starting " + "-" * 30)
 
160
  space_host_startup = os.getenv("SPACE_HOST")
161
+ space_id_startup = os.getenv("SPACE_ID")
162
 
163
  if space_host_startup:
164
+ print(f"SPACE_HOST: {space_host_startup}")
165
+ print(f" Runtime URL: https://{space_host_startup}.hf.space")
 
 
 
 
 
 
 
166
  else:
167
+ print("SPACE_HOST not set (running locally?).")
168
 
169
+ if space_id_startup:
170
+ print(f"SPACE_ID: {space_id_startup}")
171
+ print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
172
 
173
+ print("-" * (60 + len(" App Starting ")) + "\n")
174
+ print("Launching Gradio Interface for GAIA Agent Runner...")
175
+ demo.launch(debug=True, share=False)
config.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Non-secret configuration for the GAIA agent.
2
+
3
+ WHAT LIVES WHERE
4
+ - Secrets (Space → Settings → Variables and secrets):
5
+ * HF_TOKEN — required, for HF Inference Providers
6
+ * SERPER_API_KEY — required, for web search
7
+ - Everything else (model IDs, providers, step limits, tool knobs) lives in
8
+ this file. Edit values here and push — no secret changes needed.
9
+
10
+ OVERRIDE RULES
11
+ Any env var with the same name as a constant below will override the value at
12
+ import time. This lets you A/B test from Space secrets without editing code.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+
19
+ def _env(name: str, default: str) -> str:
20
+ return os.getenv(name, default)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # LLM that powers the CodeAgent (the "brain")
25
+ # ---------------------------------------------------------------------------
26
+ # Strong open-source options ranked by GAIA performance (as of 2026):
27
+ # - "deepseek-ai/DeepSeek-V3-0324" (provider="together") ← default
28
+ # - "Qwen/Qwen2.5-72B-Instruct" (provider="auto")
29
+ # - "meta-llama/Llama-3.3-70B-Instruct" (provider="auto")
30
+ # - "deepseek-ai/DeepSeek-R1-Distill-Llama-70B" (reasoning-tuned)
31
+ AGENT_MODEL_ID = _env("AGENT_MODEL_ID", "deepseek-ai/DeepSeek-V3-0324")
32
+ AGENT_PROVIDER = _env("AGENT_PROVIDER", "together")
33
+ AGENT_MAX_TOKENS = int(_env("AGENT_MAX_TOKENS", "4096"))
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Vision-language model (used by `analyze_image`)
37
+ # ---------------------------------------------------------------------------
38
+ VLM_MODEL_ID = _env("VLM_MODEL_ID", "Qwen/Qwen2.5-VL-72B-Instruct")
39
+ VLM_PROVIDER = _env("VLM_PROVIDER", "auto")
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Speech-to-text model (used by `transcribe_audio`)
43
+ # ---------------------------------------------------------------------------
44
+ ASR_MODEL_ID = _env("ASR_MODEL_ID", "openai/whisper-large-v3")
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Agent loop knobs
48
+ # ---------------------------------------------------------------------------
49
+ # Max reasoning steps before the agent must commit to a final answer.
50
+ MAX_STEPS = int(_env("AGENT_MAX_STEPS", "15"))
51
+ # How often to interleave a planning step (0 = disabled).
52
+ PLANNING_INTERVAL = int(_env("AGENT_PLANNING_INTERVAL", "4"))
53
+ # Self-consistency: independent attempts per question. 3 = balanced, 5 = max.
54
+ SELF_CONSISTENCY_N = int(_env("GAIA_SELF_CONSISTENCY_N", "3"))
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Tool defaults
58
+ # ---------------------------------------------------------------------------
59
+ SEARCH_RESULTS = int(_env("WEB_SEARCH_RESULTS", "10"))
60
+ PAGE_MAX_CHARS = int(_env("READ_WEBPAGE_MAX_CHARS", "15000"))
61
+ WIKI_SENTENCES = int(_env("WIKI_SENTENCES", "8"))
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # GAIA scoring API
65
+ # ---------------------------------------------------------------------------
66
+ GAIA_API_URL = _env("GAIA_API_URL", "https://agents-course-unit4-scoring.hf.space")
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,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import config
20
+
21
+ USER_AGENT = (
22
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
23
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
24
+ )
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Web search
29
+ # ---------------------------------------------------------------------------
30
+ @tool
31
+ def web_search(query: str, num_results: int = config.SEARCH_RESULTS) -> str:
32
+ """Search the web with Serper (Google results) and return the top hits.
33
+
34
+ Args:
35
+ query: The search query.
36
+ num_results: How many results to return (1-10).
37
+
38
+ Returns:
39
+ A text block of results: title, link, snippet. Use this to find URLs
40
+ worth reading with `read_webpage`.
41
+ """
42
+ api_key = os.getenv("SERPER_API_KEY")
43
+ num_results = max(1, min(int(num_results), 10))
44
+ if not api_key:
45
+ # Fallback to DuckDuckGo if no Serper key.
46
+ try:
47
+ from duckduckgo_search import DDGS
48
+
49
+ with DDGS() as ddgs:
50
+ hits = list(ddgs.text(query, max_results=num_results))
51
+ if not hits:
52
+ return "No results."
53
+ return "\n\n".join(
54
+ f"[{i + 1}] {h.get('title', '')}\n{h.get('href', '')}\n{h.get('body', '')}"
55
+ for i, h in enumerate(hits)
56
+ )
57
+ except Exception as e: # pragma: no cover
58
+ return f"Search failed (no SERPER_API_KEY, DDG fallback errored): {e}"
59
+
60
+ try:
61
+ resp = requests.post(
62
+ "https://google.serper.dev/search",
63
+ headers={"X-API-KEY": api_key, "Content-Type": "application/json"},
64
+ json={"q": query, "num": num_results},
65
+ timeout=20,
66
+ )
67
+ resp.raise_for_status()
68
+ data = resp.json()
69
+ except Exception as e:
70
+ return f"Serper search failed: {e}"
71
+
72
+ parts: list[str] = []
73
+ if "answerBox" in data:
74
+ ab = data["answerBox"]
75
+ parts.append(
76
+ "ANSWER BOX:\n"
77
+ + (ab.get("answer") or ab.get("snippet") or ab.get("title") or "").strip()
78
+ )
79
+ if "knowledgeGraph" in data:
80
+ kg = data["knowledgeGraph"]
81
+ parts.append(
82
+ f"KNOWLEDGE GRAPH: {kg.get('title', '')} — {kg.get('description', '')}"
83
+ )
84
+ for i, item in enumerate(data.get("organic", [])[:num_results], 1):
85
+ parts.append(
86
+ f"[{i}] {item.get('title', '')}\n{item.get('link', '')}\n"
87
+ f"{item.get('snippet', '')}"
88
+ )
89
+ return "\n\n".join(parts) if parts else "No results."
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Web page reader
94
+ # ---------------------------------------------------------------------------
95
+ @tool
96
+ def read_webpage(url: str, max_chars: int = config.PAGE_MAX_CHARS) -> str:
97
+ """Fetch a URL and return its main text content as Markdown.
98
+
99
+ Args:
100
+ url: The full URL to fetch (http or https).
101
+ max_chars: Maximum characters to return (truncated tail dropped).
102
+
103
+ Returns:
104
+ Markdown text. Use after `web_search` to actually read a page.
105
+ """
106
+ try:
107
+ from bs4 import BeautifulSoup
108
+ from markdownify import markdownify
109
+ except Exception as e: # pragma: no cover
110
+ return f"Missing deps: {e}"
111
+
112
+ if not url.startswith(("http://", "https://")):
113
+ return f"Invalid URL: {url}"
114
+
115
+ try:
116
+ resp = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=25)
117
+ resp.raise_for_status()
118
+ except Exception as e:
119
+ return f"Fetch failed for {url}: {e}"
120
+
121
+ ctype = resp.headers.get("Content-Type", "").lower()
122
+ if "pdf" in ctype or url.lower().endswith(".pdf"):
123
+ return _pdf_to_text(resp.content, max_chars)
124
+
125
+ soup = BeautifulSoup(resp.text, "html.parser")
126
+ for tag in soup(["script", "style", "noscript", "header", "footer", "nav"]):
127
+ tag.decompose()
128
+ md = markdownify(str(soup), heading_style="ATX")
129
+ md = re.sub(r"\n{3,}", "\n\n", md).strip()
130
+ if len(md) > max_chars:
131
+ md = md[:max_chars] + "\n\n[...truncated...]"
132
+ return md
133
+
134
+
135
+ def _pdf_to_text(data: bytes, max_chars: int) -> str:
136
+ try:
137
+ from pypdf import PdfReader
138
+ except Exception:
139
+ try:
140
+ from PyPDF2 import PdfReader # type: ignore
141
+ except Exception as e:
142
+ return f"PDF read failed (install pypdf): {e}"
143
+ try:
144
+ reader = PdfReader(io.BytesIO(data))
145
+ text = "\n\n".join((p.extract_text() or "") for p in reader.pages)
146
+ except Exception as e:
147
+ return f"PDF parse failed: {e}"
148
+ if len(text) > max_chars:
149
+ text = text[:max_chars] + "\n\n[...truncated...]"
150
+ return text
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Wikipedia
155
+ # ---------------------------------------------------------------------------
156
+ @tool
157
+ def wikipedia_search(query: str, sentences: int = config.WIKI_SENTENCES) -> str:
158
+ """Look up a topic on English Wikipedia.
159
+
160
+ Args:
161
+ query: The page title or topic.
162
+ sentences: Sentences of summary to return.
163
+
164
+ Returns:
165
+ A summary block with the page URL, or an error message.
166
+ """
167
+ try:
168
+ import wikipediaapi
169
+ except Exception as e: # pragma: no cover
170
+ return f"Missing deps: {e}"
171
+
172
+ wiki = wikipediaapi.Wikipedia(user_agent=USER_AGENT, language="en")
173
+ page = wiki.page(query)
174
+ if not page.exists():
175
+ # Try a search-then-fetch with the search API.
176
+ try:
177
+ resp = requests.get(
178
+ "https://en.wikipedia.org/w/api.php",
179
+ params={
180
+ "action": "query",
181
+ "list": "search",
182
+ "srsearch": query,
183
+ "format": "json",
184
+ "srlimit": 1,
185
+ },
186
+ headers={"User-Agent": USER_AGENT},
187
+ timeout=15,
188
+ )
189
+ hits = resp.json().get("query", {}).get("search", [])
190
+ if not hits:
191
+ return f"No Wikipedia page found for: {query}"
192
+ page = wiki.page(hits[0]["title"])
193
+ except Exception as e:
194
+ return f"Wikipedia lookup failed: {e}"
195
+ if not page.exists():
196
+ return f"No Wikipedia page found for: {query}"
197
+
198
+ summary = page.summary
199
+ parts = re.split(r"(?<=[.!?])\s+", summary)
200
+ out = " ".join(parts[: max(1, int(sentences))])
201
+ return f"{page.title}\n{page.fullurl}\n\n{out}"
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # YouTube transcript
206
+ # ---------------------------------------------------------------------------
207
+ @tool
208
+ def youtube_transcript(url_or_id: str) -> str:
209
+ """Fetch the transcript of a YouTube video.
210
+
211
+ Args:
212
+ url_or_id: A full YouTube URL or just the 11-char video ID.
213
+
214
+ Returns:
215
+ Plain text transcript, or an error message.
216
+ """
217
+ vid = _yt_id(url_or_id)
218
+ if not vid:
219
+ return f"Could not parse YouTube id from: {url_or_id}"
220
+ try:
221
+ from youtube_transcript_api import YouTubeTranscriptApi
222
+ except Exception as e: # pragma: no cover
223
+ return f"Missing deps: {e}"
224
+ try:
225
+ chunks = YouTubeTranscriptApi.get_transcript(vid)
226
+ except Exception as e:
227
+ return f"Transcript fetch failed: {e}"
228
+ return " ".join(c["text"] for c in chunks)
229
+
230
+
231
+ def _yt_id(s: str) -> Optional[str]:
232
+ s = s.strip()
233
+ if re.fullmatch(r"[A-Za-z0-9_-]{11}", s):
234
+ return s
235
+ try:
236
+ u = urlparse(s)
237
+ except Exception:
238
+ return None
239
+ if u.hostname in ("youtu.be",):
240
+ return u.path.lstrip("/")[:11] or None
241
+ if u.hostname and "youtube" in u.hostname:
242
+ from urllib.parse import parse_qs
243
+
244
+ qs = parse_qs(u.query)
245
+ v = qs.get("v", [None])[0]
246
+ if v:
247
+ return v[:11]
248
+ m = re.search(r"/(embed|shorts)/([A-Za-z0-9_-]{11})", u.path)
249
+ if m:
250
+ return m.group(2)
251
+ m = re.search(r"([A-Za-z0-9_-]{11})", s)
252
+ return m.group(1) if m else None
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # GAIA file attachment
257
+ # ---------------------------------------------------------------------------
258
+ @tool
259
+ def download_task_file(task_id: str) -> str:
260
+ """Download the file attachment for a GAIA task (if one exists).
261
+
262
+ Args:
263
+ task_id: The task id of the current question.
264
+
265
+ Returns:
266
+ Absolute local path of the downloaded file, or a message saying
267
+ no file is attached. Read the file with normal Python after.
268
+ """
269
+ base = config.GAIA_API_URL.rstrip("/")
270
+ url = f"{base}/files/{task_id}"
271
+ try:
272
+ resp = requests.get(url, timeout=30)
273
+ except Exception as e:
274
+ return f"Download error: {e}"
275
+ if resp.status_code == 404:
276
+ return "NO_FILE: this task has no attachment."
277
+ if resp.status_code != 200:
278
+ return f"Download failed: HTTP {resp.status_code}"
279
+
280
+ name = _filename_from_response(resp, task_id)
281
+ out_dir = Path(tempfile.gettempdir()) / "gaia_files"
282
+ out_dir.mkdir(parents=True, exist_ok=True)
283
+ path = out_dir / name
284
+ path.write_bytes(resp.content)
285
+ return str(path.resolve())
286
+
287
+
288
+ def _filename_from_response(resp: requests.Response, task_id: str) -> str:
289
+ cd = resp.headers.get("Content-Disposition", "")
290
+ m = re.search(r'filename\*?=(?:UTF-\d\'\')?"?([^";]+)"?', cd)
291
+ if m:
292
+ return m.group(1).strip()
293
+ ctype = resp.headers.get("Content-Type", "").split(";")[0].strip()
294
+ ext = {
295
+ "text/plain": ".txt",
296
+ "text/csv": ".csv",
297
+ "application/pdf": ".pdf",
298
+ "application/json": ".json",
299
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
300
+ "application/vnd.ms-excel": ".xls",
301
+ "application/x-python": ".py",
302
+ "image/png": ".png",
303
+ "image/jpeg": ".jpg",
304
+ "audio/mpeg": ".mp3",
305
+ "audio/wav": ".wav",
306
+ "audio/x-wav": ".wav",
307
+ "audio/mp4": ".m4a",
308
+ "video/mp4": ".mp4",
309
+ }.get(ctype, "")
310
+ return f"{task_id}{ext}"
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Excel / CSV reader (deterministic helper so the LLM doesn't have to handcraft)
315
+ # ---------------------------------------------------------------------------
316
+ @tool
317
+ def read_table(file_path: str, sheet: Optional[str] = None, max_rows: int = 200) -> str:
318
+ """Read an Excel/CSV file and return a textual preview.
319
+
320
+ Args:
321
+ file_path: Absolute path to .xlsx / .xls / .csv / .tsv.
322
+ sheet: Optional sheet name (Excel only). Default: first sheet.
323
+ max_rows: Max rows to include in the preview.
324
+
325
+ Returns:
326
+ Column dtypes + a CSV-style preview. For deeper analysis, load it with
327
+ pandas yourself in a code block.
328
+ """
329
+ import pandas as pd
330
+
331
+ p = Path(file_path)
332
+ if not p.exists():
333
+ return f"File not found: {file_path}"
334
+ suffix = p.suffix.lower()
335
+ try:
336
+ if suffix in (".xlsx", ".xls"):
337
+ df = pd.read_excel(p, sheet_name=sheet or 0)
338
+ elif suffix == ".tsv":
339
+ df = pd.read_csv(p, sep="\t")
340
+ else:
341
+ df = pd.read_csv(p)
342
+ except Exception as e:
343
+ return f"Read failed: {e}"
344
+
345
+ head = df.head(max_rows)
346
+ info = [
347
+ f"shape: {df.shape}",
348
+ "dtypes:",
349
+ df.dtypes.astype(str).to_string(),
350
+ "",
351
+ "preview:",
352
+ head.to_csv(index=False),
353
+ ]
354
+ return "\n".join(info)
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Audio transcription via HF Inference (Whisper)
359
+ # ---------------------------------------------------------------------------
360
+ @tool
361
+ def transcribe_audio(file_path: str) -> str:
362
+ """Transcribe an audio file (mp3/wav/m4a) using Whisper via HF Inference.
363
+
364
+ Args:
365
+ file_path: Absolute path to the audio file.
366
+
367
+ Returns:
368
+ The transcript text, or an error message.
369
+ """
370
+ from huggingface_hub import InferenceClient
371
+
372
+ token = os.getenv("HF_TOKEN")
373
+ if not token:
374
+ return "Missing HF_TOKEN for HF Inference."
375
+ p = Path(file_path)
376
+ if not p.exists():
377
+ return f"File not found: {file_path}"
378
+ model_id = config.ASR_MODEL_ID
379
+ try:
380
+ client = InferenceClient(token=token)
381
+ out = client.automatic_speech_recognition(p.read_bytes(), model=model_id)
382
+ except Exception as e:
383
+ return f"ASR failed: {e}"
384
+ if isinstance(out, dict):
385
+ return out.get("text", "")
386
+ return getattr(out, "text", str(out))
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # Image VQA via HF Inference
391
+ # ---------------------------------------------------------------------------
392
+ @tool
393
+ def analyze_image(file_path: str, question: str = "Describe this image in detail.") -> str:
394
+ """Ask a vision-language model about an image file.
395
+
396
+ Args:
397
+ file_path: Absolute path to a .png / .jpg / .jpeg / .webp file.
398
+ question: The question to ask about the image. Default: detailed description.
399
+
400
+ Returns:
401
+ The model's answer text.
402
+ """
403
+ import base64
404
+
405
+ from huggingface_hub import InferenceClient
406
+
407
+ token = os.getenv("HF_TOKEN")
408
+ if not token:
409
+ return "Missing HF_TOKEN for HF Inference."
410
+ p = Path(file_path)
411
+ if not p.exists():
412
+ return f"File not found: {file_path}"
413
+
414
+ model_id = config.VLM_MODEL_ID
415
+ provider = config.VLM_PROVIDER
416
+
417
+ suffix = p.suffix.lower().lstrip(".")
418
+ mime = {"jpg": "jpeg"}.get(suffix, suffix) or "png"
419
+ b64 = base64.b64encode(p.read_bytes()).decode("ascii")
420
+ data_url = f"data:image/{mime};base64,{b64}"
421
+
422
+ try:
423
+ client = InferenceClient(token=token, provider=provider)
424
+ resp = client.chat.completions.create(
425
+ model=model_id,
426
+ messages=[
427
+ {
428
+ "role": "user",
429
+ "content": [
430
+ {"type": "text", "text": question},
431
+ {"type": "image_url", "image_url": {"url": data_url}},
432
+ ],
433
+ }
434
+ ],
435
+ max_tokens=512,
436
+ )
437
+ return resp.choices[0].message.content or ""
438
+ except Exception as e:
439
+ return f"VLM call failed: {e}"
440
+
441
+
442
+ __all__ = [
443
+ "web_search",
444
+ "read_webpage",
445
+ "wikipedia_search",
446
+ "youtube_transcript",
447
+ "download_task_file",
448
+ "read_table",
449
+ "transcribe_audio",
450
+ "analyze_image",
451
+ ]