Files changed (5) hide show
  1. README.md +25 -1
  2. agent.py +337 -0
  3. app.py +4 -17
  4. requirements.txt +11 -1
  5. system_prompt.txt +5 -0
README.md CHANGED
@@ -12,4 +12,28 @@ hf_oauth: true
12
  hf_oauth_expiration_minutes: 480
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  hf_oauth_expiration_minutes: 480
13
  ---
14
 
15
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
16
+
17
+ ## Agent
18
+
19
+ `agent.py` defines `GaiaAgent`, a LangGraph agent (explicit `StateGraph` with an
20
+ `assistant` node and a `ToolNode`, routed by `tools_condition`) used to answer the
21
+ GAIA benchmark questions for [Unit 4's hands-on assignment](https://huggingface.co/learn/agents-course/unit4/hands-on).
22
+ Structure and prompt are adapted from the [fisherman611/gaia-agent](https://huggingface.co/spaces/fisherman611/gaia-agent)
23
+ Space, trimmed to tools that need no extra paid API keys and no arbitrary code
24
+ execution (that Space's Supabase RAG retriever, Tavily/Groq providers, and
25
+ multi-language code interpreter were left out for that reason).
26
+
27
+ It calls an LLM through the Hugging Face Inference API and has access to:
28
+ web search (DuckDuckGo), Wikipedia search, arXiv search, arithmetic tools
29
+ (add/subtract/multiply/divide/modulus/power/square_root), a tool to download a
30
+ GAIA task's attached file, a generic URL downloader, and CSV/Excel analysis
31
+ tools. The system prompt (`system_prompt.txt`) asks the model to end its reply
32
+ with `FINAL ANSWER: ...`, which `agent.py` parses out before submission — per
33
+ the assignment's rule that submitted answers must contain only the answer
34
+ itself.
35
+
36
+ To run it (locally or as a Space), set the `HF_TOKEN` secret/env var to a
37
+ Hugging Face access token with Inference API permission. Optionally set
38
+ `HF_AGENT_MODEL` to override the default model
39
+ (`Qwen/Qwen2.5-Coder-32B-Instruct`).
agent.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph agent that answers GAIA benchmark questions (Hugging Face Agents Course, Unit 4).
2
+
3
+ The agent is a LangGraph ReAct-style graph: an LLM served through the Hugging Face
4
+ Inference API, bound to a toolset (web/Wikipedia/arXiv search, arithmetic, GAIA
5
+ task-file download, and CSV/Excel analysis). Structure and prompt are adapted from
6
+ https://huggingface.co/spaces/fisherman611/gaia-agent, trimmed to tools that need no
7
+ extra paid API keys and no arbitrary code execution. See
8
+ https://huggingface.co/learn/agents-course/unit4/hands-on for the assignment.
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import tempfile
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Optional
17
+ from urllib.parse import urlparse
18
+
19
+ import pandas as pd
20
+ import requests
21
+ from langchain_community.document_loaders import ArxivLoader, WikipediaLoader
22
+ from langchain_community.tools import DuckDuckGoSearchRun
23
+ from langchain_core.messages import HumanMessage, SystemMessage
24
+ from langchain_core.tools import tool
25
+ from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
26
+ from langgraph.graph import START, MessagesState, StateGraph
27
+ from langgraph.prebuilt import ToolNode, tools_condition
28
+
29
+ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
30
+
31
+ # Any HF Inference API model that supports tool calling works here. Override via the
32
+ # HF_AGENT_MODEL env var without touching code.
33
+ HF_MODEL_REPO_ID = os.getenv("HF_AGENT_MODEL", "Qwen/Qwen2.5-Coder-32B-Instruct")
34
+
35
+ with open(Path(__file__).parent / "system_prompt.txt", "r", encoding="utf-8") as f:
36
+ SYSTEM_PROMPT = f.read()
37
+
38
+ _FINAL_ANSWER_RE = re.compile(r"final answer\s*:\s*", re.IGNORECASE)
39
+
40
+
41
+ ### =============== SEARCH TOOLS =============== ###
42
+
43
+
44
+ @tool
45
+ def web_search(query: str) -> str:
46
+ """Search the web (DuckDuckGo) for a query and return a few results.
47
+
48
+ Args:
49
+ query: The search query.
50
+ """
51
+ return DuckDuckGoSearchRun().invoke(query)
52
+
53
+
54
+ @tool
55
+ def wiki_search(query: str) -> str:
56
+ """Search Wikipedia for a query and return up to 2 results.
57
+
58
+ Args:
59
+ query: The search query.
60
+ """
61
+ docs = WikipediaLoader(query=query, load_max_docs=2).load()
62
+ return "\n\n---\n\n".join(
63
+ f'<Document source="{d.metadata.get("source", "")}"/>\n{d.page_content}\n</Document>'
64
+ for d in docs
65
+ )
66
+
67
+
68
+ @tool
69
+ def arxiv_search(query: str) -> str:
70
+ """Search arXiv for a query and return up to 2 results (abstracts truncated).
71
+
72
+ Args:
73
+ query: The search query.
74
+ """
75
+ docs = ArxivLoader(query=query, load_max_docs=2).load()
76
+ return "\n\n---\n\n".join(
77
+ f'<Document source="{d.metadata.get("source", "")}"/>\n{d.page_content[:1000]}\n</Document>'
78
+ for d in docs
79
+ )
80
+
81
+
82
+ ### =============== MATH TOOLS =============== ###
83
+
84
+
85
+ @tool
86
+ def add(a: float, b: float) -> float:
87
+ """Add two numbers.
88
+
89
+ Args:
90
+ a: the first number
91
+ b: the second number
92
+ """
93
+ return a + b
94
+
95
+
96
+ @tool
97
+ def subtract(a: float, b: float) -> float:
98
+ """Subtract two numbers.
99
+
100
+ Args:
101
+ a: the first number
102
+ b: the second number
103
+ """
104
+ return a - b
105
+
106
+
107
+ @tool
108
+ def multiply(a: float, b: float) -> float:
109
+ """Multiply two numbers.
110
+
111
+ Args:
112
+ a: the first number
113
+ b: the second number
114
+ """
115
+ return a * b
116
+
117
+
118
+ @tool
119
+ def divide(a: float, b: float) -> float:
120
+ """Divide two numbers.
121
+
122
+ Args:
123
+ a: the numerator
124
+ b: the denominator
125
+ """
126
+ if b == 0:
127
+ raise ValueError("Cannot divide by zero.")
128
+ return a / b
129
+
130
+
131
+ @tool
132
+ def modulus(a: int, b: int) -> int:
133
+ """Get the remainder of a divided by b.
134
+
135
+ Args:
136
+ a: the first number
137
+ b: the second number
138
+ """
139
+ return a % b
140
+
141
+
142
+ @tool
143
+ def power(a: float, b: float) -> float:
144
+ """Raise a to the power of b.
145
+
146
+ Args:
147
+ a: the base
148
+ b: the exponent
149
+ """
150
+ return a**b
151
+
152
+
153
+ @tool
154
+ def square_root(a: float) -> float:
155
+ """Get the square root of a non-negative number.
156
+
157
+ Args:
158
+ a: the number to get the square root of
159
+ """
160
+ if a < 0:
161
+ raise ValueError("Cannot take the square root of a negative number.")
162
+ return a**0.5
163
+
164
+
165
+ ### =============== FILE TOOLS =============== ###
166
+
167
+
168
+ @tool
169
+ def download_task_file(task_id: str) -> str:
170
+ """Download the file attached to a GAIA task (if any) and return its text content.
171
+
172
+ Only useful when the question references an attached file. Pass the task's task_id.
173
+ Returns decoded text (truncated to 4000 characters) for text-like files, or a short
174
+ description (content type and size) for files that can't be decoded as text.
175
+ """
176
+ try:
177
+ response = requests.get(f"{DEFAULT_API_URL}/files/{task_id}", timeout=30)
178
+ response.raise_for_status()
179
+ except requests.exceptions.RequestException as e:
180
+ return f"Error downloading file for task {task_id}: {e}"
181
+
182
+ try:
183
+ return response.content.decode("utf-8")[:4000]
184
+ except UnicodeDecodeError:
185
+ content_type = response.headers.get("content-type", "unknown")
186
+ return (
187
+ f"File for task {task_id} is binary (content-type: {content_type}, "
188
+ f"{len(response.content)} bytes) and cannot be read as text."
189
+ )
190
+
191
+
192
+ @tool
193
+ def download_file_from_url(url: str, filename: Optional[str] = None) -> str:
194
+ """Download a file from a URL to a temporary path, for later analysis.
195
+
196
+ Args:
197
+ url: the URL of the file to download.
198
+ filename: optional filename to save as; a random one is used if omitted.
199
+ """
200
+ try:
201
+ if not filename:
202
+ filename = os.path.basename(urlparse(url).path) or f"downloaded_{uuid.uuid4().hex[:8]}"
203
+ filepath = os.path.join(tempfile.gettempdir(), filename)
204
+ response = requests.get(url, stream=True, timeout=30)
205
+ response.raise_for_status()
206
+ with open(filepath, "wb") as f:
207
+ for chunk in response.iter_content(chunk_size=8192):
208
+ f.write(chunk)
209
+ return f"File downloaded to {filepath}."
210
+ except Exception as e:
211
+ return f"Error downloading file: {e}"
212
+
213
+
214
+ @tool
215
+ def analyze_csv_file(file_path: str) -> str:
216
+ """Load a CSV file and return its shape, columns, and summary statistics.
217
+
218
+ Args:
219
+ file_path: path to the CSV file (e.g. from download_file_from_url).
220
+ """
221
+ try:
222
+ df = pd.read_csv(file_path)
223
+ return (
224
+ f"{len(df)} rows, {len(df.columns)} columns.\n"
225
+ f"Columns: {', '.join(df.columns)}\n\n"
226
+ f"Summary statistics:\n{df.describe(include='all')}"
227
+ )
228
+ except Exception as e:
229
+ return f"Error analyzing CSV file: {e}"
230
+
231
+
232
+ @tool
233
+ def analyze_excel_file(file_path: str) -> str:
234
+ """Load an Excel file and return its shape, columns, and summary statistics.
235
+
236
+ Args:
237
+ file_path: path to the .xlsx/.xls file (e.g. from download_file_from_url).
238
+ """
239
+ try:
240
+ df = pd.read_excel(file_path)
241
+ return (
242
+ f"{len(df)} rows, {len(df.columns)} columns.\n"
243
+ f"Columns: {', '.join(df.columns)}\n\n"
244
+ f"Summary statistics:\n{df.describe(include='all')}"
245
+ )
246
+ except Exception as e:
247
+ return f"Error analyzing Excel file: {e}"
248
+
249
+
250
+ def _build_tools():
251
+ return [
252
+ web_search,
253
+ wiki_search,
254
+ arxiv_search,
255
+ add,
256
+ subtract,
257
+ multiply,
258
+ divide,
259
+ modulus,
260
+ power,
261
+ square_root,
262
+ download_task_file,
263
+ download_file_from_url,
264
+ analyze_csv_file,
265
+ analyze_excel_file,
266
+ ]
267
+
268
+
269
+ def _build_llm():
270
+ endpoint = HuggingFaceEndpoint(
271
+ repo_id=HF_MODEL_REPO_ID,
272
+ huggingfacehub_api_token=os.getenv("HF_TOKEN"),
273
+ temperature=0,
274
+ max_new_tokens=1024,
275
+ )
276
+ return ChatHuggingFace(llm=endpoint)
277
+
278
+
279
+ def build_graph():
280
+ """Build the compiled LangGraph agent graph."""
281
+ tools = _build_tools()
282
+ llm_with_tools = _build_llm().bind_tools(tools)
283
+
284
+ def assistant(state: MessagesState):
285
+ return {"messages": [llm_with_tools.invoke(state["messages"])]}
286
+
287
+ builder = StateGraph(MessagesState)
288
+ builder.add_node("assistant", assistant)
289
+ builder.add_node("tools", ToolNode(tools))
290
+ builder.add_edge(START, "assistant")
291
+ builder.add_conditional_edges("assistant", tools_condition)
292
+ builder.add_edge("tools", "assistant")
293
+
294
+ return builder.compile()
295
+
296
+
297
+ def _extract_final_answer(text: str) -> str:
298
+ match = _FINAL_ANSWER_RE.search(text)
299
+ answer = text[match.end():] if match else text
300
+ answer = answer.strip()
301
+ if len(answer) >= 2 and answer[0] == answer[-1] and answer[0] in "\"'":
302
+ answer = answer[1:-1].strip()
303
+ return answer
304
+
305
+
306
+ class GaiaAgent:
307
+ """A LangGraph ReAct agent (HF Inference API LLM + tools) for GAIA questions."""
308
+
309
+ def __init__(self):
310
+ self._graph = build_graph()
311
+ print("GaiaAgent initialized.")
312
+
313
+ def __call__(self, question: str, task_id: Optional[str] = None) -> str:
314
+ print(f"Agent received question (first 80 chars): {question[:80]}...")
315
+
316
+ user_content = question
317
+ if task_id:
318
+ user_content += (
319
+ f"\n\n(task_id: {task_id} - use download_task_file if a file is attached)"
320
+ )
321
+
322
+ try:
323
+ result = self._graph.invoke(
324
+ {
325
+ "messages": [
326
+ SystemMessage(content=SYSTEM_PROMPT),
327
+ HumanMessage(content=user_content),
328
+ ]
329
+ }
330
+ )
331
+ answer = _extract_final_answer(result["messages"][-1].content)
332
+ except Exception as e:
333
+ print(f"Agent error: {e}")
334
+ answer = f"AGENT ERROR: {e}"
335
+
336
+ print(f"Agent returning answer (first 80 chars): {answer[:80]}...")
337
+ return answer
app.py CHANGED
@@ -4,24 +4,11 @@ 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 ---
@@ -40,7 +27,7 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
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
@@ -80,7 +67,7 @@ def run_and_submit_all( profile: gr.OAuthProfile | 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:
 
4
  import inspect
5
  import pandas as pd
6
 
7
+ from agent import GaiaAgent, DEFAULT_API_URL
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  def run_and_submit_all( profile: gr.OAuthProfile | None):
10
  """
11
+ Fetches all questions, runs the GaiaAgent on them, submits all answers,
12
  and displays the results.
13
  """
14
  # --- Determine HF Space Runtime URL and Repo URL ---
 
27
 
28
  # 1. Instantiate Agent ( modify this part to create your agent)
29
  try:
30
+ agent = GaiaAgent()
31
  except Exception as e:
32
  print(f"Error instantiating agent: {e}")
33
  return f"Error initializing agent: {e}", None
 
67
  print(f"Skipping item with missing task_id or question: {item}")
68
  continue
69
  try:
70
+ submitted_answer = agent(question_text, task_id=task_id)
71
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
72
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
73
  except Exception as e:
requirements.txt CHANGED
@@ -1,2 +1,12 @@
1
  gradio
2
- requests
 
 
 
 
 
 
 
 
 
 
 
1
  gradio
2
+ itsdangerous
3
+ requests
4
+ pandas
5
+ langgraph
6
+ langchain-core
7
+ langchain-huggingface
8
+ langchain-community
9
+ duckduckgo-search
10
+ wikipedia
11
+ arxiv
12
+ openpyxl
system_prompt.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ You are a helpful assistant tasked with answering questions using a set of tools.
2
+ Now, I will ask you a question. Report your thoughts, and finish your answer with the following template:
3
+ FINAL ANSWER: [YOUR FINAL ANSWER].
4
+ YOUR FINAL ANSWER should be a number OR as few words as possible OR a comma separated list of numbers and/or strings. If you are asked for a number, don't use comma to write your number neither use units such as $ or percent sign unless specified otherwise. If you are asked for a string, don't use articles, neither abbreviations (e.g. for cities), and write the digits in plain text unless specified otherwise. If you are asked for a comma separated list, apply the rules above for each element (number or string), and ensure there is exactly one space after each comma.
5
+ Your answer should only start with "FINAL ANSWER: ", then follows with the answer.