techresearchspace commited on
Commit
af2544e
Β·
verified Β·
1 Parent(s): f525c57

Update agents.py

Browse files
Files changed (1) hide show
  1. agents.py +121 -37
agents.py CHANGED
@@ -14,9 +14,11 @@ import sys
14
  import tempfile
15
  from typing import Annotated, Literal, TypedDict
16
 
17
- from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage
 
 
 
18
  from langchain_core.tools import tool
19
- from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
20
  from langchain_tavily import TavilySearch
21
  from langgraph.graph import StateGraph, END
22
  from langgraph.graph.message import add_messages
@@ -28,16 +30,44 @@ MAX_TURNS = 6
28
 
29
 
30
  # ---------------------------------------------------------------------------
31
- # Model
32
  # ---------------------------------------------------------------------------
33
- def get_llm():
34
- endpoint = HuggingFaceEndpoint(
35
- repo_id="Qwen/Qwen2.5-72B-Instruct",
36
- huggingfacehub_api_token=os.environ.get("HF_TOKEN"),
37
- max_new_tokens=1024,
38
- temperature=0.2,
39
- )
40
- return ChatHuggingFace(llm=endpoint)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
43
  # ---------------------------------------------------------------------------
@@ -58,14 +88,32 @@ class AgentState(TypedDict):
58
  # Requires TAVILY_API_KEY as a Space secret. Tavily is built for LLM agents
59
  # (returns clean, citeable snippets rather than raw HTML), which is why it's
60
  # preferred here over scraping a generic search engine yourself.
61
- _tavily = TavilySearch(max_results=5)
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
 
64
  @tool
65
  def web_search(query: str) -> str:
66
  """Search the web for a query and return a short summary of results."""
67
  try:
68
- results = _tavily.invoke({"query": query})
 
 
 
 
 
69
  except Exception as e:
70
  return f"Search failed: {e}"
71
 
@@ -177,8 +225,8 @@ Choose "finish" once you have enough information to answer the user directly.
177
  def manager_node(state: AgentState):
178
  turn_count = state.get("turn_count", 0) + 1
179
 
180
- # Hard stop: don't even ask the LLM once we've hit the cap β€” force finish
181
- # so a manager stuck in a routing loop can't burn turns indefinitely.
182
  if turn_count > MAX_TURNS:
183
  return {
184
  "next": "finish",
@@ -186,23 +234,22 @@ def manager_node(state: AgentState):
186
  "messages": [SystemMessage(content=f"Max turns ({MAX_TURNS}) reached, finalizing with available results.")],
187
  }
188
 
189
- llm = get_llm()
190
  messages = [SystemMessage(content=MANAGER_SYSTEM_PROMPT)] + state["messages"]
191
- response = llm.invoke(messages)
192
 
193
  try:
194
- raw = response.content.strip().strip("`")
195
  if raw.startswith("json"):
196
  raw = raw[4:]
197
  decision = json.loads(raw)
198
  next_step = decision.get("next", "finish")
199
- except (json.JSONDecodeError, AttributeError):
200
  next_step = "finish"
201
 
202
  if next_step not in {"search_agent", "code_agent", "data_agent", "finish"}:
203
  next_step = "finish"
204
 
205
- return {"next": next_step, "turn_count": turn_count, "messages": [response]}
206
 
207
 
208
  def route_from_manager(state: AgentState) -> Literal["search_agent", "code_agent", "data_agent", "finalize"]:
