Spaces:
Runtime error
Runtime error
| """GAIA agent built with LangGraph's ReAct loop over a HF Inference model. | |
| The agent is given a small toolset (web search, Wikipedia, page reader, task | |
| file reader, calculator) and a system prompt that enforces the GAIA exact-match | |
| answer format. Call `GaiaAgent()(question, task_id)` to get a clean answer | |
| string ready for submission. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import time | |
| from langchain_core.messages import HumanMessage, SystemMessage | |
| from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint | |
| from langgraph.prebuilt import create_react_agent | |
| from tools import TOOLS | |
| # Lower-cased tool names, used to detect a model that echoes a tool name as its | |
| # final answer instead of producing a real answer. | |
| _TOOL_NAMES = {t.name.lower() for t in TOOLS} | |
| # Backend selection: | |
| # "hf" -> Hugging Face Inference API (needs credits) | |
| # "groq" -> Groq API (free tier, fast, strong tool-calling; needs GROQ_API_KEY) | |
| # "ollama" -> local model via Ollama (free, offline, no quota) | |
| GAIA_BACKEND = os.getenv("GAIA_BACKEND", "hf").lower() | |
| # Default model on HF Inference (OpenAI-style tool calling). Override with | |
| # GAIA_MODEL_ID. | |
| DEFAULT_MODEL_ID = os.getenv("GAIA_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct") | |
| # Default Groq model (tool-capable). Override with GAIA_GROQ_MODEL. | |
| DEFAULT_GROQ_MODEL = os.getenv("GAIA_GROQ_MODEL", "openai/gpt-oss-120b") | |
| # Local Ollama model used when GAIA_BACKEND=ollama. Must be a tool-capable | |
| # model (e.g. qwen2:7b, qwen2.5:7b, llama3.1:8b). Override with GAIA_OLLAMA_MODEL. | |
| DEFAULT_OLLAMA_MODEL = os.getenv("GAIA_OLLAMA_MODEL", "qwen2:7b") | |
| # GAIA-style answer formatting rules, minus the literal "FINAL ANSWER" text | |
| # (the course asks submissions to omit that phrase and reply with the answer | |
| # only). | |
| SYSTEM_PROMPT = """You are a precise general AI assistant solving questions from \ | |
| the GAIA benchmark. | |
| Work step by step. Use the available tools to look up facts, read web pages, \ | |
| read attached task files, and do exact arithmetic. Do not guess when a tool can \ | |
| get you the answer. | |
| When you have solved the question, reply with ONLY the final answer and nothing \ | |
| else. Do not add explanation, restating, punctuation, or any prefix such as \ | |
| "Answer:". | |
| Follow these formatting rules for the answer: | |
| - If the answer is a number, write digits only. Do not use thousands separators \ | |
| and do not include units or symbols (no $, %, etc.) unless the question asks for \ | |
| them. | |
| - If the answer is a string, do not use articles or abbreviations, and write \ | |
| digits in plain text unless told otherwise. Use the minimum number of words. | |
| - If the answer is a comma separated list, apply the rules above to each \ | |
| element and separate elements with ", ". | |
| Extra care: | |
| - Expand abbreviations fully when the question asks for an unabbreviated form \ | |
| (e.g. write "Saint Petersburg", not "St. Petersburg"). | |
| - If the question text looks reversed, scrambled, or encoded, decode it first, \ | |
| then answer what it actually asks (and match the form it requests). | |
| - If a referenced attachment cannot be downloaded, still give your single best \ | |
| concrete answer in the required format. Never reply with an apology or a request \ | |
| for the file. | |
| The answer is graded by EXACT string match, so be concise and exact.""" | |
| # Phrases / prefixes the model sometimes prepends despite instructions; we strip | |
| # them so the submitted answer is clean. | |
| _PREFIX_RE = re.compile( | |
| r"^\s*(final answer|answer|the answer is)\s*[:\-]?\s*", re.IGNORECASE | |
| ) | |
| def _build_llm(temperature: float = 0.1): | |
| """Construct the chat model for the selected backend. | |
| GAIA_BACKEND=ollama -> local model via Ollama (free, no credits). | |
| GAIA_BACKEND=groq -> Groq API (free tier). | |
| GAIA_BACKEND=hf (default) -> Hugging Face Inference API. | |
| """ | |
| if GAIA_BACKEND == "ollama": | |
| from langchain_ollama import ChatOllama | |
| return ChatOllama( | |
| model=DEFAULT_OLLAMA_MODEL, | |
| temperature=temperature, | |
| num_predict=1024, | |
| ) | |
| if GAIA_BACKEND == "groq": | |
| from langchain_groq import ChatGroq | |
| return ChatGroq( | |
| model=DEFAULT_GROQ_MODEL, | |
| temperature=temperature, | |
| max_tokens=1024, | |
| api_key=os.getenv("GROQ_API_KEY"), | |
| ) | |
| token = ( | |
| os.getenv("HF_TOKEN") | |
| or os.getenv("HUGGINGFACEHUB_API_TOKEN") | |
| or os.getenv("HUGGINGFACE_TOKEN") | |
| ) | |
| endpoint = HuggingFaceEndpoint( | |
| repo_id=DEFAULT_MODEL_ID, | |
| task="text-generation", | |
| huggingfacehub_api_token=token, | |
| temperature=temperature, | |
| max_new_tokens=1024, | |
| timeout=120, | |
| ) | |
| return ChatHuggingFace(llm=endpoint) | |
| def _clean_answer(text: str) -> str: | |
| """Normalize the model's final message into a submittable answer string.""" | |
| text = (text or "").strip() | |
| # Strip surrounding code fences / quotes the model sometimes adds. | |
| text = text.strip("`").strip().strip('"').strip("'").strip() | |
| # Drop a leading "Final answer:" style prefix if present. | |
| text = _PREFIX_RE.sub("", text) | |
| # Collapse internal whitespace/newlines. | |
| text = re.sub(r"\s+", " ", text).strip() | |
| return text | |
| # Marker of Groq/OpenAI-style server-side tool-call validation failures, which | |
| # we recover from by retrying at a higher temperature. | |
| _TOOL_FAIL_MARKERS = ("tool_use_failed", "tool call validation", "failed to call a function") | |
| # Markers of per-minute rate limiting (e.g. Groq free tier TPM). We wait out the | |
| # window and retry rather than failing the question. | |
| _RATE_LIMIT_MARKERS = ("rate_limit", "tokens per minute", "request too large", "429", "413") | |
| _RATE_LIMIT_WAIT = int(os.getenv("GAIA_RATE_LIMIT_WAIT", "30")) # seconds | |
| class GaiaAgent: | |
| """Callable agent that answers a single GAIA question.""" | |
| def __init__(self) -> None: | |
| # Primary graph is near-deterministic; the retry graph samples more so a | |
| # malformed tool call (which some providers hard-reject) can be retried. | |
| self.llm = _build_llm(temperature=0.1) | |
| self.graph = create_react_agent(self.llm, TOOLS) | |
| self._retry_llm = None | |
| self._retry_graph = None | |
| def _retry(self): | |
| """Lazily build a higher-temperature graph used for retries.""" | |
| if self._retry_graph is None: | |
| self._retry_llm = _build_llm(temperature=0.6) | |
| self._retry_graph = create_react_agent(self._retry_llm, TOOLS) | |
| return self._retry_graph | |
| def __call__( | |
| self, | |
| question: str, | |
| task_id: str | None = None, | |
| file_name: str | None = None, | |
| ) -> str: | |
| prompt = question | |
| # Only point at the file tool when the task actually has an attachment, | |
| # otherwise the model wastes a (often malformed) tool call on every task. | |
| if task_id and file_name: | |
| prompt = ( | |
| f"{question}\n\n" | |
| f"(This task has an attached file. Call read_task_file with " | |
| f"task_id='{task_id}' to read it.)" | |
| ) | |
| messages = [ | |
| SystemMessage(content=SYSTEM_PROMPT), | |
| HumanMessage(content=prompt), | |
| ] | |
| # Try the primary graph, then retry on transient/tool-validation errors. | |
| # tool-call validation failures consume an attempt (max 3); per-minute | |
| # rate limits just wait and retry without consuming one (max waits). | |
| result = None | |
| last_exc = None | |
| attempt = 0 | |
| rate_waits = 0 | |
| while attempt < 3: | |
| graph = self.graph if attempt == 0 else self._retry() | |
| try: | |
| result = graph.invoke( | |
| {"messages": messages}, | |
| config={"recursion_limit": 25}, | |
| ) | |
| break | |
| except Exception as exc: # noqa: BLE001 - keep the run alive | |
| last_exc = exc | |
| msg = str(exc).lower() | |
| # Per-minute rate limit: wait for the window to reset, then | |
| # retry the same graph without consuming a tool-fail attempt. | |
| if any(m in msg for m in _RATE_LIMIT_MARKERS) and rate_waits < 4: | |
| rate_waits += 1 | |
| time.sleep(_RATE_LIMIT_WAIT) | |
| continue | |
| # Retry tool-call validation failures at higher temperature; | |
| # bail on anything else (e.g. 402 quota) where retry won't help. | |
| if not any(m in msg for m in _TOOL_FAIL_MARKERS): | |
| return f"AGENT ERROR: {exc}" | |
| attempt += 1 | |
| if result is None: | |
| return f"AGENT ERROR: {last_exc}" | |
| convo = result["messages"] | |
| answer = _clean_answer(_message_text(convo[-1])) | |
| # Guard against degenerate finals: empty content, or the model echoing a | |
| # tool name instead of an answer (seen with smaller models). In that | |
| # case, force a final synthesis turn with no tools available. | |
| if not answer or answer.lower() in _TOOL_NAMES: | |
| try: | |
| synth = self.llm.invoke( | |
| convo | |
| + [ | |
| HumanMessage( | |
| content=( | |
| "Based on everything above, reply with ONLY the " | |
| "final answer in the required format. No " | |
| "explanation, no tool names." | |
| ) | |
| ) | |
| ] | |
| ) | |
| answer = _clean_answer(_message_text(synth)) | |
| except Exception: # noqa: BLE001 - keep whatever we had | |
| pass | |
| return answer | |
| def _message_text(msg) -> str: | |
| """Extract plain text from a message whose content may be blocks.""" | |
| content = getattr(msg, "content", msg) | |
| if isinstance(content, list): # some providers return content blocks | |
| return " ".join( | |
| block.get("text", "") for block in content if isinstance(block, dict) | |
| ) | |
| return content or "" | |