grevy commited on
Commit
3e9095a
·
1 Parent(s): 226dd7e
Files changed (5) hide show
  1. README.md +21 -1
  2. agents.py +11 -66
  3. app.py +30 -20
  4. test_web_search.py +99 -0
  5. tools.py +102 -42
README.md CHANGED
@@ -12,4 +12,24 @@ hf_oauth: true
12
  hf_oauth_expiration_minutes: 480
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  hf_oauth_expiration_minutes: 480
13
  ---
14
 
15
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
16
+
17
+ ## Agent architecture
18
+
19
+ - The manager uses `GROQ_MANAGER_MODEL` (default: `openai/gpt-oss-120b`).
20
+ - Web research is one direct, bounded Tavily request; it does not start a
21
+ second LLM agent.
22
+ - Document and media tasks keep dedicated agents because they may need to
23
+ download and inspect an attachment in multiple steps.
24
+ - Calculations use `python_exec` or `calculator` directly.
25
+ - Final answer formatting uses `GROQ_NORMALIZER_MODEL` (default:
26
+ `openai/gpt-oss-20b`).
27
+
28
+ Required Space secrets are `GROQ_API_KEY` and `TAVILY_API_KEY`. Set
29
+ `GROQ_VLM_MODEL` as well to enable image and video analysis.
30
+
31
+ Run the offline web-search regression tests with:
32
+
33
+ ```bash
34
+ python -m unittest -v test_web_search.py
35
+ ```
agents.py CHANGED
@@ -10,6 +10,8 @@ def _make_llm(model: str = "openai/gpt-oss-120b", temperature: float = 0.1) -> C
10
  model=model,
11
  temperature=temperature,
12
  api_key=os.getenv("GROQ_API_KEY"),
 
 
13
  )
14
 
15
 
@@ -19,29 +21,12 @@ def _make_llm(model: str = "openai/gpt-oss-120b", temperature: float = 0.1) -> C
19
  # curl -s https://api.groq.com/openai/v1/models \
20
  # -H "Authorization: Bearer $GROQ_API_KEY" | jq '.data[].id'
21
  _ROLE_MODELS = {
22
- "web": os.getenv("GROQ_WEB_MODEL", "openai/gpt-oss-120b"),
23
- "file": os.getenv("GROQ_FILE_MODEL", "openai/gpt-oss-120b"),
24
  "media": os.getenv("GROQ_MEDIA_MODEL", "openai/gpt-oss-120b"),
25
- "code": os.getenv("GROQ_CODE_MODEL", "openai/gpt-oss-120b"),
26
- "normalizer": os.getenv("GROQ_NORMALIZER_MODEL", "openai/gpt-oss-120b"),
27
  }
28
 
29
 
30
- WEB_RESEARCH_PROMPT = """You are a web research specialist.
31
-
32
- For each question:
33
- 1. Break it into sub-questions if needed.
34
- 2. Use `search_tool` to find candidate sources. When a search snippet is
35
- ambiguous or truncated, call `fetch_url` on the most relevant hit to
36
- read the actual page.
37
- 3. Cross-check facts across at least 2 independent sources before concluding
38
- when the answer is not obvious.
39
- 4. Return a concise, factual answer. If the question asks for a specific
40
- value (number, name, date, list), give ONLY that value. Otherwise, keep
41
- the answer under 3 sentences.
42
- 5. Never guess. If you cannot verify, say "unknown"."""
43
-
44
-
45
  FILE_ANALYSIS_PROMPT = """You are a document analysis specialist.
46
 
47
  You handle questions that reference a task_id, URL, or file path pointing to
@@ -59,22 +44,6 @@ Workflow:
59
  verbatim when possible. Do not speculate."""
60
 
61
 
62
- CODE_AGENT_PROMPT = """You are a Python computation specialist.
63
-
64
- You answer questions that need arithmetic, list/string manipulation, date math,
65
- pandas transforms, unit conversions, or any calculation better *computed* than
66
- reasoned about.
67
-
68
- Workflow:
69
- 1. Prefer `calculator` for a single closed-form math expression.
70
- 2. Otherwise write a short Python snippet and run it via `python_exec`.
71
- You MUST `print()` any value you want to see back — the tool returns stdout.
72
- 3. If `python_exec` returns an error, read the traceback, fix the code, and
73
- retry. Do not give up after one failure; iterate up to 3 times.
74
- 4. Return ONLY the final computed value. No code in the final answer, no
75
- explanation, no units unless asked."""
76
-
77
-
78
  MEDIA_ANALYSIS_PROMPT = """You are a media analysis specialist (image, audio, video).
79
 
80
  You handle questions that reference a task_id, URL, or file path pointing to
@@ -111,21 +80,6 @@ Never explain, never apologize, never quote the answer.
111
  Output ONLY the normalized answer, on a single line, nothing else."""