@@ -214,19 +261,57 @@ def route_from_manager(state: AgentState) -> Literal["search_agent", "code_agent
214
  # ---------------------------------------------------------------------------
215
  # Specialist nodes β€” each does its work, appends a message, returns to manager
216
  # ---------------------------------------------------------------------------
217
- def make_specialist_node(name: str, tools, system_prompt: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  def node(state: AgentState):
219
- llm = get_llm().bind_tools(tools)
220
- messages = [SystemMessage(content=system_prompt), HumanMessage(content=state["task"])]
221
- ai_msg = llm.invoke(messages)
222
 
223
- tool_outputs = []
224
- for call in getattr(ai_msg, "tool_calls", []) or []:
225
- tool_fn = next(t for t in tools if t.name == call["name"])
226
- output = tool_fn.invoke(call["args"])
227
- tool_outputs.append(f"{call['name']}({call['args']}) -> {output}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
- summary = ai_msg.content or "\n".join(tool_outputs) or "(no output)"
230
  state["results"][name] = summary
231
  return {
232
  "messages": [HumanMessage(content=f"[{name}] {summary}")],
@@ -250,14 +335,13 @@ data_node = make_specialist_node(
250
  # Finalize node β€” synthesizes a final answer from gathered specialist results
251
  # ---------------------------------------------------------------------------
252
  def finalize_node(state: AgentState):
253
- llm = get_llm()
254
  context = "\n".join(f"{k}: {v}" for k, v in state["results"].items())
255
- prompt = [
256
- SystemMessage(content="Write a final answer for the user based on the specialist findings below."),
257
- HumanMessage(content=f"Task: {state['task']}\n\nFindings:\n{context}"),
258
  ]
259
- response = llm.invoke(prompt)
260
- return {"messages": [response]}
261
 
262
 
263
  # ---------------------------------------------------------------------------
@@ -309,4 +393,4 @@ def run_task(app, task: str, session_state: dict) -> str:
309
  answer = final_state["messages"][-1].content
310
 
311
  session_state.setdefault("history", []).append({"task": task, "answer": answer})
312
- return answer
 
14
  import tempfile
15
  from typing import Annotated, Literal, TypedDict
16
 
17
+ import spaces
18
+ import torch
19
+ from transformers import AutoModelForCausalLM, AutoTokenizer
20
+ from langchain_core.messages import AnyMessage, AIMessage, HumanMessage, SystemMessage
21
  from langchain_core.tools import tool
 
22
  from langchain_tavily import TavilySearch
23
  from langgraph.graph import StateGraph, END
24
  from langgraph.graph.message import add_messages
 
30
 
31
 
32
  # ---------------------------------------------------------------------------
33
+ # Local model, run on a ZeroGPU-allocated GPU
34
  # ---------------------------------------------------------------------------
35
+ # Pick a model that fits comfortably in a ZeroGPU allocation. 3B is a safe
36
+ # starting point; move up to 7B/8B if quality is the bottleneck, but watch
37
+ # the `duration` below β€” bigger models need more time per call.
38
+ MODEL_ID = "Qwen/Qwen2.5-3B-Instruct"
39
+
40
+ # Loaded once at import time, kept on CPU. ZeroGPU only attaches a GPU for
41
+ # the duration of a function decorated with @spaces.GPU β€” the model is
42
+ # moved onto that GPU inside `generate()` below and this is the standard
43
+ # pattern for ZeroGPU Spaces (see huggingface.co/docs/hub/spaces-zerogpu).
44
+ _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
45
+ _model = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
46
+
47
+
48
+ @spaces.GPU(duration=60)
49
+ def generate(messages: list[dict], max_new_tokens: int = 512) -> str:
50
+ """Run one generation on a ZeroGPU-allocated GPU. `messages` is a plain
51
+ list of {"role": ..., "content": ...} dicts (chat-template format)."""
52
+ _model.to("cuda")
53
+ input_ids = _tokenizer.apply_chat_template(
54
+ messages, add_generation_prompt=True, return_tensors="pt"
55
+ ).to("cuda")
56
+ with torch.no_grad():
57
+ output_ids = _model.generate(
58
+ input_ids,
59
+ max_new_tokens=max_new_tokens,
60
+ do_sample=False,
61
+ )
62
+ new_tokens = output_ids[0][input_ids.shape[-1]:]
63
+ return _tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
64
+
65
+
66
+ def _lc_to_dicts(messages: list[AnyMessage]) -> list[dict]:
67
+ """Convert langchain message objects into the plain role/content dicts
68
+ apply_chat_template expects."""
69
+ role_map = {"human": "user", "ai": "assistant", "system": "system"}
70
+ return [{"role": role_map.get(m.type, "user"), "content": m.content} for m in messages]
71
 
72
 
73
  # ---------------------------------------------------------------------------
 
88
  # Requires TAVILY_API_KEY as a Space secret. Tavily is built for LLM agents
89
  # (returns clean, citeable snippets rather than raw HTML), which is why it's
90
  # preferred here over scraping a generic search engine yourself.
91
+ #
92
+ # Lazily constructed on first use rather than at import time: TavilySearch
93
+ # validates its API key eagerly in __init__, so building it at module load
94
+ # means a missing/bad key crashes the entire app before Gradio even starts.
95
+ # Deferring it means only the search tool fails, with a clear message,
96
+ # while the rest of the app (code_agent, data_agent) still works.
97
+ _tavily_instance = None
98
+
99
+
100
+ def _get_tavily():
101
+ global _tavily_instance
102
+ if _tavily_instance is None:
103
+ _tavily_instance = TavilySearch(max_results=5)
104
+ return _tavily_instance
105
 
106
 
107
  @tool
108
  def web_search(query: str) -> str:
109
  """Search the web for a query and return a short summary of results."""
110
  try:
111
+ tavily = _get_tavily()
112
+ except Exception as e:
113
+ return f"Search unavailable: TAVILY_API_KEY missing or invalid ({e})"
114
+
115
+ try:
116
+ results = tavily.invoke({"query": query})
117
  except Exception as e:
118
  return f"Search failed: {e}"
119
 
 
225
  def manager_node(state: AgentState):
226
  turn_count = state.get("turn_count", 0) + 1
227
 
228
+ # Hard stop: don't even call the model once we've hit the cap β€” force
229
+ # finish so a manager stuck in a routing loop can't burn turns indefinitely.
230
  if turn_count > MAX_TURNS:
231
  return {
232
  "next": "finish",
 
234
  "messages": [SystemMessage(content=f"Max turns ({MAX_TURNS}) reached, finalizing with available results.")],
235
  }
236
 
 
237
  messages = [SystemMessage(content=MANAGER_SYSTEM_PROMPT)] + state["messages"]
238
+ raw_response = generate(_lc_to_dicts(messages), max_new_tokens=200)
239
 
240
  try:
241
+ raw = raw_response.strip().strip("`")
242
  if raw.startswith("json"):
243
  raw = raw[4:]
244
  decision = json.loads(raw)
245
  next_step = decision.get("next", "finish")
246
+ except json.JSONDecodeError:
247
  next_step = "finish"
248
 
249
  if next_step not in {"search_agent", "code_agent", "data_agent", "finish"}:
250
  next_step = "finish"
251
 
252
+ return {"next": next_step, "turn_count": turn_count, "messages": [AIMessage(content=raw_response)]}
253
 
254
 
255
  def route_from_manager(state: AgentState) -> Literal["search_agent", "code_agent", "data_agent", "finalize"]:
 
261
  # ---------------------------------------------------------------------------
262
  # Specialist nodes β€” each does its work, appends a message, returns to manager
263
  # ---------------------------------------------------------------------------
264
+ def _tool_spec_text(tools) -> str:
265
+ lines = []
266
+ for t in tools:
267
+ lines.append(f'- {t.name}: {t.description} Args schema: {t.args}')
268
+ return "\n".join(lines)
269
+
270
+
271
+ def make_specialist_node(name: str, tools, role_description: str):
272
+ tool_spec = _tool_spec_text(tools)
273
+ system_prompt = (
274
+ f"{role_description}\n\n"
275
+ f"Available tools:\n{tool_spec}\n\n"
276
+ 'Respond with ONLY a JSON object. Either call a tool: '
277
+ '{"tool": "<tool_name>", "args": {...}} β€” or, if no tool is needed, '
278
+ 'give the answer directly: {"final": "<your answer>"}'
279
+ )
280
+
281
  def node(state: AgentState):
282
+ messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": state["task"]}]
283
+ raw_response = generate(messages, max_new_tokens=300)
 
284
 
285
+ summary = None
286
+ try:
287
+ raw = raw_response.strip().strip("`")
288
+ if raw.startswith("json"):
289
+ raw = raw[4:]
290
+ decision = json.loads(raw)
291
+ except json.JSONDecodeError:
292
+ decision = None
293
+
294
+ if decision and "tool" in decision:
295
+ tool_fn = next((t for t in tools if t.name == decision["tool"]), None)
296
+ if tool_fn is not None:
297
+ try:
298
+ tool_output = tool_fn.invoke(decision.get("args", {}))
299
+ except Exception as e:
300
+ tool_output = f"Tool error: {e}"
301
+ # One follow-up call to turn the raw tool output into a
302
+ # concise summary, rather than dumping it unprocessed.
303
+ followup = messages + [
304
+ {"role": "assistant", "content": raw_response},
305
+ {"role": "user", "content": f"Tool result: {tool_output}\n\nSummarize this for the manager."},
306
+ ]
307
+ summary = generate(followup, max_new_tokens=250)
308
+ else:
309
+ summary = f"Requested unknown tool '{decision['tool']}'."
310
+ elif decision and "final" in decision:
311
+ summary = decision["final"]
312
+ else:
313
+ summary = raw_response # model didn't follow the JSON protocol β€” use raw text as a fallback
314
 
 
315
  state["results"][name] = summary
316
  return {
317
  "messages": [HumanMessage(content=f"[{name}] {summary}")],
 
335
  # Finalize node β€” synthesizes a final answer from gathered specialist results
336
  # ---------------------------------------------------------------------------
337
  def finalize_node(state: AgentState):
 
338
  context = "\n".join(f"{k}: {v}" for k, v in state["results"].items())
339
+ messages = [
340
+ {"role": "system", "content": "Write a final answer for the user based on the specialist findings below."},
341
+ {"role": "user", "content": f"Task: {state['task']}\n\nFindings:\n{context}"},
342
  ]
343
+ response = generate(messages, max_new_tokens=500)
344
+ return {"messages": [AIMessage(content=response)]}
345
 
346
 
347
  # ---------------------------------------------------------------------------
 
393
  answer = final_state["messages"][-1].content
394
 
395
  session_state.setdefault("history", []).append({"task": task, "answer": answer})
396
+ return answer