GT5557 commited on
Commit
686824b
·
verified ·
1 Parent(s): 4854956

Update agent.py

Browse files
Files changed (1) hide show
  1. agent.py +258 -244
agent.py CHANGED
@@ -1,353 +1,367 @@
1
  import os
2
  import re
3
  import time
4
- import subprocess
5
  import tempfile
 
 
 
6
  from dotenv import load_dotenv
 
7
  from langgraph.graph import START, StateGraph, MessagesState
8
  from langgraph.prebuilt import ToolNode, tools_condition
 
9
  from langchain_groq import ChatGroq
10
  from langchain_core.tools import tool
 
 
11
  from langchain_community.document_loaders import WikipediaLoader
12
  from langchain_community.tools import DuckDuckGoSearchRun
13
- from langchain_core.messages import SystemMessage, HumanMessage
14
  import requests
15
  from bs4 import BeautifulSoup
16
 
17
  load_dotenv()
18
 
19
  # ============================================================
20
- # QUESTION DIFFICULTY CLASSIFIER
21
  # ============================================================
22
 
23
- # Keywords that indicate a SIMPLE, fast question
24
- SIMPLE_KEYWORDS = [
25
- "what is", "who is", "when was", "where is", "how many",
26
- "reverse", "opposite", "calculate", "what does",
27
- "reversed text", "what year", "first name", "surname",
28
- "capital of", "which country", "what color",
29
- ]
30
-
31
- # Keywords that indicate a HARD / time-consuming question
32
- HARD_KEYWORDS = [
33
- "youtube.com", "watch?v=", "video", "audio", "mp3", "wav",
34
- "image", "excel", "xlsx", "pdf", "attached", "file",
35
- "chess", "position", "board",
36
- "wikipedia featured article", "libretexts", "research paper",
37
- "nominated", "who awarded",
38
- ]
39
-
40
- # Max seconds allowed per question before we skip it
41
- QUESTION_TIMEOUT_SECONDS = 90 # 1.5 minutes max per question
42
-
43
-
44
- def classify_difficulty(question: str) -> str:
45
- """Returns 'simple', 'medium', or 'hard'."""
46
- q = question.lower()
47
-
48
- # Hard signals
49
- hard_score = sum(1 for kw in HARD_KEYWORDS if kw in q)
50
- if hard_score >= 1:
51
- return "hard"
52
-
53
- # Simple signals
54
- simple_score = sum(1 for kw in SIMPLE_KEYWORDS if kw in q)
55
- if simple_score >= 1 or len(question) < 120:
56
- return "simple"
57
-
58
- return "medium"
59
-
60
-
61
- def sort_questions_by_difficulty(questions: list) -> list:
62
- """Sort questions: simple first, medium next, hard last."""
63
- order = {"simple": 0, "medium": 1, "hard": 2}
64
-
65
- def score(item):
66
- q = item.get("question", "")
67
- return order[classify_difficulty(q)]
68
-
69
- return sorted(questions, key=score)
70
-
71
 
72
  # ============================================================
73
- # 1. TOOLS
74
  # ============================================================
75
 
76
  @tool
77
- def add(a: int, b: int) -> int:
78
  """Add two numbers."""
79
  return a + b
80
 
81
 
82
  @tool
83
- def multiply(a: int, b: int) -> int:
84
- """Multiply two numbers."""
85
- return a * b
86
 
87
 
88
  @tool
89
- def subtract(a: int, b: int) -> int:
90
- """Subtract b from a."""
91
- return a - b
92
 
93
 
94
  @tool
95
- def divide(a: float, b: float) -> float:
96
  """Divide a by b."""
97
  if b == 0:
98
- return "Error: Division by zero."
99
- return a / b
100
 
101
 
102
  @tool
103
  def reverse_text(text: str) -> str:
104
- """Reverse a string character by character."""
105
  return text[::-1]
106
 
107
 
108
  @tool
109
  def wiki_search(query: str) -> str:
110
- """Search Wikipedia for factual information."""
111
  try:
