GT5557 commited on
Commit
7da1e13
·
verified ·
1 Parent(s): 53fedbd

Upload agent.py

Browse files
Files changed (1) hide show
  1. agent.py +24 -298
agent.py CHANGED
@@ -15,7 +15,7 @@ from langgraph.prebuilt import ToolNode, tools_condition
15
 
16
  from langchain_groq import ChatGroq
17
  from langchain_core.tools import tool
18
- from langchain_core.messages import SystemMessage, AIMessage
19
 
20
  from langchain_community.document_loaders import WikipediaLoader
21
  from langchain_community.tools import DuckDuckGoSearchRun
@@ -76,22 +76,7 @@ def fetch_page(url: str) -> str:
76
 
77
  @tool
78
  def run_python(code: str) -> str:
79
- """Execute Python code and return stdout.
80
-
81
- Use for: arithmetic, counting, sorting, data transformation, and
82
- processing Excel/CSV files with pandas.
83
-
84
- pandas (3.x) and openpyxl are available. To read an Excel file use:
85
- import pandas as pd
86
- df = pd.read_excel('/tmp/<task_id>.xlsx')
87
-
88
- The attached file path is provided in the question as:
89
- [ATTACHED FILE: /tmp/<filename>]
90
- Always use that exact path when reading files.
91
- Print your final answer with print() — only stdout is returned.
92
- """
93
- if len(code) > 8000:
94
- return "Error: code too long (>8000 chars). Rewrite with a shorter, focused script."
95
  try:
96
  with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
97
  f.write(code)
@@ -124,7 +109,7 @@ def reverse_text(text: str) -> str:
124
  TOOLS = [wiki_search, web_search, fetch_page, run_python, reverse_text]
125
 
126
  # ==========================================================
127
- # MODELS — two chains based on question type
128
  # ==========================================================
129
 
130
  def _llm(name: str) -> ChatGroq:
@@ -135,30 +120,13 @@ def _llm(name: str) -> ChatGroq:
135
  )
136
 
137
 
138
- MODEL_PRIMARY = _llm("qwen/qwen3-32b") # strong reasoning, search tasks
139
- MODEL_FALLBACK = _llm("llama-3.3-70b-versatile") # better at code, numeric, structured
140
- MODEL_LAST = _llm("llama-3.1-8b-instant") # last resort
 
 
141
 
142
- # General questions: Qwen leads, 70b fallback
143
- GENERAL_CHAIN = [MODEL_PRIMARY, MODEL_FALLBACK, MODEL_LAST]
144
-
145
- # Code/numeric questions: still Qwen first to preserve 70b daily token budget (TPD=100k).
146
- # 70b fires only when Qwen returns N/A — which is when it proved superior (Q10, Q12).
147
- CODE_CHAIN = [MODEL_PRIMARY, MODEL_FALLBACK, MODEL_LAST]
148
-
149
- # Keywords that indicate a code/numeric/structured-data question
150
- _CODE_SIGNALS = [
151
- "python code", "attached python", "numeric output", "final output",
152
- "excel", "xlsx", "spreadsheet", "csv", "total sales", "sum of",
153
- "how much", "calculate", "computation", "menu items", "sales from",
154
- "grocery list", "shopping list", "pie", "filling", "recipe",
155
- "attached file", "attached excel",
156
- ]
157
-
158
- def _is_code_question(question: str) -> bool:
159
- """Return True if the question is best handled by the code-oriented chain (70B first)."""
160
- q = question.lower()
161
- return any(sig in q for sig in _CODE_SIGNALS)
162
 
163
  # ==========================================================
164
  # SYSTEM PROMPT — single prompt for all question types
@@ -174,43 +142,16 @@ Produce the exact correct answer — nothing more, nothing less.
174
  - Use web_search for recent events, specific articles, prices, or anything time-sensitive.
175
  - Use fetch_page when a URL is provided or a search result points to a relevant page.
176
  - Use run_python for any arithmetic, counting, sorting, or data transformation.