112
 
113
 
114
- class WebResearchAgent:
115
- def __init__(self):
116
- from tools import fetch_url, search_tool # lazy: avoids circular import
117
-
118
- self.graph = create_agent(
119
- _make_llm(model=_ROLE_MODELS["web"]),
120
- tools=[search_tool, fetch_url],
121
- system_prompt=WEB_RESEARCH_PROMPT,
122
- )
123
-
124
- def __call__(self, question: str) -> str:
125
- result = self.graph.invoke({"messages": [("user", question)]})
126
- return result["messages"][-1].content
127
-
128
-
129
  class FileAnalysisAgent:
130
  def __init__(self):
131
  from tools import (
@@ -143,7 +97,10 @@ class FileAnalysisAgent:
143
  )
144
 
145
  def __call__(self, question: str) -> str:
146
- result = self.graph.invoke({"messages": [("user", question)]})
 
 
 
147
  return result["messages"][-1].content
148
 
149
 
@@ -170,22 +127,10 @@ class MediaAgent:
170
  )
171
 
172
  def __call__(self, question: str) -> str:
173
- result = self.graph.invoke({"messages": [("user", question)]})
174
- return result["messages"][-1].content
175
-
176
-
177
- class CodeAgent:
178
- def __init__(self):
179
- from tools import calculator, python_exec # lazy: avoids circular import
180
-
181
- self.graph = create_agent(
182
- _make_llm(model=_ROLE_MODELS["code"]),
183
- tools=[python_exec, calculator],
184
- system_prompt=CODE_AGENT_PROMPT,
185
  )
186
-
187
- def __call__(self, question: str) -> str:
188
- result = self.graph.invoke({"messages": [("user", question)]})
189
  return result["messages"][-1].content
190
 
191
 
 
10
  model=model,
11
  temperature=temperature,
12
  api_key=os.getenv("GROQ_API_KEY"),
13
+ timeout=60,
14
+ max_retries=2,
15
  )
16
 
17
 
 
21
  # curl -s https://api.groq.com/openai/v1/models \
22
  # -H "Authorization: Bearer $GROQ_API_KEY" | jq '.data[].id'
23
  _ROLE_MODELS = {
24
+ "file": os.getenv("GROQ_FILE_MODEL", "openai/gpt-oss-20b"),
 
25
  "media": os.getenv("GROQ_MEDIA_MODEL", "openai/gpt-oss-120b"),
26
+ "normalizer": os.getenv("GROQ_NORMALIZER_MODEL", "openai/gpt-oss-20b"),
 
27
  }
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  FILE_ANALYSIS_PROMPT = """You are a document analysis specialist.
31
 
32
  You handle questions that reference a task_id, URL, or file path pointing to
 
44
  verbatim when possible. Do not speculate."""
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  MEDIA_ANALYSIS_PROMPT = """You are a media analysis specialist (image, audio, video).
48
 
49
  You handle questions that reference a task_id, URL, or file path pointing to
 
80
  Output ONLY the normalized answer, on a single line, nothing else."""
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  class FileAnalysisAgent:
84
  def __init__(self):
85
  from tools import (
 
97
  )
98
 
99
  def __call__(self, question: str) -> str:
100
+ result = self.graph.invoke(
101
+ {"messages": [("user", question)]},
102
+ config={"recursion_limit": 8},
103
+ )
104
  return result["messages"][-1].content
105
 
106
 
 
127
  )
128
 
129
  def __call__(self, question: str) -> str:
130
+ result = self.graph.invoke(
131
+ {"messages": [("user", question)]},
132
+ config={"recursion_limit": 8},
 
 
 
 
 
 
 
 
 
133
  )
 
 
 
134
  return result["messages"][-1].content
135
 
136
 
app.py CHANGED
@@ -17,23 +17,22 @@ from langgraph.prebuilt import ToolNode
17
 