112
- search_docs = WikipediaLoader(query=query, load_max_docs=2).load()
113
- if not search_docs:
114
- return "No Wikipedia results found."
115
- formatted = []
116
- for doc in search_docs:
117
- content = doc.page_content[:2000]
118
- formatted.append(f'Source: {doc.metadata.get("source", "Unknown")}\n{content}')
119
- return "\n\n---\n\n".join(formatted)
 
 
 
 
120
  except Exception as e:
121
- return f"Wiki search error: {e}"
122
 
123
 
124
  @tool
125
  def web_search(query: str) -> str:
126
- """Search the web using DuckDuckGo."""
127
  try:
128
  return DuckDuckGoSearchRun(max_results=5).run(query)
129
  except Exception as e:
130
- return f"Web search error: {e}"
131
 
132
 
133
  @tool
134
  def fetch_page(url: str) -> str:
135
- """Fetch and read the full content of a webpage."""
136
  try:
137
- headers = {"User-Agent": "Mozilla/5.0 (compatible; research-agent/1.0)"}
138
- response = requests.get(url, headers=headers, timeout=15)
139
- response.raise_for_status()
140
- soup = BeautifulSoup(response.text, "html.parser")
141
- for tag in soup(["script", "style", "nav", "footer", "header"]):
142
- tag.decompose()
143
- text = soup.get_text(separator="\n", strip=True)
144
- if len(text) > 8000:
145
- text = text[:8000] + "\n... [truncated]"
146
- return text
 
 
147
  except Exception as e:
148
- return f"Error fetching page: {e}"
149
 
150
 
151
  @tool
152
  def run_python(code: str) -> str:
153
- """Execute Python code and return output."""
154
  try:
155
- with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
 
 
156
  f.write(code)
157
  fname = f.name
 
158
  result = subprocess.run(
159
- ['python3', fname],
160
  capture_output=True,
161
  text=True,
162
- timeout=20
163
  )
164
- output = result.stdout.strip()
165
- if result.stderr.strip():
166
- output += f"\nSTDERR: {result.stderr.strip()}"
167
- return output or "(no output)"
 
 
 
 
 
168
  except subprocess.TimeoutExpired:
169
- return "Code execution timed out."
170
  except Exception as e:
171
- return f"Error running code: {e}"
172
 
173
 
174
- tools = [
175
- add, subtract, multiply, divide,
 
 
 
176
  reverse_text,
177
- wiki_search, web_search, fetch_page,
 
 
178
  run_python,
179
  ]
180
 
181
  # ============================================================
182
- # 2. MULTI-MODEL FALLBACK
183
- # Updated: Qwen3-32B replaces non-working Gemma4
184
- # Order: Qwen3 (best reasoning) → Llama 3.3 70B → Llama 3.1 8B
185
  # ============================================================
186
 
