grevy Claude Opus 4.7 commited on
Commit
123d40d
·
1 Parent(s): d0ee4f8

Add ResearchAgent subagent + wire it as a tool

Browse files

- New agents.py: ResearchAgent with breakdown → research → synthesize →
evaluate → refine loop (uses ChatGroq + DuckDuckGo)
- tools.py: expose the subagent as @tool `research` alongside `search_tool`
- requirements.txt: restore langchain-community and ddgs for DuckDuckGo

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

Files changed (4) hide show
  1. agents.py +230 -0
  2. app.py +4 -3
  3. requirements.txt +1 -1
  4. tools.py +19 -0
agents.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
app.py CHANGED
@@ -1,7 +1,7 @@
1
  import os
2
  import gradio as gr
3
  import requests
4
- import inspect
5
  import pandas as pd
6
  import spaces
7
  from langchain_groq import ChatGroq
@@ -28,6 +28,7 @@ def _zerogpu_stub():
28
 
29
 
30
  class AgentState(TypedDict):
 
31
  messages: Annotated[list[AnyMessage], add_messages]
32
 
33
 
@@ -43,12 +44,12 @@ class BasicAgent:
43
  api_key=os.getenv("GROQ_API_KEY"),
44
  )
45
  self.chat = self.chat.bind_tools(self.tools)
46
- self.agent = self.__init__agent()
47
 
48
  def __call__(self, question: str) -> str:
49
  return self.answer(question)
50
 
51
- def __init__agent(self):
52
  builder = StateGraph(AgentState)
53
  builder.add_node("assistant", self.assitant)
54
  builder.add_node("tools", ToolNode(self.tools))
 
1
  import os
2
  import gradio as gr
3
  import requests
4
+
5
  import pandas as pd
6
  import spaces
7
  from langchain_groq import ChatGroq
 
28
 
29
 
30
  class AgentState(TypedDict):
31
+
32
  messages: Annotated[list[AnyMessage], add_messages]
33
 
34
 
 
44
  api_key=os.getenv("GROQ_API_KEY"),
45
  )
46
  self.chat = self.chat.bind_tools(self.tools)
47
+ self.agent = self.__init__agent__()
48
 
49
  def __call__(self, question: str) -> str:
50
  return self.answer(question)
51
 
52
+ def __init__agent__(self):
53
  builder = StateGraph(AgentState)
54
  builder.add_node("assistant", self.assitant)
55
  builder.add_node("tools", ToolNode(self.tools))
requirements.txt CHANGED
@@ -3,6 +3,6 @@ requests
3
  pandas
4
  langgraph
5
  langchain-core
6
- langchain-community
7
  langchain-groq
 
8
  ddgs
 
3
  pandas
4
  langgraph
5
  langchain-core
 
6
  langchain-groq
7
+ langchain-community
8
  ddgs
tools.py CHANGED
@@ -1,3 +1,22 @@
1
  from langchain_community.tools import DuckDuckGoSearchRun
 
 
 
2
 
3
  search_tool = DuckDuckGoSearchRun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
10
+ @tool
11
+ def research(request: str) -> str:
12
+ """Answer factual questions using web research.
13
+
14
+ Use this when the user asks for information that requires up-to-date or
15
+ external knowledge — names, dates, events, statistics, biographies, or
16
+ anything that depends on the current state of the world. Handles query
17
+ decomposition, web search, cross-checking and synthesis.
18
+
19
+ Input: Natural language research question (e.g., 'who won the 2024 Nobel
20
+ Prize in Physics?')
21
+ """
22
+ return research_agent(request)