DriptoBhattacharyya Claude Opus 4.8 commited on
Commit
cb1b811
·
1 Parent(s): 81917a3

GAIA LangGraph agent: Groq + Tavily + LLM-as-judge

Browse files

Replace BasicAgent with a LangGraph multi-node graph (planner, research+tools, solver, LLM-as-judge eval loop, formatter). Pydantic node-to-node contracts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.env.example ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Required secrets (fill these in) ---
2
+ GROQ_API_KEY=
3
+ TAVILY_API_KEY=
4
+
5
+ # --- Optional overrides (sane defaults applied if omitted) ---
6
+ # Groq text/reasoning model used by the agent + judge nodes
7
+ GROQ_TEXT_MODEL=llama-3.3-70b-versatile
8
+ # Groq multimodal model used by the describe_image tool
9
+ GROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct
10
+ # Groq speech-to-text model used by the transcribe_audio tool
11
+ GROQ_WHISPER_MODEL=whisper-large-v3
12
+
13
+ # GAIA scoring API base URL
14
+ GAIA_API_URL=https://agents-course-unit4-scoring.hf.space
15
+
16
+ # How many times the judge may bounce a wrong answer back to research
17
+ MAX_JUDGE_RETRIES=2
18
+ # Hard cap on the LangGraph step budget per question
19
+ RECURSION_LIMIT=40
20
+ # Per-question wall-clock timeout (seconds); agent returns best-effort on expiry
21
+ QUESTION_TIMEOUT=180
.gitignore ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
110
+ .pdm.toml
111
+ .pdm-python
112
+ .pdm-build/
113
+
114
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115
+ __pypackages__/
116
+
117
+ # Celery stuff
118
+ celerybeat-schedule
119
+ celerybeat.pid
120
+
121
+ # SageMath parsed files
122
+ *.sage.py
123
+
124
+ # Environments
125
+ .env
126
+ .venv
127
+ env/
128
+ venv/
129
+ ENV/
130
+ env.bak/
131
+ venv.bak/
132
+
133
+ # Spyder project settings
134
+ .spyderproject
135
+ .spyproject
136
+
137
+ # Rope project settings
138
+ .ropeproject
139
+
140
+ # mkdocs documentation
141
+ /site
142
+
143
+ # mypy
144
+ .mypy_cache/
145
+ .dmypy.json
146
+ dmypy.json
147
+
148
+ # Pyre type checker
149
+ .pyre/
150
+
151
+ # pytype static type analyzer
152
+ .pytype/
153
+
154
+ # Cython debug symbols
155
+ cython_debug/
156
+
157
+ # PyCharm
158
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
159
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
160
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
161
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
162
+ #.idea/
163
+ .langgraph_api/
README.md CHANGED
@@ -12,4 +12,45 @@ 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
+ ---
18
+
19
+ ## GAIA Agent (LangGraph + Groq)
20
+
21
+ This Space answers GAIA Level-1 questions with a LangGraph multi-node agent:
22
+
23
+ ```
24
+ planner → [ingest_file] → research ⇄ tools → evidence → solver → judge ⇄ (loop) → formatter
25
+ ```
26
+
27
+ - **LLM:** Groq (`langchain-groq`) — text/reasoning + Llama-4 vision + Whisper audio.
28
+ - **Search:** Tavily (`langchain-tavily`) with a Wikipedia fallback.
29
+ - **Eval loop:** an LLM-as-judge node loops wrong/ill-formatted answers back to research
30
+ (up to `MAX_JUDGE_RETRIES`).
31
+ - **Structured handoffs:** every node-to-node transfer is a Pydantic model
32
+ (`gaia_agent/schemas.py`), so context stays precise.
33
+
34
+ ### Secrets (required)
35
+
36
+ Set these as **Space secrets** (Settings → Variables and secrets) — locally use a `.env`
37
+ (copy `.env.example`):
38
+
39
+ | Name | Purpose |
40
+ |------|---------|
41
+ | `GROQ_API_KEY` | Groq LLM access |
42
+ | `TAVILY_API_KEY` | Tavily web search |
43
+
44
+ Optional overrides: `GROQ_TEXT_MODEL`, `GROQ_VISION_MODEL`, `GROQ_WHISPER_MODEL`,
45
+ `MAX_JUDGE_RETRIES`, `RECURSION_LIMIT`. See `.env.example`.
46
+
47
+ ### Local development
48
+
49
+ ```bash
50
+ uv venv --python 3.11
51
+ uv pip install -e ".[dev]"
52
+ cp .env.example .env # then fill in your keys
53
+ uv run pytest -q # unit + graph/judge-loop tests
54
+ uv run langgraph dev # LangGraph Studio: visualise the graph
55
+ python scripts/dry_run.py 3 # run the agent on 3 questions (no submit)
56
+ ```
app.py CHANGED
@@ -3,21 +3,18 @@ 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
  """
@@ -40,7 +37,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 +77,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:
 
3
  import requests
4
  import inspect
5
  import pandas as pd
6
+ from dotenv import load_dotenv
7
+
8
+ # Load secrets from a local .env when running outside an HF Space.
9
+ load_dotenv()
10
 
11
  # (Keep Constants as is)
12
  # --- Constants ---
13
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
14
 
15
+ # --- Agent Definition ---
16
+ # The real agent is a LangGraph multi-node graph (Groq + Tavily + LLM-as-judge).
17
+ from gaia_agent import GaiaAgent
 
 
 
 
 
 
 
18
 
19
  def run_and_submit_all( profile: gr.OAuthProfile | None):
20
  """
 
37
 
38
  # 1. Instantiate Agent ( modify this part to create your agent)
39
  try:
40
+ agent = GaiaAgent()
41
  except Exception as e:
42
  print(f"Error instantiating agent: {e}")
43
  return f"Error initializing agent: {e}", None
 
77
  print(f"Skipping item with missing task_id or question: {item}")
78
  continue
79
  try:
80
+ submitted_answer = agent(question_text, task_id)
81
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
82
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
83
  except Exception as e:
