Commit ·
cb1b811
1
Parent(s): 81917a3
GAIA LangGraph agent: Groq + Tavily + LLM-as-judge
Browse filesReplace 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 +21 -0
- .gitignore +163 -0
- README.md +42 -1
- app.py +9 -12
- gaia_agent/__init__.py +5 -0
- gaia_agent/agent.py +32 -0
- gaia_agent/config.py +40 -0
- gaia_agent/graph.py +61 -0
- gaia_agent/llm.py +33 -0
- gaia_agent/nodes.py +221 -0
- gaia_agent/prompts.py +99 -0
- gaia_agent/schemas.py +85 -0
- gaia_agent/state.py +47 -0
- gaia_agent/tools/__init__.py +38 -0
- gaia_agent/tools/files.py +127 -0
- gaia_agent/tools/media.py +69 -0
- gaia_agent/tools/python_tool.py +58 -0
- gaia_agent/tools/search.py +54 -0
- langgraph.json +8 -0
- pyproject.toml +46 -0
- requirements.txt +15 -1
- scripts/dry_run.py +39 -0
- tests/conftest.py +7 -0
- tests/test_graph.py +60 -0
- tests/test_tools.py +58 -0
- uv.lock +0 -0
.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 |
-
# ---
|
| 12 |
-
#
|
| 13 |
-
|
| 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 =
|
| 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
|
|
|