18
  MANAGER_SYSTEM_PROMPT = """You are a QA agent for the GAIA benchmark.
19
 
20
- You have four specialist tools:
21
- - web_research(question): factual / current world knowledge (people, dates,
22
- events, statistics).
23
  - file_analysis(question): question about an attached document
24
  (pdf / xlsx / csv / txt / json). Include the task_id or URL in your question.
25
  - media_analysis(question): question about an image, audio, or video (also
26
  YouTube URLs). Include the task_id or URL in your question.
27
- - compute(question): math, arithmetic, list ops, date math, string parsing,
28
- unit conversions. State the computation in plain language with the inputs.
29
 
30
  Rules:
31
- 1. When a task has an attached file (mentioned by task_id), route to
32
- file_analysis or media_analysis with the task_id embedded in the question.
33
- 2. Ask each specialist ONE precise question at a time.
34
- 3. Chain tools when needed (e.g. web_research to find numbers, then
35
- compute to combine them).
36
- 4. Final answer: give ONLY the value the question asks for. No preamble,
37
  no explanation."""
38
 
39
  # (Keep Constants as is)
@@ -62,27 +61,29 @@ class BasicAgent:
62
  # Requires GROQ_API_KEY in the environment (set as a Space secret on HF).
63
  self.tools = ALL_TOOLS
64
  self.chat = ChatGroq(
65
- model="openai/gpt-oss-120b",
66
  temperature=0.1,
67
  api_key=os.getenv("GROQ_API_KEY"),
 
 
68
  )
69
  self.chat = self.chat.bind_tools(self.tools)
70
  self.agent = self.__init__agent__()
71
  self.normalizer = AnswerNormalizer()
72
 
73
- def __call__(self, question: str) -> str:
74
- return self.answer(question)
75
 
76
  def __init__agent__(self):
77
  builder = StateGraph(AgentState)
78
- builder.add_node("assistant", self.assitant)
79
  builder.add_node("tools", ToolNode(self.tools))
80
  builder.add_edge(START, "assistant")
81
  builder.add_conditional_edges("assistant", tools_condition)
82
  builder.add_edge("tools", "assistant")
83
  return builder.compile()
84
 
85
- def assitant(self, state: AgentState):
86
  messages = state["messages"]
87
  if not messages or not isinstance(messages[0], SystemMessage):
88
  messages = [SystemMessage(content=MANAGER_SYSTEM_PROMPT)] + list(messages)
@@ -90,9 +91,18 @@ class BasicAgent:
90
  "messages": [self.chat.invoke(messages)],
91
  }
92
 
93
- def answer(self, question):
94
- messages = [HumanMessage(content=question)]
95
- response = self.agent.invoke({"messages": messages})
 
 
 
 
 
 
 
 
 
96
  raw = response["messages"][-1].content
97
  return self.normalizer(question, raw)
98
 
@@ -158,7 +168,7 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
158
  print(f"Skipping item with missing task_id or question: {item}")
159
  continue
160
  try:
161
- submitted_answer = agent(question_text)
162
  answers_payload.append(
163
  {"task_id": task_id, "submitted_answer": submitted_answer}
164
  )
 
17
 
18
  MANAGER_SYSTEM_PROMPT = """You are a QA agent for the GAIA benchmark.
19
 
20
+ You have five tools:
21
+ - web_research(question): one concise web search (maximum 25 words) returning
22
+ a short summary and three source excerpts.
23
  - file_analysis(question): question about an attached document
24
  (pdf / xlsx / csv / txt / json). Include the task_id or URL in your question.
25
  - media_analysis(question): question about an image, audio, or video (also
26
  YouTube URLs). Include the task_id or URL in your question.
27
+ - calculator(expression): one arithmetic expression.
28
+ - python_exec(code): short Python computation; print the result.
29
 
30
  Rules:
31
+ 1. Use the task_id only when the question refers to an attached file or media.
32
+ 2. For web research, search for one fact at a time. Never paste a long question
33
+ or previous search results into a new search query.
34
+ 3. Use no more than three tool rounds. Chain tools only when required.
35
+ 4. Final answer: give ONLY the requested value. No preamble,
 
36
  no explanation."""
37
 
38
  # (Keep Constants as is)
 
61
  # Requires GROQ_API_KEY in the environment (set as a Space secret on HF).
62
  self.tools = ALL_TOOLS
63
  self.chat = ChatGroq(
64
+ model=os.getenv("GROQ_MANAGER_MODEL", "openai/gpt-oss-120b"),
65
  temperature=0.1,
66
  api_key=os.getenv("GROQ_API_KEY"),
67
+ timeout=60,
68
+ max_retries=2,
69
  )
70
  self.chat = self.chat.bind_tools(self.tools)
71
  self.agent = self.__init__agent__()
72
  self.normalizer = AnswerNormalizer()
