Commit ·
6fe947e
1
Parent(s): 37bc9aa
Slim graph + cap tool rounds for gateway latency; pin gpt-oss-120b
Browse filesDrop planner LLM (always probe for file), merge evidence into solver, make formatter pure-Python: ~8-12 calls/question -> ~3-6. Add max_tool_rounds cap so research-heavy questions terminate fast (no more 300s timeouts). Pin openai/gpt-oss-120b on the gateway (fast + reliable tool/structured); force function_calling for structured output since routed models ignore json_schema.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- gaia_agent/config.py +6 -1
- gaia_agent/graph.py +2 -11
- gaia_agent/llm.py +13 -0
- gaia_agent/nodes.py +45 -78
- gaia_agent/prompts.py +5 -7
- gaia_agent/state.py +2 -0
- tests/test_graph.py +14 -19
gaia_agent/config.py
CHANGED
|
@@ -35,7 +35,9 @@ class Settings(BaseSettings):
|
|
| 35 |
# --- OpenAI-compatible gateway (freellmapi) ---
|
| 36 |
openai_compatible_base_url: str = "" # e.g. https://my-gateway.example.com/v1
|
| 37 |
openai_compatible_api_key: str = ""
|
| 38 |
-
|
|
|
|
|
|
|
| 39 |
groq_vision_model: str = "meta-llama/llama-4-scout-17b-16e-instruct"
|
| 40 |
groq_whisper_model: str = "whisper-large-v3"
|
| 41 |
|
|
@@ -46,6 +48,9 @@ class Settings(BaseSettings):
|
|
| 46 |
# --- API + control knobs ---
|
| 47 |
gaia_api_url: str = "https://agents-course-unit4-scoring.hf.space"
|
| 48 |
max_judge_retries: int = 1
|
|
|
|
|
|
|
|
|
|
| 49 |
recursion_limit: int = 40
|
| 50 |
question_timeout: int = 300
|
| 51 |
|
|
|
|
| 35 |
# --- OpenAI-compatible gateway (freellmapi) ---
|
| 36 |
openai_compatible_base_url: str = "" # e.g. https://my-gateway.example.com/v1
|
| 37 |
openai_compatible_api_key: str = ""
|
| 38 |
+
# Pin a model that reliably does tool calls + function-calling structured output.
|
| 39 |
+
# "auto"/Balanced routing bounces to models that don't, breaking the graph.
|
| 40 |
+
openai_compatible_model: str = "openai/gpt-oss-120b"
|
| 41 |
groq_vision_model: str = "meta-llama/llama-4-scout-17b-16e-instruct"
|
| 42 |
groq_whisper_model: str = "whisper-large-v3"
|
| 43 |
|
|
|
|
| 48 |
# --- API + control knobs ---
|
| 49 |
gaia_api_url: str = "https://agents-course-unit4-scoring.hf.space"
|
| 50 |
max_judge_retries: int = 1
|
| 51 |
+
# Hard cap on research tool-calling rounds; after this, the model must answer
|
| 52 |
+
# without tools. Bounds worst-case latency on research-heavy questions.
|
| 53 |
+
max_tool_rounds: int = 3
|
| 54 |
recursion_limit: int = 40
|
| 55 |
question_timeout: int = 300
|
| 56 |
|
gaia_agent/graph.py
CHANGED
|
@@ -10,14 +10,11 @@ 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 |
)
|
|
@@ -29,25 +26,19 @@ 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, "
|
| 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", "
|
| 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"}
|
|
|
|
| 10 |
from langgraph.prebuilt import ToolNode
|
| 11 |
|
| 12 |
from gaia_agent.nodes import (
|
|
|
|
| 13 |
formatter,
|
| 14 |
ingest_file,
|
| 15 |
judge,
|
|
|
|
| 16 |
research,
|
| 17 |
route_after_judge,
|
|
|
|
| 18 |
route_after_research,
|
| 19 |
solver,
|
| 20 |
)
|
|
|
|
| 26 |
"""Build and compile the GAIA agent graph."""
|
| 27 |
g = StateGraph(GraphState)
|
| 28 |
|
|
|
|
| 29 |
g.add_node("ingest_file", ingest_file)
|
| 30 |
g.add_node("research", research)
|
| 31 |
g.add_node("tools", ToolNode(RESEARCH_TOOLS))
|
|
|
|
| 32 |
g.add_node("solver", solver)
|
| 33 |
g.add_node("judge", judge)
|
| 34 |
g.add_node("formatter", formatter)
|
| 35 |
|
| 36 |
+
g.add_edge(START, "ingest_file")
|
|
|
|
|
|
|
|
|
|
| 37 |
g.add_edge("ingest_file", "research")
|
| 38 |
g.add_conditional_edges(
|
| 39 |
+
"research", route_after_research, {"tools": "tools", "solver": "solver"}
|
| 40 |
)
|
| 41 |
g.add_edge("tools", "research")
|
|
|
|
| 42 |
g.add_edge("solver", "judge")
|
| 43 |
g.add_conditional_edges(
|
| 44 |
"judge", route_after_judge, {"research": "research", "formatter": "formatter"}
|
gaia_agent/llm.py
CHANGED
|
@@ -72,6 +72,19 @@ def get_text_llm(temperature: float = 0.0):
|
|
| 72 |
)
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
@lru_cache(maxsize=1)
|
| 76 |
def get_vision_llm():
|
| 77 |
"""Return the Groq multimodal model used for image understanding."""
|
|
|
|
| 72 |
)
|
| 73 |
|
| 74 |
|
| 75 |
+
def get_structured_llm(schema, temperature: float = 0.0):
|
| 76 |
+
"""Return an LLM that emits an instance of ``schema`` (Pydantic).
|
| 77 |
+
|
| 78 |
+
On the openai_compatible gateway the routed model often ignores json_schema
|
| 79 |
+
response_format and returns prose, so we force tool/function-calling-based
|
| 80 |
+
structured output (which the gateway models do support).
|
| 81 |
+
"""
|
| 82 |
+
llm = get_text_llm(temperature)
|
| 83 |
+
if get_settings().llm_provider.lower() == "openai_compatible":
|
| 84 |
+
return llm.with_structured_output(schema, method="function_calling")
|
| 85 |
+
return llm.with_structured_output(schema)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
@lru_cache(maxsize=1)
|
| 89 |
def get_vision_llm():
|
| 90 |
"""Return the Groq multimodal model used for image understanding."""
|
gaia_agent/nodes.py
CHANGED
|
@@ -6,26 +6,22 @@ Flow: planner -> [ingest_file] -> research <-> tools -> evidence -> solver
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
|
|
|
| 9 |
from langchain_core.messages import AIMessage, 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 (
|
|
@@ -39,39 +35,20 @@ from gaia_agent.tools import (
|
|
| 39 |
)
|
| 40 |
|
| 41 |
# --------------------------------------------------------------------------- #
|
| 42 |
-
#
|
| 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"]
|
|
@@ -90,8 +67,8 @@ def ingest_file(state: GraphState) -> dict:
|
|
| 90 |
extracted = f"File extraction failed: {exc}"
|
| 91 |
|
| 92 |
fe = FileExtract(kind=kind, summary=f"Attached {kind} file.", extracted=str(extracted)[:8000])
|
| 93 |
-
notes =
|
| 94 |
-
return {"file_extract": fe, "context_notes": notes}
|
| 95 |
|
| 96 |
|
| 97 |
# --------------------------------------------------------------------------- #
|
|
@@ -122,8 +99,16 @@ def _invoke_with_tools(llm, msgs: list):
|
|
| 122 |
|
| 123 |
|
| 124 |
def research(state: GraphState) -> dict:
|
| 125 |
-
"""Run one step of the research tool-calling loop.
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
if not state.get("messages"):
|
| 128 |
system = RESEARCH_PROMPT.format(
|
| 129 |
rules=GAIA_RULES,
|
|
@@ -138,47 +123,34 @@ def research(state: GraphState) -> dict:
|
|
| 138 |
),
|
| 139 |
]
|
| 140 |
ai = _invoke_with_tools(llm, seed)
|
| 141 |
-
return {"messages": seed + [ai]}
|
| 142 |
ai = _invoke_with_tools(llm, state["messages"])
|
| 143 |
-
return {"messages": [ai]}
|
| 144 |
|
| 145 |
|
| 146 |
def route_after_research(state: GraphState) -> str:
|
| 147 |
-
"""Route to tools if the model requested any, else
|
| 148 |
last = state["messages"][-1]
|
| 149 |
if getattr(last, "tool_calls", None):
|
| 150 |
return "tools"
|
| 151 |
-
return "
|
| 152 |
|
| 153 |
|
| 154 |
# --------------------------------------------------------------------------- #
|
| 155 |
-
#
|
| 156 |
# --------------------------------------------------------------------------- #
|
| 157 |
|
| 158 |
|
| 159 |
-
def evidence(state: GraphState) -> dict:
|
| 160 |
-
"""Distil the research conversation into structured Evidence."""
|
| 161 |
-
try:
|
| 162 |
-
llm = get_text_llm().with_structured_output(Evidence)
|
| 163 |
-
ev = llm.invoke([SystemMessage(EVIDENCE_PROMPT), *state.get("messages", [])])
|
| 164 |
-
except Exception: # noqa: BLE001
|
| 165 |
-
ev = Evidence(findings=[], sources=[], confidence=0.0)
|
| 166 |
-
return {"evidence": ev}
|
| 167 |
-
|
| 168 |
-
|
| 169 |
def solver(state: GraphState) -> dict:
|
| 170 |
-
"""Synthesize
|
| 171 |
-
ev = state.get("evidence")
|
| 172 |
try:
|
| 173 |
-
llm =
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
evidence=ev.model_dump() if ev else "{}",
|
| 179 |
-
context_notes=state.get("context_notes", "") or "(none)",
|
| 180 |
-
)
|
| 181 |
)
|
|
|
|
| 182 |
except Exception: # noqa: BLE001
|
| 183 |
cand = Candidate(answer="", justification="solver-fallback")
|
| 184 |
return {"candidate": cand}
|
|
@@ -187,14 +159,13 @@ def solver(state: GraphState) -> dict:
|
|
| 187 |
def judge(state: GraphState) -> dict:
|
| 188 |
"""LLM-as-judge: PASS, or REVISE with feedback fed back into research."""
|
| 189 |
cand = state.get("candidate")
|
| 190 |
-
ev = state.get("evidence")
|
| 191 |
try:
|
| 192 |
-
llm =
|
| 193 |
verdict = llm.invoke(
|
| 194 |
JUDGE_PROMPT.format(
|
| 195 |
rules=GAIA_RULES,
|
| 196 |
question=state["question"],
|
| 197 |
-
|
| 198 |
candidate=cand.answer if cand else "",
|
| 199 |
)
|
| 200 |
)
|
|
@@ -227,17 +198,13 @@ def route_after_judge(state: GraphState) -> str:
|
|
| 227 |
|
| 228 |
|
| 229 |
def formatter(state: GraphState) -> dict:
|
| 230 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
cand = state.get("candidate")
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
FORMATTER_PROMPT.format(
|
| 237 |
-
rules=GAIA_RULES, question=state["question"], candidate=candidate_text
|
| 238 |
-
)
|
| 239 |
-
)
|
| 240 |
-
answer = final.answer
|
| 241 |
-
except Exception: # noqa: BLE001
|
| 242 |
-
answer = candidate_text
|
| 243 |
-
return {"final_answer": (answer or candidate_text or "").strip()}
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
| 12 |
|
| 13 |
from gaia_agent.config import get_settings
|
| 14 |
+
from gaia_agent.llm import get_structured_llm, get_text_llm
|
| 15 |
from gaia_agent.prompts import (
|
|
|
|
|
|
|
| 16 |
GAIA_RULES,
|
| 17 |
JUDGE_PROMPT,
|
|
|
|
| 18 |
RESEARCH_PROMPT,
|
| 19 |
SOLVER_PROMPT,
|
| 20 |
)
|
| 21 |
from gaia_agent.schemas import (
|
| 22 |
Candidate,
|
|
|
|
| 23 |
FileExtract,
|
|
|
|
| 24 |
JudgeVerdict,
|
|
|
|
| 25 |
)
|
| 26 |
from gaia_agent.state import GraphState
|
| 27 |
from gaia_agent.tools import (
|
|
|
|
| 35 |
)
|
| 36 |
|
| 37 |
# --------------------------------------------------------------------------- #
|
| 38 |
+
# File ingestion (entry node: always probe for an attached file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# --------------------------------------------------------------------------- #
|
| 40 |
|
| 41 |
|
| 42 |
def ingest_file(state: GraphState) -> dict:
|
| 43 |
+
"""Download and extract the task's attached file (if any) into context_notes.
|
| 44 |
+
|
| 45 |
+
Always attempts the fetch (cheap HTTP) instead of asking an LLM whether a file
|
| 46 |
+
is needed -- saves a call per question and never misses an attachment.
|
| 47 |
+
"""
|
| 48 |
task_id = state.get("task_id", "")
|
| 49 |
path = fetch_task_file(task_id)
|
| 50 |
if not path:
|
| 51 |
+
return {"file_extract": None, "attempts": 0, "context_notes": ""}
|
| 52 |
|
| 53 |
kind = classify_file(path)
|
| 54 |
question = state["question"]
|
|
|
|
| 67 |
extracted = f"File extraction failed: {exc}"
|
| 68 |
|
| 69 |
fe = FileExtract(kind=kind, summary=f"Attached {kind} file.", extracted=str(extracted)[:8000])
|
| 70 |
+
notes = f"[FILE:{kind}]\n{fe.extracted}".strip()
|
| 71 |
+
return {"file_extract": fe, "context_notes": notes, "attempts": 0}
|
| 72 |
|
| 73 |
|
| 74 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
def research(state: GraphState) -> dict:
|
| 102 |
+
"""Run one step of the research tool-calling loop.
|
| 103 |
+
|
| 104 |
+
Tools are bound only while under the max_tool_rounds cap; past it, the model is
|
| 105 |
+
called without tools so it must answer, guaranteeing the loop terminates fast.
|
| 106 |
+
"""
|
| 107 |
+
rounds = state.get("tool_rounds", 0)
|
| 108 |
+
base = get_text_llm()
|
| 109 |
+
use_tools = rounds < get_settings().max_tool_rounds
|
| 110 |
+
llm = base.bind_tools(RESEARCH_TOOLS) if use_tools else base
|
| 111 |
+
|
| 112 |
if not state.get("messages"):
|
| 113 |
system = RESEARCH_PROMPT.format(
|
| 114 |
rules=GAIA_RULES,
|
|
|
|
| 123 |
),
|
| 124 |
]
|
| 125 |
ai = _invoke_with_tools(llm, seed)
|
| 126 |
+
return {"messages": seed + [ai], "tool_rounds": rounds + 1}
|
| 127 |
ai = _invoke_with_tools(llm, state["messages"])
|
| 128 |
+
return {"messages": [ai], "tool_rounds": rounds + 1}
|
| 129 |
|
| 130 |
|
| 131 |
def route_after_research(state: GraphState) -> str:
|
| 132 |
+
"""Route to tools if the model requested any, else go straight to the solver."""
|
| 133 |
last = state["messages"][-1]
|
| 134 |
if getattr(last, "tool_calls", None):
|
| 135 |
return "tools"
|
| 136 |
+
return "solver"
|
| 137 |
|
| 138 |
|
| 139 |
# --------------------------------------------------------------------------- #
|
| 140 |
+
# Solver / Judge / Formatter
|
| 141 |
# --------------------------------------------------------------------------- #
|
| 142 |
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
def solver(state: GraphState) -> dict:
|
| 145 |
+
"""Synthesize the research conversation + context into one Candidate answer."""
|
|
|
|
| 146 |
try:
|
| 147 |
+
llm = get_structured_llm(Candidate)
|
| 148 |
+
system = SOLVER_PROMPT.format(
|
| 149 |
+
rules=GAIA_RULES,
|
| 150 |
+
question=state["question"],
|
| 151 |
+
context_notes=state.get("context_notes", "") or "(none)",
|
|
|
|
|
|
|
|
|
|
| 152 |
)
|
| 153 |
+
cand = llm.invoke([SystemMessage(system), *state.get("messages", [])])
|
| 154 |
except Exception: # noqa: BLE001
|
| 155 |
cand = Candidate(answer="", justification="solver-fallback")
|
| 156 |
return {"candidate": cand}
|
|
|
|
| 159 |
def judge(state: GraphState) -> dict:
|
| 160 |
"""LLM-as-judge: PASS, or REVISE with feedback fed back into research."""
|
| 161 |
cand = state.get("candidate")
|
|
|
|
| 162 |
try:
|
| 163 |
+
llm = get_structured_llm(JudgeVerdict)
|
| 164 |
verdict = llm.invoke(
|
| 165 |
JUDGE_PROMPT.format(
|
| 166 |
rules=GAIA_RULES,
|
| 167 |
question=state["question"],
|
| 168 |
+
context_notes=state.get("context_notes", "") or "(none)",
|
| 169 |
candidate=cand.answer if cand else "",
|
| 170 |
)
|
| 171 |
)
|
|
|
|
| 198 |
|
| 199 |
|
| 200 |
def formatter(state: GraphState) -> dict:
|
| 201 |
+
"""Pure-Python scrub of the candidate into the bare exact-match string (no LLM).
|
| 202 |
+
|
| 203 |
+
The solver already formats per GAIA rules; this just strips stray prefixes,
|
| 204 |
+
quotes, and whitespace.
|
| 205 |
+
"""
|
| 206 |
cand = state.get("candidate")
|
| 207 |
+
text = (cand.answer if cand else "") or ""
|
| 208 |
+
text = re.sub(r"(?is)^\s*(final answer|answer)\s*:?\s*", "", text).strip()
|
| 209 |
+
text = text.strip().strip('"').strip("'").strip()
|
| 210 |
+
return {"final_answer": text}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gaia_agent/prompts.py
CHANGED
|
@@ -50,17 +50,15 @@ 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
|
| 54 |
-
|
|
|
|
| 55 |
|
| 56 |
{rules}
|
| 57 |
|
| 58 |
Question:
|
| 59 |
{question}
|
| 60 |
|
| 61 |
-
Evidence:
|
| 62 |
-
{evidence}
|
| 63 |
-
|
| 64 |
Context notes:
|
| 65 |
{context_notes}
|
| 66 |
"""
|
|
@@ -75,8 +73,8 @@ rules. Be strict: a near-miss in format is a REVISE.
|
|
| 75 |
Question:
|
| 76 |
{question}
|
| 77 |
|
| 78 |
-
|
| 79 |
-
{
|
| 80 |
|
| 81 |
Candidate answer:
|
| 82 |
{candidate}
|
|
|
|
| 50 |
"""
|
| 51 |
|
| 52 |
SOLVER_PROMPT = """\
|
| 53 |
+
You are the SOLVER. Using the research conversation above and the context below,
|
| 54 |
+
produce ONE candidate answer. The `answer` field MUST already obey the GAIA
|
| 55 |
+
exact-match formatting rules (bare answer, no prose, no "FINAL ANSWER").
|
| 56 |
|
| 57 |
{rules}
|
| 58 |
|
| 59 |
Question:
|
| 60 |
{question}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
| 62 |
Context notes:
|
| 63 |
{context_notes}
|
| 64 |
"""
|
|
|
|
| 73 |
Question:
|
| 74 |
{question}
|
| 75 |
|
| 76 |
+
Context notes:
|
| 77 |
+
{context_notes}
|
| 78 |
|
| 79 |
Candidate answer:
|
| 80 |
{candidate}
|
gaia_agent/state.py
CHANGED
|
@@ -42,6 +42,8 @@ class GraphState(TypedDict, total=False):
|
|
| 42 |
|
| 43 |
# Eval-loop bookkeeping
|
| 44 |
attempts: int
|
|
|
|
|
|
|
| 45 |
|
| 46 |
# Output
|
| 47 |
final_answer: str
|
|
|
|
| 42 |
|
| 43 |
# Eval-loop bookkeeping
|
| 44 |
attempts: int
|
| 45 |
+
# Research tool-calling rounds used (capped by max_tool_rounds)
|
| 46 |
+
tool_rounds: int
|
| 47 |
|
| 48 |
# Output
|
| 49 |
final_answer: str
|
tests/test_graph.py
CHANGED
|
@@ -6,10 +6,9 @@ from unittest.mock import MagicMock, patch
|
|
| 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
|
| 13 |
|
| 14 |
|
| 15 |
def test_graph_compiles():
|
|
@@ -17,19 +16,14 @@ def test_graph_compiles():
|
|
| 17 |
|
| 18 |
assert graph.name == "GAIA Agent"
|
| 19 |
nodes = set(graph.get_graph().nodes.keys())
|
| 20 |
-
assert {"
|
| 21 |
|
| 22 |
|
| 23 |
-
def
|
| 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]}) == "
|
| 33 |
|
| 34 |
|
| 35 |
def test_route_after_judge_pass():
|
|
@@ -38,23 +32,24 @@ def test_route_after_judge_pass():
|
|
| 38 |
|
| 39 |
|
| 40 |
def test_route_after_judge_revise_within_budget():
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
|
| 45 |
|
| 46 |
def test_route_after_judge_revise_budget_exhausted():
|
| 47 |
-
state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts":
|
| 48 |
-
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def test_judge_increments_attempts_on_revise():
|
| 52 |
-
|
| 53 |
-
|
| 54 |
verdict="REVISE", feedback="wrong format", missing=["units"]
|
| 55 |
)
|
| 56 |
-
with patch("gaia_agent.nodes.
|
| 57 |
-
out = judge({"question": "q", "candidate": None, "
|
| 58 |
assert out["attempts"] == 1
|
| 59 |
assert out["verdict"].verdict == "REVISE"
|
| 60 |
assert "messages" in out # feedback injected back into the loop
|
|
|
|
| 6 |
from gaia_agent.nodes import (
|
| 7 |
judge,
|
| 8 |
route_after_judge,
|
|
|
|
| 9 |
route_after_research,
|
| 10 |
)
|
| 11 |
+
from gaia_agent.schemas import JudgeVerdict
|
| 12 |
|
| 13 |
|
| 14 |
def test_graph_compiles():
|
|
|
|
| 16 |
|
| 17 |
assert graph.name == "GAIA Agent"
|
| 18 |
nodes = set(graph.get_graph().nodes.keys())
|
| 19 |
+
assert {"ingest_file", "research", "tools", "solver", "judge", "formatter"} <= nodes
|
| 20 |
|
| 21 |
|
| 22 |
+
def test_route_after_research_tools_vs_solver():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
with_calls = SimpleNamespace(tool_calls=[{"name": "tavily_search"}])
|
| 24 |
without = SimpleNamespace(tool_calls=[])
|
| 25 |
assert route_after_research({"messages": [with_calls]}) == "tools"
|
| 26 |
+
assert route_after_research({"messages": [without]}) == "solver"
|
| 27 |
|
| 28 |
|
| 29 |
def test_route_after_judge_pass():
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def test_route_after_judge_revise_within_budget():
|
| 35 |
+
state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts": 0}
|
| 36 |
+
with patch("gaia_agent.nodes.get_settings", return_value=SimpleNamespace(max_judge_retries=1)):
|
| 37 |
+
assert route_after_judge(state) == "research"
|
| 38 |
|
| 39 |
|
| 40 |
def test_route_after_judge_revise_budget_exhausted():
|
| 41 |
+
state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts": 1}
|
| 42 |
+
with patch("gaia_agent.nodes.get_settings", return_value=SimpleNamespace(max_judge_retries=1)):
|
| 43 |
+
assert route_after_judge(state) == "formatter"
|
| 44 |
|
| 45 |
|
| 46 |
def test_judge_increments_attempts_on_revise():
|
| 47 |
+
structured = MagicMock()
|
| 48 |
+
structured.invoke.return_value = JudgeVerdict(
|
| 49 |
verdict="REVISE", feedback="wrong format", missing=["units"]
|
| 50 |
)
|
| 51 |
+
with patch("gaia_agent.nodes.get_structured_llm", return_value=structured):
|
| 52 |
+
out = judge({"question": "q", "candidate": None, "attempts": 0})
|
| 53 |
assert out["attempts"] == 1
|
| 54 |
assert out["verdict"].verdict == "REVISE"
|
| 55 |
assert "messages" in out # feedback injected back into the loop
|