187
- def get_fallback_models():
188
- """
189
- Updated model stack (April 2026):
190
- - qwen/qwen3-32b: Best reasoning + agentic capabilities on Groq
191
- - llama-3.3-70b-versatile: Strong general fallback
192
- - llama-3.1-8b-instant: Fast last-resort
193
- """
194
- models = []
195
-
196
- if os.getenv("GROQ_API_KEY"):
197
- groq_key = os.getenv("GROQ_API_KEY")
198
-
199
- # Llama 3.1 8B - fast last-resort
200
- models.append(("Llama 3.1 8B", ChatGroq(
201
- model="llama-3.1-8b-instant",
202
- api_key=groq_key,
203
- temperature=0
204
- )))
205
-
206
-
207
- # Qwen3 32B - best reasoning, replaces non-working Gemma4
208
- models.append(("Qwen3 32B", ChatGroq(
209
- model="qwen/qwen3-32b",
210
- api_key=groq_key,
211
- temperature=0,
212
- )))
213
-
214
- # Llama 3.3 70B - strong general-purpose fallback
215
- models.append(("Llama 3.3 70B", ChatGroq(
216
- model="llama-3.3-70b-versatile",
217
- api_key=groq_key,
218
- temperature=0
219
- )))
220
-
221
-
222
-
223
- if not models:
224
- raise RuntimeError("No API keys found! Set GROQ_API_KEY.")
225
-
226
- print(f"✅ Loaded {len(models)} model(s): {[n for n, _ in models]}")
227
- return models
228
-
229
-
230
- FALLBACK_MODELS = get_fallback_models()
231
-
232
-
233
- def invoke_with_fallback(messages):
234
- """Try each model in order with retries on rate limit and tool failures."""
235
- last_error = None
236
-
237
- for name, m in FALLBACK_MODELS:
238
- for attempt in range(2): # Max 2 attempts per model
239
- try:
240
- print(f"🤖 [{name}] Attempt {attempt + 1}...")
241
- result = m.bind_tools(tools).invoke(messages)
242
- print(f"✅ [{name}] Success.")
243
- return result
244
-
245
- except Exception as e:
246
- err = str(e).lower()
247
- last_error = e
248
-
249
- is_rate_limit = any(x in err for x in [
250
- "rate_limit", "429", "quota",
251
- "too many", "resource exhausted", "token"
252
- ])
253
- is_tool_failed = any(x in err for x in [
254
- "tool_use_failed", "failed_generation",
255
- "tool call validation failed"
256
- ])
257
-
258
- if "decommissioned" in err or "invalid_argument" in err:
259
- print(f"❌ [{name}] Invalid/decommissioned. Skipping.")
260
- break
261
-
262
- elif is_rate_limit:
263
- wait = (attempt + 1) * 8
264
- print(f"⚠️ [{name}] Rate limited. Waiting {wait}s...")
265
- time.sleep(wait)
266
- last_error = e
267
- break # move to next model
268
-
269
- elif is_tool_failed:
270
- wait = (attempt + 1) * 3
271
- print(f"⚠️ [{name}] Tool call failed. Retrying in {wait}s...")
272
- time.sleep(wait)
273
- last_error = e
274
- continue # retry same model
275
-
276
- else:
277
- print(f"❌ [{name}] Error: {e}")
278
- break # move to next model
279
-
280
- raise RuntimeError(f"All models exhausted. Last error: {last_error}")
281
 
282
 
283
  # ============================================================
284
- # 3. ASSISTANT NODE
285
  # ============================================================
286
 
287
- SYSTEM_PROMPT = """You are a precise AI assistant for the GAIA benchmark evaluation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
- TOOLS AVAILABLE: wiki_search, web_search, fetch_page, run_python,
290
- add, subtract, multiply, divide, reverse_text.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
  STRICT RULES:
293
- 1. ALWAYS use tools for factual questions. Never rely on memory alone.
294
- 2. Search before answering any question about names, dates, numbers, or facts.
295
- 3. If wiki_search is unclear → follow up with web_search.
296
- 4. If a question references a URL → use fetch_page immediately.
297
- 5. If a question includes or references a Python file or code → use run_python to execute it.
298
- 6. For reversed/encoded text → use reverse_text first, then answer what it asks.
299
- 7. For math → always use the math tools (add, subtract, multiply, divide).
300
- 8. For YouTube or audio/video questions → search for transcripts with web_search.
301
- 9. Never give up — try multiple search strategies with different keywords if needed.
302
- 10. Be concise in your reasoning. Do not over-think simple questions.
303
-
304
- OUTPUT FORMAT — you MUST end EVERY response with exactly this line:
305
- FINAL ANSWER: [your answer here]
306
-
307
- Rules for the answer content:
308
- - Numbers: no commas, no units unless specifically asked (write 3 not $3 or 3,000)
309
- - Strings: no articles (a/an/the), no abbreviations, write out digits in plain text
310
- - Lists: comma-separated, apply above rules per element
311
- - Write ONLY the bare answer after "FINAL ANSWER:" — no extra explanation
312
  """
313
 
314
 
