ColettoG commited on
Commit
2ecfafe
·
1 Parent(s): 2e069a9

add: swap agent

Browse files
requirements.txt CHANGED
@@ -59,4 +59,6 @@ mypy>=1.5.0
59
 
60
  # ClickHouse dependencies
61
  clickhouse-connect>=0.7.0
62
- clickhouse-sqlalchemy==0.3.2
 
 
 
59
 
60
  # ClickHouse dependencies
61
  clickhouse-connect>=0.7.0
62
+ clickhouse-sqlalchemy==0.3.2
63
+
64
+ langchain-tavily>=0.2.11
src/agents/search/agent.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from langgraph.prebuilt import create_react_agent
3
+
4
+ from src.agents.search.tools import get_tools
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class SearchAgent:
10
+ """Agent dedicated to answering queries via web search tools."""
11
+
12
+ def __init__(self, llm):
13
+ self.llm = llm
14
+ tools = get_tools()
15
+ if not tools:
16
+ logger.warning("Search agent initialised without tools; it will act as a plain LLM.")
17
+ self.agent = create_react_agent(
18
+ model=llm,
19
+ tools=tools,
20
+ name="search_agent",
21
+ )
src/agents/search/tools.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import logging
3
+ import os
4
+ from typing import List
5
+
6
+ from langchain_core.tools import Tool
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def _build_tavily_tool():
12
+ """Create a Tavily search tool if the dependency and API key are available."""
13
+ api_key = os.getenv("TAVILY_API_KEY")
14
+ if not api_key:
15
+ logger.warning("TAVILY_API_KEY not set; web search tool disabled.")
16
+ return None
17
+
18
+ try:
19
+ from langchain_tavily import TavilySearch
20
+ except ImportError as exc: # pragma: no cover - dependency may be optional
21
+ logger.warning("langchain_tavily package is unavailable: %s", exc)
22
+ return None
23
+
24
+ try:
25
+ # TavilySearch currently expects the API key either via keyword or env var.
26
+ try:
27
+ return TavilySearch(api_key=api_key)
28
+ except TypeError:
29
+ return TavilySearch(tavily_api_key=api_key)
30
+ except Exception as exc: # pragma: no cover - runtime init guard
31
+ logger.error("Failed to initialise Tavily search tool: %s", exc)
32
+ return None
33
+
34
+
35
+ def _search_unavailable(query: str) -> str:
36
+ return (
37
+ "Search service is unavailable right now."
38
+ " Configure TAVILY_API_KEY and install langchain-tavily to enable live results."
39
+ )
40
+
41
+
42
+ def get_tools() -> List:
43
+ """Return the toolset used by the search agent."""
44
+ tools = []
45
+ tavily_tool = _build_tavily_tool()
46
+ if tavily_tool:
47
+ tools.append(tavily_tool)
48
+ else:
49
+ tools.append(
50
+ Tool(
51
+ name="search_unavailable",
52
+ func=_search_unavailable,
53
+ description=(
54
+ "Fallback search stub that informs the user when the web search"
55
+ " service is not configured."
56
+ ),
57
+ )
58
+ )
59
+ return tools
src/agents/supervisor/agent.py CHANGED
@@ -1,16 +1,18 @@
1
  from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
2
  from langgraph_supervisor import create_supervisor
3
  from src.agents.config import Config
4
- from typing import TypedDict, Literal, List, Any
5
  import re
6
  import json
7
  from src.agents.metadata import metadata
 
8
 
9
  # Agents
10
  from src.agents.crypto_data.agent import CryptoDataAgent
11
  from src.agents.database.agent import DatabaseAgent
12
  from src.agents.default.agent import DefaultAgent
13
  from src.agents.swap.agent import SwapAgent
 
14
  from src.agents.database.client import is_database_available
15
 
