psykoss Cursor commited on
Commit
7c9bf9d
Β·
1 Parent(s): 20daafa

Add GAIA agent pipeline without committing secrets.

Browse files

Ignore .env and local artifacts; use Space secrets for API keys.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (8) hide show
  1. .env.example +4 -0
  2. .gitignore +10 -0
  3. README.md +0 -15
  4. agent.py +301 -0
  5. app.py +18 -3
  6. prompts.py +81 -0
  7. requirements.txt +6 -1
  8. tools.py +13 -0
.env.example ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Copy to .env locally and set Space secrets on Hugging Face (never commit .env)
2
+ GOOGLE_API_KEY=your_google_api_key_here
3
+ GEMINI_MODEL=gemini-2.0-flash
4
+ QUESTION_DELAY_SECONDS=2
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+
5
+ .DS_Store
6
+ __pycache__/
7
+ *.py[cod]
8
+ .ipynb_checkpoints/
9
+ .venv/
10
+ *.png
README.md DELETED
@@ -1,15 +0,0 @@
1
- ---
2
- title: Template Final Assignment
3
- emoji: πŸ•΅πŸ»β€β™‚οΈ
4
- colorFrom: indigo
5
- colorTo: indigo
6
- sdk: gradio
7
- 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
agent.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This file contains the agent that will be used to evaluate GAIA benchmark questions.
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import json
8
+ import time
9
+ from dotenv import load_dotenv
10
+ from langchain_google_genai import ChatGoogleGenerativeAI
11
+ from langchain_google_genai.chat_models import ChatGoogleGenerativeAIError
12
+ from typing_extensions import TypedDict
13
+ from prompts import PLANNER_PROMPT, SYNTHESIS_ANSWER_PROMPT, ANSWER_PROMPT
14
+ from langgraph.graph import StateGraph, START, END
15
+ from tools import search_web
16
+
17
+ load_dotenv()
18
+
19
+ MAX_SEARCH_QUERIES = 3
20
+ MIN_SEARCH_QUERIES = 1
21
+ MAX_LLM_RETRIES = 6
22
+ DEFAULT_RETRY_SECONDS = 15
23
+ QUESTION_DELAY_SECONDS = float(os.getenv("QUESTION_DELAY_SECONDS", "2"))
24
+
25
+ #--- Model --------------------------------------------------------------#
26
+
27
+ model = ChatGoogleGenerativeAI(
28
+ model=os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
29
+ api_key=os.getenv("GOOGLE_API_KEY"),
30
+ temperature=0,
31
+ )
32
+
33
+ #--- State --------------------------------------------------------------#
34
+
35
+ class AgentState(TypedDict):
36
+ question: str
37
+ plan: list[str]
38
+ search_queries: list[str]
39
+ search_results: list[str]
40
+ synthesis_notes: str
41
+ answer: str
42
+
43
+ #--- Helpers ------------------------------------------------------------#
44
+
45
+ def _normalize(text: str) -> str:
46
+ return " ".join(text.lower().split())
47
+
48
+
49
+ def _strip_json_fences(text: str) -> str:
50
+ text = text.strip()
51
+ match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
52
+ if match:
53
+ return match.group(1).strip()
54
+ return text
55
+
56
+
57
+ def _retry_delay_seconds(error: Exception, attempt: int) -> float:
58
+ msg = str(error)
59
+ match = re.search(r"retry in (\d+(?:\.\d+)?)s", msg, re.IGNORECASE)
60
+ if match:
61
+ return float(match.group(1)) + 1.0
62
+ return min(DEFAULT_RETRY_SECONDS * (attempt + 1), 60.0)
63
+
64
+
65
+ def _is_rate_limit_error(error: Exception) -> bool:
66
+ msg = str(error).lower()
67
+ return "429" in msg or "resource_exhausted" in msg or "quota" in msg
68
+
69
+
70
+ def invoke_llm(prompt: str) -> str:
71
+ """Call Gemini with retries on rate-limit / quota errors."""
72
+ last_error: Exception | None = None
73
+ for attempt in range(MAX_LLM_RETRIES):
74
+ try:
75
+ response = model.invoke(prompt)
76
+ return response.content.strip()
77
+ except ChatGoogleGenerativeAIError as e:
78
+ last_error = e
79
+ if _is_rate_limit_error(e) and attempt < MAX_LLM_RETRIES - 1:
80
+ delay = _retry_delay_seconds(e, attempt)
81
+ print(f"Rate limited; retrying in {delay:.0f}s (attempt {attempt + 1}/{MAX_LLM_RETRIES})...")
82
+ time.sleep(delay)
83
+ continue
84
+ raise
85
+ if last_error:
86
+ raise last_error
87
+ raise RuntimeError("invoke_llm failed without an exception")
88
+
89
+
90
+ def _is_question_copy(text: str, question: str) -> bool:
91
+ t, q = _normalize(text), _normalize(question)
92
+ if not t:
93
+ return True
94
+ if t == q:
95
+ return True
96
+ if len(t) >= len(q) * 0.85 and q in t:
97
+ return True
98
+ return False
99
+
100
+
101
+ def _keyword_fallback_query(question: str) -> str:
102
+ stop = {
103
+ "what", "which", "when", "where", "who", "whom", "whose", "why", "how",
104
+ "the", "a", "an", "is", "are", "was", "were", "of", "in", "on", "at",
105
+ "to", "for", "and", "or", "does", "do", "did", "many", "much",
106
+ }
107
+ words = [
108
+ w.strip(".,?!\"'()[]")
109
+ for w in question.split()
110
+ if w.lower().strip(".,?!\"'()[]") not in stop and len(w) > 2
111
+ ]
112
+ return " ".join(words[:8]) if words else question[:80]
113
+
114
+
115
+ def _parse_planner_response(content: str, question: str) -> dict:
116
+ raw = _strip_json_fences(content)
117
+ data = json.loads(raw)
118
+ steps = data.get("steps") or []
119
+ queries = data.get("search_queries") or []
120
+
121
+ if not isinstance(steps, list):
122
+ steps = [str(steps)]
123
+ if not isinstance(queries, list):
124
+ queries = [str(queries)]
125
+
126
+ steps = [str(s).strip() for s in steps if str(s).strip()]
127
+ steps = [s for s in steps if not _is_question_copy(s, question)]
128
+ if not steps:
129
+ steps = [
130
+ "Identify entities and constraints in the question",
131
+ "Determine what facts must be verified externally",
132
+ "Cross-check candidates against authoritative sources",
133
+ ]
134
+
135
+ queries = [str(q).strip() for q in queries if str(q).strip()]
136
+ queries = [q for q in queries if not _is_question_copy(q, question)]
137
+ seen: set[str] = set()
138
+ deduped: list[str] = []
139
+ for q in queries:
140
+ key = _normalize(q)
141
+ if key in seen or len(q.split()) > 10:
142
+ continue
143
+ seen.add(key)
144
+ deduped.append(q)
145
+ if len(deduped) >= MAX_SEARCH_QUERIES:
146
+ break
147
+
148
+ if len(deduped) < MIN_SEARCH_QUERIES:
149
+ deduped = [_keyword_fallback_query(question)]
150
+
151
+ return {"steps": steps, "search_queries": deduped}
152
+
153
+
154
+ def _format_plan(plan: list[str]) -> str:
155
+ return "\n".join(f"- {step}" for step in plan)
156
+
157
+
158
+ def _format_search_results(results: list[str], max_chars: int = 6000) -> str:
159
+ blocks = []
160
+ total = 0
161
+ for i, block in enumerate(results, 1):
162
+ chunk = f"### Query {i}\n{block}"
163
+ if total + len(chunk) > max_chars:
164
+ chunk = chunk[: max_chars - total]
165
+ blocks.append(chunk)
166
+ total += len(chunk)
167
+ if total >= max_chars:
168
+ break
169
+ return "\n\n".join(blocks) if blocks else "(no search results)"
170
+
171
+
172
+ def _heuristic_synthesis(search_results: list[str]) -> str:
173
+ bullets: list[str] = []
174
+ for block in search_results:
175
+ for line in block.split("\n"):
176
+ line = line.strip()
177
+ if not line or line == "No results found.":
178
+ continue
179
+ bullets.append(f"- {line[:400]}")
180
+ if len(bullets) >= 12:
181
+ break
182
+ if len(bullets) >= 12:
183
+ break
184
+ return "\n".join(bullets) if bullets else "- No usable search evidence."
185
+
186
+
187
+ def _parse_synthesis_answer(content: str) -> dict:
188
+ data = json.loads(_strip_json_fences(content))
189
+ notes = str(data.get("synthesis_notes", "")).strip()
190
+ answer = str(data.get("answer", "")).strip()
191
+ answer = re.sub(r'^["\']|["\']$', "", answer)
192
+ if not notes:
193
+ raise ValueError("missing synthesis_notes")
194
+ if not answer:
195
+ raise ValueError("missing answer")
196
+ return {"synthesis_notes": notes, "answer": answer}
197
+
198
+
199
+ def _answer_from_notes(question: str, plan: list[str], synthesis_notes: str) -> str:
200
+ prompt = ANSWER_PROMPT.format(
201
+ question=question,
202
+ plan=_format_plan(plan),
203
+ synthesis_notes=synthesis_notes,
204
+ )
205
+ content = invoke_llm(prompt)
206
+ answer = re.sub(r'^["\']|["\']$', "", content.strip())
207
+ return answer
208
+
209
+
210
+ #--- Nodes --------------------------------------------------------------#
211
+
212
+ def plan_node(state: AgentState) -> AgentState:
213
+ """Plan steps and capped search queries."""
214
+ question = state["question"]
215
+ prompt = PLANNER_PROMPT.format(question=question)
216
+
217
+ try:
218
+ content = invoke_llm(prompt)
219
+ data = _parse_planner_response(content, question)
220
+ except (json.JSONDecodeError, TypeError, KeyError, ChatGoogleGenerativeAIError):
221
+ data = {
222
+ "steps": [
223
+ "Identify entities and constraints in the question",
224
+ "Determine what external facts are required",
225
+ "Verify the answer against reliable sources",
226
+ ],
227
+ "search_queries": [_keyword_fallback_query(question)],
228
+ }
229
+
230
+ return {
231
+ "plan": data["steps"],
232
+ "search_queries": data["search_queries"],
233
+ }
234
+
235
+
236
+ def search_node(state: AgentState) -> AgentState:
237
+ """Run 1–3 web searches."""
238
+ search_queries = state["search_queries"][:MAX_SEARCH_QUERIES]
239
+ results = [search_web(query) for query in search_queries]
240
+ return {"search_results": results}
241
+
242
+
243
+ def finalize_node(state: AgentState) -> AgentState:
244
+ """Synthesize evidence and produce answer in one LLM call (saves quota)."""
245
+ question = state["question"]
246
+ plan = state["plan"]
247
+ search_results = state["search_results"]
248
+ formatted_results = _format_search_results(search_results)
249
+
250
+ try:
251
+ prompt = SYNTHESIS_ANSWER_PROMPT.format(
252
+ question=question,
253
+ plan=_format_plan(plan),
254
+ search_results=formatted_results,
255
+ )
256
+ parsed = _parse_synthesis_answer(invoke_llm(prompt))
257
+ return parsed
258
+ except (json.JSONDecodeError, TypeError, KeyError, ValueError, ChatGoogleGenerativeAIError) as e:
259
+ print(f"Finalize LLM failed ({e}); using heuristic synthesis.")
260
+ notes = _heuristic_synthesis(search_results)
261
+ try:
262
+ answer = _answer_from_notes(question, plan, notes)
263
+ except ChatGoogleGenerativeAIError:
264
+ first_line = notes.split("\n", 1)[0].lstrip("- ").strip()
265
+ answer = first_line[:200] if first_line else "Unknown"
266
+ return {"synthesis_notes": notes, "answer": answer}
267
+
268
+
269
+ #--- Graph --------------------------------------------------------------#
270
+
271
+ agent_builder = StateGraph(AgentState)
272
+ agent_builder.add_node("plan", plan_node)
273
+ agent_builder.add_node("search", search_node)
274
+ agent_builder.add_node("finalize", finalize_node)
275
+ agent_builder.add_edge(START, "plan")
276
+ agent_builder.add_edge("plan", "search")
277
+ agent_builder.add_edge("search", "finalize")
278
+ agent_builder.add_edge("finalize", END)
279
+
280
+ agent = agent_builder.compile()
281
+
282
+
283
+ def run_agent(question: str) -> AgentState:
284
+ """Run the full pipeline for a single question (2 LLM calls: plan + finalize)."""
285
+ return agent.invoke({"question": question})
286
+
287
+
288
+ if __name__ == "__main__":
289
+ # Free tier: ~20 requests/day per model β€” use 1 question while testing.
290
+ questions = [
291
+ "What is the capital of France?",
292
+ ]
293
+ for i, question in enumerate(questions):
294
+ if i > 0:
295
+ time.sleep(QUESTION_DELAY_SECONDS)
296
+ result = run_agent(question)
297
+ print(f"Question: {result['question']}")
298
+ print(f"Plan: {result['plan']}")
299
+ print(f"Queries: {result['search_queries']}")
300
+ print(f"Synthesis:\n{result['synthesis_notes']}")
301
+ print(f"Answer: {result['answer']}\n")
app.py CHANGED
@@ -1,9 +1,16 @@
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"
@@ -13,11 +20,13 @@ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
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
  """
@@ -176,6 +185,12 @@ if __name__ == "__main__":
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}")
 
1
  import os
2
+
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
  import gradio as gr
8
  import requests
9
  import inspect
10
  import pandas as pd
11
 
12
+ from agent import run_agent
13
+
14
  # (Keep Constants as is)
15
  # --- Constants ---
16
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
 
20
  class BasicAgent:
21
  def __init__(self):
22
  print("BasicAgent initialized.")
23
+
24
  def __call__(self, question: str) -> str:
25
  print(f"Agent received question (first 50 chars): {question[:50]}...")
26
+ result = run_agent(question)
27
+ answer = result["answer"]
28
+ print(f"Agent answer (first 80 chars): {answer[:80]}...")
29
+ return answer
30
 
31
  def run_and_submit_all( profile: gr.OAuthProfile | None):
32
  """
 