177
- - Use run_python with pandas for Excel/CSV questions. When the question contains
178
- [ATTACHED FILE DATA], that IS the file content as CSV text. Parse it with:
179
- import pandas as pd, io
180
- data = '<copy the CSV lines from the question here>'
181
- df = pd.read_csv(io.StringIO(data))
182
- Then compute and print() the answer.
183
- - If the question says [ATTACHED FILE CONTENT], that is Python code. Run it with run_python directly
184
- — copy the code exactly as given into the run_python tool and print the final output.
185
- - When given an operation table on a set S with a specific subset question,
186
- use run_python to systematically check the required property rather than
187
- reasoning about it manually. Manual reasoning on table operations is error-prone.
188
- - If a question mentions an attached file but the file is not available in the question text
189
- (marked as [ATTACHED FILE DATA...] or [ATTACHED FILE CONTENT...]), return N/A.
190
- Do NOT invent or hallucinate file contents.
191
- - NEVER call the same tool with the same query twice. If a tool returns no useful result,
192
- try a different tool or a different query — do not repeat.
193
 
194
  ## Answer format rules
195
- 1. Output the raw value only — no explanation, no preamble, no surrounding quotes.
196
- 2. If asked for a first name, output ONLY the first/given name — not the full name, not the surname.
197
- 3. If asked for a surname or last name, output ONLY the family name — not the full name.
198
- 4. Numbers: digits only. Never include currency symbols ($, €, £) even if the question
199
- mentions USD just output the number e.g. 300.00 not $300.00.
200
- 5. Lists: comma-separated with a space after each comma on one line
201
- (e.g. "a, b, e" not "a,b,e"). Follow the model's natural formatting.
202
- 6. For subset/set questions (e.g. "subset of S involving..."), output only the elements, comma-separated.
203
- 7. If you cannot find the answer after searching, output: N/A
204
- 8. For discography questions: count studio albums only. Live albums, compilations, box sets do not count.
205
- 9. For botany/classification questions: use strict scientific categories.
206
- - Fruits (botanical): tomato, pepper, cucumber, avocado, squash, beans, corn kernels.
207
- - Vegetables (botanical): true vegetables are leaves (lettuce, spinach), stems (celery), roots
208
- (carrot, sweet potato), bulbs (onion), or flowers (broccoli, cauliflower).
209
- - Do NOT confuse culinary and botanical definitions. A tomato is a fruit botanically.
210
- 10. Never abbreviate UNLESS the question explicitly asks for an abbreviation or code:
211
- - If asked for an IOC country code: return the 3-letter code (e.g. CUB, GBR, EGY).
212
- - If asked for an ISO code, airport code, or similar: return the code as-is.
213
- - Otherwise write full words: "United States" not "US", "Saint Petersburg" not "St. Petersburg".
214
 
215
  ## Required final line
216
  Always end your response with exactly:
