Spaces:
Running
Running
add: swap agent
Browse files- requirements.txt +3 -1
- src/agents/search/agent.py +21 -0
- src/agents/search/tools.py +59 -0
- src/agents/supervisor/agent.py +201 -42
- src/agents/swap/config.py +122 -29
- src/agents/swap/tools.py +267 -32
- src/app.py +26 -7
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 =
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# Conditionally include database agent
|
| 42 |
if is_database_available():
|
| 43 |
databaseAgent = DatabaseAgent(llm)
|
| 44 |
agents.append(databaseAgent)
|
| 45 |
-
available_agents_text +=
|
|
|
|
|
|
|
| 46 |
else:
|
| 47 |
databaseAgent = None
|
| 48 |
|
| 49 |
swapAgent = SwapAgent(llm)
|
| 50 |
agents.append(swapAgent.agent)
|
| 51 |
-
available_agents_text +=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
defaultAgent = DefaultAgent(llm)
|
| 54 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
"
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
@classmethod
|
| 20 |
-
def
|
| 21 |
-
"""Return
|
| 22 |
-
return (
|
| 23 |
|
| 24 |
@classmethod
|
| 25 |
-
def
|
| 26 |
-
"""
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
@classmethod
|
| 30 |
-
def
|
| 31 |
-
"""
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
raise ValueError(
|
| 35 |
-
f"Unsupported token '{
|
| 36 |
)
|
| 37 |
return canonical
|
| 38 |
|
| 39 |
@classmethod
|
| 40 |
-
def
|
| 41 |
-
"""Return a
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 17 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
try:
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
print(f"Swapping {amount} {canonical_from} for {canonical_to}")
|
| 25 |
meta = {
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
metadata.set_swap_agent(meta)
|
| 31 |
-
return
|
| 32 |
|
| 33 |
-
@tool
|
| 34 |
-
def get_avaialble_tokens():
|
| 35 |
-
"""
|
| 36 |
-
Get the available tokens for swapping
|
| 37 |
-
"""
|
| 38 |
-
return SwapConfig.list_supported()
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
def get_tools():
|
| 49 |
-
return [
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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:
|