185
  # Check for SPACE_HOST and SPACE_ID at startup for information
186
  space_host_startup = os.getenv("SPACE_HOST")
187
  space_id_startup = os.getenv("SPACE_ID") # Get SPACE_ID at startup
188
+ GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
189
+
190
+ if GOOGLE_API_KEY:
191
+ print(f"βœ… GOOGLE_API_KEY found: {GOOGLE_API_KEY}")
192
+ else:
193
+ print("ℹ️ GOOGLE_API_KEY environment variable not found (running locally?).")
194
 
195
  if space_host_startup:
196
  print(f"βœ… SPACE_HOST found: {space_host_startup}")
prompts.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PLANNER_PROMPT = """
2
+ You are a planning agent for a GAIA-style question-answering benchmark.
3
+
4
+ Break the question into:
5
+ 1. A step-by-step reasoning plan (3–6 atomic steps)
6
+ 2. Web search queries to gather missing facts
7
+
8
+ Hard rules:
9
+ - Output ONLY valid JSON. No markdown, no code fences, no commentary.
10
+ - Use EXACTLY this schema (array lengths: steps 3–6, search_queries 1–3):
11
+ {{
12
+ "steps": ["..."],
13
+ "search_queries": ["..."]
14
+ }}
15
+ - NEVER copy or paraphrase the full question as a step or as a search query.
16
+ - NEVER use the question text verbatim in search_queries.
17
+ - Steps must be actionable reasoning actions (e.g. "Identify the entity", "Verify the date from an authoritative source").
18
+ - search_queries: 1–3 items only. Short keyword phrases (2–8 words). Each query must target a distinct fact gap.
19
+ - Prefer precision over breadth: one excellent query beats three redundant ones.
20
+ - If the question is answerable without search, still return 1 minimal disambiguation query.
21
+
22
+ Penalties (invalid output β€” avoid):
23
+ - steps or search_queries containing the full question string
24
+ - search_queries longer than 10 words
25
+ - more than 3 search_queries or fewer than 1 search_queries
26
+ - placeholder text like "step1", "query1", "..."
27
+
28
+ Question:
29
+ {question}
30
+ """
31
+
32
+ SYNTHESIS_ANSWER_PROMPT = """
33
+ You are a GAIA research and answer agent. Do two tasks in one response.
34
+
35
+ Task 1 β€” Synthesis: compress search results into bullet notes (max 12, prefix "- ").
36
+ Task 2 β€” Answer: produce the final GAIA answer from those notes.
37
+
38
+ Answer rules:
39
+ - Return ONLY valid JSON. No markdown fences.
40
+ - Schema:
41
+ {{
42
+ "synthesis_notes": "- fact1\\n- fact2",
43
+ "answer": "final answer only"
44
+ }}
45
+ - synthesis_notes: bullets only, no preamble.
46
+ - answer: concise; no quotes, meta-commentary, or search artifacts (no broken fragments like "Rome., is good?").
47
+ - Do NOT repeat the question text in the answer.
48
+
49
+ Question:
50
+ {question}
51
+
52
+ Plan:
53
+ {plan}
54
+
55
+ Search results:
56
+ {search_results}
57
+ """
58
+
59
+ ANSWER_PROMPT = """
60
+ You are a GAIA benchmark answer agent. Produce the final answer only.
61
+
62
+ Rules:
63
+ - Use the question, plan, and synthesis notes β€” not raw search snippets.
64
+ - Return ONLY the final answer text. No preamble, labels, quotes, or explanation unless the question requires it.
65
+ - Be concise: prefer a short phrase, name, number, or yes/no when appropriate.
66
+ - Do NOT echo search artifacts, typos, or broken fragments (e.g. "Rome., is good?", trailing punctuation glitches).
67
+ - Do NOT repeat the question, plan, or notes.
68
+ - Do NOT hedge with "based on the search results" or similar meta-commentary.
69
+ - For factual questions: give the single best-supported answer.
70
+ - For lists: use the format the question implies (comma-separated or one item per line).
71
+ - If evidence is insufficient, give the most likely short answer; do not invent citations.
72
+
73
+ Question:
74
+ {question}
75
+
76
+ Plan:
77
+ {plan}
78
+
79
+ Synthesis notes:
80
+ {synthesis_notes}
81
+ """
requirements.txt CHANGED
@@ -1,6 +1,11 @@
1
  gradio
 
2
  requests
3
  langgraph
4
  langchain
5
  langchain-google-genai
6
- duckduckgo-search
 
 
 
 
 
1
  gradio
2
+ python-dotenv
3
  requests
4
  langgraph
5
  langchain
6
  langchain-google-genai
7
+ ddgs
8
+ google-generativeai
9
+ google-auth
10
+ gradio[oauth]
11
+ ipython
tools.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ddgs import DDGS
2
+
3
+ def search_web(query: str) -> str:
4
+ """Search the web for information related to the query."""
5
+ results = DDGS().text(query, max_results=10)
6
+ if not results:
7
+ return "No results found."
8
+ return "\n\n".join(
9
+ f"{r.get('title', '')}: {r.get('body', r.get('href', ''))}"
10
+ for r in results
11
+ )
12
+
13
+