grevy Claude Opus 4.7 commited on
Commit
03da63b
·
1 Parent(s): 7dfdca0

Simplify ResearchAgent + switch search backend to Tavily

Browse files

- Replace hand-rolled LangGraph state machine in agents.py with
langchain.agents.create_agent (system prompt + tool loop).
- Swap search_tool from DuckDuckGo to Tavily in tools.py.
- Requirements: add spaces (HF ZeroGPU), tavily-python, langchain;
drop ddgs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ryPUj8AtsFopnufGRSc4L

Files changed (3) hide show
  1. agents.py +21 -211
  2. requirements.txt +3 -1
  3. tools.py +28 -2
agents.py CHANGED
@@ -1,230 +1,40 @@
1
  import os
2
- import re
3
- from typing import TypedDict, Annotated, Literal
4
 
5
- from pydantic import BaseModel, Field
6
  from langchain_groq import ChatGroq
7
- from langgraph.graph.message import add_messages
8
- from langgraph.graph import StateGraph, END
9
- from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage
10
 
11
 
12
- class ResearchAgentState(TypedDict):
13
- question: str
14
- sub_questions: list[str]
15
- research_findings: dict[str, str]
16
- research_rounds: int
17
- final_answer: str | None
18
- evaluation: str | None
19
- refinement_count: int
20
- messages: Annotated[list[AnyMessage], add_messages]
21
 
22
-
23
- class _Evaluation(BaseModel):
24
- label: Literal["excellent", "adequate", "needs_improvement"] = Field(
25
- description="Overall quality of the answer."
26
- )
 
 
 
 
27
 
28
 
29
  class ResearchAgent:
30
- MAX_RESEARCH_ROUNDS = 5
31
- MAX_REFINEMENTS = 2
32
- MAX_SNIPPET_CHARS = 3000
33
-
34
  def __init__(self):
35
- self.chat = ChatGroq(
36
  model="openai/gpt-oss-120b",
37
  temperature=0.1,
38
  api_key=os.getenv("GROQ_API_KEY"),
39
  )
40
- self.graph = self._build_graph()
41
-
42
- def __call__(self, question: str) -> str:
43
- return self.answer(question)
44
-
45
- def answer(self, question: str) -> str:
46
- initial_state: ResearchAgentState = {
47
- "question": question,
48
- "sub_questions": [],
49
- "research_findings": {},
50
- "research_rounds": 0,
51
- "final_answer": None,
52
- "evaluation": None,
53
- "refinement_count": 0,
54
- "messages": [HumanMessage(content=question)],
55
- }
56
- final_state = self.graph.invoke(initial_state)
57
- return final_state["final_answer"] or "(no answer produced)"
58
-
59
- def _build_graph(self):
60
- builder = StateGraph(ResearchAgentState)
61
- builder.add_node("breakdown", self.breakdown_question)
62
- builder.add_node("research", self.research_with_search)
63
- builder.add_node("synthesize", self.synthesize_findings)
64
- builder.add_node("evaluate", self.evaluate_answer)
65
- builder.add_node("refine", self.refine_answer)
66
-
67
- builder.set_entry_point("breakdown")
68
- builder.add_edge("breakdown", "research")
69
- builder.add_conditional_edges(
70
- "research",
71
- self.select_next_step,
72
- {"research": "research", "synthesize": "synthesize"},
73
- )
74
- builder.add_edge("synthesize", "evaluate")
75
- builder.add_conditional_edges(
76
- "evaluate",
77
- self.needs_refinement,
78
- {"refine": "refine", "complete": END},
79
- )
80
- builder.add_edge("refine", "evaluate")
81
-
82
- return builder.compile()
83
-
84
- def parse_sub_questions(self, text: str) -> list[str]:
85
- lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
86
- cleaned: list[str] = []
87
- for ln in lines:
88
- stripped = re.sub(r"^(\d+[\.\)]\s+|[-*]\s+)", "", ln)
89
- if stripped:
90
- cleaned.append(stripped)
91
- seen: set[str] = set()
92
- result: list[str] = []
93
- for q in cleaned:
94
- if q not in seen:
95
- seen.add(q)
96
- result.append(q)
97
- return result[:5]
98
-
99
- def pick_next_unanswered(
100
- self, sub_questions: list[str], findings: dict[str, str]
101
- ) -> str | None:
102
- for q in sub_questions:
103
- if q not in findings:
104
- return q
105
- return None
106
-
107
- def breakdown_question(self, state: ResearchAgentState) -> dict:
108
- print("[Node] breakdown_question")
109
- response = self.chat.invoke([
110
- SystemMessage(
111
- content="Break the main question into 3-5 concise, standalone sub-questions. "
112
- "Return each on a new line without bullets or numbers."
113
- ),
114
- HumanMessage(content=state["question"]),
115
- ])
116
- sub_questions = self.parse_sub_questions(response.content)
117
- if not sub_questions:
118
- sub_questions = [state["question"]]
119
- return {
120
- "sub_questions": sub_questions,
121
- "research_findings": {},
122
- "research_rounds": 0,
123
- "refinement_count": 0,
124
- }
125
-
126
- def research_with_search(self, state: ResearchAgentState) -> dict:
127
- print("[Node] research_with_search")
128
- next_q = self.pick_next_unanswered(
129
- state["sub_questions"], state["research_findings"]
130
- )
131
- if next_q is None or state["research_rounds"] >= self.MAX_RESEARCH_ROUNDS:
132
- return {}
133
-
134
  from tools import search_tool # lazy: avoids circular import with tools.py