@@ -218,88 +159,19 @@ FINAL ANSWER: <your answer>
218
  """
219
 
220
  # ==========================================================
221
- # DIRECT ANSWER PRECHECKS
222
- # ==========================================================
223
-
224
- def maybe_answer_direct(question: str) -> str | None:
225
- """
226
- Return a direct answer string for questions that don't need tool calls,
227
- or None if normal graph processing should continue.
228
- """
229
- q = question.strip()
230
- ql = q.lower()
231
-
232
- # Reversed sentence asking for the opposite of "left"
233
- if "etisoppo" in ql and "tfel" in ql:
234
- return "right"
235
- if 'write the opposite of the word "left"' in ql:
236
- return "right"
237
-
238
- # Algebraic subset question — verified correct answer is b, e (with space after comma)
239
- # The question involves a 5-element set {a,b,c,d,e} with operation table
240
- if 'set s = {a, b, c, d, e}' in ql and 'subset' in ql:
241
- return "b, e"
242
-
243
- return None
244
-
245
-
246
- # ==========================================================
247
- # ANSWER QUALITY CHECK — used by invoke to decide fallback
248
- # ==========================================================
249
-
250
- def _answer_looks_weak(result) -> bool:
251
- """
252
- Return True if the model's response content does not contain a usable
253
- FINAL ANSWER — meaning we should try the next model in the chain.
254
- Only applies when the model made NO tool calls (pure text response).
255
- If tool calls are present, we let the graph continue normally.
256
- """
257
- # If the model wants to call tools, don't short-circuit — let the graph run
258
- tool_calls = getattr(result, "tool_calls", None)
259
- if tool_calls:
260
- return False
261
-
262
- content = getattr(result, "content", "") or ""
263
- if not isinstance(content, str):
264
- return False
265
-
266
- # Check if a FINAL ANSWER line is present and non-empty
267
- match = re.search(r"FINAL ANSWER:\s*(.+)", content, re.I | re.S)
268
- if not match:
269
- return True
270
-
271
- answer = match.group(1).strip()
272
- # Treat explicit N/A or empty as weak
273
- if not answer or answer.lower() in ("n/a", "none", "unknown", ""):
274
- return True
275
-
276
- return False
277
-
278
-
279
- # ==========================================================
280
- # INVOKE — exception fallback + content-quality fallback
281
  # ==========================================================
282
 
283
- def invoke(messages: list, is_final: bool = False, chain: list = None) -> object:
284
- """
285
- Call models in chain order with quality-based fallback.
286
-
287
- chain — ordered list of models to try (GENERAL_CHAIN or CODE_CHAIN)
288
- is_final — True when tool results are already in history (terminal answer turn)
289
- """
290
  global LAST_MODEL_USED, LAST_MODEL_FALLBACK, LAST_MODEL_ERROR
291
 
292
- if chain is None:
293
- chain = GENERAL_CHAIN
294
-
295
  LAST_MODEL_FALLBACK = "No"
296
  LAST_MODEL_ERROR = "None"
297
 
298
  seen: set[str] = set()
299
  first = True
300
- last_result = None
301
 
302
- for model in chain:
303
  key = model.model_name
304
  if key in seen:
305
  continue
@@ -310,52 +182,11 @@ def invoke(messages: list, is_final: bool = False, chain: list = None) -> object
310
  if not first:
311
  LAST_MODEL_FALLBACK = "Yes"
312
  first = False
313
-
314
- result = model.bind_tools(TOOLS).invoke(messages)
315
- last_result = result
316
-
317
- # Model wants to call a tool — return immediately regardless of turn
318
- tool_calls = getattr(result, "tool_calls", None)
319
- if tool_calls:
320
- return result
321
-
322
- # Not a final turn — allow ONE fallback if answer is weak, then stop
323
- if not is_final:
324
- if _answer_looks_weak(result):
325
- LAST_MODEL_FALLBACK = "Yes"
326
- LAST_MODEL_ERROR = f"weak first-turn answer from {key}"
327
- time.sleep(0.5)
328
- for next_model in chain:
329
- nkey = next_model.model_name
330
- if nkey in seen:
331
- continue
332
- seen.add(nkey)
333
- try:
334
- LAST_MODEL_USED = nkey
335
- r2 = next_model.bind_tools(TOOLS).invoke(messages)
336
- return r2
337
- except Exception as e2:
338
- LAST_MODEL_ERROR = str(e2)
339
- continue
340
- return result
341
-
342
- # Final turn — try all remaining models until a non-weak answer
343
- if not _answer_looks_weak(result):
344
- return result
345
-
346
- LAST_MODEL_FALLBACK = "Yes"
347
- LAST_MODEL_ERROR = f"weak answer from {key}"
348
- time.sleep(0.5)
349
- continue
350
-
351
  except Exception as e:
352
  LAST_MODEL_ERROR = str(e)
353
- time.sleep(0.5)
354
  continue
355
 
356
- if last_result is not None:
357
- return last_result
358
-
359
  raise RuntimeError(f"All models failed. Last error: {LAST_MODEL_ERROR}")
360
 
361
  # ==========================================================
@@ -363,28 +194,8 @@ def invoke(messages: list, is_final: bool = False, chain: list = None) -> object
363
  # ==========================================================
364
 
365
  def assistant(state: MessagesState) -> dict:
366
- user_q = state["messages"][-1].content if state.get("messages") else ""
367
-
368
- # Short-circuit for questions answerable without any tool calls
369
- direct = maybe_answer_direct(user_q)
370
- if direct is not None:
371
- return {"messages": [AIMessage(content=f"FINAL ANSWER: {direct}")]}
372
-
373
- # Pick model chain based on question type:
374
- # code/numeric/structured-data → 70B leads (observed better accuracy)
375
- # everything else → Qwen leads
376
- # Extract the original user question (not injected file content) for routing
377
- original_q = user_q.split("\n[ATTACHED FILE")[0]
378
- chain = CODE_CHAIN if _is_code_question(original_q) else GENERAL_CHAIN
379
-
380
- # Detect whether this is a final turn (tool results already in history)
381
- is_final = any(
382
- getattr(m, "type", "") == "tool"
383
- for m in state["messages"]
384
- )
385
-
386
  messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
387
- result = invoke(messages, is_final=is_final, chain=chain)
388
  return {"messages": [result]}
389
 
390
 
@@ -404,25 +215,10 @@ def build_graph():
404
  def _clean_answer(raw: str) -> str:
405
  """Normalise the extracted answer string."""
406
  answer = raw.strip()
407
-
408
- # Strip surrounding quotes the model sometimes wraps answers in
409
- # e.g. '"Extremely"' → 'Extremely', '"No, it\'s a sarcophagus."' → stripped later
410
- if len(answer) >= 2 and answer[0] in ('"', "'", "\u201c", "\u2018") and answer[-1] in ('"', "'", "\u201d", "\u2019"):
411
- answer = answer[1:-1].strip()
412
-
413
- # Strip trailing punctuation
414
  answer = answer.rstrip(".,;:")
415
-
416
- # Strip leading currency symbols — benchmark expects raw numbers, not formatted currency
417
- # e.g. "$300.00" → "300.00", "£1,234.56" → "1,234.56"
418
- # Exception: if question explicitly asks for USD/currency format, the system prompt
419
- # instructs the model accordingly — but _clean_answer always strips symbols here
420
- # because the scorer does exact-match and won't accept "$"
421
- answer = re.sub(r'^[$€£¥₹]\s*', '', answer)
422
-
423
  # Collapse internal whitespace / newlines
424
  answer = " ".join(answer.split())
425
-
426
  # Remove common LLM filler prefixes the regex sometimes captures
427
  for prefix in (
428
  "the answer is",
@@ -435,76 +231,6 @@ def _clean_answer(raw: str) -> str:
435
  ):
436
  if answer.lower().startswith(prefix):
437
  answer = answer[len(prefix):].strip()
438
-
439
- # Expand common abbreviations
440
- answer = _expand_abbreviation(answer)
441
- return answer
442
-
443
-
444
- # Whole-answer abbreviation expansion table.
445
- # Keys are lowercase stripped versions; values are the canonical full forms.
446
- _ABBREV_MAP: dict[str, str] = {
447
- # Countries / regions
448
- "us": "United States",
449
- "usa": "United States",
450
- "u.s.": "United States",
451
- "u.s.a.":"United States",
452
- "uk": "United Kingdom",
453
- "u.k.": "United Kingdom",
454
- "uae": "United Arab Emirates",
455
- "ussr": "Soviet Union",
456
- "drc": "Democratic Republic of the Congo",
457
- "dprk": "North Korea",
458
- "rok": "South Korea",
459
- # Cities that frequently get abbreviated
460
- "hcmc": "Ho Chi Minh City",
461
- "nyc": "New York City",
462
- "la": "Los Angeles",
463
- "dc": "Washington, D.C.",
464
- # Saint / Sainte prefixes
465
- "st.": "Saint",
466
- # Common institution abbreviations
467
- "mit": "Massachusetts Institute of Technology",
468
- "un": "United Nations",
469
- "eu": "European Union",
470
- "nato": "North Atlantic Treaty Organization",
471
- "who": "World Health Organization",
472
- "imf": "International Monetary Fund",
473
- "nba": "National Basketball Association",
474
- "nfl": "National Football League",
475
- "fifa": "Fédération Internationale de Football Association",
476
- }
477
-
478
-
479
- def _expand_abbreviation(answer: str) -> str:
480
- """
481
- If the entire answer is a known abbreviation, replace it with the full form.
482
- Also expands 'St. <Name>' → 'Saint <Name>' for city/place names.
483
-
484
- Does NOT expand:
485
- - IOC country codes (3 uppercase letters like CUB, EGY, GBR) — these are
486
- intentional when the question asks for the IOC code.
487
- - Answers longer than 5 words — too risky to mutate longer text.
488
- """
489
- # Only act on short answers (≤ 5 words) to stay safe
490
- if len(answer.split()) > 5:
491
- return answer
492
-
493
- # Never expand 3-letter ALL-CAPS strings — these are almost certainly
494
- # IOC codes, ISO codes, or other intentional abbreviations the question asked for
495
- if re.fullmatch(r'[A-Z]{3}', answer.strip()):
496
- return answer
497
-
498
- # Whole-answer lookup (case-insensitive)
499
- lookup = answer.lower().strip(".")
500
- if lookup in _ABBREV_MAP:
501
- return _ABBREV_MAP[lookup]
502
-
503
- # Expand "St. <Word>" → "Saint <Word>" (e.g. "St. Petersburg" → "Saint Petersburg")
504
- expanded = re.sub(r'\bSt\.\s+', 'Saint ', answer)
505
- if expanded != answer:
506
- return expanded
507
-
508
  return answer
509
 
510
 
@@ -544,4 +270,4 @@ def get_last_trace() -> dict:
544
  "model": LAST_MODEL_USED,
545
  "fallback": LAST_MODEL_FALLBACK,
546
  "model_error": LAST_MODEL_ERROR,
547
- }
 
15
 
16
  from langchain_groq import ChatGroq
17
  from langchain_core.tools import tool
18
+ from langchain_core.messages import SystemMessage
19
 
20
  from langchain_community.document_loaders import WikipediaLoader
21
  from langchain_community.tools import DuckDuckGoSearchRun
 
76
 
77
  @tool
78
  def run_python(code: str) -> str:
79
+ """Execute Python code and return stdout. Use for calculations, counting, data processing."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  try:
81
  with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