16
  llm = ChatGoogleGenerativeAI(
@@ -24,10 +26,12 @@ embeddings = GoogleGenerativeAIEmbeddings(
24
  google_api_key=Config.GEMINI_API_KEY
25
  )
26
 
 
27
  class ChatMessage(TypedDict):
28
  role: Literal["system", "user", "assistant"]
29
  content: str
30
 
 
31
  class Supervisor:
32
  def __init__(self, llm):
33
  self.llm = llm
@@ -36,25 +40,85 @@ class Supervisor:
36
  cryptoDataAgent = cryptoDataAgentClass.agent
37
 
38
  agents = [cryptoDataAgent]
39
- available_agents_text = "- crypto_agent: Handles cryptocurrency-related queries like price checks, market data, NFT floor prices, DeFi protocol TVL, etc.\n"
 
 
40
 
41
  # Conditionally include database agent
42
  if is_database_available():
43
  databaseAgent = DatabaseAgent(llm)
44
  agents.append(databaseAgent)
45
- available_agents_text += "- database_agent: Handles database queries and data analysis. Can search and analyze data from the database.\n"
 
 
46
  else:
47
  databaseAgent = None
48
 
49
  swapAgent = SwapAgent(llm)
50
  agents.append(swapAgent.agent)
51
- available_agents_text += "- swap_agent: Handles swap operations on the Avalanche network and any other swap question related.\n"
 
 
 
 
 
 
 
 
 
52
 
53
  defaultAgent = DefaultAgent(llm)
54
- agents.append(defaultAgent.agent)
 
55
 
56
  # Track known agent names for response extraction
57
- self.known_agent_names = {"crypto_agent", "database_agent", "swap_agent", "default_agent"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  # Prepare database guidance text to avoid backslashes in f-string expressions
60
  if databaseAgent:
@@ -70,6 +134,16 @@ class Supervisor:
70
  database_instruction = "Do not delegate to a database agent; answer best-effort without DB access or ask the user to start the database."
71
  database_examples = ""
72
 
 
 
 
 
 
 
 
 
 
 
73
  # System prompt to guide the supervisor
74
  system_prompt = f"""You are a helpful supervisor that routes user queries to the appropriate specialized agents.
75
 
@@ -78,6 +152,7 @@ Available agents:
78
 
79
  When a user asks about cryptocurrency prices, market data, NFTs, or DeFi protocols, delegate to the crypto_agent.
80
  {database_instruction}
 
81
  For all other queries, respond directly as a helpful assistant.
82
 
83
  IMPORTANT: your final response should answer the user's query. Use the agents response to answer the user's query if necessary. Avoid returning control-transfer notes like 'Transferring back to supervisor' — return the substantive answer instead.
@@ -93,12 +168,20 @@ Examples of swap queries to delegate:
93
  - What are the available tokens for swapping?
94
  - I want to swap 100 USD for AVAX
95
 
 
 
 
 
 
96
  {database_examples}
97
 
 
 
98
  Examples of general queries to handle directly:
99
  - "Hello, how are you?"
100
  - "What's the weather like?"
101
  - "Tell me a joke"
 
102
  """
103
 
104
  self.supervisor = create_supervisor(
@@ -180,7 +263,7 @@ Examples of general queries to handle directly:
180
  if collected:
181
  return " ".join(collected)
182
  return None
183
-
184
  def _extract_payload(self, text: str) -> tuple[dict, str]:
185
  # Try JSON payload first
186
  try:
@@ -214,32 +297,8 @@ Examples of general queries to handle directly:
214
  return art
215
  return {}
216
 
217
- def invoke(self, messages: List[ChatMessage]) -> dict:
218
- from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
219
-
220
- langchain_messages = []
221
- for msg in messages:
222
- if msg.get("role") == "user":
223
- langchain_messages.append(HumanMessage(content=msg.get("content", "")))
224
- elif msg.get("role") == "system":
225
- langchain_messages.append(SystemMessage(content=msg.get("content", "")))
226
- elif msg.get("role") == "assistant":
227
- langchain_messages.append(AIMessage(content=msg.get("content", "")))
228
-
229
- try:
230
- response = self.app.invoke({"messages": langchain_messages})
231
- print("DEBUG: response", response)
232
- except Exception as e:
233
- print(f"Error in Supervisor: {e}")
234
- return {
235
- "messages": [],
236
- "agent": "supervisor",
237
- "response": "Sorry, an error occurred while processing your request."
238
- }
239
-
240
  messages_out = response.get("messages", []) if isinstance(response, dict) else []
241
-
242
- # Prefer the last specialized agent message over any router/supervisor meta message
243
  final_response = None
244
  final_agent = "supervisor"
245
 
@@ -254,7 +313,6 @@ Examples of general queries to handle directly:
254
  # Prefer sanitized content
255
  return sanitized, agent_name
256
  return None, None
257
-
258
 
259
  # 1) Try to find the last message from a known specialized agent that is not a handoff/route-back note
260
  for m in reversed(messages_out):
@@ -286,16 +344,117 @@ Examples of general queries to handle directly:
286
  final_response = "No response available"
287
 
288
  cleaned_response = final_response or "Sorry, no meaningful response was returned."
289
- meta = {}
290
- if final_agent == "swap_agent":
291
- meta = {'src': 'AVAX', 'dst': 'USDC', 'amount': '100'}
292
- elif final_agent == "crypto_agent":
293
- meta = metadata.get_crypto_data_agent() or {}
294
- else:
295
- meta = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  print("meta: ", meta)
297
  print("cleaned_response: ", cleaned_response)
298
-
299
  print("final_agent: ", final_agent)
300
 
301
  return {
@@ -303,4 +462,4 @@ Examples of general queries to handle directly:
303
  "agent": final_agent,
304
  "response": cleaned_response or "Sorry, no meaningful response was returned.",
305
  "metadata": meta,
306
- }
 
1
  from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
2
  from langgraph_supervisor import create_supervisor
3
  from src.agents.config import Config
4
+ from typing import TypedDict, Literal, List, Any, Tuple
5
  import re
6
  import json
7
  from src.agents.metadata import metadata
8
+ from src.agents.crypto_data.config import Config as CryptoConfig
9
 
10
  # Agents
11
  from src.agents.crypto_data.agent import CryptoDataAgent
12
  from src.agents.database.agent import DatabaseAgent
13
  from src.agents.default.agent import DefaultAgent
14
  from src.agents.swap.agent import SwapAgent
15
+ from src.agents.search.agent import SearchAgent
16
  from src.agents.database.client import is_database_available
17
 
18
  llm = ChatGoogleGenerativeAI(
 
26
  google_api_key=Config.GEMINI_API_KEY
27
  )
28
 
29
+
30
  class ChatMessage(TypedDict):
31
  role: Literal["system", "user", "assistant"]
32
  content: str
33
 
34
+
35
  class Supervisor:
36
  def __init__(self, llm):
37
  self.llm = llm
 
40
  cryptoDataAgent = cryptoDataAgentClass.agent
41
 
42
  agents = [cryptoDataAgent]
43
+ available_agents_text = (
44
+ "- crypto_agent: Handles cryptocurrency-related queries like price checks, market data, NFT floor prices, DeFi protocol TVL (don't route to this agent if none of the mention information).\n"
45
+ )
46
 
47
  # Conditionally include database agent
48
  if is_database_available():
49
  databaseAgent = DatabaseAgent(llm)
50
  agents.append(databaseAgent)
51
+ available_agents_text += (
52
+ "- database_agent: Handles database queries and data analysis. Can search and analyze data from the database.\n"
53
+ )
54
  else:
55
  databaseAgent = None
56
 
57
  swapAgent = SwapAgent(llm)
58
  agents.append(swapAgent.agent)
59
+ available_agents_text += (
60
+ "- swap_agent: Handles swap operations on the Avalanche network and any other swap question related.\n"
61
+ )
62
+
63
+ searchAgent = SearchAgent(llm)
64
+ self.search_agent = searchAgent.agent
65
+ agents.append(self.search_agent)
66
+ available_agents_text += (
67
+ "- search_agent: Uses web search tools for current events and factual lookups.\n"
68
+ )
69
 
70
  defaultAgent = DefaultAgent(llm)
71
+ self.default_agent = defaultAgent.agent
72
+ agents.append(self.default_agent)
73
 
74
  # Track known agent names for response extraction
75
+ self.known_agent_names = {"crypto_agent", "database_agent", "swap_agent", "search_agent", "default_agent"}
76
+ self.specialized_agents = {"crypto_agent", "database_agent", "swap_agent", "search_agent"}
77
+ self.failure_markers = (
78
+ "cannot fulfill",
79
+ "can't fulfill",
80
+ "cannot assist",
81
+ "can't assist",
82
+ "cannot help",
83
+ "can't help",
84
+ "cannot tell",
85
+ "can't tell",
86
+ "cannot tell you",
87
+ "can't tell you",
88
+ "cannot comply",
89
+ "transfer you",
90
+ "specialized agent",
91
+ "no response available",
92
+ "failed to retrieve",
93
+ "api at the moment",
94
+ "handling your request",
95
+ "crypto_agent is handling",
96
+ "will provide",
97
+ "provide the price",
98
+ "tool error",
99
+ "search service is unavailable",
100
+ "configure tavily_api_key",
101
+ "no results found",
102
+ "unable to",
103
+ "cannot get",
104
+ "can't get",
105
+ "cannot find",
106
+ "can't find",
107
+ "could not find",
108
+ "do not have that information",
109
+ "don't have that information",
110
+ "having trouble",
111
+ "trouble finding",
112
+ "I can only"
113
+ )
114
+ self.config_failure_messages = {
115
+ CryptoConfig.PRICE_FAILURE_MESSAGE.lower(),
116
+ CryptoConfig.FLOOR_PRICE_FAILURE_MESSAGE.lower(),
117
+ CryptoConfig.TVL_FAILURE_MESSAGE.lower(),
118
+ CryptoConfig.FDV_FAILURE_MESSAGE.lower(),
119
+ CryptoConfig.MARKET_CAP_FAILURE_MESSAGE.lower(),
120
+ CryptoConfig.API_ERROR_MESSAGE.lower(),
121
+ }
122
 
123
  # Prepare database guidance text to avoid backslashes in f-string expressions
124
  if databaseAgent:
 
134
  database_instruction = "Do not delegate to a database agent; answer best-effort without DB access or ask the user to start the database."
135
  database_examples = ""
136
 
137
+ search_instruction = (
138
+ "When the user asks about breaking news, recent developments, or requests a web lookup, delegate to the search_agent first."
139
+ )
140
+ search_examples = (
141
+ "Examples of search queries to delegate:\n"
142
+ "- \"What happened with Bitcoin this week?\"\n"
143
+ "- \"Find the latest Avalanche ecosystem partnerships\"\n"
144
+ "- \"Who just won the most recent Formula 1 race?\"\n"
145
+ )
146
+
147
  # System prompt to guide the supervisor
148
  system_prompt = f"""You are a helpful supervisor that routes user queries to the appropriate specialized agents.
149
 
 
152
 
153
  When a user asks about cryptocurrency prices, market data, NFTs, or DeFi protocols, delegate to the crypto_agent.
154
  {database_instruction}
155
+ {search_instruction}
156
  For all other queries, respond directly as a helpful assistant.
157
 
158
  IMPORTANT: your final response should answer the user's query. Use the agents response to answer the user's query if necessary. Avoid returning control-transfer notes like 'Transferring back to supervisor' — return the substantive answer instead.
 
168
  - What are the available tokens for swapping?
169
  - I want to swap 100 USD for AVAX
170
 
171
+ When a swap conversation is already underway (the user is still providing swap
172
+ details or the swap_agent requested follow-up information), keep routing those
173
+ messages to the swap_agent until it has gathered every field and signals the
174
+ swap intent is ready.
175
+
176
  {database_examples}
177
 
178
+ {search_examples}
179
+
180
  Examples of general queries to handle directly:
181
  - "Hello, how are you?"
182
  - "What's the weather like?"
183
  - "Tell me a joke"
184
+ - "What is the biggest poll in Trader Joe?"
185
  """
186
 
187
  self.supervisor = create_supervisor(
 
263
  if collected:
264
  return " ".join(collected)
265
  return None
266
+
267
  def _extract_payload(self, text: str) -> tuple[dict, str]:
268
  # Try JSON payload first
269
  try:
 
297
  return art
298
  return {}
299
 
300
+ def _extract_response_from_graph(self, response: Any) -> Tuple[str, str, list]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  messages_out = response.get("messages", []) if isinstance(response, dict) else []
 
 
302
  final_response = None
303
  final_agent = "supervisor"
304
 
 
313
  # Prefer sanitized content
314
  return sanitized, agent_name
315
  return None, None
 
316
 
317
  # 1) Try to find the last message from a known specialized agent that is not a handoff/route-back note
318
  for m in reversed(messages_out):
 
344
  final_response = "No response available"
345
 
346
  cleaned_response = final_response or "Sorry, no meaningful response was returned."
347
+ final_agent = final_agent or "supervisor"
348
+ return final_agent, cleaned_response, messages_out
349
+
350
+ def _needs_supervisor_fallback(self, agent_name: str, response_text: str) -> bool:
351
+ if not response_text:
352
+ return agent_name in self.specialized_agents
353
+ lowered = response_text.strip().lower()
354
+ if lowered in self.config_failure_messages:
355
+ return True
356
+ if any(marker in lowered for marker in self.failure_markers):
357
+ return True
358
+ if agent_name in self.specialized_agents and not lowered:
359
+ return True
360
+ return False
361
+
362
+ def _run_default_agent(self, langchain_messages: List[Any]) -> Tuple[str | None, str | None, list]:
363
+ if not getattr(self, "default_agent", None):
364
+ return None, None, []
365
+ try:
366
+ fallback_response = self.default_agent.invoke({"messages": langchain_messages})
367
+ print("DEBUG: default agent fallback response", fallback_response)
368
+ except Exception as exc:
369
+ print(f"Error invoking default agent fallback: {exc}")
370
+ return None, None, []
371
+ fallback_agent, fallback_text, fallback_messages = self._extract_response_from_graph(fallback_response)
372
+ if not fallback_agent:
373
+ fallback_agent = "default_agent"
374
+ return fallback_agent, fallback_text, fallback_messages
375
+
376
+ def _run_search_agent(self, langchain_messages: List[Any]) -> Tuple[str | None, str | None, list]:
377
+ if not getattr(self, "search_agent", None):
378
+ return None, None, []
379
+ try:
380
+ fallback_response = self.search_agent.invoke({"messages": langchain_messages})
381
+ print("DEBUG: search agent fallback response", fallback_response)
382
+ except Exception as exc:
383
+ print(f"Error invoking search agent fallback: {exc}")
384
+ return None, None, []
385
+ fallback_agent, fallback_text, fallback_messages = self._extract_response_from_graph(fallback_response)
386
+ if not fallback_agent:
387
+ fallback_agent = "search_agent"
388
+ return fallback_agent, fallback_text, fallback_messages
389
+
390
+ def _build_metadata(self, agent_name: str, messages_out) -> dict:
391
+ if agent_name == "swap_agent":
392
+ swap_meta = metadata.get_swap_agent()
393
+ return swap_meta.copy() if swap_meta else {}
394
+ if agent_name == "crypto_agent":
395
+ tool_meta = self._collect_tool_metadata(messages_out)
396
+ if tool_meta:
397
+ metadata.set_crypto_data_agent(tool_meta)
398
+ return metadata.get_crypto_data_agent() or {}
399
+ return {}
400
+
401
+ def invoke(self, messages: List[ChatMessage]) -> dict:
402
+ from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
403
+
404
+ langchain_messages = []
405
+ for msg in messages:
406
+ if msg.get("role") == "user":
407
+ langchain_messages.append(HumanMessage(content=msg.get("content", "")))
408
+ elif msg.get("role") == "system":
409
+ langchain_messages.append(SystemMessage(content=msg.get("content", "")))
410
+ elif msg.get("role") == "assistant":
411
+ langchain_messages.append(AIMessage(content=msg.get("content", "")))
412
+
413
+ try:
414
+ response = self.app.invoke({"messages": langchain_messages})
415
+ print("DEBUG: response", response)
416
+ except Exception as e:
417
+ print(f"Error in Supervisor: {e}")
418
+ return {
419
+ "messages": [],
420
+ "agent": "supervisor",
421
+ "response": "Sorry, an error occurred while processing your request."
422
+ }
423
+
424
+ final_agent, cleaned_response, messages_out = self._extract_response_from_graph(response)
425
+
426
+ if self._needs_supervisor_fallback(final_agent, cleaned_response):
427
+ print("INFO: Fallback triggered for agent", final_agent)
428
+ fallback_agent = None
429
+ fallback_response = None
430
+ fallback_messages: list = []
431
+
432
+ if final_agent != "search_agent":
433
+ search_agent, search_response, search_messages = self._run_search_agent(langchain_messages)
434
+ if search_response and not self._needs_supervisor_fallback(search_agent or "search_agent", search_response):
435
+ fallback_agent = search_agent or "search_agent"
436
+ fallback_response = search_response
437
+ fallback_messages = search_messages
438
+ else:
439
+ fallback_agent = search_agent
440
+ fallback_response = search_response
441
+ fallback_messages = search_messages
442
+
443
+ if not fallback_response or self._needs_supervisor_fallback(fallback_agent or "", fallback_response):
444
+ default_agent, default_response, default_messages = self._run_default_agent(langchain_messages)
445
+ if default_response:
446
+ fallback_agent = default_agent or "default_agent"
447
+ fallback_response = default_response
448
+ fallback_messages = default_messages
449
+
450
+ if fallback_response:
451
+ final_agent = fallback_agent or "default_agent"
452
+ cleaned_response = fallback_response
453
+ messages_out = fallback_messages
454
+
455
+ meta = self._build_metadata(final_agent, messages_out)
456
  print("meta: ", meta)
457
  print("cleaned_response: ", cleaned_response)
 
458
  print("final_agent: ", final_agent)
459
 
460
  return {
 
462
  "agent": final_agent,
463
  "response": cleaned_response or "Sorry, no meaningful response was returned.",
464
  "metadata": meta,
465
+ }
src/agents/swap/config.py CHANGED
@@ -1,42 +1,135 @@
 
 
 
 
 
 
 
1
  class SwapConfig:
2
- """Configuration and simple interface for supported swap tokens.
3
-
4
- This provides a canonical set of allowed token symbols and helpers to
5
- normalize and validate user input before executing swaps.
6
- """
7
-
8
- # Canonical symbols for Avalanche swaps (expand as needed)
9
- SUPPORTED_TOKENS = {
10
- "AVAX", # Native token
11
- "WAVAX", # Wrapped AVAX
12
- "USDC",
13
- "USDT",
14
- "DAI",
15
- "WBTC",
16
- "WETH",
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  @classmethod
20
- def normalize_symbol(cls, symbol: str) -> str:
21
- """Return canonical uppercase symbol without surrounding whitespace."""
22
- return (symbol or "").strip().upper()
23
 
24
  @classmethod
25
- def is_supported(cls, symbol: str) -> bool:
26
- """Check if a token symbol is supported (case-insensitive)."""
27
- return cls.normalize_symbol(symbol) in cls.SUPPORTED_TOKENS
 
 
 
 
 
28
 
29
  @classmethod
30
- def validate_or_raise(cls, symbol: str) -> str:
31
- """Validate token symbol and return its canonical form, or raise ValueError."""
32
- canonical = cls.normalize_symbol(symbol)
33
- if canonical not in cls.SUPPORTED_TOKENS:
 
 
 
 
 
 
 
 
 
 
 
 
34
  raise ValueError(
35
- f"Unsupported token '{symbol}'. Supported tokens: {sorted(cls.SUPPORTED_TOKENS)}"
36
  )
37
  return canonical
38
 
39
  @classmethod
40
- def list_supported(cls):
41
- """Return a sorted list of supported token symbols."""
42
- return sorted(cls.SUPPORTED_TOKENS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Network-aware swap configuration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Iterable, Optional, Set, Tuple
6
+
7
+
8
  class SwapConfig:
9
+ """Expose swap metadata so tools can validate user input safely."""
10
+
11
+ # Canonical networks we support. Values are the tokens available on that chain.
12
+ _NETWORK_TOKENS: Dict[str, Set[str]] = {
13
+ "avalanche": {
14
+ "AVAX",
15
+ "WAVAX",
16
+ "USDC",
17
+ "USDT",
18
+ "DAI",
19
+ "BTC.B",
20
+ },
21
+ "ethereum": {
22
+ "ETH",
23
+ "WETH",
24
+ "USDC",
25
+ "USDT",
26
+ "DAI",
27
+ "WBTC",
28
+ },
29
+ }
30
+
31
+ # Friendly aliases -> canonical keys
32
+ _NETWORK_ALIASES: Dict[str, str] = {
33
+ "avax": "avalanche",
34
+ "avalanche": "avalanche",
35
+ "ethereum": "ethereum",
36
+ "eth": "ethereum",
37
  }
38
 
39
+ _TOKEN_ALIASES: Dict[str, str] = {
40
+ "avax": "AVAX",
41
+ "wavax": "WAVAX",
42
+ "usdc": "USDC",
43
+ "usdt": "USDT",
44
+ "dai": "DAI",
45
+ "btc.b": "BTC.B",
46
+ "btcb": "BTC.B",
47
+ "wbtc": "WBTC",
48
+ "eth": "ETH",
49
+ "weth": "WETH",
50
+ }
51
+
52
+ # Optional allow list of directional routes (canonical network names).
53
+ _SUPPORTED_ROUTES: Set[Tuple[str, str]] = {
54
+ ("avalanche", "ethereum"),
55
+ ("ethereum", "avalanche"),
56
+ ("avalanche", "avalanche"),
57
+ ("ethereum", "ethereum"),
58
+ }
59
+
60
+ # ---------- Public helpers ----------
61
  @classmethod
62
+ def list_networks(cls) -> Iterable[str]:
63
+ """Return supported networks in a stable order."""
64
+ return sorted(cls._NETWORK_TOKENS.keys())
65
 
66
  @classmethod
67
+ def list_tokens(cls, network: str) -> Iterable[str]:
68
+ """Return supported tokens for a given network."""
69
+ normalized = cls._normalize_network(network)
70
+ if normalized not in cls._NETWORK_TOKENS:
71
+ raise ValueError(
72
+ f"Unsupported network '{network}'. Available: {sorted(cls._NETWORK_TOKENS)}"
73
+ )
74
+ return sorted(cls._NETWORK_TOKENS[normalized])
75
 
76
  @classmethod
77
+ def validate_network(cls, network: str) -> str:
78
+ """Return the canonical network name or raise ValueError."""
79
+ return cls._normalize_network(network)
80
+
81
+ @classmethod
82
+ def validate_or_raise(cls, token: str, network: Optional[str] = None) -> str:
83
+ """Validate a token, optionally scoping by network, and return canonical symbol."""
84
+ canonical = cls._normalize_token(token)
85
+ if network is not None:
86
+ normalized_network = cls._normalize_network(network)
87
+ tokens = cls._NETWORK_TOKENS.get(normalized_network, set())
88
+ if canonical not in tokens:
89
+ raise ValueError(
90
+ f"Unsupported token '{token}' on {normalized_network}. Available: {sorted(tokens)}"
91
+ )
92
+ elif canonical not in cls._all_tokens():
93
  raise ValueError(
94
+ f"Unsupported token '{token}'. Supported tokens: {sorted(cls._all_tokens())}"
95
  )
96
  return canonical
97
 
98
  @classmethod
99
+ def routes_supported(cls, from_network: str, to_network: str) -> bool:
100
+ """Return whether a swap route is supported."""
101
+ source = cls._normalize_network(from_network)
102
+ dest = cls._normalize_network(to_network)
103
+ return (source, dest) in cls._SUPPORTED_ROUTES
104
+
105
+ @classmethod
106
+ def list_supported(cls) -> Iterable[str]:
107
+ """Backwards compatible helper returning all tokens across networks."""
108
+ return sorted(cls._all_tokens())
109
+
110
+ # ---------- Internal helpers ----------
111
+ @classmethod
112
+ def _normalize_network(cls, network: str) -> str:
113
+ key = (network or "").strip().lower()
114
+ if not key:
115
+ raise ValueError("Network is required.")
116
+ normalized = cls._NETWORK_ALIASES.get(key)
117
+ if normalized is None:
118
+ raise ValueError(
119
+ f"Unsupported network '{network}'. Available: {sorted(cls._NETWORK_TOKENS)}"
120
+ )
121
+ return normalized
122
+
123
+ @classmethod
124
+ def _normalize_token(cls, token: str) -> str:
125
+ key = (token or "").strip().lower()
126
+ if not key:
127
+ raise ValueError("Token is required.")
128
+ return cls._TOKEN_ALIASES.get(key, key.upper())
129
+
130
+ @classmethod
131
+ def _all_tokens(cls) -> Set[str]:
132
+ tokens: Set[str] = set()
133
+ for chain_tokens in cls._NETWORK_TOKENS.values():
134
+ tokens.update(chain_tokens)
135
+ return tokens
src/agents/swap/tools.py CHANGED
@@ -1,49 +1,284 @@
 
 
 
 
 
 
 
1
  from langchain_core.tools import tool
 
 
2
  from src.agents.metadata import metadata
3
  from src.agents.swap.config import SwapConfig
4
 
5
- @tool
6
- def swap_avax(amount: float, from_token: str, to_token: str):
7
- """
8
- Swap AVAX for a given amount of tokens
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- Args:
11
- amount: The amount of tokens to swap
12
- from_token: The token to swap from
13
- to_token: The token to swap to
 
 
 
 
 
 
 
14
 
15
- Returns:
16
- The amount of tokens received
 
17
  """
 
 
 
 
 
 
 
18
  try:
19
- canonical_from = SwapConfig.validate_or_raise(from_token)
20
- canonical_to = SwapConfig.validate_or_raise(to_token)
21
- except ValueError as e:
22
- return str(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- print(f"Swapping {amount} {canonical_from} for {canonical_to}")
25
  meta = {
26
- "from_token": canonical_from,
27
- "to_token": canonical_to,
28
- "amount": amount
 
 
 
29
  }
30
  metadata.set_swap_agent(meta)
31
- return f'Swapped {amount} {canonical_from} for {canonical_to}'
32
 
33
- @tool
34
- def get_avaialble_tokens():
35
- """
36
- Get the available tokens for swapping
37
- """
38
- return SwapConfig.list_supported()
39
 
40
- @tool
41
- def default_response():
42
- """
43
- Normal response when the user asks for a swap
44
- """
45
- return f'What would you like to swap? The available agents are{SwapConfig.list_supported}'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
 
48
  def get_tools():
49
- return [swap_avax, get_avaialble_tokens, default_response]
 
1
+ """Swap tools that manage a conversational swap intent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Dict, List, Optional
7
+
8
  from langchain_core.tools import tool
9
+ from pydantic import BaseModel, Field, field_validator
10
+
11
  from src.agents.metadata import metadata
12
  from src.agents.swap.config import SwapConfig
13
 
14
+ # ---------- In-memory intent store (swap session) ----------
15
+ # Replace with persistent storage if the agent runs in multiple instances.
16
+ _INTENTS: Dict[str, "SwapIntent"] = {}
17
+ _DEFAULT_USER_ID = "__default_swap_user__"
18
+
19
+
20
+ @dataclass
21
+ class SwapIntent:
22
+ user_id: str = _DEFAULT_USER_ID
23
+ from_network: Optional[str] = None
24
+ from_token: Optional[str] = None
25
+ to_network: Optional[str] = None
26
+ to_token: Optional[str] = None
27
+ amount: Optional[float] = None
28
+
29
+ def is_complete(self) -> bool:
30
+ return all(
31
+ [
32
+ self.from_network,
33
+ self.from_token,
34
+ self.to_network,
35
+ self.to_token,
36
+ self.amount,
37
+ ]
38
+ )
39
+
40
+ def missing_fields(self) -> List[str]:
41
+ fields: List[str] = []
42
+ if not self.from_network:
43
+ fields.append("from_network")
44
+ if not self.from_token:
45
+ fields.append("from_token")
46
+ if not self.to_network:
47
+ fields.append("to_network")
48
+ if not self.to_token:
49
+ fields.append("to_token")
50
+ if self.amount is None:
51
+ fields.append("amount")
52
+ return fields
53
+
54
+
55
+ # ---------- Pydantic input schema ----------
56
+ class UpdateSwapIntentInput(BaseModel):
57
+ user_id: Optional[str] = Field(
58
+ default=None,
59
+ description="Stable ID for the end user / chat session. Optional, but required for multi-user disambiguation.",
60
+ )
61
+ from_network: Optional[str] = None
62
+ from_token: Optional[str] = None
63
+ to_network: Optional[str] = None
64
+ to_token: Optional[str] = None
65
+ amount: Optional[float] = Field(None, gt=0)
66
+
67
+ @field_validator("from_network", "to_network", mode="before")
68
+ @classmethod
69
+ def _norm_network(cls, value: Optional[str]) -> Optional[str]:
70
+ return value.lower() if isinstance(value, str) else value
71
+
72
+ @field_validator("from_token", "to_token", mode="before")
73
+ @classmethod
74
+ def _norm_token(cls, value: Optional[str]) -> Optional[str]:
75
+ return value.upper() if isinstance(value, str) else value
76
+
77
+
78
+ # ---------- Output helpers ----------
79
+ def _response(
80
+ intent: SwapIntent,
81
+ ask: Optional[str],
82
+ choices: Optional[List[str]] = None,
83
+ done: bool = False,
84
+ error: Optional[str] = None,
85
+ ) -> Dict[str, object]:
86
+ """Consistent payload for the UI layer."""
87
+
88
+ payload: Dict[str, object] = {
89
+ "event": "swap_intent_ready" if done else "ask_user",
90
+ "intent": asdict(intent),
91
+ "ask": ask,
92
+ "choices": choices or [],
93
+ "error": error,
94
+ }
95
+ if done:
96
+ payload["metadata"] = {
97
+ "event": "swap_intent_ready",
98
+ "from_network": intent.from_network,
99
+ "from_token": intent.from_token,
100
+ "to_network": intent.to_network,
101
+ "to_token": intent.to_token,
102
+ "amount": intent.amount,
103
+ }
104
+ return payload
105
+
106
+
107
+ # ---------- Validation utilities ----------
108
+ def _validate_network(network: Optional[str]) -> Optional[str]:
109
+ if network is None:
110
+ return None
111
+ return SwapConfig.validate_network(network)
112
+
113
+
114
+ def _validate_token(token: Optional[str], network: Optional[str]) -> Optional[str]:
115
+ if token is None:
116
+ return None
117
+ if network is None:
118
+ raise ValueError("Please provide the network before choosing a token.")
119
+ supported = list(SwapConfig.list_tokens(network))
120
+ if token not in supported:
121
+ raise ValueError(
122
+ f"Unsupported token '{token}' on {network}. Available: {supported}"
123
+ )
124
+ return SwapConfig.validate_or_raise(token, network)
125
+
126
+
127
+ def _validate_route(from_network: str, to_network: str) -> None:
128
+ if hasattr(SwapConfig, "routes_supported"):
129
+ if not SwapConfig.routes_supported(from_network, to_network):
130
+ raise ValueError(
131
+ f"Route {from_network} -> {to_network} is not supported."
132
+ )
133
+
134
 
135
+ # ---------- Core tool ----------
136
+ @tool("update_swap_intent", args_schema=UpdateSwapIntentInput)
137
+ def update_swap_intent_tool(
138
+ user_id: Optional[str] = None,
139
+ from_network: Optional[str] = None,
140
+ from_token: Optional[str] = None,
141
+ to_network: Optional[str] = None,
142
+ to_token: Optional[str] = None,
143
+ amount: Optional[float] = None,
144
+ ):
145
+ """Update the swap intent and surface the next question or final metadata.
146
 
147
+ Call this tool whenever the user provides new swap details. Supply only the
148
+ fields that were mentioned in the latest message (leave the others as None)
149
+ and keep calling it until the response event becomes 'swap_intent_ready'.
150
  """
151
+
152
+ intent_key = user_id or _DEFAULT_USER_ID
153
+ intent = _INTENTS.get(intent_key) or SwapIntent(user_id=intent_key)
154
+ if user_id:
155
+ intent.user_id = user_id
156
+ _INTENTS[intent_key] = intent
157
+
158
  try:
159
+ if from_network is not None:
160
+ intent.from_network = _validate_network(from_network)
161
+
162
+ if intent.from_network is None and from_token is not None:
163
+ return _response(
164
+ intent,
165
+ "From which network?",
166
+ list(SwapConfig.list_networks()),
167
+ )
168
+
169
+ if from_token is not None:
170
+ intent.from_token = _validate_token(from_token, intent.from_network)
171
+
172
+ if to_network is not None:
173
+ intent.to_network = _validate_network(to_network)
174
+
175
+ if intent.to_network is None and to_token is not None:
176
+ return _response(
177
+ intent,
178
+ "To which network?",
179
+ list(SwapConfig.list_networks()),
180
+ )
181
+
182
+ if to_token is not None:
183
+ intent.to_token = _validate_token(to_token, intent.to_network)
184
+
185
+ if amount is not None:
186
+ intent.amount = amount
187
+
188
+ if intent.from_network and intent.to_network:
189
+ _validate_route(intent.from_network, intent.to_network)
190
+
191
+ except ValueError as exc:
192
+ message = str(exc)
193
+ lowered = message.lower()
194
+ if "network" in lowered:
195
+ return _response(
196
+ intent,
197
+ "Choose a network.",
198
+ list(SwapConfig.list_networks()),
199
+ error=message,
200
+ )
201
+ if "token" in lowered and intent.from_network:
202
+ return _response(
203
+ intent,
204
+ f"Choose a token on {intent.from_network}.",
205
+ list(SwapConfig.list_tokens(intent.from_network)),
206
+ error=message,
207
+ )
208
+ return _response(intent, "Please correct the input.", error=message)
209
+
210
+ if intent.from_network is None:
211
+ return _response(
212
+ intent,
213
+ "From which network?",
214
+ list(SwapConfig.list_networks()),
215
+ )
216
+ if intent.from_token is None:
217
+ return _response(
218
+ intent,
219
+ f"Which token on {intent.from_network}?",
220
+ list(SwapConfig.list_tokens(intent.from_network)),
221
+ )
222
+ if intent.to_network is None:
223
+ return _response(
224
+ intent,
225
+ "To which network?",
226
+ list(SwapConfig.list_networks()),
227
+ )
228
+ if intent.to_token is None:
229
+ return _response(
230
+ intent,
231
+ f"Which token on {intent.to_network}?",
232
+ list(SwapConfig.list_tokens(intent.to_network)),
233
+ )
234
+ if intent.amount is None:
235
+ denom = intent.from_token
236
+ return _response(intent, f"What is the amount in {denom}?")
237
 
 
238
  meta = {
239
+ "event": "swap_intent_ready",
240
+ "from_network": intent.from_network,
241
+ "from_token": intent.from_token,
242
+ "to_network": intent.to_network,
243
+ "to_token": intent.to_token,
244
+ "amount": intent.amount,
245
  }
246
  metadata.set_swap_agent(meta)
247
+ return _response(intent, ask=None, done=True)
248
 
 
 
 
 
 
 
249
 
250
+ class ListTokensInput(BaseModel):
251
+ network: str
252
+
253
+ @field_validator("network", mode="before")
254
+ @classmethod
255
+ def _norm_network(cls, value: str) -> str:
256
+ return value.lower() if isinstance(value, str) else value
257
+
258
+
259
+ @tool("list_tokens", args_schema=ListTokensInput)
260
+ def list_tokens_tool(network: str):
261
+ """List the supported tokens for a given network."""
262
+
263
+ try:
264
+ canonical = _validate_network(network)
265
+ return {
266
+ "network": canonical,
267
+ "tokens": list(SwapConfig.list_tokens(canonical)),
268
+ }
269
+ except ValueError as exc:
270
+ return {
271
+ "error": str(exc),
272
+ "choices": list(SwapConfig.list_networks()),
273
+ }
274
+
275
+
276
+ @tool("list_networks")
277
+ def list_networks_tool():
278
+ """List supported networks."""
279
+
280
+ return {"networks": list(SwapConfig.list_networks())}
281
 
282
 
283
  def get_tools():
284
+ return [update_swap_intent_tool, list_tokens_tool, list_networks_tool]
src/app.py CHANGED
@@ -17,6 +17,7 @@ from src.models.chatMessage import ChatMessage
17
  from src.routes.chat_manager_routes import router as chat_manager_router
18
  from src.service.chat_manager import chat_manager_instance
19
  from src.agents.crypto_data.tools import get_coingecko_id, get_tradingview_symbol
 
20
 
21
  # Initialize FastAPI app
22
  app = FastAPI(title="Zico Agent API", version="1.0")
@@ -107,6 +108,7 @@ def _map_agent_type(agent_name: str) -> str:
107
  "crypto_agent": "crypto data",
108
  "default_agent": "default",
109
  "database_agent": "analysis",
 
110
  "swap_agent": "token swap",
111
  "supervisor": "supervisor",
112
  }
@@ -115,9 +117,6 @@ def _map_agent_type(agent_name: str) -> str:
115
  @app.get("/health")
116
  def health_check():
117
  return {"status": "ok"}
118
- @app.get("/")
119
- def health_check_root():
120
- return {"status": "ok"}
121
 
122
  @app.get("/chat/messages")
123
  def get_messages(request: Request):
@@ -161,10 +160,16 @@ def chat(request: ChatRequest):
161
 
162
  # Build response metadata and enrich with coin info for crypto price queries
163
  response_metadata = {"supervisor_result": result}
 
164
  # Prefer supervisor-provided metadata
165
  if isinstance(result, dict) and result.get("metadata"):
166
  response_metadata.update(result.get("metadata") or {})
167
- print("response_metadata: ", response_metadata)
 
 
 
 
 
168
 
169
  # Create a ChatMessage from the supervisor response
170
  response_message = ChatMessage(
@@ -185,12 +190,26 @@ def chat(request: ChatRequest):
185
  conversation_id=request.conversation_id,
186
  user_id=request.user_id
187
  )
188
-
189
  # Return only the clean response
190
- return {
191
  "response": result.get("response", "No response available"),
192
- "agentName": agent_name
193
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  return {"response": "No response available", "agent": "supervisor"}
196
  except Exception as e:
 
17
  from src.routes.chat_manager_routes import router as chat_manager_router
18
  from src.service.chat_manager import chat_manager_instance
19
  from src.agents.crypto_data.tools import get_coingecko_id, get_tradingview_symbol
20
+ from src.agents.metadata import metadata
21
 
22
  # Initialize FastAPI app
23
  app = FastAPI(title="Zico Agent API", version="1.0")
 
108
  "crypto_agent": "crypto data",
109
  "default_agent": "default",
110
  "database_agent": "analysis",
111
+ "search_agent": "realtime search",
112
  "swap_agent": "token swap",
113
  "supervisor": "supervisor",
114
  }
 
117
  @app.get("/health")
118
  def health_check():
119
  return {"status": "ok"}
 
 
 
120
 
121
  @app.get("/chat/messages")
122
  def get_messages(request: Request):
 
160
 
161
  # Build response metadata and enrich with coin info for crypto price queries
162
  response_metadata = {"supervisor_result": result}
163
+ swap_meta_snapshot = None
164
  # Prefer supervisor-provided metadata
165
  if isinstance(result, dict) and result.get("metadata"):
166
  response_metadata.update(result.get("metadata") or {})
167
+ elif agent_name == "token swap":
168
+ swap_meta = metadata.get_swap_agent()
169
+ if swap_meta:
170
+ response_metadata.update(swap_meta)
171
+ swap_meta_snapshot = swap_meta
172
+ print("response_metadata: ", response_metadata)
173
 
174
  # Create a ChatMessage from the supervisor response
175
  response_message = ChatMessage(
 
190
  conversation_id=request.conversation_id,
191
  user_id=request.user_id
192
  )
193
+
194
  # Return only the clean response
195
+ response_payload = {
196
  "response": result.get("response", "No response available"),
197
+ "agentName": agent_name,
198
  }
199
+ response_meta = result.get("metadata") or {}
200
+ if agent_name == "token swap" and not response_meta:
201
+ if swap_meta_snapshot:
202
+ response_meta = swap_meta_snapshot
203
+ else:
204
+ swap_meta = metadata.get_swap_agent()
205
+ if swap_meta:
206
+ response_meta = swap_meta
207
+ metadata.set_swap_agent({})
208
+ if response_meta:
209
+ response_payload["metadata"] = response_meta
210
+ if agent_name == "token swap":
211
+ metadata.set_swap_agent({})
212
+ return response_payload
213
 
214
  return {"response": "No response available", "agent": "supervisor"}
215
  except Exception as e: