psykoss commited on
Commit
347b1d2
·
1 Parent(s): 576b51d
Files changed (4) hide show
  1. README.md +1 -1
  2. agent.py +415 -108
  3. prompts.py +150 -41
  4. tools.py +189 -3
README.md CHANGED
@@ -13,6 +13,6 @@ hf_oauth_expiration_minutes: 480
13
 
14
  # GAIA Agent Evaluation
15
 
16
- LangGraph agent with web search for the Hugging Face agents course final assignment.
17
 
18
  **Setup:** Add `ANTHROPIC_API_KEY` in Space secrets (default model: `claude-haiku-4-5-20251001`). Log in with Hugging Face, then run evaluation submit.
 
13
 
14
  # GAIA Agent Evaluation
15
 
16
+ LangGraph GAIA agent: router (factual|numeric|visual|hybrid) → plan → conditional tools → synthesize → confidence → format.
17
 
18
  **Setup:** Add `ANTHROPIC_API_KEY` in Space secrets (default model: `claude-haiku-4-5-20251001`). Log in with Hugging Face, then run evaluation submit.
agent.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- This file contains the agent that will be used to evaluate GAIA benchmark questions.
3
  """
4
 
5
  import os
@@ -9,56 +9,69 @@ import time
9
  from dotenv import load_dotenv
10
  from langchain_anthropic import ChatAnthropic
11
  from typing_extensions import TypedDict
12
- from prompts import PLANNER_PROMPT, SYNTHESIS_ANSWER_PROMPT, ANSWER_PROMPT
 
 
 
 
 
 
 
13
  from langgraph.graph import StateGraph, START, END
14
- from tools import search_web
15
 
16
  load_dotenv(override=True)
17
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  def _resolve_anthropic_api_key() -> str:
20
- """Use ANTHROPIC_API_KEY from .env (override=True), not IDE session tokens."""
21
  key = (os.getenv("ANTHROPIC_API_KEY") or "").strip().strip('"').strip("'")
22
  if not key:
23
  raise ValueError(
24
- "ANTHROPIC_API_KEY is not set. Add sk-ant-... to .env (see .env.example) "
25
- "or Space secrets. Create keys at https://console.anthropic.com/settings/keys"
26
  )
27
  if key.startswith("sess-") or key.startswith("hf_"):
28
- raise ValueError(
29
- "ANTHROPIC_API_KEY must be an Anthropic key (sk-ant-...), not a Cursor/HF token."
30
- )
31
  if not key.startswith("sk-ant-"):
32
- raise ValueError(
33
- "ANTHROPIC_API_KEY should start with sk-ant-. "
34
- "Create one at https://console.anthropic.com/settings/keys"
35
- )
36
  return key
37
 
38
 
39
- MAX_SEARCH_QUERIES = 3
40
- MIN_SEARCH_QUERIES = 1
41
- MAX_LLM_RETRIES = 6
42
- DEFAULT_RETRY_SECONDS = 15
43
- QUESTION_DELAY_SECONDS = float(os.getenv("QUESTION_DELAY_SECONDS", "2"))
44
-
45
- #--- Model --------------------------------------------------------------#
46
-
47
  model = ChatAnthropic(
48
  model=os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001"),
49
  api_key=_resolve_anthropic_api_key(),
50
  temperature=0,
51
  )
52
 
53
- #--- State --------------------------------------------------------------#
54
 
55
- class AgentState(TypedDict):
56
  question: str
 
 
57
  plan: list[str]
58
  search_queries: list[str]
 
 
 
 
59
  search_results: list[str]
60
  synthesis_notes: str
 
61
  answer: str
 
 
 
 
 
62
 
63
  #--- Helpers ------------------------------------------------------------#
64
 
@@ -111,34 +124,28 @@ def _message_content(response) -> str:
111
 
112
 
113
  def invoke_llm(prompt: str) -> str:
114
- """Call Claude with retries on rate-limit errors."""
115
  last_error: Exception | None = None
116
  for attempt in range(MAX_LLM_RETRIES):
117
  try:
118
- response = model.invoke(prompt)
119
- return _message_content(response)
120
  except Exception as e:
121
  last_error = e
122
  if _is_rate_limit_error(e) and attempt < MAX_LLM_RETRIES - 1:
123
  delay = _retry_delay_seconds(e, attempt)
124
- print(f"Rate limited; retrying in {delay:.0f}s (attempt {attempt + 1}/{MAX_LLM_RETRIES})...")
125
  time.sleep(delay)
126
  continue
127
  raise
128
  if last_error:
129
  raise last_error
130
- raise RuntimeError("invoke_llm failed without an exception")
131
 
132
 
133
  def _is_question_copy(text: str, question: str) -> bool:
134
  t, q = _normalize(text), _normalize(question)
135
- if not t:
136
- return True
137
- if t == q:
138
  return True
139
- if len(t) >= len(q) * 0.85 and q in t:
140
- return True
141
- return False
142
 
143
 
144
  def _keyword_fallback_query(question: str) -> str:
@@ -155,24 +162,81 @@ def _keyword_fallback_query(question: str) -> str:
155
  return " ".join(words[:8]) if words else question[:80]
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  def _parse_planner_response(content: str, question: str) -> dict:
159
- raw = _strip_json_fences(content)
160
- data = json.loads(raw)
161
  steps = data.get("steps") or []
162
  queries = data.get("search_queries") or []
 
 
163
 
164
  if not isinstance(steps, list):
165
  steps = [str(steps)]
166
  if not isinstance(queries, list):
167
  queries = [str(queries)]
 
 
 
 
168
 
169
  steps = [str(s).strip() for s in steps if str(s).strip()]
170
  steps = [s for s in steps if not _is_question_copy(s, question)]
171
  if not steps:
172
  steps = [
173
- "Identify entities and constraints in the question",
174
- "Determine what facts must be verified externally",
175
- "Cross-check candidates against authoritative sources",
 
176
  ]
177
 
178
  queries = [str(q).strip() for q in queries if str(q).strip()]
@@ -187,22 +251,49 @@ def _parse_planner_response(content: str, question: str) -> dict:
187
  deduped.append(q)
188
  if len(deduped) >= MAX_SEARCH_QUERIES:
189
  break
190
-
191
  if len(deduped) < MIN_SEARCH_QUERIES:
192
  deduped = [_keyword_fallback_query(question)]
193
 
194
- return {"steps": steps, "search_queries": deduped}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
 
197
  def _format_plan(plan: list[str]) -> str:
198
- return "\n".join(f"- {step}" for step in plan)
 
 
 
 
 
 
199
 
200
 
201
- def _format_search_results(results: list[str], max_chars: int = 6000) -> str:
202
- blocks = []
203
- total = 0
 
 
 
 
 
204
  for i, block in enumerate(results, 1):
205
- chunk = f"### Query {i}\n{block}"
206
  if total + len(chunk) > max_chars:
207
  chunk = chunk[: max_chars - total]
208
  blocks.append(chunk)
@@ -212,133 +303,349 @@ def _format_search_results(results: list[str], max_chars: int = 6000) -> str:
212
  return "\n\n".join(blocks) if blocks else "(no search results)"
213
 
214
 
215
- def _heuristic_synthesis(search_results: list[str]) -> str:
 
 
 
 
216
  bullets: list[str] = []
 
 
 
 
217
  for block in search_results:
218
  for line in block.split("\n"):
219
  line = line.strip()
220
- if not line or line == "No results found.":
221
- continue
222
- bullets.append(f"- {line[:400]}")
223
- if len(bullets) >= 12:
224
  break
225
- if len(bullets) >= 12:
226
  break
227
- return "\n".join(bullets) if bullets else "- No usable search evidence."
228
 
229
 
230
- def _parse_synthesis_answer(content: str) -> dict:
231
  data = json.loads(_strip_json_fences(content))
232
  notes = str(data.get("synthesis_notes", "")).strip()
233
- answer = str(data.get("answer", "")).strip()
234
- answer = re.sub(r'^["\']|["\']$', "", answer)
235
  if not notes:
236
  raise ValueError("missing synthesis_notes")
237
- if not answer:
238
- raise ValueError("missing answer")
239
- return {"synthesis_notes": notes, "answer": answer}
240
 
241
 
242
- def _answer_from_notes(question: str, plan: list[str], synthesis_notes: str) -> str:
243
- prompt = ANSWER_PROMPT.format(
244
- question=question,
245
- plan=_format_plan(plan),
246
- synthesis_notes=synthesis_notes,
247
- )
248
- content = invoke_llm(prompt)
249
- answer = re.sub(r'^["\']|["\']$', "", content.strip())
250
- return answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
 
253
  #--- Nodes --------------------------------------------------------------#
254
 
255
- def plan_node(state: AgentState) -> AgentState:
256
- """Plan steps and capped search queries."""
257
  question = state["question"]
258
- prompt = PLANNER_PROMPT.format(question=question)
 
 
 
 
 
 
 
 
 
 
259
 
 
 
 
260
  try:
261
- content = invoke_llm(prompt)
262
- data = _parse_planner_response(content, question)
 
 
 
 
263
  except Exception:
264
  data = {
265
  "steps": [
266
- "Identify entities and constraints in the question",
267
- "Determine what external facts are required",
268
- "Verify the answer against reliable sources",
269
  ],
270
  "search_queries": [_keyword_fallback_query(question)],
 
 
271
  }
272
-
273
  return {
274
  "plan": data["steps"],
275
  "search_queries": data["search_queries"],
 
 
 
276
  }
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  def search_node(state: AgentState) -> AgentState:
280
- """Run 1–3 web searches."""
281
- search_queries = state["search_queries"][:MAX_SEARCH_QUERIES]
282
- results = [search_web(query) for query in search_queries]
283
  return {"search_results": results}
284
 
285
 
286
- def finalize_node(state: AgentState) -> AgentState:
287
- """Synthesize evidence and produce answer in one LLM call (saves quota)."""
288
  question = state["question"]
289
- plan = state["plan"]
290
- search_results = state["search_results"]
291
- formatted_results = _format_search_results(search_results)
 
292
 
293
  try:
294
  prompt = SYNTHESIS_ANSWER_PROMPT.format(
295
  question=question,
296
  plan=_format_plan(plan),
297
- search_results=formatted_results,
 
 
298
  )
299
- parsed = _parse_synthesis_answer(invoke_llm(prompt))
300
  return parsed
301
  except Exception as e:
302
- print(f"Finalize LLM failed ({e}); using heuristic synthesis.")
303
- notes = _heuristic_synthesis(search_results)
304
  try:
305
- answer = _answer_from_notes(question, plan, notes)
 
 
 
 
 
 
 
 
306
  except Exception:
307
- first_line = notes.split("\n", 1)[0].lstrip("- ").strip()
308
- answer = first_line[:200] if first_line else "Unknown"
309
- return {"synthesis_notes": notes, "answer": answer}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
 
312
  #--- Graph --------------------------------------------------------------#
313
 
314
  agent_builder = StateGraph(AgentState)
 
315
  agent_builder.add_node("plan", plan_node)
 
 
316
  agent_builder.add_node("search", search_node)
317
- agent_builder.add_node("finalize", finalize_node)
318
- agent_builder.add_edge(START, "plan")
319
- agent_builder.add_edge("plan", "search")
320
- agent_builder.add_edge("search", "finalize")
321
- agent_builder.add_edge("finalize", END)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
  agent = agent_builder.compile()
324
 
325
 
326
  def run_agent(question: str) -> AgentState:
327
- """Run the full pipeline for a single question (2 LLM calls: plan + finalize)."""
328
- return agent.invoke({"question": question})
 
 
 
 
 
 
 
 
329
 
330
 
331
  if __name__ == "__main__":
332
- # Use 1 question while testing to save API usage.
333
- questions = [
334
- "What is the capital of France?",
335
- ]
336
  for i, question in enumerate(questions):
337
  if i > 0:
338
  time.sleep(QUESTION_DELAY_SECONDS)
339
  result = run_agent(question)
340
  print(f"Question: {result['question']}")
341
- print(f"Plan: {result['plan']}")
342
- print(f"Queries: {result['search_queries']}")
343
- print(f"Synthesis:\n{result['synthesis_notes']}")
344
- print(f"Answer: {result['answer']}\n")
 
 
 
 
 
 
 
1
  """
2
+ GAIA benchmark agent: router → plan → [vision|math|search]* → synthesize → confidence → format.
3
  """
4
 
5
  import os
 
9
  from dotenv import load_dotenv
10
  from langchain_anthropic import ChatAnthropic
11
  from typing_extensions import TypedDict
12
+ from prompts import (
13
+ ROUTER_PROMPT,
14
+ PLANNER_PROMPT,
15
+ SYNTHESIS_ANSWER_PROMPT,
16
+ CONFIDENCE_PROMPT,
17
+ FORMAT_ANSWER_PROMPT,
18
+ ANSWER_PROMPT,
19
+ )
20
  from langgraph.graph import StateGraph, START, END
21
+ from tools import search_web, run_calculations, extract_image_urls, analyze_images
22
 
23
  load_dotenv(override=True)
24
 
25
+ MAX_SEARCH_QUERIES = 3
26
+ MIN_SEARCH_QUERIES = 1
27
+ MAX_SUPPLEMENTAL_QUERIES = 2
28
+ CONFIDENCE_THRESHOLD = 0.72
29
+ MAX_LLM_RETRIES = 6
30
+ DEFAULT_RETRY_SECONDS = 15
31
+ QUESTION_DELAY_SECONDS = float(os.getenv("QUESTION_DELAY_SECONDS", "2"))
32
+
33
+ VALID_TASK_TYPES = frozenset({"factual", "numeric", "visual", "hybrid"})
34
+
35
 
36
  def _resolve_anthropic_api_key() -> str:
 
37
  key = (os.getenv("ANTHROPIC_API_KEY") or "").strip().strip('"').strip("'")
38
  if not key:
39
  raise ValueError(
40
+ "ANTHROPIC_API_KEY is not set. Add sk-ant-... to .env or Space secrets."
 
41
  )
42
  if key.startswith("sess-") or key.startswith("hf_"):
43
+ raise ValueError("ANTHROPIC_API_KEY must be sk-ant-..., not a session/HF token.")
 
 
44
  if not key.startswith("sk-ant-"):
45
+ raise ValueError("ANTHROPIC_API_KEY should start with sk-ant-.")
 
 
 
46
  return key
47
 
48
 
 
 
 
 
 
 
 
 
49
  model = ChatAnthropic(
50
  model=os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001"),
51
  api_key=_resolve_anthropic_api_key(),
52
  temperature=0,
53
  )
54
 
 
55
 
56
+ class AgentState(TypedDict, total=False):
57
  question: str
58
+ task_type: str
59
+ task_reason: str
60
  plan: list[str]
61
  search_queries: list[str]
62
+ calculations: list[str]
63
+ image_urls: list[str]
64
+ vision_results: list[str]
65
+ math_results: list[str]
66
  search_results: list[str]
67
  synthesis_notes: str
68
+ draft_answer: str
69
  answer: str
70
+ confidence: float
71
+ confidence_issues: list[str]
72
+ follow_up_queries: list[str]
73
+ retry_count: int
74
+
75
 
76
  #--- Helpers ------------------------------------------------------------#
77
 
 
124
 
125
 
126
  def invoke_llm(prompt: str) -> str:
 
127
  last_error: Exception | None = None
128
  for attempt in range(MAX_LLM_RETRIES):
129
  try:
130
+ return _message_content(model.invoke(prompt))
 
131
  except Exception as e:
132
  last_error = e
133
  if _is_rate_limit_error(e) and attempt < MAX_LLM_RETRIES - 1:
134
  delay = _retry_delay_seconds(e, attempt)
135
+ print(f"Rate limited; retrying in {delay:.0f}s...")
136
  time.sleep(delay)
137
  continue
138
  raise
139
  if last_error:
140
  raise last_error
141
+ raise RuntimeError("invoke_llm failed")
142
 
143
 
144
  def _is_question_copy(text: str, question: str) -> bool:
145
  t, q = _normalize(text), _normalize(question)
146
+ if not t or t == q:
 
 
147
  return True
148
+ return len(t) >= len(q) * 0.85 and q in t
 
 
149
 
150
 
151
  def _keyword_fallback_query(question: str) -> str:
 
162
  return " ".join(words[:8]) if words else question[:80]
163
 
164
 
165
+ def _classify_task_heuristic(question: str) -> tuple[str, str]:
166
+ """Fast routing when the router LLM call fails."""
167
+ q = question.lower()
168
+ urls = extract_image_urls(question)
169
+ if urls or any(
170
+ w in q
171
+ for w in (
172
+ "image", "picture", "photo", "chart", "graph", "figure", "diagram",
173
+ "screenshot", "plot", "table in the", "shown in",
174
+ )
175
+ ):
176
+ return "visual", "Question references an image or chart."
177
+ if _extract_inline_calculations(question) or any(
178
+ w in q
179
+ for w in (
180
+ "how many", "how much", "calculate", "sum", "total", "average",
181
+ "percent", "ratio", "difference", "multiply", "divide",
182
+ )
183
+ ):
184
+ return "numeric", "Question requires counting or arithmetic."
185
+ if any(
186
+ w in q
187
+ for w in ("what is the capital", "who wrote", "when was", "where is", "name of")
188
+ ):
189
+ return "factual", "Simple factual lookup."
190
+ return "hybrid", "Default multi-tool pipeline."
191
+
192
+
193
+ def _parse_router_response(content: str, question: str) -> dict:
194
+ data = json.loads(_strip_json_fences(content))
195
+ task_type = str(data.get("task_type", "hybrid")).strip().lower()
196
+ if task_type not in VALID_TASK_TYPES:
197
+ task_type, reason = _classify_task_heuristic(question)
198
+ return {"task_type": task_type, "task_reason": reason}
199
+ reason = str(data.get("reason", "")).strip() or f"Classified as {task_type}."
200
+ return {"task_type": task_type, "task_reason": reason}
201
+
202
+
203
+ def _extract_inline_calculations(question: str) -> list[str]:
204
+ """Heuristic: pull simple arithmetic patterns from the question."""
205
+ found: list[str] = []
206
+ for match in re.finditer(
207
+ r"\b\d+(?:\.\d+)?\s*[\+\-\*\/]\s*\d+(?:\.\d+)?(?:\s*[\+\-\*\/]\s*\d+(?:\.\d+)?)*",
208
+ question,
209
+ ):
210
+ expr = re.sub(r"\s+", "", match.group(0))
211
+ if expr and expr not in found:
212
+ found.append(expr)
213
+ return found[:3]
214
+
215
+
216
  def _parse_planner_response(content: str, question: str) -> dict:
217
+ data = json.loads(_strip_json_fences(content))
 
218
  steps = data.get("steps") or []
219
  queries = data.get("search_queries") or []
220
+ calcs = data.get("calculations") or []
221
+ images = data.get("image_urls") or []
222
 
223
  if not isinstance(steps, list):
224
  steps = [str(steps)]
225
  if not isinstance(queries, list):
226
  queries = [str(queries)]
227
+ if not isinstance(calcs, list):
228
+ calcs = []
229
+ if not isinstance(images, list):
230
+ images = []
231
 
232
  steps = [str(s).strip() for s in steps if str(s).strip()]
233
  steps = [s for s in steps if not _is_question_copy(s, question)]
234
  if not steps:
235
  steps = [
236
+ "Identify entities, dates, and constraints",
237
+ "Gather missing facts from search",
238
+ "Verify numeric relationships with calculations if needed",
239
+ "Form the minimal GAIA answer",
240
  ]
241
 
242
  queries = [str(q).strip() for q in queries if str(q).strip()]
 
251
  deduped.append(q)
252
  if len(deduped) >= MAX_SEARCH_QUERIES:
253
  break
 
254
  if len(deduped) < MIN_SEARCH_QUERIES:
255
  deduped = [_keyword_fallback_query(question)]
256
 
257
+ calcs = [str(c).strip() for c in calcs if str(c).strip()]
258
+ for expr in _extract_inline_calculations(question):
259
+ if expr not in calcs:
260
+ calcs.append(expr)
261
+ calcs = calcs[:5]
262
+
263
+ image_urls = [str(u).strip() for u in images if str(u).strip().startswith("http")]
264
+ for url in extract_image_urls(question):
265
+ if url not in image_urls:
266
+ image_urls.append(url)
267
+ image_urls = image_urls[:3]
268
+
269
+ return {
270
+ "steps": steps,
271
+ "search_queries": deduped,
272
+ "calculations": calcs,
273
+ "image_urls": image_urls,
274
+ }
275
 
276
 
277
  def _format_plan(plan: list[str]) -> str:
278
+ return "\n".join(f"- {s}" for s in plan)
279
+
280
+
281
+ def _format_math(math_results: list[str]) -> str:
282
+ if not math_results:
283
+ return "(no calculations)"
284
+ return "\n".join(f"- {m}" for m in math_results)
285
 
286
 
287
+ def _format_vision(vision_results: list[str]) -> str:
288
+ if not vision_results:
289
+ return "(no images analyzed)"
290
+ return "\n\n".join(vision_results)
291
+
292
+
293
+ def _format_search_results(results: list[str], max_chars: int = 7000) -> str:
294
+ blocks, total = [], 0
295
  for i, block in enumerate(results, 1):
296
+ chunk = f"### Result block {i}\n{block}"
297
  if total + len(chunk) > max_chars:
298
  chunk = chunk[: max_chars - total]
299
  blocks.append(chunk)
 
303
  return "\n\n".join(blocks) if blocks else "(no search results)"
304
 
305
 
306
+ def _heuristic_synthesis(
307
+ search_results: list[str],
308
+ math_results: list[str],
309
+ vision_results: list[str] | None = None,
310
+ ) -> str:
311
  bullets: list[str] = []
312
+ for v in vision_results or []:
313
+ bullets.append(f"- {v[:500]}")
314
+ for m in math_results:
315
+ bullets.append(f"- {m}")
316
  for block in search_results:
317
  for line in block.split("\n"):
318
  line = line.strip()
319
+ if line and line != "No results found.":
320
+ bullets.append(f"- {line[:350]}")
321
+ if len(bullets) >= 14:
 
322
  break
323
+ if len(bullets) >= 14:
324
  break
325
+ return "\n".join(bullets) if bullets else "- No usable evidence."
326
 
327
 
328
+ def _parse_synthesis(content: str) -> dict:
329
  data = json.loads(_strip_json_fences(content))
330
  notes = str(data.get("synthesis_notes", "")).strip()
331
+ draft = str(data.get("draft_answer", data.get("answer", ""))).strip()
332
+ draft = re.sub(r'^["\']|["\']$', "", draft)
333
  if not notes:
334
  raise ValueError("missing synthesis_notes")
335
+ if not draft:
336
+ raise ValueError("missing draft_answer")
337
+ return {"synthesis_notes": notes, "draft_answer": draft}
338
 
339
 
340
+ def _parse_confidence(content: str) -> dict:
341
+ data = json.loads(_strip_json_fences(content))
342
+ conf = float(data.get("confidence", 0.5))
343
+ conf = max(0.0, min(1.0, conf))
344
+ issues = data.get("issues") or []
345
+ if not isinstance(issues, list):
346
+ issues = [str(issues)]
347
+ follow = data.get("follow_up_queries") or []
348
+ if not isinstance(follow, list):
349
+ follow = []
350
+ draft = str(data.get("draft_answer", "")).strip()
351
+ draft = re.sub(r'^["\']|["\']$', "", draft)
352
+ return {
353
+ "confidence": conf,
354
+ "confidence_issues": [str(i) for i in issues if str(i).strip()],
355
+ "follow_up_queries": [str(q).strip() for q in follow if str(q).strip()],
356
+ "draft_answer": draft,
357
+ }
358
+
359
+
360
+ def _clean_final_answer(text: str) -> str:
361
+ text = text.strip()
362
+ text = re.sub(r'^["\']|["\']$', "", text)
363
+ text = re.sub(r"^(the answer is|answer:)\s*", "", text, flags=re.I).strip()
364
+ return text
365
 
366
 
367
  #--- Nodes --------------------------------------------------------------#
368
 
369
+ def router_node(state: AgentState) -> AgentState:
370
+ """Classify question type to route the tool pipeline."""
371
  question = state["question"]
372
+ try:
373
+ parsed = _parse_router_response(
374
+ invoke_llm(ROUTER_PROMPT.format(question=question)),
375
+ question,
376
+ )
377
+ except Exception:
378
+ task_type, reason = _classify_task_heuristic(question)
379
+ parsed = {"task_type": task_type, "task_reason": reason}
380
+ print(f"Router: {parsed['task_type']} — {parsed['task_reason']}")
381
+ return parsed
382
+
383
 
384
+ def plan_node(state: AgentState) -> AgentState:
385
+ question = state["question"]
386
+ task_type = state.get("task_type", "hybrid")
387
  try:
388
+ data = _parse_planner_response(
389
+ invoke_llm(
390
+ PLANNER_PROMPT.format(question=question, task_type=task_type)
391
+ ),
392
+ question,
393
+ )
394
  except Exception:
395
  data = {
396
  "steps": [
397
+ "Identify entities, dates, and constraints",
398
+ "Search for authoritative facts",
399
+ "Apply calculations if the question is numeric",
400
  ],
401
  "search_queries": [_keyword_fallback_query(question)],
402
+ "calculations": _extract_inline_calculations(question),
403
+ "image_urls": extract_image_urls(question),
404
  }
 
405
  return {
406
  "plan": data["steps"],
407
  "search_queries": data["search_queries"],
408
+ "calculations": data["calculations"],
409
+ "image_urls": data.get("image_urls", []),
410
+ "retry_count": state.get("retry_count", 0),
411
  }
412
 
413
 
414
+ def vision_node(state: AgentState) -> AgentState:
415
+ """Analyze images in the question with Claude vision."""
416
+ urls = state.get("image_urls") or []
417
+ if not urls:
418
+ return {"vision_results": []}
419
+ print(f"Analyzing {len(urls)} image(s)...")
420
+ results = analyze_images(urls, state["question"], model)
421
+ return {"vision_results": results}
422
+
423
+
424
+ def math_node(state: AgentState) -> AgentState:
425
+ calcs = state.get("calculations") or []
426
+ return {"math_results": run_calculations(calcs)}
427
+
428
+
429
  def search_node(state: AgentState) -> AgentState:
430
+ queries = state.get("search_queries") or []
431
+ results = [search_web(q) for q in queries[:MAX_SEARCH_QUERIES]]
 
432
  return {"search_results": results}
433
 
434
 
435
+ def synthesize_node(state: AgentState) -> AgentState:
 
436
  question = state["question"]
437
+ plan = state.get("plan") or []
438
+ math_results = state.get("math_results") or []
439
+ vision_results = state.get("vision_results") or []
440
+ search_results = state.get("search_results") or []
441
 
442
  try:
443
  prompt = SYNTHESIS_ANSWER_PROMPT.format(
444
  question=question,
445
  plan=_format_plan(plan),
446
+ math_results=_format_math(math_results),
447
+ vision_results=_format_vision(vision_results),
448
+ search_results=_format_search_results(search_results),
449
  )
450
+ parsed = _parse_synthesis(invoke_llm(prompt))
451
  return parsed
452
  except Exception as e:
453
+ print(f"Synthesize failed ({e}); using fallback.")
454
+ notes = _heuristic_synthesis(search_results, math_results, vision_results)
455
  try:
456
+ draft = _clean_final_answer(
457
+ invoke_llm(
458
+ ANSWER_PROMPT.format(
459
+ question=question,
460
+ plan=_format_plan(plan),
461
+ synthesis_notes=notes,
462
+ )
463
+ )
464
+ )
465
  except Exception:
466
+ draft = "Unknown"
467
+ return {"synthesis_notes": notes, "draft_answer": draft}
468
+
469
+
470
+ def confidence_node(state: AgentState) -> AgentState:
471
+ question = state["question"]
472
+ draft = state.get("draft_answer") or ""
473
+ try:
474
+ prompt = CONFIDENCE_PROMPT.format(
475
+ question=question,
476
+ plan=_format_plan(state.get("plan") or []),
477
+ synthesis_notes=state.get("synthesis_notes") or "",
478
+ math_results=_format_math(state.get("math_results") or []),
479
+ vision_results=_format_vision(state.get("vision_results") or []),
480
+ draft_answer=draft,
481
+ )
482
+ parsed = _parse_confidence(invoke_llm(prompt))
483
+ if parsed["draft_answer"]:
484
+ draft = parsed["draft_answer"]
485
+ return {
486
+ "confidence": parsed["confidence"],
487
+ "confidence_issues": parsed["confidence_issues"],
488
+ "follow_up_queries": parsed["follow_up_queries"][:MAX_SUPPLEMENTAL_QUERIES],
489
+ "draft_answer": draft,
490
+ }
491
+ except Exception as e:
492
+ print(f"Confidence check failed ({e}); assuming moderate confidence.")
493
+ return {
494
+ "confidence": 0.75,
495
+ "confidence_issues": [],
496
+ "follow_up_queries": [],
497
+ "draft_answer": draft,
498
+ }
499
+
500
+
501
+ def supplemental_search_node(state: AgentState) -> AgentState:
502
+ follow = state.get("follow_up_queries") or []
503
+ follow = [q for q in follow if not _is_question_copy(q, state["question"])]
504
+ if not follow:
505
+ return {"retry_count": state.get("retry_count", 0) + 1}
506
+ new_results = [search_web(q) for q in follow[:MAX_SUPPLEMENTAL_QUERIES]]
507
+ existing = state.get("search_results") or []
508
+ return {
509
+ "search_results": existing + new_results,
510
+ "retry_count": state.get("retry_count", 0) + 1,
511
+ }
512
+
513
+
514
+ def format_node(state: AgentState) -> AgentState:
515
+ question = state["question"]
516
+ draft = state.get("draft_answer") or ""
517
+ issues = state.get("confidence_issues") or []
518
+ issues_text = "; ".join(issues) if issues else "(none)"
519
+
520
+ try:
521
+ answer = _clean_final_answer(
522
+ invoke_llm(
523
+ FORMAT_ANSWER_PROMPT.format(
524
+ question=question,
525
+ draft_answer=draft,
526
+ issues=issues_text,
527
+ )
528
+ )
529
+ )
530
+ except Exception:
531
+ answer = _clean_final_answer(draft)
532
+ if not answer:
533
+ answer = "Unknown"
534
+ return {"answer": answer}
535
+
536
+
537
+ def route_after_confidence(state: AgentState) -> str:
538
+ if state.get("confidence", 0) >= CONFIDENCE_THRESHOLD:
539
+ return "format"
540
+ if state.get("retry_count", 0) >= 1:
541
+ return "format"
542
+ if state.get("follow_up_queries"):
543
+ return "supplemental_search"
544
+ return "format"
545
+
546
+
547
+ def route_after_plan(state: AgentState) -> str:
548
+ """Pick the first tool node based on task type and planned resources."""
549
+ task = state.get("task_type", "hybrid")
550
+ urls = state.get("image_urls") or []
551
+ calcs = state.get("calculations") or []
552
+
553
+ if task == "factual" and not urls and not calcs:
554
+ return "search"
555
+ if task == "visual" or urls:
556
+ return "vision"
557
+ if task == "numeric" or calcs:
558
+ return "math"
559
+ if urls:
560
+ return "vision"
561
+ if calcs:
562
+ return "math"
563
+ return "search"
564
+
565
+
566
+ def route_after_vision(state: AgentState) -> str:
567
+ """After vision, run math only if calculations were planned."""
568
+ if state.get("calculations"):
569
+ return "math"
570
+ return "search"
571
 
572
 
573
  #--- Graph --------------------------------------------------------------#
574
 
575
  agent_builder = StateGraph(AgentState)
576
+ agent_builder.add_node("router", router_node)
577
  agent_builder.add_node("plan", plan_node)
578
+ agent_builder.add_node("vision", vision_node)
579
+ agent_builder.add_node("math", math_node)
580
  agent_builder.add_node("search", search_node)
581
+ agent_builder.add_node("synthesize", synthesize_node)
582
+ agent_builder.add_node("confidence", confidence_node)
583
+ agent_builder.add_node("supplemental_search", supplemental_search_node)
584
+ agent_builder.add_node("format", format_node)
585
+
586
+ agent_builder.add_edge(START, "router")
587
+ agent_builder.add_edge("router", "plan")
588
+ agent_builder.add_conditional_edges(
589
+ "plan",
590
+ route_after_plan,
591
+ {
592
+ "vision": "vision",
593
+ "math": "math",
594
+ "search": "search",
595
+ },
596
+ )
597
+ agent_builder.add_conditional_edges(
598
+ "vision",
599
+ route_after_vision,
600
+ {
601
+ "math": "math",
602
+ "search": "search",
603
+ },
604
+ )
605
+ agent_builder.add_edge("math", "search")
606
+ agent_builder.add_edge("search", "synthesize")
607
+ agent_builder.add_edge("synthesize", "confidence")
608
+ agent_builder.add_conditional_edges(
609
+ "confidence",
610
+ route_after_confidence,
611
+ {
612
+ "format": "format",
613
+ "supplemental_search": "supplemental_search",
614
+ },
615
+ )
616
+ agent_builder.add_edge("supplemental_search", "synthesize")
617
+ agent_builder.add_edge("format", END)
618
 
619
  agent = agent_builder.compile()
620
 
621
 
622
  def run_agent(question: str) -> AgentState:
623
+ """Run the full GAIA pipeline for one question."""
624
+ return agent.invoke(
625
+ {
626
+ "question": question,
627
+ "retry_count": 0,
628
+ "search_results": [],
629
+ "math_results": [],
630
+ "vision_results": [],
631
+ }
632
+ )
633
 
634
 
635
  if __name__ == "__main__":
636
+ questions = ["What is the capital of France?"]
 
 
 
637
  for i, question in enumerate(questions):
638
  if i > 0:
639
  time.sleep(QUESTION_DELAY_SECONDS)
640
  result = run_agent(question)
641
  print(f"Question: {result['question']}")
642
+ print(f"Task: {result.get('task_type')} — {result.get('task_reason')}")
643
+ print(f"Plan: {result.get('plan')}")
644
+ print(f"Calculations: {result.get('calculations')}")
645
+ print(f"Images: {result.get('image_urls')}")
646
+ print(f"Vision: {result.get('vision_results')}")
647
+ print(f"Math: {result.get('math_results')}")
648
+ print(f"Queries: {result.get('search_queries')}")
649
+ print(f"Confidence: {result.get('confidence')}")
650
+ print(f"Synthesis:\n{result.get('synthesis_notes')}")
651
+ print(f"Answer: {result.get('answer')}\n")
prompts.py CHANGED
@@ -1,50 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}
@@ -52,23 +96,88 @@ Question:
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}
 
1
+ ROUTER_PROMPT = """
2
+ You classify GAIA benchmark questions by the primary skill needed to answer.
3
+
4
+ Output ONLY valid JSON:
5
+ {{
6
+ "task_type": "factual|numeric|visual|hybrid",
7
+ "reason": "one short sentence"
8
+ }}
9
+
10
+ Task types:
11
+ - factual: lookup a name, place, date, definition, or single known fact (web search is enough).
12
+ - numeric: counting, arithmetic, units, percentages, or multi-step math (calculator needed).
13
+ - visual: answer depends on an image, chart, diagram, screenshot, or figure (vision needed).
14
+ - hybrid: needs multiple skills (e.g. read a chart AND search AND calculate).
15
+
16
+ Pick exactly one task_type. Use hybrid only when two or more of search, math, and vision are clearly required.
17
+
18
+ Question:
19
+ {question}
20
+ """
21
+
22
  PLANNER_PROMPT = """
23
+ You are a planning agent for the GAIA benchmark (level 1).
24
+
25
+ Task type for this question: {task_type}
26
 
27
+ Analyze the question and output JSON only (no markdown fences).
 
 
28
 
29
+ Schema (all four keys required):
 
 
30
  {{
31
+ "steps": ["3-6 actionable reasoning steps"],
32
+ "search_queries": ["1-3 short keyword queries"],
33
+ "calculations": ["0-5 math expressions, or []"],
34
+ "image_urls": ["0-3 image URLs from the question, or []"]
35
  }}
36
+
37
+ Rules for steps:
38
+ - Atomic, specific (identify entities, dates, units, constraints).
39
+ - If the question needs counting, differences, ratios, or multi-step arithmetic, include a step to verify with calculation.
40
+
41
+ Rules for search_queries:
42
+ - 1-3 items, 2-8 words each, distinct fact gaps.
43
+ - NEVER copy the full question verbatim.
44
+ - Target authoritative facts (names, dates, numbers, definitions).
45
+
46
+ Rules for calculations:
47
+ - List plain math expressions the answer depends on (e.g. "1250-380", "24*365", "sqrt(144)").
48
+ - Use only numbers and operators + - * / // ** % and functions sqrt, abs, round.
49
+ - Empty array [] if no math is needed.
50
+
51
+ Rules for image_urls:
52
+ - Copy any http(s) image links from the question (jpg, png, gif, webp, or dataset/file URLs).
53
+ - If the question refers to "the image", "this figure", "the chart", or an attachment URL, include it.
54
+ - Max 3 URLs. Empty array [] if no image is involved.
55
 
56
  Question:
57
  {question}
58
  """
59
 
60
+ IMAGE_ANALYSIS_PROMPT = """
61
+ You are a vision analyst for GAIA benchmark questions.
62
+
63
+ Describe everything in the image that could help answer the question:
64
+ - Text (OCR): transcribe labels, numbers, titles, legends, axes, units.
65
+ - Objects, people, places, charts, tables, maps.
66
+ - Counts, colors, dates, and relationships visible in the image.
67
+ - If a chart/graph: report series names, values, trends, min/max.
68
+
69
+ Be factual and specific. Do not answer the question yet — only report visual evidence.
70
+
71
+ Question:
72
+ {question}
73
 
74
+ Image URL:
75
+ {url}
76
+ """
77
+
78
+ SYNTHESIS_ANSWER_PROMPT = """
79
+ You are a GAIA research agent. Use search results, math tool outputs, image vision notes, and the plan to answer.
80
 
81
+ Return ONLY valid JSON:
 
 
82
  {{
83
+ "synthesis_notes": "- bullet facts (max 14, prefix '- ')",
84
+ "draft_answer": "best answer before final formatting"
85
  }}
86
+
87
+ Rules:
88
+ - synthesis_notes: relevant facts only; note conflicts if sources disagree.
89
+ - draft_answer: concise, directly answers the question; use math results when provided.
90
+ - For numeric questions, compute carefully and show the number you believe is correct.
91
+ - Do not copy long search snippets into draft_answer.
92
 
93
  Question:
94
  {question}
 
96
  Plan:
97
  {plan}
98
 
99
+ Math tool results:
100
+ {math_results}
101
+
102
+ Image vision analysis:
103
+ {vision_results}
104
+
105
  Search results:
106
  {search_results}
107
  """
108
 
109
+ CONFIDENCE_PROMPT = """
110
+ You are a GAIA answer verifier. Judge whether the draft answer is well-supported and correctly formatted.
111
+
112
+ Return ONLY valid JSON:
113
+ {{
114
+ "confidence": 0.0,
115
+ "issues": ["list of problems, or empty"],
116
+ "follow_up_queries": ["0-2 NEW search queries if evidence is weak, else empty"],
117
+ "draft_answer": "revised short answer if you can improve it, else repeat the current draft"
118
+ }}
119
+
120
+ Scoring confidence (0.0-1.0):
121
+ - 0.9+: answer directly matches question, supported by notes/math, format likely correct for GAIA.
122
+ - 0.7-0.89: plausible but missing verification or ambiguous evidence.
123
+ - Below 0.7: wrong, vague, unsupported, or format wrong — add follow_up_queries to fill gaps.
124
+
125
+ GAIA format checks:
126
+ - Number questions: answer should be a number (or number with unit if question asks).
127
+ - Yes/no: only yes or no.
128
+ - Name/place: proper noun, minimal words.
129
+ - List: match requested format.
130
+ - No sentences from search titles, no "based on", no hedging.
131
+
132
+ Question:
133
+ {question}
134
+
135
+ Plan:
136
+ {plan}
137
+
138
+ Synthesis notes:
139
+ {synthesis_notes}
140
+
141
+ Math results:
142
+ {math_results}
143
+
144
+ Image vision analysis:
145
+ {vision_results}
146
+
147
+ Current draft answer:
148
+ {draft_answer}
149
+ """
150
+
151
+ FORMAT_ANSWER_PROMPT = """
152
+ You format the final GAIA submission answer. Output ONLY the answer string, nothing else.
153
+
154
+ Strict GAIA rules:
155
+ - Minimal: shortest correct form (usually 1-5 words, or a single number).
156
+ - If the question asks for a number only, output digits (and decimal point if needed) — no units unless explicitly required.
157
+ - If yes/no, output exactly: yes or no (lowercase).
158
+ - No preamble, quotes, explanation, or "The answer is".
159
+ - No trailing period unless the answer is a full sentence the question requires.
160
+ - Strip Wikipedia-style titles, colons, or citation artifacts.
161
+ - Prefer the draft if correct; fix format only.
162
+
163
+ Question:
164
+ {question}
165
+
166
+ Draft answer:
167
+ {draft_answer}
168
+
169
+ Confidence notes (issues to fix if any):
170
+ {issues}
171
+ """
172
+
173
  ANSWER_PROMPT = """