315
- def assistant(state: MessagesState):
316
- sys_msg = SystemMessage(content=SYSTEM_PROMPT)
317
- messages = [sys_msg] + state["messages"]
318
- return {"messages": [invoke_with_fallback(messages)]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
 
321
  # ============================================================
322
- # 4. BUILD GRAPH
323
  # ============================================================
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  def build_graph():
326
- builder = StateGraph(MessagesState)
327
- builder.add_node("assistant", assistant)
328
- builder.add_node("tools", ToolNode(tools))
329
- builder.add_edge(START, "assistant")
330
- builder.add_conditional_edges("assistant", tools_condition)
331
- builder.add_edge("tools", "assistant")
332
- return builder.compile()
 
 
 
333
 
334
 
 
 
335
  # ============================================================
336
- # 5. ANSWER EXTRACTION
337
  # ============================================================
338
 
339
- def extract_final_answer(agent_output: dict) -> str:
340
- """Extract FINAL ANSWER from agent output."""
341
- messages = agent_output.get("messages", [])
342
- if not messages:
343
- return "N/A"
344
 
345
- for msg in reversed(messages):
346
- content = getattr(msg, "content", "")
347
  if isinstance(content, str):
348
- match = re.search(r"FINAL ANSWER:\s*(.+)", content, re.IGNORECASE | re.DOTALL)
 
 
 
 
349
  if match:
350
- answer = match.group(1).strip()
351
- return answer if answer else "N/A"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
- return "N/A"
 
 
 
1
  import os
2
  import re
3
  import time
4
+ import json
5
  import tempfile
6
+ import subprocess
7
+ from typing import Literal
8
+
9
  from dotenv import load_dotenv
10
+
11
  from langgraph.graph import START, StateGraph, MessagesState
12
  from langgraph.prebuilt import ToolNode, tools_condition
13
+
14
  from langchain_groq import ChatGroq
15
  from langchain_core.tools import tool
16
+ from langchain_core.messages import SystemMessage
17
+
18
  from langchain_community.document_loaders import WikipediaLoader
19
  from langchain_community.tools import DuckDuckGoSearchRun
20
+
21
  import requests
22
  from bs4 import BeautifulSoup
23
 
24
  load_dotenv()
25
 
26
  # ============================================================
27
+ # CONFIG
28
  # ============================================================
29
 
30
+ MAX_TOOL_LOOPS = 3
31
+ QUESTION_TIMEOUT_SECONDS = 85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  # ============================================================
34
+ # TOOLS
35
  # ============================================================
36
 
37
  @tool
38
+ def add(a: float, b: float) -> float:
39
  """Add two numbers."""
40
  return a + b
41
 
42
 
43
  @tool
44
+ def subtract(a: float, b: float) -> float:
45
+ """Subtract b from a."""
46
+ return a - b
47
 
48
 
49
  @tool
50
+ def multiply(a: float, b: float) -> float:
51
+ """Multiply two numbers."""
52
+ return a * b
53
 
54
 
55
  @tool
56
+ def divide(a: float, b: float) -> str:
57
  """Divide a by b."""
58
  if b == 0:
59
+ return "division by zero"
60
+ return str(a / b)
61
 
62
 
63
  @tool
64
  def reverse_text(text: str) -> str:
65
+ """Reverse text character by character."""
66
  return text[::-1]
67
 
68
 
69
  @tool
70
  def wiki_search(query: str) -> str:
71
+ """Search Wikipedia."""
72
  try:
73
+ docs = WikipediaLoader(query=query, load_max_docs=2).load()
74
+ if not docs:
75
+ return "No results"
76
+
77
+ out = []
78
+ for d in docs:
79
+ out.append(
80
+ f"TITLE: {d.metadata.get('title','')}\n"
81
+ f"{d.page_content[:2200]}"
82
+ )
83
+ return "\n\n---\n\n".join(out)
84
+
85
  except Exception as e:
86
+ return f"wiki error: {e}"
87
 
88
 
89
  @tool
90
  def web_search(query: str) -> str:
91
+ """Search web using DuckDuckGo."""
92
  try:
93
  return DuckDuckGoSearchRun(max_results=5).run(query)
94
  except Exception as e:
95
+ return f"web error: {e}"
96
 
97
 
98
  @tool
99
  def fetch_page(url: str) -> str:
100
+ """Fetch webpage text."""
101
  try:
102
+ headers = {"User-Agent": "Mozilla/5.0"}
103
+ r = requests.get(url, timeout=15, headers=headers)
104
+ r.raise_for_status()
105
+
106
+ soup = BeautifulSoup(r.text, "html.parser")
107
+
108
+ for t in soup(["script", "style", "nav", "footer", "header"]):
109
+ t.decompose()
110
+
111
+ txt = soup.get_text(separator="\n", strip=True)
112
+ return txt[:8000]
113
+
114
  except Exception as e:
115
+ return f"fetch error: {e}"
116
 
117
 
118
  @tool
119
  def run_python(code: str) -> str:
120
+ """Execute Python safely."""
121
  try:
122
+ with tempfile.NamedTemporaryFile(
123
+ mode="w", suffix=".py", delete=False
124
+ ) as f:
125
  f.write(code)
126
  fname = f.name
127
+
128
  result = subprocess.run(
129
+ ["python3", fname],
130
  capture_output=True,
131
  text=True,
132
+ timeout=18
133
  )
134
+
135
+ out = result.stdout.strip()
136
+ err = result.stderr.strip()
137
+
138
+ if err:
139
+ out += f"\nSTDERR:\n{err}"
140
+
141
+ return out if out else "(no output)"
142
+
143
  except subprocess.TimeoutExpired:
144
+ return "python timeout"
145
  except Exception as e:
146
+ return f"python error: {e}"
147
 
148
 
149
+ TOOLS = [
150
+ add,
151
+ subtract,
152
+ multiply,
153
+ divide,
154
  reverse_text,
155
+ wiki_search,
156
+ web_search,
157
+ fetch_page,
158
  run_python,
159
  ]
160
 
161
  # ============================================================
162
+ # MODEL LAYER
 
 
163
  # ============================================================
164
 
165
+ def build_model(model_name: str):
166
+ return ChatGroq(
167
+ model=model_name,
168
+ api_key=os.getenv("GROQ_API_KEY"),
169
+ temperature=0
170
+ )
171
+
172
+
173
+ MODEL_8B = build_model("llama-3.1-8b-instant")
174
+ MODEL_QWEN = build_model("qwen/qwen3-32b")
175
+
176
+ # optional
177
+ MODEL_70B = build_model("llama-3.3-70b-versatile")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
 
180
  # ============================================================
181
+ # ROUTER
182
  # ============================================================
183
 
184
+ def classify_question(question: str) -> Literal["simple", "medium", "hard"]:
185
+ q = question.lower()
186
+
187
+ if any(x in q for x in [
188
+ "youtube.com", "watch?v=", "video", "audio",
189
+ "image", "xlsx", "excel", "pdf",
190
+ "attached file", "chess", "board position"
191
+ ]):
192
+ return "hard"
193
+
194
+ if any(x in q for x in [
195
+ "calculate", "reverse", "opposite",
196
+ "who is", "what is", "where is",
197
+ "how many", "first name", "surname"
198
+ ]):
199
+ return "simple"
200
+
201
+ return "medium"
202
+
203
 
204
+ def choose_model(question: str):
205
+ level = classify_question(question)
206
+
207
+ if level == "simple":
208
+ return MODEL_8B
209
+
210
+ if level == "medium":
211
+ return MODEL_QWEN
212
+
213
+ # hard
214
+ try:
215
+ return MODEL_70B
216
+ except Exception:
217
+ return MODEL_QWEN
218
+
219
+
220
+ # ============================================================
221
+ # SYSTEM PROMPT
222
+ # ============================================================
223
+
224
+ SYSTEM_PROMPT = """
225
+ You are an exact-answer benchmark agent.
226
 
227
  STRICT RULES:
228
+
229
+ 1. Use tools whenever facts, names, dates, counts, URLs, files or code are involved.
230
+ 2. If URL appears -> use fetch_page first.
231
+ 3. If code appears -> use run_python.
232
+ 4. If text seems reversed -> use reverse_text.
233
+ 5. For factual lookup: wiki_search first, then web_search if needed.
234
+ 6. Be concise.
235
+ 7. Never hallucinate.
236
+ 8. If unsure, search again once.
237
+
238
+ FINAL OUTPUT MUST END EXACTLY AS:
239
+
240
+ FINAL ANSWER: answer_here
241
+
242
+ Rules:
243
+ - no explanation after final answer
244
+ - plain text only
245
+ - list answers comma-separated
 
246
  """
247
 
248
 
249
+ # ============================================================
250
+ # FALLBACK INVOKER
251
+ # ============================================================
252
+
253
+ def invoke_with_escalation(messages, question: str):
254
+ primary = choose_model(question)
255
+
256
+ order = [primary, MODEL_QWEN, MODEL_8B]
257
+
258
+ # keep unique order
259
+ unique = []
260
+ seen = set()
261
+
262
+ for m in order:
263
+ key = str(m)
264
+ if key not in seen:
265
+ unique.append(m)
266
+ seen.add(key)
267
+
268
+ last_err = None
269
+
270
+ for model in unique:
271
+ try:
272
+ return model.bind_tools(TOOLS).invoke(messages)
273
+
274
+ except Exception as e:
275
+ last_err = e
276
+ time.sleep(1)
277
+
278
+ raise RuntimeError(f"all models failed: {last_err}")
279
 
280
 
281
  # ============================================================
282
+ # GRAPH STATE
283
  # ============================================================
284
 
285
+ def assistant(state: MessagesState):
286
+ msgs = state["messages"]
287
+
288
+ user_question = ""
289
+ for m in reversed(msgs):
290
+ if hasattr(m, "content"):
291
+ user_question = str(m.content)
292
+ break
293
+
294
+ messages = [SystemMessage(content=SYSTEM_PROMPT)] + msgs
295
+
296
+ result = invoke_with_escalation(messages, user_question)
297
+
298
+ return {"messages": [result]}
299
+
300
+
301
  def build_graph():
302
+ graph = StateGraph(MessagesState)
303
+
304
+ graph.add_node("assistant", assistant)
305
+ graph.add_node("tools", ToolNode(TOOLS))
306
+
307
+ graph.add_edge(START, "assistant")
308
+ graph.add_conditional_edges("assistant", tools_condition)
309
+ graph.add_edge("tools", "assistant")
310
+
311
+ return graph.compile()
312
 
313
 
314
+ agent = build_graph()
315
+
316
  # ============================================================
317
+ # ANSWER EXTRACTION
318
  # ============================================================
319
 
320
+ def extract_final_answer(output: dict) -> str:
321
+ msgs = output.get("messages", [])
 
 
 
322
 
323
+ for m in reversed(msgs):
324
+ content = getattr(m, "content", "")
325
  if isinstance(content, str):
326
+ match = re.search(
327
+ r"FINAL ANSWER:\s*(.+)",
328
+ content,
329
+ re.I | re.S
330
+ )
331
  if match:
332
+ return match.group(1).strip()
333
+
334
+ return "N/A"
335
+
336
+
337
+ # ============================================================
338
+ # PUBLIC SOLVER
339
+ # ============================================================
340
+
341
+ def solve_question(question: str) -> str:
342
+ start = time.time()
343
+
344
+ try:
345
+ result = agent.invoke(
346
+ {"messages": [("user", question)]},
347
+ {"recursion_limit": MAX_TOOL_LOOPS * 2}
348
+ )
349
+
350
+ answer = extract_final_answer(result)
351
+
352
+ if time.time() - start > QUESTION_TIMEOUT_SECONDS:
353
+ return "N/A"
354
+
355
+ return answer
356
+
357
+ except Exception:
358
+ return "N/A"
359
+
360
+
361
+ # ============================================================
362
+ # TEST
363
+ # ============================================================
364
 
365
+ if __name__ == "__main__":
366
+ q = "Write the opposite of left."
367
+ print(solve_question(q))