73
 
74
+ def __call__(self, question: str, task_id: str | None = None) -> str:
75
+ return self.answer(question, task_id=task_id)
76
 
77
  def __init__agent__(self):
78
  builder = StateGraph(AgentState)
79
+ builder.add_node("assistant", self.assistant)
80
  builder.add_node("tools", ToolNode(self.tools))
81
  builder.add_edge(START, "assistant")
82
  builder.add_conditional_edges("assistant", tools_condition)
83
  builder.add_edge("tools", "assistant")
84
  return builder.compile()
85
 
86
+ def assistant(self, state: AgentState):
87
  messages = state["messages"]
88
  if not messages or not isinstance(messages[0], SystemMessage):
89
  messages = [SystemMessage(content=MANAGER_SYSTEM_PROMPT)] + list(messages)
 
91
  "messages": [self.chat.invoke(messages)],
92
  }
93
 
94
+ def answer(self, question: str, task_id: str | None = None) -> str:
95
+ user_content = f"QUESTION:\n{question}"
96
+ if task_id:
97
+ user_content += (
98
+ f"\n\nTASK_ID: {task_id}\n"
99
+ "Use this id only if the question requires an attached file."
100
+ )
101
+ messages = [HumanMessage(content=user_content)]
102
+ response = self.agent.invoke(
103
+ {"messages": messages},
104
+ config={"recursion_limit": 8},
105
+ )
106
  raw = response["messages"][-1].content
107
  return self.normalizer(question, raw)
108
 
 
168
  print(f"Skipping item with missing task_id or question: {item}")
169
  continue
170
  try:
171
+ submitted_answer = agent(question_text, task_id=task_id)
172
  answers_payload.append(
173
  {"task_id": task_id, "submitted_answer": submitted_answer}
174
  )
test_web_search.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline regression tests for the bounded web-search path."""
2
+
3
+ import os
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ import tools
8
+
9
+
10
+ class FakeTavilyClient:
11
+ def __init__(self, response):
12
+ self.response = response
13
+ self.calls = []
14
+
15
+ def search(self, **kwargs):
16
+ self.calls.append(kwargs)
17
+ return self.response
18
+
19
+
20
+ class WebSearchTests(unittest.TestCase):
21
+ def tearDown(self):
22
+ tools._tavily_client = None
23
+
24
+ def test_query_is_normalized_and_capped(self):
25
+ self.assertEqual(tools._compact_query(" a short\nquery "), "a short query")
26
+
27
+ long_query = "word " * 200
28
+ compact = tools._compact_query(long_query)
29
+ self.assertLessEqual(len(compact), tools.WEB_QUERY_MAX_CHARS)
30
+ self.assertFalse(compact.endswith(" "))
31
+
32
+ def test_result_payload_has_hard_limits(self):
33
+ response = {
34
+ "answer": "a" * 3_000,
35
+ "results": [
36
+ {
37
+ "title": f"Result {index}",
38
+ "url": f"https://example.com/{index}",
39
+ "content": "x" * 4_000,
40
+ }
41
+ for index in range(8)
42
+ ],
43
+ }
44
+
45
+ output = tools._format_search_results(response)
46
+
47
+ self.assertLessEqual(len(output), tools.WEB_OUTPUT_MAX_CHARS)
48
+ self.assertIn("Source 3", output)
49
+ self.assertNotIn("Source 4", output)
50
+ self.assertIn("[truncated]", output)
51
+
52
+ def test_web_research_is_one_direct_tavily_call(self):
53
+ fake = FakeTavilyClient(
54
+ {
55
+ "answer": "Paris",
56
+ "results": [
57
+ {
58
+ "title": "France",
59
+ "url": "https://example.com/france",
60
+ "content": "Paris is the capital of France.",
61
+ }
62
+ ],
63
+ }
64
+ )
65
+ tools._tavily_client = fake
66
+
67
+ output = tools.web_research.invoke(
68
+ {"question": " capital of France "}
69
+ )
70
+
71
+ self.assertIn("Paris", output)
72
+ self.assertEqual(len(fake.calls), 1)
73
+ self.assertEqual(fake.calls[0]["query"], "capital of France")
74
+ self.assertEqual(fake.calls[0]["max_results"], tools.WEB_MAX_RESULTS)
75
+ self.assertTrue(fake.calls[0]["include_answer"])
76
+ self.assertFalse(fake.calls[0]["include_raw_content"])
77
+ self.assertFalse(fake.calls[0]["include_images"])
78
+
79
+ def test_missing_api_key_fails_only_when_search_is_used(self):
80
+ tools._tavily_client = None
81
+ with patch.dict(os.environ, {}, clear=True):
82
+ with self.assertRaisesRegex(RuntimeError, "TAVILY_API_KEY"):
83
+ tools._get_tavily_client()
84
+
85
+ def test_manager_exposes_only_five_bounded_tools(self):
86
+ self.assertEqual(
87
+ [tool.name for tool in tools.ALL_TOOLS],
88
+ [
89
+ "web_research",
90
+ "file_analysis",
91
+ "media_analysis",
92
+ "python_exec",
93
+ "calculator",
94
+ ],
95
+ )
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()
tools.py CHANGED
@@ -11,13 +11,76 @@ import requests
11
  from langchain_core.messages import HumanMessage
12
  from langchain_core.tools import tool
13
  from langchain_groq import ChatGroq
14
- from tavily import TavilyClient
15
 
16
- from agents import CodeAgent, FileAnalysisAgent, MediaAgent, WebResearchAgent
17
 
18
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
19
 
20
- tavily_client = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  @tool
@@ -28,16 +91,16 @@ def search_tool(query: str) -> str:
28
  Args:
29
  query: Search query.
30
  """
31
- results = tavily_client.search(query=query, max_results=5, search_depth="basic")
32
-
33
- output = []
34
- for r in results["results"]:
35
- output.append(f"""
36
- Title: {r['title']}
37
- URL: {r['url']}
38
- Content: {r['content']}
39
- """)
40
- return "\n".join(output)
41
 
42
 
43
  @tool
@@ -391,25 +454,36 @@ def youtube_transcript(url_or_id: str) -> str:
391
  return " ".join(e["text"] for e in entries)
392
 
393
 
394
- _web_agent = WebResearchAgent()
395
- _file_agent = FileAnalysisAgent()
396
- _media_agent = MediaAgent()
397
- _code_agent = CodeAgent()
 
 
 
 
 
 
 
 
 
 
 
 
398
 
399
 
400
  @tool
401
  def web_research(question: str) -> str:
402
- """Answer a factual question using web search and page reading.
403
 
404
- Delegates to a specialist that searches (Tavily), visits pages when
405
- snippets are insufficient, and cross-checks. Use for anything requiring
406
- up-to-date or external world knowledge: people, dates, events, statistics,
407
- biographies, current affairs.
408
 
409
  Args:
410
- question: Natural-language research question.
411
  """
412
- return _web_agent(question)
413
 
414
 
415
  @tool
@@ -425,7 +499,7 @@ def file_analysis(question: str) -> str:
425
  Args:
426
  question: Natural-language question mentioning the task_id / URL / path.
427
  """
428
- return _file_agent(question)
429
 
430
 
431
  @tool
@@ -442,27 +516,13 @@ def media_analysis(question: str) -> str:
442
  Args:
443
  question: Natural-language question mentioning the task_id / URL / path.
444
  """
445
- return _media_agent(question)
446
-
447
-
448
- @tool
449
- def compute(question: str) -> str:
450
- """Delegate math, arithmetic, data manipulation, or string parsing to a code specialist.
451
-
452
- The specialist writes Python, runs it, iterates on errors, and returns the
453
- computed value. Use for anything better *computed* than reasoned about:
454
- sums / averages / dates / list ops / unit conversions / regex extraction.
455
-
456
- Args:
457
- question: What to compute, plainly stated. Include any input data or
458
- values needed to perform the computation.
459
- """
460
- return _code_agent(question)
461
 
462
 
463
  ALL_TOOLS = [
464
  web_research,
465
  file_analysis,
466
  media_analysis,
467
- compute,
 
468
  ]
 
11
  from langchain_core.messages import HumanMessage
12
  from langchain_core.tools import tool
13
  from langchain_groq import ChatGroq
 
14
 
15
+ from agents import FileAnalysisAgent, MediaAgent
16
 
17
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
18
 