135
 
136
- try:
137
- search_results = search_tool.invoke(next_q)
138
- except Exception as e:
139
- search_results = f"(Search error) {e}"
140
-
141
- response = self.chat.invoke([
142
- SystemMessage(
143
- content="Answer the sub-question using the provided web search snippets. "
144
- "Be concise, cite key points, and avoid speculation."
145
- ),
146
- HumanMessage(
147
- content=(
148
- f"Sub-question: {next_q}\n\n"
149
- f"Search snippets:\n{search_results[: self.MAX_SNIPPET_CHARS]}"
150
- )
151
- ),
152
- ])
153
-
154
- findings = {**state["research_findings"], next_q: response.content}
155
- return {
156
- "research_findings": findings,
157
- "research_rounds": state["research_rounds"] + 1,
158
- }
159
-
160
- def select_next_step(self, state: ResearchAgentState) -> str:
161
- remaining = [
162
- q for q in state["sub_questions"] if q not in state["research_findings"]
163
- ]
164
- if remaining and state["research_rounds"] < self.MAX_RESEARCH_ROUNDS:
165
- return "research"
166
- return "synthesize"
167
-
168
- def synthesize_findings(self, state: ResearchAgentState) -> dict:
169
- print("[Node] synthesize_findings")
170
- findings_text = "\n\n".join(
171
- f"{q}:\n{a}" for q, a in state["research_findings"].items()
172
  )
173
- response = self.chat.invoke([
174
- SystemMessage(
175
- content="Synthesize a clear, structured answer to the main question "
176
- "using the findings provided. Be balanced, specific, and concise."
177
- ),
178
- HumanMessage(
179
- content=(
180
- f"Main question:\n{state['question']}\n\n"
181
- f"Findings:\n{findings_text or '(No findings gathered)'}"
182
- )
183
- ),
184
- ])
185
- return {"final_answer": response.content}
186
-
187
- def evaluate_answer(self, state: ResearchAgentState) -> dict:
188
- print("[Node] evaluate_answer")
189
- structured = self.chat.with_structured_output(_Evaluation)
190
- try:
191
- result: _Evaluation = structured.invoke([
192
- SystemMessage(
193
- content="Evaluate the answer for correctness, completeness and clarity."
194
- ),
195
- HumanMessage(
196
- content=f"Question:\n{state['question']}\n\nAnswer:\n{state.get('final_answer') or '(empty)'}"
197
- ),
198
- ])
199
- label = result.label
200
- except Exception as e:
201
- print(f" [warn] structured eval failed: {e}; defaulting to adequate")
202
- label = "adequate"
203
- return {"evaluation": label}
204
 