82
  f.write(code)
 
109
  TOOLS = [wiki_search, web_search, fetch_page, run_python, reverse_text]
110
 
111
  # ==========================================================
112
+ # MODELS — primary + ordered fallback chain
113
  # ==========================================================
114
 
115
  def _llm(name: str) -> ChatGroq:
 
120
  )
121
 
122
 
123
+ # All questions use the same primary model.
124
+ # Fallback chain kicks in only on errors (rate limits, timeouts, etc.)
125
+ MODEL_PRIMARY = _llm("qwen/qwen3-32b")
126
+ MODEL_FALLBACK = _llm("llama-3.3-70b-versatile")
127
+ MODEL_LAST = _llm("llama-3.1-8b-instant")
128
 
129
+ FALLBACK_CHAIN = [MODEL_PRIMARY, MODEL_FALLBACK, MODEL_LAST]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  # ==========================================================
132
  # SYSTEM PROMPT — single prompt for all question types
 
142
  - Use web_search for recent events, specific articles, prices, or anything time-sensitive.
143
  - Use fetch_page when a URL is provided or a search result points to a relevant page.
144
  - Use run_python for any arithmetic, counting, sorting, or data transformation.
145
+ - Use reverse_text only when asked to reverse a string.
146
+ - You may use up to 5 tool calls. Stop as soon as you have a confident answer.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  ## Answer format rules
149
+ 1. Output the raw value only — no explanation, no preamble.
150
+ 2. If asked for a first name, output only the first name.
151
+ 3. If asked for a surname, output only the surname.
152
+ 4. Numbers: digits only unless units were explicitly requested.
153
+ 5. Lists: comma-separated on one line.
154
+ 6. If you cannot find the answer after searching, output: N/A
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
  ## Required final line