gaia_agent/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """LangGraph multi-agent for the HF Agents Course GAIA final assignment."""
2
+
3
+ from gaia_agent.agent import GaiaAgent
4
+
5
+ __all__ = ["GaiaAgent"]
gaia_agent/agent.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public agent wrapper used by app.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gaia_agent.config import get_settings
6
+ from gaia_agent.graph import graph
7
+
8
+
9
+ class GaiaAgent:
10
+ """Callable wrapper around the compiled LangGraph graph.
11
+
12
+ ``app.py`` invokes ``agent(question, task_id)`` per question and submits the
13
+ returned string for exact-match scoring.
14
+ """
15
+
16
+ def __init__(self):
17
+ self._graph = graph
18
+ self._settings = get_settings()
19
+ print("GaiaAgent initialised (LangGraph + Groq).")
20
+
21
+ def __call__(self, question: str, task_id: str = "") -> str:
22
+ """Run the graph on one question and return the bare answer string."""
23
+ try:
24
+ result = self._graph.invoke(
25
+ {"question": question, "task_id": task_id},
26
+ config={"recursion_limit": self._settings.recursion_limit},
27
+ )
28
+ answer = (result.get("final_answer") or "").strip()
29
+ return answer or "Unable to determine an answer."
30
+ except Exception as exc: # noqa: BLE001
31
+ print(f"GaiaAgent error on task {task_id}: {exc}")
32
+ return "Unable to determine an answer."
gaia_agent/config.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralised configuration loaded from environment / .env.
2
+
3
+ All tunables live here so swapping a (renamed) Groq model or the scoring URL is a
4
+ config change, not a code change.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import lru_cache
10
+
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+
14
+ class Settings(BaseSettings):
15
+ """Runtime settings sourced from environment variables / `.env`."""
16
+
17
+ model_config = SettingsConfigDict(
18
+ env_file=".env", env_file_encoding="utf-8", extra="ignore"
19
+ )
20
+
21
+ # --- Secrets ---
22
+ groq_api_key: str = ""
23
+ tavily_api_key: str = ""
24
+
25
+ # --- Models (override via env) ---
26
+ groq_text_model: str = "llama-3.3-70b-versatile"
27
+ groq_vision_model: str = "meta-llama/llama-4-scout-17b-16e-instruct"
28
+ groq_whisper_model: str = "whisper-large-v3"
29
+
30
+ # --- API + control knobs ---
31
+ gaia_api_url: str = "https://agents-course-unit4-scoring.hf.space"
32
+ max_judge_retries: int = 2
33
+ recursion_limit: int = 40
34
+ question_timeout: int = 180
35
+
36
+
37
+ @lru_cache(maxsize=1)
38
+ def get_settings() -> Settings:
39
+ """Return a cached Settings instance (read once per process)."""
40
+ return Settings()
gaia_agent/graph.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assemble and compile the GAIA agent StateGraph.
2
+
3
+ The module-level ``graph`` is what ``langgraph.json`` points at, so ``langgraph dev``
4
+ can render and step through it in LangGraph Studio.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from langgraph.graph import END, START, StateGraph
10
+ from langgraph.prebuilt import ToolNode
11
+
12
+ from gaia_agent.nodes import (
13
+ evidence,
14
+ formatter,
15
+ ingest_file,
16
+ judge,
17
+ planner,
18
+ research,
19
+ route_after_judge,
20
+ route_after_planner,
21
+ route_after_research,
22
+ solver,
23
+ )
24
+ from gaia_agent.state import GraphState
25
+ from gaia_agent.tools import RESEARCH_TOOLS
26
+
27
+
28
+ def build_graph():
29
+ """Build and compile the GAIA agent graph."""
30
+ g = StateGraph(GraphState)
31
+
32
+ g.add_node("planner", planner)
33
+ g.add_node("ingest_file", ingest_file)
34
+ g.add_node("research", research)
35
+ g.add_node("tools", ToolNode(RESEARCH_TOOLS))
36
+ g.add_node("evidence", evidence)
37
+ g.add_node("solver", solver)
38
+ g.add_node("judge", judge)
39
+ g.add_node("formatter", formatter)
40
+
41
+ g.add_edge(START, "planner")
42
+ g.add_conditional_edges(
43
+ "planner", route_after_planner, {"ingest_file": "ingest_file", "research": "research"}
44
+ )
45
+ g.add_edge("ingest_file", "research")
46
+ g.add_conditional_edges(
47
+ "research", route_after_research, {"tools": "tools", "evidence": "evidence"}
48
+ )
49
+ g.add_edge("tools", "research")
50
+ g.add_edge("evidence", "solver")
51
+ g.add_edge("solver", "judge")
52
+ g.add_conditional_edges(
53
+ "judge", route_after_judge, {"research": "research", "formatter": "formatter"}
54
+ )
55
+ g.add_edge("formatter", END)
56
+
57
+ return g.compile(name="GAIA Agent")
58
+
59
+
60
+ # Exposed for langgraph.json and direct imports.
61
+ graph = build_graph()
gaia_agent/llm.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Groq chat-model factories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+
7
+ from langchain_groq import ChatGroq
8
+
9
+ from gaia_agent.config import get_settings
10
+
11
+
12
+ @lru_cache(maxsize=4)
13
+ def get_text_llm(temperature: float = 0.0) -> ChatGroq:
14
+ """Return the Groq text/reasoning model used by most nodes."""
15
+ s = get_settings()
16
+ return ChatGroq(
17
+ model=s.groq_text_model,
18
+ api_key=s.groq_api_key,
19
+ temperature=temperature,
20
+ max_retries=2,
21
+ )
22
+
23
+
24
+ @lru_cache(maxsize=1)
25
+ def get_vision_llm() -> ChatGroq:
26
+ """Return the Groq multimodal model used for image understanding."""
27
+ s = get_settings()
28
+ return ChatGroq(
29
+ model=s.groq_vision_model,
30
+ api_key=s.groq_api_key,
31
+ temperature=0.0,
32
+ max_retries=2,
33
+ )
gaia_agent/nodes.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph nodes and routing functions for the GAIA agent.
2
+
3
+ Flow: planner -> [ingest_file] -> research <-> tools -> evidence -> solver
4
+ -> judge -> (research on REVISE within budget | formatter) -> END
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from langchain_core.messages import HumanMessage, SystemMessage
10
+
11
+ from gaia_agent.config import get_settings
12
+ from gaia_agent.llm import get_text_llm
13
+ from gaia_agent.prompts import (
14
+ EVIDENCE_PROMPT,
15
+ FORMATTER_PROMPT,
16
+ GAIA_RULES,
17
+ JUDGE_PROMPT,
18
+ PLANNER_PROMPT,
19
+ RESEARCH_PROMPT,
20
+ SOLVER_PROMPT,
21
+ )
22
+ from gaia_agent.schemas import (
23
+ Candidate,
24
+ Evidence,
25
+ FileExtract,
26
+ FinalAnswer,
27
+ JudgeVerdict,
28
+ Plan,
29
+ )
30
+ from gaia_agent.state import GraphState
31
+ from gaia_agent.tools import (
32
+ RESEARCH_TOOLS,
33
+ classify_file,
34
+ describe_image,
35
+ fetch_task_file,
36
+ read_spreadsheet,
37
+ read_text_file,
38
+ transcribe_audio,
39
+ )
40
+
41
+ # --------------------------------------------------------------------------- #
42
+ # Planner
43
+ # --------------------------------------------------------------------------- #
44
+
45
+
46
+ def planner(state: GraphState) -> dict:
47
+ """Produce a structured Plan and initialise loop bookkeeping."""
48
+ try:
49
+ llm = get_text_llm().with_structured_output(Plan)
50
+ plan = llm.invoke(
51
+ PLANNER_PROMPT.format(question=state["question"], task_id=state.get("task_id", ""))
52
+ )
53
+ except Exception: # noqa: BLE001
54
+ plan = Plan(needs_file=False, reasoning="planner-fallback")
55
+ return {"plan": plan, "attempts": 0, "context_notes": ""}
56
+
57
+
58
+ def route_after_planner(state: GraphState) -> str:
59
+ """Go fetch the file only if the plan says one is needed."""
60
+ plan = state.get("plan")
61
+ return "ingest_file" if (plan and plan.needs_file) else "research"
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # File ingestion
66
+ # --------------------------------------------------------------------------- #
67
+
68
+
69
+ def ingest_file(state: GraphState) -> dict:
70
+ """Download and extract the task's attached file into context_notes."""
71
+ task_id = state.get("task_id", "")
72
+ path = fetch_task_file(task_id)
73
+ if not path:
74
+ return {"file_extract": None}
75
+
76
+ kind = classify_file(path)
77
+ question = state["question"]
78
+ try:
79
+ if kind == "spreadsheet":
80
+ extracted = read_spreadsheet.invoke({"path": path})
81
+ elif kind == "audio":
82
+ extracted = transcribe_audio.invoke({"path": path})
83
+ elif kind == "image":
84
+ extracted = describe_image.invoke({"path": path, "question": question})
85
+ elif kind in ("text", "code"):
86
+ extracted = read_text_file.invoke({"path": path})
87
+ else:
88
+ extracted = f"File downloaded to {path} (kind={kind}); no extractor available."
89
+ except Exception as exc: # noqa: BLE001
90
+ extracted = f"File extraction failed: {exc}"
91
+
92
+ fe = FileExtract(kind=kind, summary=f"Attached {kind} file.", extracted=str(extracted)[:8000])
93
+ notes = (state.get("context_notes", "") + f"\n[FILE:{kind}]\n{fe.extracted}").strip()
94
+ return {"file_extract": fe, "context_notes": notes}
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # Research (ReAct tool-calling loop)
99
+ # --------------------------------------------------------------------------- #
100
+
101
+
102
+ def research(state: GraphState) -> dict:
103
+ """Run one step of the research tool-calling loop."""
104
+ llm = get_text_llm().bind_tools(RESEARCH_TOOLS)
105
+ if not state.get("messages"):
106
+ system = RESEARCH_PROMPT.format(
107
+ rules=GAIA_RULES,
108
+ question=state["question"],
109
+ context_notes=state.get("context_notes", "") or "(none)",
110
+ )
111
+ seed = [
112
+ SystemMessage(system),
113
+ HumanMessage(
114
+ f"Task id: {state.get('task_id', '')}. Research and gather the facts "
115
+ "needed to answer precisely. Call tools as needed; stop when confident."
116
+ ),
117
+ ]
118
+ ai = llm.invoke(seed)
119
+ return {"messages": seed + [ai]}
120
+ ai = llm.invoke(state["messages"])
121
+ return {"messages": [ai]}
122
+
123
+
124
+ def route_after_research(state: GraphState) -> str:
125
+ """Route to tools if the model requested any, else summarise evidence."""
126
+ last = state["messages"][-1]
127
+ if getattr(last, "tool_calls", None):
128
+ return "tools"
129
+ return "evidence"
130
+
131
+
132
+ # --------------------------------------------------------------------------- #
133
+ # Evidence / Solver / Judge / Formatter
134
+ # --------------------------------------------------------------------------- #
135
+
136
+
137
+ def evidence(state: GraphState) -> dict:
138
+ """Distil the research conversation into structured Evidence."""
139
+ try:
140
+ llm = get_text_llm().with_structured_output(Evidence)
141
+ ev = llm.invoke([SystemMessage(EVIDENCE_PROMPT), *state.get("messages", [])])
142
+ except Exception: # noqa: BLE001
143
+ ev = Evidence(findings=[], sources=[], confidence=0.0)
144
+ return {"evidence": ev}
145
+
146
+
147
+ def solver(state: GraphState) -> dict:
148
+ """Synthesize evidence + context into a single Candidate answer."""
149
+ ev = state.get("evidence")
150
+ try:
151
+ llm = get_text_llm().with_structured_output(Candidate)
152
+ cand = llm.invoke(
153
+ SOLVER_PROMPT.format(
154
+ rules=GAIA_RULES,
155
+ question=state["question"],
156
+ evidence=ev.model_dump() if ev else "{}",
157
+ context_notes=state.get("context_notes", "") or "(none)",
158
+ )
159
+ )
160
+ except Exception: # noqa: BLE001
161
+ cand = Candidate(answer="", justification="solver-fallback")
162
+ return {"candidate": cand}
163
+
164
+
165
+ def judge(state: GraphState) -> dict:
166
+ """LLM-as-judge: PASS, or REVISE with feedback fed back into research."""
167
+ cand = state.get("candidate")
168
+ ev = state.get("evidence")
169
+ try:
170
+ llm = get_text_llm().with_structured_output(JudgeVerdict)
171
+ verdict = llm.invoke(
172
+ JUDGE_PROMPT.format(
173
+ rules=GAIA_RULES,
174
+ question=state["question"],
175
+ evidence=ev.model_dump() if ev else "{}",
176
+ candidate=cand.answer if cand else "",
177
+ )
178
+ )
179
+ except Exception: # noqa: BLE001
180
+ verdict = JudgeVerdict(verdict="PASS", feedback="judge-fallback")
181
+
182
+ updates: dict = {"verdict": verdict}
183
+ if verdict.verdict == "REVISE":
184
+ attempts = state.get("attempts", 0) + 1
185
+ updates["attempts"] = attempts
186
+ feedback = (
187
+ f"Judge requested a revision. Feedback: {verdict.feedback}. "
188
+ f"Gaps to close: {verdict.missing}. Re-research as needed and improve the answer."
189
+ )
190
+ updates["messages"] = [HumanMessage(feedback)]
191
+ updates["context_notes"] = (
192
+ state.get("context_notes", "") + f"\n[JUDGE] {verdict.feedback}"
193
+ ).strip()
194
+ return updates
195
+
196
+
197
+ def route_after_judge(state: GraphState) -> str:
198
+ """Loop back to research on REVISE while within the retry budget."""
199
+ verdict = state.get("verdict")
200
+ if verdict and verdict.verdict == "PASS":
201
+ return "formatter"
202
+ if state.get("attempts", 0) >= get_settings().max_judge_retries:
203
+ return "formatter"
204
+ return "research"
205
+
206
+
207
+ def formatter(state: GraphState) -> dict:
208
+ """Render the candidate into the bare exact-match answer string."""
209
+ cand = state.get("candidate")
210
+ candidate_text = cand.answer if cand else ""
211
+ try:
212
+ llm = get_text_llm().with_structured_output(FinalAnswer)
213
+ final = llm.invoke(
214
+ FORMATTER_PROMPT.format(
215
+ rules=GAIA_RULES, question=state["question"], candidate=candidate_text
216
+ )
217
+ )
218
+ answer = final.answer
219
+ except Exception: # noqa: BLE001
220
+ answer = candidate_text
221
+ return {"final_answer": (answer or candidate_text or "").strip()}
gaia_agent/prompts.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System prompts. GAIA exact-match rules are centralised here."""
2
+
3
+ # Shared rules every node should respect.
4
+ GAIA_RULES = """\
5
+ You are a careful research agent solving GAIA benchmark questions. Answers are scored
6
+ by EXACT STRING MATCH, so precision of the final token matters more than explanation.
7
+
8
+ Final-answer formatting rules:
9
+ - Output ONLY the answer. Never write the words "FINAL ANSWER".
10
+ - A number: no thousands separators, no units (unless the question asks for units),
11
+ no '$' or '%' unless explicitly requested. Write digits, e.g. 1024 not "1,024".
12
+ - A string: as few words as possible; do not use articles or abbreviations unless the
13
+ question demands them; spell out digits in words only if asked.
14
+ - A comma-separated list: apply the number/string rules to each element, one space
15
+ after each comma.
16
+ - Do not add trailing punctuation, quotes, or commentary.
17
+ """
18
+
19
+ PLANNER_PROMPT = """\
20
+ You are the PLANNER. Read the question and decide how to solve it.
21
+ Determine whether an attached file is needed, guess its kind, list concrete subtasks,
22
+ and name the tools likely required (tavily_search, wikipedia_search, read_spreadsheet,
23
+ transcribe_audio, describe_image, read_text_file, python_repl).
24
+ Be concise. Do not answer the question yet.
25
+
26
+ Question:
27
+ {question}
28
+
29
+ A file MAY be attached to this task (task_id={task_id}). If the question references a
30
+ file, attachment, image, audio, spreadsheet, table, or document, set needs_file=true.
31
+ """
32
+
33
+ RESEARCH_PROMPT = """\
34
+ You are the RESEARCHER. Gather the facts needed to answer the question precisely.
35
+ Use the available tools. Prefer authoritative sources. Do exact arithmetic / string
36
+ work with python_repl rather than guessing. Stop calling tools once you have enough.
37
+
38
+ {rules}
39
+
40
+ Question:
41
+ {question}
42
+
43
+ Known context (from file extraction and prior judge feedback):
44
+ {context_notes}
45
+ """
46
+
47
+ EVIDENCE_PROMPT = """\
48
+ Summarise what the research established into structured evidence: the concrete findings,
49
+ their sources, and your confidence (0-1) that they pin down the answer.
50
+ """
51
+
52
+ SOLVER_PROMPT = """\
53
+ You are the SOLVER. Using the evidence and context below, produce ONE candidate answer.
54
+ Apply the GAIA formatting rules to your candidate, but you may keep a short justification.
55
+
56
+ {rules}
57
+
58
+ Question:
59
+ {question}
60
+
61
+ Evidence:
62
+ {evidence}
63
+
64
+ Context notes:
65
+ {context_notes}
66
+ """
67
+
68
+ JUDGE_PROMPT = """\
69
+ You are the JUDGE (LLM-as-a-judge). Decide whether the candidate answer correctly and
70
+ completely answers the question, and whether it obeys the GAIA exact-match formatting
71
+ rules. Be strict: a near-miss in format is a REVISE.
72
+
73
+ {rules}
74
+
75
+ Question:
76
+ {question}
77
+
78
+ Evidence:
79
+ {evidence}
80
+
81
+ Candidate answer:
82
+ {candidate}
83
+
84
+ If correct AND well-formatted -> verdict PASS.
85
+ Otherwise -> verdict REVISE, with specific feedback and the gaps to close.
86
+ """
87
+
88
+ FORMATTER_PROMPT = """\
89
+ You are the FORMATTER. Convert the candidate answer into the FINAL exact-match string.
90
+ Output strictly the answer per the rules below — nothing else.
91
+
92
+ {rules}
93
+
94
+ Question:
95
+ {question}
96
+
97
+ Candidate answer:
98
+ {candidate}
99
+ """
gaia_agent/schemas.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic contracts for node-to-node handoffs.
2
+
3
+ Every agent->agent transfer is a typed model produced via
4
+ ``llm.with_structured_output(Model)``, so the context flowing between graph nodes
5
+ stays precise instead of being re-parsed from free text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ FileKind = Literal["spreadsheet", "audio", "image", "text", "code", "pdf", "other"]
15
+
16
+
17
+ class Plan(BaseModel):
18
+ """Planner output: how to approach a single GAIA question."""
19
+
20
+ needs_file: bool = Field(
21
+ description="True if answering requires the attached task file."
22
+ )
23
+ file_expected_kind: FileKind | None = Field(
24
+ default=None, description="Best guess of the attached file kind, if any."
25
+ )
26
+ subtasks: list[str] = Field(
27
+ default_factory=list, description="Ordered steps to solve the question."
28
+ )
29
+ tools_hint: list[str] = Field(
30
+ default_factory=list,
31
+ description="Tool names likely needed, e.g. tavily_search, python_repl.",
32
+ )
33
+ reasoning: str = Field(default="", description="Short rationale for the plan.")
34
+
35
+
36
+ class FileExtract(BaseModel):
37
+ """Structured result of ingesting an attached file."""
38
+
39
+ kind: FileKind
40
+ summary: str = Field(description="One-paragraph summary of the file contents.")
41
+ extracted: str = Field(
42
+ description="Raw-ish extracted content (transcript, table, OCR/description)."
43
+ )
44
+
45
+
46
+ class Evidence(BaseModel):
47
+ """Research output: facts gathered to answer the question."""
48
+
49
+ findings: list[str] = Field(
50
+ default_factory=list, description="Concrete facts relevant to the answer."
51
+ )
52
+ sources: list[str] = Field(
53
+ default_factory=list, description="URLs or tool names backing the findings."
54
+ )
55
+ confidence: float = Field(
56
+ default=0.0, ge=0.0, le=1.0, description="0-1 confidence in the evidence."
57
+ )
58
+
59
+
60
+ class Candidate(BaseModel):
61
+ """Solver output: a single candidate answer with rationale."""
62
+
63
+ answer: str = Field(description="Candidate answer (pre-formatting).")
64
+ justification: str = Field(default="", description="Why this answer follows.")
65
+ assumptions: list[str] = Field(default_factory=list)
66
+
67
+
68
+ class JudgeVerdict(BaseModel):
69
+ """LLM-as-judge verdict that drives the evaluation loop."""
70
+
71
+ verdict: Literal["PASS", "REVISE"]
72
+ feedback: str = Field(
73
+ default="", description="What is wrong / what to fix on REVISE."
74
+ )
75
+ missing: list[str] = Field(
76
+ default_factory=list, description="Specific gaps to close on REVISE."
77
+ )
78
+
79
+
80
+ class FinalAnswer(BaseModel):
81
+ """Formatter output: the exact-match string submitted to the API."""
82
+
83
+ answer: str = Field(
84
+ description="Bare answer for exact-match scoring; no prose, no 'FINAL ANSWER'."
85
+ )
gaia_agent/state.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph state shared across nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from langgraph.graph.message import add_messages
8
+ from typing_extensions import TypedDict
9
+
10
+ from gaia_agent.schemas import (
11
+ Candidate,
12
+ Evidence,
13
+ FileExtract,
14
+ JudgeVerdict,
15
+ Plan,
16
+ )
17
+
18
+
19
+ class GraphState(TypedDict, total=False):
20
+ """State threaded through the GAIA agent graph.
21
+
22
+ Structured node outputs (Plan/Evidence/Candidate/JudgeVerdict) live here so each
23
+ node reads the prior node's typed object rather than re-parsing raw messages.
24
+ """
25
+
26
+ # Inputs
27
+ task_id: str
28
+ question: str
29
+
30
+ # ReAct scratchpad for the research tool-calling loop
31
+ messages: Annotated[list, add_messages]
32
+
33
+ # Structured handoffs
34
+ plan: Plan
35
+ file_extract: FileExtract | None
36
+ evidence: Evidence
37
+ candidate: Candidate
38
+ verdict: JudgeVerdict
39
+
40
+ # Free-form accumulated context (file extracts + judge feedback)
41
+ context_notes: str
42
+
43
+ # Eval-loop bookkeeping
44
+ attempts: int
45
+
46
+ # Output
47
+ final_answer: str
gaia_agent/tools/__init__.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool registry. ``RESEARCH_TOOLS`` is the list bound to the research agent."""
2
+
3
+ from gaia_agent.tools.files import (
4
+ classify_file,
5
+ download_task_file,
6
+ fetch_task_file,
7
+ read_spreadsheet,
8
+ read_text_file,
9
+ )
10
+ from gaia_agent.tools.media import describe_image, transcribe_audio
11
+ from gaia_agent.tools.python_tool import python_repl
12
+ from gaia_agent.tools.search import tavily_search, wikipedia_search
13
+
14
+ # Tools the research ReAct loop may call.
15
+ RESEARCH_TOOLS = [
16
+ tavily_search,
17
+ wikipedia_search,
18
+ read_spreadsheet,
19
+ read_text_file,
20
+ transcribe_audio,
21
+ describe_image,
22
+ python_repl,
23
+ download_task_file,
24
+ ]
25
+
26
+ __all__ = [
27
+ "RESEARCH_TOOLS",
28
+ "classify_file",
29
+ "fetch_task_file",
30
+ "download_task_file",
31
+ "read_spreadsheet",
32
+ "read_text_file",
33
+ "transcribe_audio",
34
+ "describe_image",
35
+ "python_repl",
36
+ "tavily_search",
37
+ "wikipedia_search",
38
+ ]
gaia_agent/tools/files.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File tools: download a task's attachment and read spreadsheets / text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import os
7
+ import tempfile
8
+
9
+ import requests
10
+ from langchain_core.tools import tool
11
+
12
+ from gaia_agent.config import get_settings
13
+ from gaia_agent.schemas import FileKind
14
+
15
+ _DOWNLOAD_DIR = os.path.join(tempfile.gettempdir(), "gaia_files")
16
+ os.makedirs(_DOWNLOAD_DIR, exist_ok=True)
17
+
18
+ # Extension -> logical kind used by the router / readers.
19
+ _EXT_KIND: dict[str, FileKind] = {
20
+ ".xlsx": "spreadsheet",
21
+ ".xls": "spreadsheet",
22
+ ".csv": "spreadsheet",
23
+ ".mp3": "audio",
24
+ ".wav": "audio",
25
+ ".m4a": "audio",
26
+ ".flac": "audio",
27
+ ".ogg": "audio",
28
+ ".png": "image",
29
+ ".jpg": "image",
30
+ ".jpeg": "image",
31
+ ".webp": "image",
32
+ ".gif": "image",
33
+ ".pdf": "pdf",
34
+ ".py": "code",
35
+ ".json": "code",
36
+ ".txt": "text",
37
+ ".md": "text",
38
+ }
39
+
40
+
41
+ def classify_file(path: str) -> FileKind:
42
+ """Map a local file path to a logical FileKind via its extension."""
43
+ _, ext = os.path.splitext(path.lower())
44
+ return _EXT_KIND.get(ext, "other")
45
+
46
+
47
+ def fetch_task_file(task_id: str) -> str | None:
48
+ """Download ``/files/{task_id}`` to a temp path. Return path or None if absent.
49
+
50
+ Used directly by the ingest_file node (not only as an LLM tool).
51
+ """
52
+ s = get_settings()
53
+ url = f"{s.gaia_api_url}/files/{task_id}"
54
+ try:
55
+ resp = requests.get(url, timeout=30)
56
+ except requests.RequestException:
57
+ return None
58
+ if resp.status_code != 200 or not resp.content:
59
+ return None
60
+
61
+ # Prefer a filename from Content-Disposition; fall back to mimetype/extension.
62
+ filename = None
63
+ cd = resp.headers.get("content-disposition", "")
64
+ if "filename=" in cd:
65
+ filename = cd.split("filename=")[-1].strip().strip('"')
66
+ if not filename:
67
+ ext = mimetypes.guess_extension(resp.headers.get("content-type", "").split(";")[0]) or ".bin"
68
+ filename = f"{task_id}{ext}"
69
+
70
+ path = os.path.join(_DOWNLOAD_DIR, filename)
71
+ with open(path, "wb") as fh:
72
+ fh.write(resp.content)
73
+ return path
74
+
75
+
76
+ @tool
77
+ def download_task_file(task_id: str) -> str:
78
+ """Download the file attached to a GAIA task and return its local path and kind.
79
+
80
+ Args:
81
+ task_id: The task identifier whose attachment should be fetched.
82
+ """
83
+ path = fetch_task_file(task_id)
84
+ if not path:
85
+ return "No file is attached to this task (or it could not be downloaded)."
86
+ return f"Downloaded to {path} (kind={classify_file(path)})."
87
+
88
+
89
+ @tool
90
+ def read_spreadsheet(path: str) -> str:
91
+ """Read an Excel/CSV file and return a markdown table plus basic stats.
92
+
93
+ Args:
94
+ path: Local path to a .xlsx/.xls/.csv file.
95
+ """
96
+ import pandas as pd
97
+
98
+ try:
99
+ if path.lower().endswith(".csv"):
100
+ df = pd.read_csv(path)
101
+ else:
102
+ df = pd.read_excel(path)
103
+ except Exception as exc: # noqa: BLE001
104
+ return f"Could not read spreadsheet: {exc}"
105
+
106
+ preview = df.head(50).to_markdown(index=False)
107
+ return (
108
+ f"Shape: {df.shape[0]} rows x {df.shape[1]} cols.\n"
109
+ f"Columns: {list(df.columns)}\n\n{preview}"
110
+ )
111
+
112
+
113
+ @tool
114
+ def read_text_file(path: str) -> str:
115
+ """Read a plain-text, code, JSON, or markdown file and return its contents.
116
+
117
+ Args:
118
+ path: Local path to a text-like file.
119
+ """
120
+ try:
121
+ with open(path, encoding="utf-8", errors="replace") as fh:
122
+ content = fh.read()
123
+ except Exception as exc: # noqa: BLE001
124
+ return f"Could not read file: {exc}"
125
+ if len(content) > 20000:
126
+ content = content[:20000] + "\n... [truncated]"
127
+ return content
gaia_agent/tools/media.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal tools: audio transcription (Whisper) and image understanding (vision)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+
8
+ from langchain_core.messages import HumanMessage
9
+ from langchain_core.tools import tool
10
+
11
+ from gaia_agent.config import get_settings
12
+
13
+
14
+ @tool
15
+ def transcribe_audio(path: str) -> str:
16
+ """Transcribe an audio file to text using Groq Whisper.
17
+
18
+ Args:
19
+ path: Local path to an audio file (mp3/wav/m4a/flac/ogg).
20
+ """
21
+ from groq import Groq
22
+
23
+ s = get_settings()
24
+ try:
25
+ client = Groq(api_key=s.groq_api_key)
26
+ with open(path, "rb") as fh:
27
+ resp = client.audio.transcriptions.create(
28
+ file=(os.path.basename(path), fh.read()),
29
+ model=s.groq_whisper_model,
30
+ )
31
+ return resp.text
32
+ except Exception as exc: # noqa: BLE001
33
+ return f"Audio transcription failed: {exc}"
34
+
35
+
36
+ def _mime_for(path: str) -> str:
37
+ ext = os.path.splitext(path.lower())[1]
38
+ return {
39
+ ".png": "image/png",
40
+ ".jpg": "image/jpeg",
41
+ ".jpeg": "image/jpeg",
42
+ ".webp": "image/webp",
43
+ ".gif": "image/gif",
44
+ }.get(ext, "image/png")
45
+
46
+
47
+ @tool
48
+ def describe_image(path: str, question: str) -> str:
49
+ """Answer a question about an image using the Groq vision model.
50
+
51
+ Args:
52
+ path: Local path to an image file.
53
+ question: What to determine from the image.
54
+ """
55
+ from gaia_agent.llm import get_vision_llm
56
+
57
+ try:
58
+ with open(path, "rb") as fh:
59
+ b64 = base64.b64encode(fh.read()).decode()
60
+ data_url = f"data:{_mime_for(path)};base64,{b64}"
61
+ msg = HumanMessage(
62
+ content=[
63
+ {"type": "text", "text": question},
64
+ {"type": "image_url", "image_url": {"url": data_url}},
65
+ ]
66
+ )
67
+ return get_vision_llm().invoke([msg]).content
68
+ except Exception as exc: # noqa: BLE001
69
+ return f"Image understanding failed: {exc}"
gaia_agent/tools/python_tool.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A restricted Python REPL for exact arithmetic, string, and date work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ import math
8
+
9
+ from langchain_core.tools import tool
10
+
11
+ # A small, deliberately limited set of safe builtins/modules.
12
+ _SAFE_BUILTINS = {
13
+ "abs": abs, "all": all, "any": any, "bin": bin, "bool": bool, "chr": chr,
14
+ "dict": dict, "divmod": divmod, "enumerate": enumerate, "filter": filter,
15
+ "float": float, "hex": hex, "int": int, "len": len, "list": list, "map": map,
16
+ "max": max, "min": min, "oct": oct, "ord": ord, "pow": pow, "range": range,
17
+ "reversed": reversed, "round": round, "set": set, "sorted": sorted, "str": str,
18
+ "sum": sum, "tuple": tuple, "zip": zip, "print": print,
19
+ }
20
+
21
+
22
+ @tool
23
+ def python_repl(code: str) -> str:
24
+ """Execute a short Python snippet and return its stdout (and `result` if set).
25
+
26
+ Use for exact arithmetic, string manipulation, sorting, and date math. The
27
+ ``math``, ``statistics``, ``datetime``, ``re``, ``itertools``, and ``collections``
28
+ modules are available. Assign to a variable named ``result`` to return a value.
29
+
30
+ Args:
31
+ code: Python source to execute.
32
+ """
33
+ import collections
34
+ import datetime
35
+ import itertools
36
+ import re
37
+ import statistics
38
+
39
+ env = {
40
+ "__builtins__": _SAFE_BUILTINS,
41
+ "math": math,
42
+ "statistics": statistics,
43
+ "datetime": datetime,
44
+ "re": re,
45
+ "itertools": itertools,
46
+ "collections": collections,
47
+ }
48
+ buf = io.StringIO()
49
+ try:
50
+ with contextlib.redirect_stdout(buf):
51
+ exec(code, env) # noqa: S102 - sandboxed builtins, internal use only
52
+ except Exception as exc: # noqa: BLE001
53
+ return f"Error: {exc}\nOutput so far:\n{buf.getvalue()}"
54
+
55
+ out = buf.getvalue()
56
+ if "result" in env:
57
+ out += ("\n" if out else "") + f"result = {env['result']!r}"
58
+ return out or "(no output)"
gaia_agent/tools/search.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web search tools: Tavily (primary) and Wikipedia (free fallback)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from langchain_core.tools import tool
6
+
7
+ from gaia_agent.config import get_settings
8
+
9
+
10
+ @tool
11
+ def tavily_search(query: str) -> str:
12
+ """Search the web with Tavily and return the top results as text.
13
+
14
+ Args:
15
+ query: A focused natural-language search query.
16
+ """
17
+ from langchain_tavily import TavilySearch
18
+
19
+ s = get_settings()
20
+ try:
21
+ searcher = TavilySearch(max_results=5, tavily_api_key=s.tavily_api_key)
22
+ result = searcher.invoke({"query": query})
23
+ except Exception as exc: # noqa: BLE001
24
+ return f"Tavily search failed: {exc}"
25
+
26
+ # TavilySearch returns a dict with a "results" list.
27
+ if isinstance(result, dict):
28
+ rows = result.get("results", [])
29
+ lines = []
30
+ for r in rows:
31
+ lines.append(f"- {r.get('title', '')}\n {r.get('url', '')}\n {r.get('content', '')}")
32
+ answer = result.get("answer")
33
+ head = f"Answer: {answer}\n\n" if answer else ""
34
+ return head + "\n".join(lines) if lines else "No results."
35
+ return str(result)
36
+
37
+
38
+ @tool
39
+ def wikipedia_search(query: str) -> str:
40
+ """Look up a topic on Wikipedia and return a summary of the best-matching page.
41
+
42
+ Args:
43
+ query: The topic or page title to look up.
44
+ """
45
+ from langchain_community.tools import WikipediaQueryRun
46
+ from langchain_community.utilities import WikipediaAPIWrapper
47
+
48
+ try:
49
+ runner = WikipediaQueryRun(
50
+ api_wrapper=WikipediaAPIWrapper(top_k_results=3, doc_content_chars_max=4000)
51
+ )
52
+ return runner.run(query)
53
+ except Exception as exc: # noqa: BLE001
54
+ return f"Wikipedia lookup failed: {exc}"
langgraph.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://langgra.ph/schema.json",
3
+ "dependencies": ["."],
4
+ "graphs": {
5
+ "gaia": "./gaia_agent/graph.py:graph"
6
+ },
7
+ "env": ".env"
8
+ }
pyproject.toml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "gaia-agent"
3
+ version = "0.1.0"
4
+ description = "LangGraph multi-agent for the HF Agents Course GAIA final assignment."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "gradio>=5.25.2",
9
+ "requests>=2.32.0",
10
+ "pandas>=2.2.0",
11
+ "openpyxl>=3.1.0",
12
+ "tabulate>=0.9.0",
13
+ "langgraph>=1.0.0",
14
+ "langchain>=0.3.0",
15
+ "langchain-core>=0.3.0",
16
+ "langchain-groq>=0.2.0",
17
+ "langchain-tavily>=0.1.0",
18
+ "langchain-community>=0.3.0",
19
+ "wikipedia>=1.4.0",
20
+ "groq>=0.13.0",
21
+ "pydantic>=2.9.0",
22
+ "pydantic-settings>=2.6.0",
23
+ "python-dotenv>=1.0.1",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "langgraph-cli[inmem]>=0.3.0",
29
+ "pytest>=8.3.0",
30
+ "ruff>=0.8.0",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["setuptools>=73.0.0", "wheel"]
35
+ build-backend = "setuptools.build_meta"
36
+
37
+ [tool.setuptools]
38
+ packages = ["gaia_agent", "gaia_agent.tools"]
39
+
40
+ [tool.ruff]
41
+ line-length = 100
42
+ lint.select = ["E", "F", "I", "UP"]
43
+ lint.ignore = ["E501"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
requirements.txt CHANGED
@@ -1,2 +1,16 @@
1
  gradio
2
- requests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  gradio
2
+ requests
3
+ python-dotenv
4
+ pandas
5
+ openpyxl
6
+ tabulate
7
+ langgraph>=1.0.0
8
+ langchain>=0.3.0
9
+ langchain-core>=0.3.0
10
+ langchain-groq>=0.2.0
11
+ langchain-tavily>=0.1.0
12
+ langchain-community>=0.3.0
13
+ wikipedia
14
+ groq>=0.13.0
15
+ pydantic>=2.9.0
16
+ pydantic-settings>=2.6.0
scripts/dry_run.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetch all GAIA questions, run the agent locally, print answers — DO NOT submit.
2
+
3
+ Usage (from the template dir, venv active):
4
+ python scripts/dry_run.py # all questions
5
+ python scripts/dry_run.py 3 # first 3 only
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ import requests
13
+ from dotenv import load_dotenv
14
+
15
+ load_dotenv()
16
+
17
+ from gaia_agent import GaiaAgent # noqa: E402
18
+ from gaia_agent.config import get_settings # noqa: E402
19
+
20
+
21
+ def main(limit: int | None) -> None:
22
+ api = get_settings().gaia_api_url
23
+ questions = requests.get(f"{api}/questions", timeout=30).json()
24
+ if limit:
25
+ questions = questions[:limit]
26
+
27
+ agent = GaiaAgent()
28
+ for i, item in enumerate(questions, 1):
29
+ tid = item.get("task_id", "")
30
+ qtext = item.get("question", "")
31
+ answer = agent(qtext, tid)
32
+ print(f"\n[{i}/{len(questions)}] task={tid}")
33
+ print(f" Q: {qtext[:120]}...")
34
+ print(f" A: {answer!r}")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ n = int(sys.argv[1]) if len(sys.argv) > 1 else None
39
+ main(n)
tests/conftest.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Shared test fixtures."""
2
+
3
+ import os
4
+
5
+ # Ensure settings never pick up real keys during tests.
6
+ os.environ.setdefault("GROQ_API_KEY", "test-key")
7
+ os.environ.setdefault("TAVILY_API_KEY", "test-key")
tests/test_graph.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for graph wiring, routing, and the judge evaluation loop."""
2
+
3
+ from types import SimpleNamespace
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from gaia_agent.nodes import (
7
+ judge,
8
+ route_after_judge,
9
+ route_after_planner,
10
+ route_after_research,
11
+ )
12
+ from gaia_agent.schemas import JudgeVerdict, Plan
13
+
14
+
15
+ def test_graph_compiles():
16
+ from gaia_agent.graph import graph
17
+
18
+ assert graph.name == "GAIA Agent"
19
+ nodes = set(graph.get_graph().nodes.keys())
20
+ assert {"planner", "research", "tools", "judge", "formatter"} <= nodes
21
+
22
+
23
+ def test_route_after_planner():
24
+ assert route_after_planner({"plan": Plan(needs_file=True)}) == "ingest_file"
25
+ assert route_after_planner({"plan": Plan(needs_file=False)}) == "research"
26
+
27
+
28
+ def test_route_after_research_tools_vs_evidence():
29
+ with_calls = SimpleNamespace(tool_calls=[{"name": "tavily_search"}])
30
+ without = SimpleNamespace(tool_calls=[])
31
+ assert route_after_research({"messages": [with_calls]}) == "tools"
32
+ assert route_after_research({"messages": [without]}) == "evidence"
33
+
34
+
35
+ def test_route_after_judge_pass():
36
+ state = {"verdict": JudgeVerdict(verdict="PASS"), "attempts": 0}
37
+ assert route_after_judge(state) == "formatter"
38
+
39
+
40
+ def test_route_after_judge_revise_within_budget():
41
+ # max_judge_retries default is 2; one attempt used -> still loop back.
42
+ state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts": 1}
43
+ assert route_after_judge(state) == "research"
44
+
45
+
46
+ def test_route_after_judge_revise_budget_exhausted():
47
+ state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts": 2}
48
+ assert route_after_judge(state) == "formatter"
49
+
50
+
51
+ def test_judge_increments_attempts_on_revise():
52
+ fake_llm = MagicMock()
53
+ fake_llm.with_structured_output.return_value.invoke.return_value = JudgeVerdict(
54
+ verdict="REVISE", feedback="wrong format", missing=["units"]
55
+ )
56
+ with patch("gaia_agent.nodes.get_text_llm", return_value=fake_llm):
57
+ out = judge({"question": "q", "candidate": None, "evidence": None, "attempts": 0})
58
+ assert out["attempts"] == 1
59
+ assert out["verdict"].verdict == "REVISE"
60
+ assert "messages" in out # feedback injected back into the loop
tests/test_tools.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for tools with mocked HTTP / external calls."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from gaia_agent.tools import (
6
+ classify_file,
7
+ python_repl,
8
+ read_spreadsheet,
9
+ read_text_file,
10
+ )
11
+ from gaia_agent.tools.files import fetch_task_file
12
+
13
+
14
+ def test_classify_file():
15
+ assert classify_file("a/b/data.xlsx") == "spreadsheet"
16
+ assert classify_file("clip.MP3") == "audio"
17
+ assert classify_file("pic.jpeg") == "image"
18
+ assert classify_file("script.py") == "code"
19
+ assert classify_file("notes.txt") == "text"
20
+ assert classify_file("weird.xyz") == "other"
21
+
22
+
23
+ def test_python_repl_arithmetic():
24
+ out = python_repl.invoke({"code": "result = sum(range(1, 11))"})
25
+ assert "55" in out
26
+
27
+
28
+ def test_python_repl_handles_error():
29
+ out = python_repl.invoke({"code": "1/0"})
30
+ assert "Error" in out
31
+
32
+
33
+ def test_read_text_file(tmp_path):
34
+ p = tmp_path / "f.txt"
35
+ p.write_text("hello gaia", encoding="utf-8")
36
+ assert "hello gaia" in read_text_file.invoke({"path": str(p)})
37
+
38
+
39
+ def test_read_spreadsheet_csv(tmp_path):
40
+ p = tmp_path / "t.csv"
41
+ p.write_text("a,b\n1,2\n3,4\n", encoding="utf-8")
42
+ out = read_spreadsheet.invoke({"path": str(p)})
43
+ assert "2 rows" in out and "['a', 'b']" in out
44
+
45
+
46
+ def test_fetch_task_file_no_file():
47
+ """A non-200 response yields None rather than a path."""
48
+ with patch("gaia_agent.tools.files.requests.get") as mock_get:
49
+ mock_get.return_value = MagicMock(status_code=404, content=b"")
50
+ assert fetch_task_file("missing") is None
51
+
52
+
53
+ def test_fetch_task_file_writes(tmp_path):
54
+ resp = MagicMock(status_code=200, content=b"col\n1\n",
55
+ headers={"content-disposition": 'attachment; filename="x.csv"'})
56
+ with patch("gaia_agent.tools.files.requests.get", return_value=resp):
57
+ path = fetch_task_file("task1")
58
+ assert path is not None and path.endswith("x.csv")
uv.lock ADDED
The diff for this file is too large to render. See raw diff