205
- def needs_refinement(self, state: ResearchAgentState) -> str:
206
- if state["refinement_count"] >= self.MAX_REFINEMENTS:
207
- return "complete"
208
- if state["evaluation"] in ("excellent", "adequate"):
209
- return "complete"
210
- return "refine"
211
 
212
- def refine_answer(self, state: ResearchAgentState) -> dict:
213
- print("[Node] refine_answer")
214
- response = self.chat.invoke([
215
- SystemMessage(
216
- content="Refine the answer to address weaknesses and improve clarity, "
217
- "structure, and specificity."
218
- ),
219
- HumanMessage(
220
- content=(
221
- f"Main question:\n{state['question']}\n\n"
222
- f"Current answer:\n{state.get('final_answer') or '(empty)'}\n\n"
223
- f"Evaluation: {state.get('evaluation')}"
224
- )
225
- ),
226
- ])
227
- return {
228
- "final_answer": response.content,
229
- "refinement_count": state["refinement_count"] + 1,
230
- }
 
1
  import os
 
 
2
 
 
3
  from langchain_groq import ChatGroq
4
+ from langchain.agents import create_agent
 
 
5
 
6
 
7
+ RESEARCH_SYSTEM_PROMPT = """You are a research specialist.
 
 
 
 
 
 
 
 
8
 
9
+ For each question:
10
+ 1. Mentally break it into sub-questions if it has multiple parts.
11
+ 2. Use the search tool to find sources. When the fact is not obvious,
12
+ cross-check across at least 2 independent sources before concluding.
13
+ 3. Return a concise, factual answer. If the question asks for a specific
14
+ value (number, name, date, list), give ONLY that value in the final
15
+ answer. Otherwise, keep the answer under 3 sentences.
16
+ 4. Never guess. If you cannot verify, say "unknown".
17
+ """
18
 
19
 
20
  class ResearchAgent:
 
 
 
 
21
  def __init__(self):
22
+ llm = ChatGroq(
23
  model="openai/gpt-oss-120b",
24
  temperature=0.1,
25
  api_key=os.getenv("GROQ_API_KEY"),
26
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  from tools import search_tool # lazy: avoids circular import with tools.py
28
 
29
+ self.graph = create_agent(
30
+ llm,
31
+ tools=[search_tool],
32
+ system_prompt=RESEARCH_SYSTEM_PROMPT,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
+ def __call__(self, question: str) -> str:
36
+ return self.answer(question)
 
 
 
 
37
 
38
+ def answer(self, question: str) -> str:
39
+ result = self.graph.invoke({"messages": [("user", question)]})
40
+ return result["messages"][-1].content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,8 +1,10 @@
1
  gradio[oauth]
2
  requests
3
  pandas
 
 
4
  langgraph
 
5
  langchain-core
6
  langchain-groq
7
  langchain-community
8
- ddgs
 
1
  gradio[oauth]
2
  requests
3
  pandas
4
+ spaces
5
+ tavily-python
6
  langgraph
7
+ langchain
8
  langchain-core
9
  langchain-groq
10
  langchain-community
 
tools.py CHANGED
@@ -1,9 +1,35 @@
1
- from langchain_community.tools import DuckDuckGoSearchRun
 
2
  from langchain_core.tools import tool
3
 
4
  from agents import ResearchAgent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- search_tool = DuckDuckGoSearchRun()
7
  research_agent = ResearchAgent()
8
 
9
 
 
1
+ import os
2
+
3
  from langchain_core.tools import tool
4
 
5
  from agents import ResearchAgent
6
+ from tavily import TavilyClient
7
+
8
+ client = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
9
+
10
+
11
+ @tool
12
+ def search_tool(query: str) -> str:
13
+ """
14
+ Search the web for current information.
15
+
16
+ Args:
17
+ query: Search query.
18
+ """
19
+ results = client.search(query=query, max_results=5, search_depth="basic")
20
+
21
+ output = []
22
+
23
+ for r in results["results"]:
24
+ output.append(f"""
25
+ Title: {r['title']}
26
+ URL: {r['url']}
27
+ Content: {r['content']}
28
+ """)
29
+
30
+ return "\n".join(output)
31
+
32
 
 
33
  research_agent = ResearchAgent()
34
 
35