relisora commited on
Commit
6fa5050
·
unverified ·
1 Parent(s): da0eaef

better tool search, better end of turn

Browse files
Files changed (2) hide show
  1. agent.py +122 -7
  2. app.py +6 -6
agent.py CHANGED
@@ -1,6 +1,10 @@
1
  import os
 
2
  import re
3
  import tempfile
 
 
 
4
  from pathlib import Path
5
 
6
  import requests
@@ -9,6 +13,7 @@ from smolagents import (
9
  CodeAgent,
10
  InferenceClientModel,
11
  LiteLLMModel,
 
12
  VisitWebpageTool,
13
  WebSearchTool,
14
  WikipediaSearchTool,
@@ -16,13 +21,14 @@ from smolagents import (
16
 
17
 
18
  DEFAULT_MODEL_ID = "Qwen/Qwen2.5-Coder-32B-Instruct"
19
- ANTHROPIC_MODEL_CHOICES = [
 
 
 
20
  "anthropic/claude-sonnet-4-5",
21
  "anthropic/claude-opus-4-1",
22
  "anthropic/claude-haiku-4-5",
23
- "anthropic/claude-3-7-sonnet-latest",
24
- ]
25
- DEFAULT_ANTHROPIC_MODEL_ID = ANTHROPIC_MODEL_CHOICES[0]
26
  # smolagents defaults to 120s, which slow reasoning models exceed on long steps.
27
  DEFAULT_MODEL_TIMEOUT = 600
28
  SCORING_API_URL = "https://agents-course-unit4-scoring.hf.space"
@@ -48,15 +54,91 @@ ANSWER_INSTRUCTIONS = (
48
  "page and parse the table with pandas rather than reading the prose. Your final answer must "
49
  "follow the requested format exactly and contain nothing else: no explanation, reasoning, "
50
  "labels, units that were not asked for, quotation marks, or bullet points."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  )
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  _ANSWER_PREFIX = re.compile(r"^\s*(final\s*answer|answer)\s*[:\-]\s*", re.IGNORECASE)
 
54
  _GROUPED_NUMBER = re.compile(r"-?\d{1,3}(,\d{3})+(\.\d+)?")
55
 
56
 
57
  def normalize_answer(answer: object) -> str:
58
  """Grading is exact string match, so strip the decorations models wrap answers in."""
59
- text = _ANSWER_PREFIX.sub("", str(answer).strip()).strip(" `*").strip()
 
 
 
 
 
 
 
 
60
  if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
61
  text = text[1:-1].strip()
62
  text = text.rstrip(".").strip()
@@ -83,11 +165,43 @@ def download_attachment(task_id: str, file_name: str) -> Path | None:
83
  return target
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def build_model(timeout: int, model_id: str | None = None, requests_per_minute: int = 30):
87
  """Use Anthropic when an API key is set, otherwise Hugging Face inference."""
88
  anthropic_key = os.getenv("ANTHROPIC_API_KEY")
89
  if anthropic_key:
90
- model_id = model_id or os.getenv("ANTHROPIC_MODEL_ID") or DEFAULT_ANTHROPIC_MODEL_ID
91
  # LiteLLM needs the provider prefix to route to Anthropic.
92
  if "/" not in model_id:
93
  model_id = f"anthropic/{model_id}"
@@ -120,7 +234,7 @@ class BasicAgent:
120
  self.agent = CodeAgent(
121
  model=model,
122
  tools=[
123
- WebSearchTool(max_results=10),
124
  WikipediaSearchTool(extract_format="HTML"),
125
  VisitWebpageTool(),
126
  ],
@@ -129,6 +243,7 @@ class BasicAgent:
129
  planning_interval=3,
130
  verbosity_level=2 if verbose else 0,
131
  )
 
132
  print(
133
  f"BasicAgent initialized with model: {self.model_id} "
134
  f"(request timeout: {self.timeout}s)"
 
1
  import os
2
+ import random
3
  import re
4
  import tempfile
5
+ import threading
6
+ import time
7
+ from functools import lru_cache
8
  from pathlib import Path
9
 
10
  import requests
 
13
  CodeAgent,
14
  InferenceClientModel,
15
  LiteLLMModel,
16
+ Tool,
17
  VisitWebpageTool,
18
  WebSearchTool,
19
  WikipediaSearchTool,
 
21
 
22
 
23
  DEFAULT_MODEL_ID = "Qwen/Qwen2.5-Coder-32B-Instruct"
24
+ ANTHROPIC_MODELS_URL = "https://api.anthropic.com/v1/models"
25
+ ANTHROPIC_FAMILIES = ("sonnet", "opus", "haiku")
26
+ # Used when the models endpoint cannot be reached (no key, offline, API change).
27
+ FALLBACK_ANTHROPIC_MODELS = (
28
  "anthropic/claude-sonnet-4-5",
29
  "anthropic/claude-opus-4-1",
30
  "anthropic/claude-haiku-4-5",
31
+ )
 
 
32
  # smolagents defaults to 120s, which slow reasoning models exceed on long steps.
33
  DEFAULT_MODEL_TIMEOUT = 600
34
  SCORING_API_URL = "https://agents-course-unit4-scoring.hf.space"
 
54
  "page and parse the table with pandas rather than reading the prose. Your final answer must "
55
  "follow the requested format exactly and contain nothing else: no explanation, reasoning, "
56
  "labels, units that were not asked for, quotation marks, or bullet points."
57
+ "\n\nPick the single most appropriate tool for each step and call only that one:\n"
58
+ "- Reasoning, parsing, arithmetic, sorting, string work, or anything about a file you were "
59
+ "given: write Python. Never search the web for something you can compute or already know.\n"
60
+ "- A fact that lives on Wikipedia: wikipedia_search.\n"
61
+ "- A page whose URL you already have: visit_webpage.\n"
62
+ "- Only when you need to find a URL you do not have: web_search, once. If the results are "
63
+ "usable, open the best one with visit_webpage instead of searching again; only rephrase the "
64
+ "query if the results were genuinely irrelevant.\n"
65
+ "Never call two tools for the same piece of information, and stop searching as soon as you "
66
+ "have what the question asks for."
67
+ )
68
+
69
+ # Used when the agent hits max_steps: an imperfect guess can still score, an apology never does.
70
+ BEST_GUESS_INSTRUCTIONS = (
71
+ "\n\nThe agent ran out of steps, so you must answer from the evidence it already gathered. "
72
+ "Commit to the single most likely answer, even if the evidence is incomplete: infer it from "
73
+ "partial results, and if you truly have nothing, fall back on your own knowledge. Never reply "
74
+ "that the answer could not be determined, and never describe what is missing. "
75
+ "Reply with one line only, in the exact format the question asked for, prefixed by "
76
+ "'FINAL ANSWER: '."
77
  )
78
 
79
+ SEARCH_ENGINES = ("duckduckgo", "bing")
80
+ SEARCH_ATTEMPTS = 3
81
+ # Free search endpoints throttle hard, and parallel workers share this process.
82
+ SEARCH_MIN_INTERVAL = 2.0
83
+ _search_lock = threading.Lock()
84
+ _last_search_at = 0.0
85
+
86
+
87
+ class ResilientWebSearchTool(Tool):
88
+ """Web search that retries and falls back between engines instead of failing a whole run."""
89
+
90
+ name = "web_search"
91
+ description = (
92
+ "Performs a web search (think a Google search) and returns the top results as markdown "
93
+ "links with short descriptions. Retries automatically across several search engines."
94
+ )
95
+ inputs = {"query": {"type": "string", "description": "The search query to perform."}}
96
+ output_type = "string"
97
+
98
+ def __init__(self, max_results: int = 10):
99
+ super().__init__()
100
+ self.engines = [WebSearchTool(max_results=max_results, engine=name) for name in SEARCH_ENGINES]
101
+
102
+ def _throttle(self) -> None:
103
+ global _last_search_at
104
+ with _search_lock:
105
+ wait = SEARCH_MIN_INTERVAL - (time.monotonic() - _last_search_at)
106
+ if wait > 0:
107
+ time.sleep(wait)
108
+ _last_search_at = time.monotonic()
109
+
110
+ def forward(self, query: str) -> str:
111
+ failures = []
112
+ for attempt in range(SEARCH_ATTEMPTS):
113
+ for engine, tool in zip(SEARCH_ENGINES, self.engines):
114
+ self._throttle()
115
+ try:
116
+ return tool.forward(query)
117
+ except Exception as error:
118
+ failures.append(f"{engine}: {error}")
119
+ # Jitter so parallel workers do not retry in lockstep.
120
+ time.sleep(2**attempt + random.uniform(0, 1))
121
+ raise Exception(
122
+ f"Web search failed for {query!r} after {SEARCH_ATTEMPTS} rounds ({'; '.join(failures)}). "
123
+ "Try a shorter query, or use wikipedia_search / visit_webpage instead."
124
+ )
125
+
126
  _ANSWER_PREFIX = re.compile(r"^\s*(final\s*answer|answer)\s*[:\-]\s*", re.IGNORECASE)
127
+ _FINAL_ANSWER_LINE = re.compile(r"final\s*answer\s*[:\-]\s*(.+)", re.IGNORECASE)
128
  _GROUPED_NUMBER = re.compile(r"-?\d{1,3}(,\d{3})+(\.\d+)?")
129
 
130
 
131
  def normalize_answer(answer: object) -> str:
132
  """Grading is exact string match, so strip the decorations models wrap answers in."""
133
+ text = str(answer).strip()
134
+ # The max-steps fallback answers in prose ending with a FINAL ANSWER line.
135
+ matches = _FINAL_ANSWER_LINE.findall(text)
136
+ if matches:
137
+ text = matches[-1].strip()
138
+ elif "\n" in text:
139
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
140
+ text = lines[-1] if lines else text
141
+ text = _ANSWER_PREFIX.sub("", text.strip()).strip(" `*").strip()
142
  if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
143
  text = text[1:-1].strip()
144
  text = text.rstrip(".").strip()
 
165
  return target
166
 
167
 
168
+ @lru_cache(maxsize=1)
169
+ def anthropic_model_choices() -> tuple[str, ...]:
170
+ """Latest Sonnet, Opus and Haiku, read from the Anthropic models endpoint."""
171
+ anthropic_key = os.getenv("ANTHROPIC_API_KEY")
172
+ if not anthropic_key:
173
+ return FALLBACK_ANTHROPIC_MODELS
174
+ try:
175
+ response = requests.get(
176
+ ANTHROPIC_MODELS_URL,
177
+ headers={"x-api-key": anthropic_key, "anthropic-version": "2023-06-01"},
178
+ params={"limit": 100},
179
+ timeout=15,
180
+ )
181
+ response.raise_for_status()
182
+ models = response.json().get("data", [])
183
+ except (requests.RequestException, ValueError) as error:
184
+ print(f"Could not list Anthropic models, using defaults: {error}")
185
+ return FALLBACK_ANTHROPIC_MODELS
186
+
187
+ models.sort(key=lambda model: model.get("created_at", ""), reverse=True)
188
+ latest = []
189
+ for family in ANTHROPIC_FAMILIES:
190
+ newest = next((m["id"] for m in models if family in m.get("id", "")), None)
191
+ if newest:
192
+ latest.append(f"anthropic/{newest}")
193
+ return tuple(latest) or FALLBACK_ANTHROPIC_MODELS
194
+
195
+
196
+ def default_anthropic_model() -> str:
197
+ return anthropic_model_choices()[0]
198
+
199
+
200
  def build_model(timeout: int, model_id: str | None = None, requests_per_minute: int = 30):
201
  """Use Anthropic when an API key is set, otherwise Hugging Face inference."""
202
  anthropic_key = os.getenv("ANTHROPIC_API_KEY")
203
  if anthropic_key:
204
+ model_id = model_id or default_anthropic_model()
205
  # LiteLLM needs the provider prefix to route to Anthropic.
206
  if "/" not in model_id:
207
  model_id = f"anthropic/{model_id}"
 
234
  self.agent = CodeAgent(
235
  model=model,
236
  tools=[
237
+ ResilientWebSearchTool(max_results=10),
238
  WikipediaSearchTool(extract_format="HTML"),
239
  VisitWebpageTool(),
240
  ],
 
243
  planning_interval=3,
244
  verbosity_level=2 if verbose else 0,
245
  )
246
+ self.agent.prompt_templates["final_answer"]["post_messages"] += BEST_GUESS_INSTRUCTIONS
247
  print(
248
  f"BasicAgent initialized with model: {self.model_id} "
249
  f"(request timeout: {self.timeout}s)"
app.py CHANGED
@@ -9,10 +9,10 @@ import inspect
9
  import pandas as pd
10
 
11
  from agent import (
12
- ANTHROPIC_MODEL_CHOICES,
13
- DEFAULT_ANTHROPIC_MODEL_ID,
14
  DEFAULT_MODEL_ID,
15
  BasicAgent,
 
 
16
  )
17
 
18
  # (Keep Constants as is)
@@ -31,7 +31,7 @@ _thread_local = threading.local()
31
 
32
  def describe_backend() -> str:
33
  if os.getenv("ANTHROPIC_API_KEY"):
34
- return f"**Backend:** Anthropic (`ANTHROPIC_API_KEY` found) — default model `{DEFAULT_ANTHROPIC_MODEL_ID}`"
35
  return (
36
  "**Backend:** Hugging Face Inference (no `ANTHROPIC_API_KEY` set) — default model "
37
  f"`{os.getenv('HF_MODEL_ID') or DEFAULT_MODEL_ID}`"
@@ -300,11 +300,11 @@ with gr.Blocks() as demo:
300
  gr.LoginButton()
301
 
302
  model_dropdown = gr.Dropdown(
303
- choices=ANTHROPIC_MODEL_CHOICES,
304
- value=DEFAULT_ANTHROPIC_MODEL_ID,
305
  allow_custom_value=True,
306
  label="Model",
307
- info="Anthropic model id (requires ANTHROPIC_API_KEY). Leave empty to use the configured default / Hugging Face model.",
308
  )
309
 
310
  concurrency_slider = gr.Slider(
 
9
  import pandas as pd
10
 
11
  from agent import (
 
 
12
  DEFAULT_MODEL_ID,
13
  BasicAgent,
14
+ anthropic_model_choices,
15
+ default_anthropic_model,
16
  )
17
 
18
  # (Keep Constants as is)
 
31
 
32
  def describe_backend() -> str:
33
  if os.getenv("ANTHROPIC_API_KEY"):
34
+ return f"**Backend:** Anthropic (`ANTHROPIC_API_KEY` found) — default model `{default_anthropic_model()}`"
35
  return (
36
  "**Backend:** Hugging Face Inference (no `ANTHROPIC_API_KEY` set) — default model "
37
  f"`{os.getenv('HF_MODEL_ID') or DEFAULT_MODEL_ID}`"
 
300
  gr.LoginButton()
301
 
302
  model_dropdown = gr.Dropdown(
303
+ choices=list(anthropic_model_choices()),
304
+ value=default_anthropic_model(),
305
  allow_custom_value=True,
306
  label="Model",
307
+ info="Latest Anthropic Sonnet / Opus / Haiku (requires ANTHROPIC_API_KEY). Any other model id can be typed in.",
308
  )
309
 
310
  concurrency_slider = gr.Slider(