174
  You are a GAIA benchmark answer agent. Produce the final answer only.
175
 
176
  Rules:
177
  - Use the question, plan, and synthesis notes — not raw search snippets.
178
+ - Return ONLY the final answer text.
179
+ - Be concise: short phrase, name, number, or yes/no.
180
+ - Do NOT echo search artifacts or broken fragments.
 
 
 
 
 
181
 
182
  Question:
183
  {question}
tools.py CHANGED
@@ -1,13 +1,199 @@
 
 
 
 
 
 
 
 
 
 
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
-
 
1
+ """Tools for the GAIA agent: web search, arithmetic, and image recognition."""
2
+
3
+ import ast
4
+ import base64
5
+ import math
6
+ import operator
7
+ import re
8
+ from typing import Any
9
+
10
+ import requests
11
  from ddgs import DDGS
12
+ from langchain_core.messages import HumanMessage
13
+ from prompts import IMAGE_ANALYSIS_PROMPT
14
+
15
+ MAX_IMAGES_PER_QUESTION = 3
16
+ _IMAGE_EXTENSIONS = r"(?:jpg|jpeg|png|gif|webp|bmp|svg)"
17
+ _URL_TAIL = r"(?:\?[^\s\)\]\"\'<>]*)?"
18
+
19
+ # Safe operators for GAIA-style numeric questions
20
+ _SAFE_OPERATORS = {
21
+ ast.Add: operator.add,
22
+ ast.Sub: operator.sub,
23
+ ast.Mult: operator.mul,
24
+ ast.Div: operator.truediv,
25
+ ast.FloorDiv: operator.floordiv,
26
+ ast.Pow: operator.pow,
27
+ ast.Mod: operator.mod,
28
+ ast.USub: operator.neg,
29
+ ast.UAdd: operator.pos,
30
+ }
31
+
32
+
33
+ def _eval_ast(node: ast.AST) -> float:
34
+ if isinstance(node, ast.Constant):
35
+ if isinstance(node.value, (int, float)):
36
+ return float(node.value)
37
+ raise ValueError(f"Unsupported constant: {node.value}")
38
+ if isinstance(node, ast.Num): # pragma: no cover - py<3.8
39
+ return float(node.n)
40
+ if isinstance(node, ast.BinOp):
41
+ op = _SAFE_OPERATORS.get(type(node.op))
42
+ if not op:
43
+ raise ValueError(f"Unsupported operator: {type(node.op).__name__}")
44
+ return op(_eval_ast(node.left), _eval_ast(node.right))
45
+ if isinstance(node, ast.UnaryOp):
46
+ op = _SAFE_OPERATORS.get(type(node.op))
47
+ if not op:
48
+ raise ValueError(f"Unsupported unary op: {type(node.op).__name__}")
49
+ return op(_eval_ast(node.operand))
50
+ if isinstance(node, ast.Call):
51
+ if isinstance(node.func, ast.Name):
52
+ name = node.func.id
53
+ args = [_eval_ast(a) for a in node.args]
54
+ if name == "sqrt" and len(args) == 1:
55
+ return math.sqrt(args[0])
56
+ if name == "round" and len(args) in (1, 2):
57
+ return float(round(args[0], int(args[1]) if len(args) == 2 else 0))
58
+ if name == "abs" and len(args) == 1:
59
+ return abs(args[0])
60
+ raise ValueError("Unsupported function call")
61
+ raise ValueError(f"Unsupported expression node: {type(node).__name__}")
62
+
63
+
64
+ def _normalize_expression(expr: str) -> str:
65
+ expr = expr.strip()
66
+ expr = expr.replace("×", "*").replace("÷", "/").replace("^", "**")
67
+ expr = re.sub(r"\s+", "", expr)
68
+ return expr
69
+
70
+
71
+ def calculate(expression: str) -> str:
72
+ """
73
+ Evaluate a numeric expression safely (numbers, + - * / // ** %, sqrt, abs, round).
74
+ Returns a string result for the agent to use as evidence.
75
+ """
76
+ expr = _normalize_expression(expression)
77
+ if not expr:
78
+ return "Error: empty expression"
79
+ try:
80
+ tree = ast.parse(expr, mode="eval")
81
+ result = _eval_ast(tree.body)
82
+ if result == int(result) and "." not in str(expr).split("/")[-1]:
83
+ return str(int(result))
84
+ return str(round(result, 10)).rstrip("0").rstrip(".")
85
+ except Exception as e:
86
+ return f"Error: {e}"
87
+
88
+
89
+ def run_calculations(expressions: list[str]) -> list[str]:
90
+ """Run multiple calculations; skip empty or duplicate expressions."""
91
+ seen: set[str] = set()
92
+ outputs: list[str] = []
93
+ for raw in expressions:
94
+ expr = str(raw).strip()
95
+ if not expr or expr in seen:
96
+ continue
97
+ seen.add(expr)
98
+ result = calculate(expr)
99
+ outputs.append(f"{expr} = {result}")
100
+ return outputs
101
+
102
+
103
+ def _message_content(response: Any) -> str:
104
+ content = response.content
105
+ if isinstance(content, str):
106
+ return content.strip()
107
+ if isinstance(content, list):
108
+ parts = []
109
+ for block in content:
110
+ if isinstance(block, str):
111
+ parts.append(block)
112
+ elif isinstance(block, dict) and block.get("text"):
113
+ parts.append(str(block["text"]))
114
+ return "\n".join(parts).strip()
115
+ return str(content).strip()
116
+
117
+
118
+ def extract_image_urls(text: str) -> list[str]:
119
+ """Find image URLs in question text (markdown, HTML, plain links)."""
120
+ candidates: list[str] = []
121
+ patterns = [
122
+ rf"https?://[^\s\)\]\"\'<>]+\.{_IMAGE_EXTENSIONS}{_URL_TAIL}",
123
+ r"!\[[^\]]*\]\((https?://[^\)]+)\)",
124
+ r"https?://huggingface\.co/datasets/[^\s\)\]\"\'<>]+",
125
+ r"https?://[^\s\)\]\"\'<>]*(?:/image|/images|/file|/files)[^\s\)\]\"\'<>]*",
126
+ ]
127
+ for pattern in patterns:
128
+ for match in re.finditer(pattern, text, re.IGNORECASE):
129
+ url = match.group(1) if match.lastindex else match.group(0)
130
+ url = url.rstrip(".,;:")
131
+ if url.startswith("http"):
132
+ candidates.append(url)
133
+
134
+ seen: set[str] = set()
135
+ deduped: list[str] = []
136
+ for url in candidates:
137
+ key = url.lower()
138
+ if key not in seen:
139
+ seen.add(key)
140
+ deduped.append(url)
141
+ if len(deduped) >= MAX_IMAGES_PER_QUESTION:
142
+ break
143
+ return deduped
144
+
145
+
146
+ def _fetch_image_base64(url: str) -> tuple[str, str]:
147
+ """Download image and return (media_type, base64_data)."""
148
+ headers = {"User-Agent": "Mozilla/5.0 (compatible; GAIA-Agent/1.0)"}
149
+ resp = requests.get(url, timeout=45, headers=headers)
150
+ resp.raise_for_status()
151
+ content_type = (resp.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
152
+ if not content_type.startswith("image/"):
153
+ content_type = "image/jpeg"
154
+ return content_type, base64.standard_b64encode(resp.content).decode("ascii")
155
+
156
+
157
+ def describe_image(url: str, question: str, vision_model: Any) -> str:
158
+ """
159
+ Analyze an image URL with Claude vision and return a text description.
160
+ """
161
+ try:
162
+ media_type, data = _fetch_image_base64(url)
163
+ prompt = IMAGE_ANALYSIS_PROMPT.format(question=question, url=url)
164
+ message = HumanMessage(
165
+ content=[
166
+ {"type": "text", "text": prompt},
167
+ {
168
+ "type": "image",
169
+ "source": {
170
+ "type": "base64",
171
+ "media_type": media_type,
172
+ "data": data,
173
+ },
174
+ },
175
+ ]
176
+ )
177
+ return _message_content(vision_model.invoke([message]))
178
+ except Exception as e:
179
+ return f"Vision error: {e}"
180
+
181
+
182
+ def analyze_images(urls: list[str], question: str, vision_model: Any) -> list[str]:
183
+ """Run vision on up to MAX_IMAGES_PER_QUESTION URLs."""
184
+ results: list[str] = []
185
+ for url in urls[:MAX_IMAGES_PER_QUESTION]:
186
+ description = describe_image(url, question, vision_model)
187
+ results.append(f"[Image: {url}]\n{description}")
188
+ return results
189
+
190
 
191
  def search_web(query: str) -> str:
192
  """Search the web for information related to the query."""
193
+ results = DDGS().text(query, max_results=8)
194
  if not results:
195
  return "No results found."
196
  return "\n\n".join(
197
  f"{r.get('title', '')}: {r.get('body', r.get('href', ''))}"
198
  for r in results
199
  )