Singaraj commited on
Commit
a556e45
Β·
verified Β·
1 Parent(s): ee9ee09

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. agent.py +173 -18
  2. gemini_model.py +109 -0
  3. run_local.py +7 -1
  4. run_parallel.py +90 -0
agent.py CHANGED
@@ -1,6 +1,12 @@
1
- """GAIA agent β€” the part you own. Edit the tools and the prompt to raise your score."""
 
 
 
 
 
2
 
3
  import os
 
4
 
5
  from smolagents import (
6
  CodeAgent,
@@ -10,6 +16,7 @@ from smolagents import (
10
  PythonInterpreterTool,
11
  InferenceClientModel,
12
  LiteLLMModel,
 
13
  tool,
14
  )
15
 
@@ -17,19 +24,69 @@ from smolagents import (
17
  # Groq is preferred: roughly 10x faster than Gemini's thinking model and it follows
18
  # the CodeAgent format cleanly. GEMINI_API_KEY is still worth setting alongside it β€”
19
  # the vision and audio tools in gaia_tools.py call Gemini directly regardless.
20
- GROQ_MODEL = "groq/llama-3.3-70b-versatile"
21
- GEMINI_MODEL = "gemini/gemini-flash-latest"
 
 
 
 
 
 
22
  HF_MODEL_ID = "Qwen/Qwen2.5-Coder-32B-Instruct"
23
 
24
 
25
  def make_model():
26
  """Returns a model backed by whichever provider has a key configured."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  if os.getenv("GROQ_API_KEY"):
28
- print(f"[model] {GROQ_MODEL}")
29
- return LiteLLMModel(model_id=GROQ_MODEL, temperature=0.2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  if os.getenv("GEMINI_API_KEY"):
31
- print(f"[model] {GEMINI_MODEL}")
32
- return LiteLLMModel(model_id=GEMINI_MODEL, temperature=0.2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  print(f"[model] {HF_MODEL_ID} (HF free tier β€” expect quota errors)")
34
  return InferenceClientModel(model_id=HF_MODEL_ID, max_tokens=2048, temperature=0.2)
35
 
@@ -47,6 +104,14 @@ Rules for your final answer:
47
  - If asked for a name, give just the name.
48
 
49
  Work step by step and use your tools before answering. Never guess if a tool can check.
 
 
 
 
 
 
 
 
50
  """
51
 
52
 
@@ -90,6 +155,53 @@ def wikipedia_page(title: str) -> str:
90
  return f"No Wikipedia article found for '{title}'."
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def build_agent(max_steps: int = 8):
94
  """Constructs the agent. Add tools here to handle more question types.
95
 
@@ -102,25 +214,34 @@ def build_agent(max_steps: int = 8):
102
  from gaia_tools import ALL_TOOLS
103
 
104
  model = make_model()
 
 
 
105
  tools = [
106
- DuckDuckGoSearchTool(),
107
- VisitWebpageTool(),
108
  PythonInterpreterTool(),
109
  reverse_text,
110
  wikipedia_page,
 
111
  *ALL_TOOLS,
112
  ]
113
 
114
- if os.getenv("GEMINI_API_KEY") and not os.getenv("GROQ_API_KEY"):
 
 
 
 
 
115
  return ToolCallingAgent(
116
- model=model, tools=tools, max_steps=max_steps, verbosity_level=1
117
  )
118
 
119
  return CodeAgent(
120
  model=model,
121
  tools=tools,
122
  max_steps=max_steps,
123
- verbosity_level=1,
124
  additional_authorized_imports=["json", "re", "math", "datetime", "itertools"],
125
  )
126
 
@@ -128,7 +249,8 @@ def build_agent(max_steps: int = 8):
128
  class GaiaAgent:
129
  """Wrapper matching the interface app.py expects."""
130
 
131
- def __init__(self):
 
132
  self.agent = build_agent()
133
 
134
  def __call__(self, question: str, task_id: str = "", file_name: str = "") -> str:
@@ -149,8 +271,41 @@ class GaiaAgent:
149
  f"then pass the returned path to {reader}."
150
  )
151
 
152
- try:
153
- return str(self.agent.run(task)).strip()
154
- except Exception as e:
155
- print(f" [agent error] {type(e).__name__}: {e}")
156
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GAIA agent for the Hugging Face Agents Course, Unit 4.
2
+
3
+ Answers questions from a 20-question subset of the GAIA benchmark. The provider is
4
+ selected at runtime from whichever API key is present, because the free tiers differ
5
+ sharply in what they allow β€” see make_model() for the measured limits of each.
6
+ """
7
 
8
  import os
9
+ import time
10
 
11
  from smolagents import (
12
  CodeAgent,
 
16
  PythonInterpreterTool,
17
  InferenceClientModel,
18
  LiteLLMModel,
19
+ OpenAIServerModel,
20
  tool,
21
  )
22
 
 
24
  # Groq is preferred: roughly 10x faster than Gemini's thinking model and it follows
25
  # the CodeAgent format cleanly. GEMINI_API_KEY is still worth setting alongside it β€”
26
  # the vision and audio tools in gaia_tools.py call Gemini directly regardless.
27
+ NVIDIA_MODEL_ID = "nvidia/nemotron-3-ultra-550b-a55b"
28
+ GROQ_MODEL_ID = "llama-3.3-70b-versatile"
29
+ # Model choice is quota-driven as much as quality-driven β€” check what actually has
30
+ # headroom before assuming. On this key: flash-latest and 3.6-flash return 429,
31
+ # 2.5-flash is 404, 3-flash-preview works but is 4x slower. gemini-3.5-flash answered
32
+ # a multi-hop research question correctly in 6.3s and is the strongest available.
33
+ # flash-lite is the fallback: fast, but too weak to chain several lookups.
34
+ GEMINI_MODEL_ID = "gemini-3.5-flash"
35
  HF_MODEL_ID = "Qwen/Qwen2.5-Coder-32B-Instruct"
36
 
37
 
38
  def make_model():
39
  """Returns a model backed by whichever provider has a key configured."""
40
+ if os.getenv("NVIDIA_API_KEY"):
41
+ # Preferred. NVIDIA removed its credit cap, so this is 40 requests/minute with
42
+ # no daily token ceiling β€” the only provider tried that can run the full set
43
+ # repeatedly. Groq is faster per call but dies after ~100K tokens/day; Gemini's
44
+ # stronger models cap at 5 RPM. Slower per call, but it actually finishes.
45
+ print(f"[model] {NVIDIA_MODEL_ID} (nim)")
46
+ return OpenAIServerModel(
47
+ model_id=NVIDIA_MODEL_ID,
48
+ api_base="https://integrate.api.nvidia.com/v1",
49
+ api_key=os.environ["NVIDIA_API_KEY"],
50
+ temperature=0.2,
51
+ requests_per_minute=35,
52
+ retry=False,
53
+ client_kwargs={"max_retries": 0, "timeout": 120},
54
+ )
55
  if os.getenv("GROQ_API_KEY"):
56
+ # Groq through LiteLLM stalls for minutes per call; the same request through
57
+ # the OpenAI SDK returns in under a second. Groq is OpenAI-compatible, so go
58
+ # direct. Measured 0.7s vs several minutes on identical prompts.
59
+ print(f"[model] groq/{GROQ_MODEL_ID} (direct)")
60
+ # retry=False is the important one. smolagents wraps every model call in its
61
+ # own exponential-backoff retryer (smolagents/utils.py Retrying) that catches
62
+ # rate-limit errors and sleeps silently with growing delays β€” no log line, no
63
+ # traceback, just a process using zero CPU. That is what every "hang" today
64
+ # actually was. client_kwargs disables the OpenAI SDK's separate retry layer.
65
+ return OpenAIServerModel(
66
+ model_id=GROQ_MODEL_ID,
67
+ api_base="https://api.groq.com/openai/v1",
68
+ api_key=os.environ["GROQ_API_KEY"],
69
+ temperature=0.2,
70
+ retry=False,
71
+ client_kwargs={"max_retries": 0, "timeout": 45},
72
+ )
73
  if os.getenv("GEMINI_API_KEY"):
74
+ # Google exposes an OpenAI-compatible endpoint, so Gemini gets the same fast
75
+ # path as Groq β€” no LiteLLM, no retry loop. Measured 0.9s per call and 1.4s
76
+ # for a full agent run. Gemini was never slow; the retry wrapper was.
77
+ print(f"[model] {GEMINI_MODEL_ID} (openai-compat)")
78
+ return OpenAIServerModel(
79
+ model_id=GEMINI_MODEL_ID,
80
+ api_base="https://generativelanguage.googleapis.com/v1beta/openai/",
81
+ api_key=os.environ["GEMINI_API_KEY"],
82
+ temperature=0.2,
83
+ # gemini-3.5-flash allows only 5 requests/minute. It is much stronger than
84
+ # flash-lite, so pace to the limit rather than dropping to a weaker model:
85
+ # smolagents' own limiter spaces calls out instead of firing into a 429.
86
+ requests_per_minute=5,
87
+ retry=False,
88
+ client_kwargs={"max_retries": 0, "timeout": 60},
89
+ )
90
  print(f"[model] {HF_MODEL_ID} (HF free tier β€” expect quota errors)")
91
  return InferenceClientModel(model_id=HF_MODEL_ID, max_tokens=2048, temperature=0.2)
92
 
 
104
  - If asked for a name, give just the name.
105
 
106
  Work step by step and use your tools before answering. Never guess if a tool can check.
107
+
108
+ Read the question for the exact sense of its words. "Botanically a vegetable" is not
109
+ the same as "sold as a vegetable" β€” a green bean, zucchini, pepper and tomato are all
110
+ botanically fruits. "Studio album" excludes live albums and compilations. Where a
111
+ question names a definition or a cutoff date, apply it literally.
112
+
113
+ Be economical: prefer one targeted search over several broad ones, and stop as soon
114
+ as you can support an answer.
115
  """
116
 
117
 
 
155
  return f"No Wikipedia article found for '{title}'."
156
 
157
 
158
+ @tool
159
+ def wikipedia_tables(title: str) -> str:
160
+ """Returns the TABLES from an English Wikipedia article as text. Use this whenever
161
+ a question needs data held in a table β€” discographies, rosters, medal counts,
162
+ award winners, years. wikipedia_page strips tables out and will show the section
163
+ heading with nothing under it, so reach for this tool instead.
164
+
165
+ Args:
166
+ title: The article title, e.g. 'Mercedes Sosa'.
167
+ """
168
+ import pandas as pd
169
+ import requests
170
+
171
+ try:
172
+ r = requests.get(
173
+ "https://en.wikipedia.org/w/api.php",
174
+ params={
175
+ "action": "parse",
176
+ "page": title,
177
+ "prop": "text",
178
+ "format": "json",
179
+ "redirects": 1,
180
+ },
181
+ headers={"User-Agent": "gaia-agent/1.0 (course exercise)"},
182
+ timeout=45,
183
+ )
184
+ html = r.json()["parse"]["text"]["*"]
185
+ except Exception as e:
186
+ return f"Could not fetch tables for '{title}': {e}"
187
+
188
+ try:
189
+ from io import StringIO
190
+
191
+ tables = pd.read_html(StringIO(html))
192
+ except ValueError:
193
+ return f"No tables found in the article '{title}'."
194
+ except Exception as e:
195
+ return f"Could not parse tables for '{title}': {e}"
196
+
197
+ out = []
198
+ for i, df in enumerate(tables[:12]):
199
+ if df.shape[0] < 2: # skip infoboxes and one-row layout tables
200
+ continue
201
+ out.append(f"--- table {i} ({df.shape[0]} rows) ---\n{df.to_string(max_rows=60)}")
202
+ return "\n\n".join(out)[:25000] or f"No usable tables in '{title}'."
203
+
204
+
205
  def build_agent(max_steps: int = 8):
206
  """Constructs the agent. Add tools here to handle more question types.
207
 
 
214
  from gaia_tools import ALL_TOOLS
215
 
216
  model = make_model()
217
+ # Tool output was capped hard when Groq's 100K/day was the binding constraint.
218
+ # On Gemini it isn't, and starving the model of search results costs accuracy on
219
+ # the multi-hop questions β€” so these are set for quality, not frugality.
220
  tools = [
221
+ DuckDuckGoSearchTool(max_results=6),
222
+ VisitWebpageTool(max_output_length=10000),
223
  PythonInterpreterTool(),
224
  reverse_text,
225
  wikipedia_page,
226
+ wikipedia_tables,
227
  *ALL_TOOLS,
228
  ]
229
 
230
+ # flash-lite is too weak to write reliable Python for CodeAgent β€” it returns raw
231
+ # code as its final answer. It handles JSON tool calls fine, so it gets
232
+ # ToolCallingAgent. Groq's llama-3.3-70b is strong enough for CodeAgent.
233
+ if os.getenv("NVIDIA_API_KEY") or (
234
+ os.getenv("GEMINI_API_KEY") and not os.getenv("GROQ_API_KEY")
235
+ ):
236
  return ToolCallingAgent(
237
+ model=model, tools=tools, max_steps=max_steps, verbosity_level=0
238
  )
239
 
240
  return CodeAgent(
241
  model=model,
242
  tools=tools,
243
  max_steps=max_steps,
244
+ verbosity_level=0,
245
  additional_authorized_imports=["json", "re", "math", "datetime", "itertools"],
246
  )
247
 
 
249
  class GaiaAgent:
250
  """Wrapper matching the interface app.py expects."""
251
 
252
+ def __init__(self, max_attempts: int = 2):
253
+ self.max_attempts = max_attempts
254
  self.agent = build_agent()
255
 
256
  def __call__(self, question: str, task_id: str = "", file_name: str = "") -> str:
 
271
  f"then pass the returned path to {reader}."
272
  )
273
 
274
+ # A single attempt returning "" is a guaranteed zero, so retry: transient
275
+ # rate limits and one-off tool failures are the usual cause, and a fresh
276
+ # agent with cleared memory often succeeds where the first run stalled.
277
+ last_error = ""
278
+ for attempt in range(1, self.max_attempts + 1):
279
+ try:
280
+ result = str(self.agent.run(task)).strip()
281
+ # smolagents returns internal state as the "answer" in several failure
282
+ # modes: raw tool-call text when max_steps runs out, and a serialised
283
+ # error object when the final generation fails. Both would be submitted
284
+ # verbatim and score zero, so treat them as failures and retry.
285
+ # The leaked shapes seen in practice: "Calling tools:" prose, a list of
286
+ # typed content dicts, a bare tool-call object, and a serialised error.
287
+ # Anything that looks like JSON or mentions a tool name is not an answer.
288
+ junk = (
289
+ result.startswith("Calling tools:")
290
+ or result.startswith(("[{", "{'", '{"'))
291
+ or "'function':" in result
292
+ or '"function"' in result
293
+ or '"tool":' in result
294
+ or "Error in generating" in result
295
+ )
296
+ if junk:
297
+ last_error = f"agent returned internal state on attempt {attempt}"
298
+ elif result and result.lower() not in {"none", "unknown", "n/a"}:
299
+ return result
300
+ else:
301
+ last_error = f"empty result on attempt {attempt}"
302
+ except Exception as e:
303
+ last_error = f"{type(e).__name__}: {e}"
304
+
305
+ if attempt < self.max_attempts:
306
+ print(f" [retry {attempt}/{self.max_attempts - 1}] {last_error[:120]}")
307
+ time.sleep(5 * attempt)
308
+ self.agent = build_agent() # fresh memory for the next attempt
309
+
310
+ print(f" [gave up] {last_error[:160]}")
311
+ return ""
gemini_model.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A smolagents Model that talks to the Gemini REST API directly.
2
+
3
+ LiteLLM's Gemini path hangs for minutes per call once tools are attached, while the
4
+ same request over plain HTTP returns in seconds. This class skips LiteLLM entirely.
5
+
6
+ It targets CodeAgent-style usage: it returns plain text and lets smolagents parse the
7
+ code block, rather than using Gemini's native function-calling schema.
8
+ """
9
+
10
+ import os
11
+
12
+ import requests
13
+ from smolagents.models import ChatMessage, Model
14
+
15
+ ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models"
16
+
17
+
18
+ def _text_of(content) -> str:
19
+ """smolagents passes content as either a string or a list of typed parts."""
20
+ if isinstance(content, str):
21
+ return content
22
+ if isinstance(content, list):
23
+ return "\n".join(
24
+ part.get("text", "") for part in content if isinstance(part, dict)
25
+ )
26
+ return str(content or "")
27
+
28
+
29
+ class GeminiRestModel(Model):
30
+ """Direct Gemini REST client. Set GEMINI_API_KEY in the environment."""
31
+
32
+ def __init__(
33
+ self,
34
+ model_id: str = "gemini-flash-lite-latest",
35
+ temperature: float = 0.2,
36
+ max_output_tokens: int = 2048,
37
+ timeout: int = 120,
38
+ **kwargs,
39
+ ):
40
+ super().__init__(model_id=model_id, **kwargs)
41
+ self.temperature = temperature
42
+ self.max_output_tokens = max_output_tokens
43
+ self.timeout = timeout
44
+
45
+ def generate(
46
+ self,
47
+ messages,
48
+ stop_sequences=None,
49
+ response_format=None,
50
+ tools_to_call_from=None,
51
+ **kwargs,
52
+ ) -> ChatMessage:
53
+ key = os.getenv("GEMINI_API_KEY")
54
+ if not key:
55
+ raise RuntimeError("GEMINI_API_KEY is not set")
56
+
57
+ # Gemini has no system role: fold system turns into the first user turn,
58
+ # and collapse consecutive same-role turns, which it also rejects.
59
+ system_text, contents = [], []
60
+ for m in messages:
61
+ role = m.role if hasattr(m, "role") else m.get("role")
62
+ role = getattr(role, "value", role)
63
+ text = _text_of(m.content if hasattr(m, "content") else m.get("content"))
64
+ if not text:
65
+ continue
66
+ if role == "system":
67
+ system_text.append(text)
68
+ continue
69
+ gem_role = "model" if role == "assistant" else "user"
70
+ if contents and contents[-1]["role"] == gem_role:
71
+ contents[-1]["parts"][0]["text"] += "\n\n" + text
72
+ else:
73
+ contents.append({"role": gem_role, "parts": [{"text": text}]})
74
+
75
+ if system_text and contents:
76
+ contents[0]["parts"][0]["text"] = (
77
+ "\n\n".join(system_text) + "\n\n" + contents[0]["parts"][0]["text"]
78
+ )
79
+ elif system_text:
80
+ contents = [{"role": "user", "parts": [{"text": "\n\n".join(system_text)}]}]
81
+
82
+ config = {
83
+ "temperature": self.temperature,
84
+ "maxOutputTokens": self.max_output_tokens,
85
+ }
86
+ if stop_sequences:
87
+ config["stopSequences"] = list(stop_sequences)[:5] # Gemini allows max 5
88
+
89
+ r = requests.post(
90
+ f"{ENDPOINT}/{self.model_id}:generateContent?key={key}",
91
+ json={"contents": contents, "generationConfig": config},
92
+ timeout=self.timeout,
93
+ )
94
+ if r.status_code != 200:
95
+ raise RuntimeError(f"Gemini {r.status_code}: {r.text[:300]}")
96
+
97
+ data = r.json()
98
+ try:
99
+ parts = data["candidates"][0]["content"]["parts"]
100
+ text = "".join(p.get("text", "") for p in parts)
101
+ except (KeyError, IndexError):
102
+ finish = data.get("candidates", [{}])[0].get("finishReason", "?")
103
+ raise RuntimeError(f"Gemini returned no text (finishReason={finish})")
104
+
105
+ usage = data.get("usageMetadata", {})
106
+ self.last_input_token_count = usage.get("promptTokenCount", 0)
107
+ self.last_output_token_count = usage.get("candidatesTokenCount", 0)
108
+
109
+ return ChatMessage(role="assistant", content=text, raw=data)
run_local.py CHANGED
@@ -28,7 +28,13 @@ def main():
28
  questions = [q for q in questions if not q.get("file_name")]
29
  else:
30
  print("(including questions with file attachments)")
31
- if limit:
 
 
 
 
 
 
32
  questions = questions[:limit]
33
 
34
  print(f"Running {len(questions)} question(s)\n")
 
28
  questions = [q for q in questions if not q.get("file_name")]
29
  else:
30
  print("(including questions with file attachments)")
31
+
32
+ # --only 3,5,6 keeps just those 1-indexed questions, in the order given, so a
33
+ # limited token budget goes to the questions most likely to land.
34
+ if "--only" in args:
35
+ picks = [int(n) for n in args[args.index("--only") + 1].split(",")]
36
+ questions = [questions[i - 1] for i in picks if 1 <= i <= len(questions)]
37
+ elif limit:
38
  questions = questions[:limit]
39
 
40
  print(f"Running {len(questions)} question(s)\n")
run_parallel.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run GAIA questions concurrently. Each question gets its own agent instance.
2
+
3
+ Wall-clock is set by the slowest single question rather than the sum of all of
4
+ them, so 15 questions take about as long as the worst one β€” a few minutes instead
5
+ of an hour.
6
+
7
+ Usage:
8
+ python run_parallel.py # all questions without attachments
9
+ python run_parallel.py --workers 8 # tune concurrency
10
+ python run_parallel.py --all # include file-attachment questions
11
+ """
12
+
13
+ import json
14
+ import sys
15
+ import threading
16
+ import time
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from pathlib import Path
19
+
20
+ from agent import GaiaAgent
21
+
22
+ HERE = Path(__file__).parent
23
+ QUESTIONS = HERE / "gaia_questions.json"
24
+ ANSWERS = HERE / "answers.json"
25
+
26
+ _print_lock = threading.Lock()
27
+
28
+
29
+ def log(msg: str):
30
+ with _print_lock:
31
+ print(msg, flush=True)
32
+
33
+
34
+ def answer_one(index: int, total: int, q: dict) -> dict:
35
+ """Runs one question on its own agent. Never raises β€” a failure returns ''."""
36
+ label = q["question"][:60].replace("\n", " ")
37
+ start = time.time()
38
+ try:
39
+ agent = GaiaAgent()
40
+ answer = agent(
41
+ q["question"], task_id=q["task_id"], file_name=q.get("file_name", "")
42
+ )
43
+ except Exception as e:
44
+ log(f"[{index}/{total}] FAILED {type(e).__name__}: {label}")
45
+ answer = ""
46
+
47
+ log(f"[{index}/{total}] {time.time() - start:5.0f}s {answer!r:<40} | {label}")
48
+ return {"task_id": q["task_id"], "submitted_answer": answer}
49
+
50
+
51
+ def main():
52
+ args = sys.argv[1:]
53
+ include_files = "--all" in args
54
+ workers = 6
55
+ if "--workers" in args:
56
+ workers = int(args[args.index("--workers") + 1])
57
+
58
+ questions = json.loads(QUESTIONS.read_text())
59
+ if not include_files:
60
+ questions = [q for q in questions if not q.get("file_name")]
61
+
62
+ # --only 3,5,6 keeps just those 1-indexed questions, in the order given.
63
+ # Free-tier token budgets are small, so spending them on the questions most
64
+ # likely to land beats spreading them evenly across ones that cannot.
65
+ if "--only" in args:
66
+ picks = [int(n) for n in args[args.index("--only") + 1].split(",")]
67
+ questions = [questions[i - 1] for i in picks if 1 <= i <= len(questions)]
68
+
69
+ total = len(questions)
70
+ log(f"Running {total} questions with {workers} workers\n")
71
+ started = time.time()
72
+
73
+ results = []
74
+ with ThreadPoolExecutor(max_workers=workers) as pool:
75
+ futures = {
76
+ pool.submit(answer_one, i, total, q): q
77
+ for i, q in enumerate(questions, 1)
78
+ }
79
+ for fut in as_completed(futures):
80
+ results.append(fut.result())
81
+ ANSWERS.write_text(json.dumps(results, indent=2))
82
+
83
+ answered = sum(1 for r in results if r["submitted_answer"])
84
+ log(f"\n{'=' * 70}")
85
+ log(f"Answered {answered}/{total} in {time.time() - started:.0f}s β†’ {ANSWERS.name}")
86
+ log("Verify by hand before submitting β€” GAIA grades on exact match.")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()