19
+ WEB_QUERY_MAX_CHARS = 400
20
+ WEB_RESULT_MAX_CHARS = 1_000
21
+ WEB_ANSWER_MAX_CHARS = 1_200
22
+ WEB_OUTPUT_MAX_CHARS = 5_000
23
+ WEB_MAX_RESULTS = 3
24
+
25
+ _tavily_client = None
26
+
27
+
28
+ def _truncate(text: str, max_chars: int) -> str:
29
+ """Keep tool payloads bounded while making truncation visible."""
30
+ text = str(text or "").strip()
31
+ if len(text) <= max_chars:
32
+ return text
33
+ marker = "\n[truncated]"
34
+ if max_chars <= len(marker):
35
+ return text[:max_chars]
36
+ return f"{text[: max_chars - len(marker)].rstrip()}{marker}"
37
+
38
+
39
+ def _compact_query(query: str) -> str:
40
+ """Normalize and cap a natural-language query before sending it to Tavily."""
41
+ compact = " ".join(str(query or "").split())
42
+ if not compact:
43
+ raise ValueError("The web search query is empty.")
44
+ if len(compact) <= WEB_QUERY_MAX_CHARS:
45
+ return compact
46
+
47
+ shortened = compact[:WEB_QUERY_MAX_CHARS]
48
+ if " " in shortened:
49
+ shortened = shortened.rsplit(" ", 1)[0]
50
+ return shortened
51
+
52
+
53
+ def _get_tavily_client():
54
+ """Create the client only when web search is actually requested."""
55
+ global _tavily_client
56
+ if _tavily_client is None:
57
+ api_key = os.getenv("TAVILY_API_KEY")
58
+ if not api_key:
59
+ raise RuntimeError("TAVILY_API_KEY is not configured.")
60
+ from tavily import TavilyClient
61
+
62
+ _tavily_client = TavilyClient(api_key=api_key)
63
+ return _tavily_client
64
+
65
+
66
+ def _format_search_results(response: dict) -> str:
67
+ """Return a small, predictable search payload for the manager model."""
68
+ blocks = []
69
+ answer = response.get("answer")
70
+ if answer:
71
+ blocks.append(f"Search summary: {_truncate(answer, WEB_ANSWER_MAX_CHARS)}")
72
+
73
+ for index, result in enumerate(response.get("results", [])[:WEB_MAX_RESULTS], 1):
74
+ title = _truncate(result.get("title", "Untitled"), 200)
75
+ url = str(result.get("url", "")).strip()
76
+ content = _truncate(result.get("content", ""), WEB_RESULT_MAX_CHARS)
77
+ blocks.append(
78
+ f"Source {index}\nTitle: {title}\nURL: {url}\nExcerpt: {content}"
79
+ )
80
+
81
+ if not blocks:
82
+ return "No web results found."
83
+ return _truncate("\n\n".join(blocks), WEB_OUTPUT_MAX_CHARS)
84
 
85
 
86
  @tool
 
91
  Args:
92
  query: Search query.
93
  """
94
+ compact_query = _compact_query(query)
95
+ response = _get_tavily_client().search(
96
+ query=compact_query,
97
+ max_results=WEB_MAX_RESULTS,
98
+ search_depth="basic",
99
+ include_answer=True,
100
+ include_raw_content=False,
101
+ include_images=False,
102
+ )
103
+ return _format_search_results(response)
104
 
105
 
106
  @tool
 
454
  return " ".join(e["text"] for e in entries)
455
 
456
 
457
+ _file_agent = None
458
+ _media_agent = None
459
+
460
+
461
+ def _get_file_agent() -> FileAnalysisAgent:
462
+ global _file_agent
463
+ if _file_agent is None:
464
+ _file_agent = FileAnalysisAgent()
465
+ return _file_agent
466
+
467
+
468
+ def _get_media_agent() -> MediaAgent:
469
+ global _media_agent
470
+ if _media_agent is None:
471
+ _media_agent = MediaAgent()
472
+ return _media_agent
473
 
474
 
475
  @tool
476
  def web_research(question: str) -> str:
477
+ """Search the web and return a compact summary with source excerpts.
478
 
479
+ This is deliberately a direct, bounded Tavily call rather than a nested
480
+ agent. Use a short query about one fact at a time. The query is capped at
481
+ 400 characters and the result payload at 5,000 characters.
 
482
 
483
  Args:
484
+ question: Concise natural-language search query.
485
  """
486
+ return search_tool.invoke({"query": question})
487
 
488
 
489
  @tool
 
499
  Args:
500
  question: Natural-language question mentioning the task_id / URL / path.
501
  """
502
+ return _get_file_agent()(question)
503
 
504
 
505
  @tool
 
516
  Args:
517
  question: Natural-language question mentioning the task_id / URL / path.
518
  """
519
+ return _get_media_agent()(question)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
 
521
 
522
  ALL_TOOLS = [
523
  web_research,
524
  file_analysis,
525
  media_analysis,
526
+ python_exec,
527
+ calculator,
528
  ]