157
  Always end your response with exactly:
 
159
  """
160
 
161
  # ==========================================================
162
+ # INVOKE with fallback chain
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  # ==========================================================
164
 
165
+ def invoke(messages: list) -> object:
 
 
 
 
 
 
166
  global LAST_MODEL_USED, LAST_MODEL_FALLBACK, LAST_MODEL_ERROR
167
 
 
 
 
168
  LAST_MODEL_FALLBACK = "No"
169
  LAST_MODEL_ERROR = "None"
170
 
171
  seen: set[str] = set()
172
  first = True
 
173
 
174
+ for model in FALLBACK_CHAIN:
175
  key = model.model_name
176
  if key in seen:
177
  continue
 
182
  if not first:
183
  LAST_MODEL_FALLBACK = "Yes"
184
  first = False
185
+ return model.bind_tools(TOOLS).invoke(messages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  except Exception as e:
187
  LAST_MODEL_ERROR = str(e)
 
188
  continue
189
 
 
 
 
190
  raise RuntimeError(f"All models failed. Last error: {LAST_MODEL_ERROR}")
191
 
192
  # ==========================================================
 
194
  # ==========================================================
195
 
196
  def assistant(state: MessagesState) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
198
+ result = invoke(messages)
199
  return {"messages": [result]}
200
 
201
 
 
215
  def _clean_answer(raw: str) -> str:
216
  """Normalise the extracted answer string."""
217
  answer = raw.strip()
218
+ # Strip trailing punctuation that the model sometimes adds
 
 
 
 
 
 
219
  answer = answer.rstrip(".,;:")
 
 
 
 
 
 
 
 
220
  # Collapse internal whitespace / newlines
221
  answer = " ".join(answer.split())
 
222
  # Remove common LLM filler prefixes the regex sometimes captures
223
  for prefix in (
224
  "the answer is",
 
231
  ):
232
  if answer.lower().startswith(prefix):
233
  answer = answer[len(prefix):].strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  return answer
235
 
236
 
 
270
  "model": LAST_MODEL_USED,
271
  "fallback": LAST_MODEL_FALLBACK,
272
  "model_error": LAST_MODEL_ERROR,
273
+ }