Spaces:
Running
Running
add: improvements swap agent deploy
Browse files- src/agents/metadata.py +43 -9
- src/agents/supervisor/agent.py +85 -8
- src/agents/swap/config.py +134 -137
- src/agents/swap/prompt.py +37 -0
- src/agents/swap/registry.json +94 -0
- src/agents/swap/storage.py +228 -0
- src/agents/swap/swap_state.json +63 -0
- src/agents/swap/tools.py +328 -59
- src/app.py +52 -13
- src/service/chat_manager.py +2 -0
src/agents/metadata.py
CHANGED
|
@@ -1,18 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
class Metadata:
|
| 2 |
def __init__(self):
|
| 3 |
-
self.crypto_data_agent = {}
|
| 4 |
-
self.
|
| 5 |
|
| 6 |
def get_crypto_data_agent(self):
|
| 7 |
return self.crypto_data_agent
|
| 8 |
-
|
| 9 |
def set_crypto_data_agent(self, crypto_data_agent):
|
| 10 |
self.crypto_data_agent = crypto_data_agent
|
| 11 |
|
| 12 |
-
def get_swap_agent(self):
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
metadata = Metadata()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
|
| 5 |
+
from src.agents.swap.storage import SwapStateRepository
|
| 6 |
+
|
| 7 |
+
|
| 8 |
class Metadata:
|
| 9 |
def __init__(self):
|
| 10 |
+
self.crypto_data_agent: Dict[str, Any] = {}
|
| 11 |
+
self._swap_repo = SwapStateRepository.instance()
|
| 12 |
|
| 13 |
def get_crypto_data_agent(self):
|
| 14 |
return self.crypto_data_agent
|
| 15 |
+
|
| 16 |
def set_crypto_data_agent(self, crypto_data_agent):
|
| 17 |
self.crypto_data_agent = crypto_data_agent
|
| 18 |
|
| 19 |
+
def get_swap_agent(self, user_id: str | None = None, conversation_id: str | None = None):
|
| 20 |
+
try:
|
| 21 |
+
return self._swap_repo.get_metadata(user_id, conversation_id)
|
| 22 |
+
except ValueError:
|
| 23 |
+
return {}
|
| 24 |
+
|
| 25 |
+
def set_swap_agent(
|
| 26 |
+
self,
|
| 27 |
+
swap_agent: Dict[str, Any] | None,
|
| 28 |
+
user_id: str | None = None,
|
| 29 |
+
conversation_id: str | None = None,
|
| 30 |
+
):
|
| 31 |
+
try:
|
| 32 |
+
if swap_agent:
|
| 33 |
+
self._swap_repo.set_metadata(user_id, conversation_id, swap_agent)
|
| 34 |
+
else:
|
| 35 |
+
self._swap_repo.clear_metadata(user_id, conversation_id)
|
| 36 |
+
except ValueError:
|
| 37 |
+
# Ignore clears when identity is missing; no actionable state to update.
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
def get_swap_history(
|
| 41 |
+
self,
|
| 42 |
+
user_id: str | None = None,
|
| 43 |
+
conversation_id: str | None = None,
|
| 44 |
+
limit: int | None = None,
|
| 45 |
+
):
|
| 46 |
+
try:
|
| 47 |
+
return self._swap_repo.get_history(user_id, conversation_id, limit)
|
| 48 |
+
except ValueError:
|
| 49 |
+
return []
|
| 50 |
+
|
| 51 |
|
| 52 |
+
metadata = Metadata()
|
src/agents/supervisor/agent.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 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
|
|
@@ -12,6 +13,8 @@ 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 |
|
|
@@ -55,7 +58,8 @@ class Supervisor:
|
|
| 55 |
databaseAgent = None
|
| 56 |
|
| 57 |
swapAgent = SwapAgent(llm)
|
| 58 |
-
|
|
|
|
| 59 |
available_agents_text += (
|
| 60 |
"- swap_agent: Handles swap operations on the Avalanche network and any other swap question related.\n"
|
| 61 |
)
|
|
@@ -120,6 +124,9 @@ class Supervisor:
|
|
| 120 |
CryptoConfig.API_ERROR_MESSAGE.lower(),
|
| 121 |
}
|
| 122 |
|
|
|
|
|
|
|
|
|
|
| 123 |
# Prepare database guidance text to avoid backslashes in f-string expressions
|
| 124 |
if databaseAgent:
|
| 125 |
database_instruction = "When a user asks for data analysis, database queries, or information from the database, delegate to the database_agent."
|
|
@@ -297,6 +304,25 @@ Examples of general queries to handle directly:
|
|
| 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
|
|
@@ -389,8 +415,21 @@ Examples of general queries to handle directly:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
if agent_name == "crypto_agent":
|
| 395 |
tool_meta = self._collect_tool_metadata(messages_out)
|
| 396 |
if tool_meta:
|
|
@@ -398,8 +437,15 @@ Examples of general queries to handle directly:
|
|
| 398 |
return metadata.get_crypto_data_agent() or {}
|
| 399 |
return {}
|
| 400 |
|
| 401 |
-
def invoke(
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
|
| 404 |
langchain_messages = []
|
| 405 |
for msg in messages:
|
|
@@ -410,9 +456,37 @@ Examples of general queries to handle directly:
|
|
| 410 |
elif msg.get("role") == "assistant":
|
| 411 |
langchain_messages.append(AIMessage(content=msg.get("content", "")))
|
| 412 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
try:
|
| 414 |
-
|
| 415 |
-
|
|
|
|
| 416 |
except Exception as e:
|
| 417 |
print(f"Error in Supervisor: {e}")
|
| 418 |
return {
|
|
@@ -457,6 +531,9 @@ Examples of general queries to handle directly:
|
|
| 457 |
print("cleaned_response: ", cleaned_response)
|
| 458 |
print("final_agent: ", final_agent)
|
| 459 |
|
|
|
|
|
|
|
|
|
|
| 460 |
return {
|
| 461 |
"messages": messages_out,
|
| 462 |
"agent": final_agent,
|
|
|
|
| 1 |
from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
|
| 2 |
from langgraph_supervisor import create_supervisor
|
| 3 |
+
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
| 4 |
from src.agents.config import Config
|
| 5 |
+
from typing import TypedDict, Literal, List, Any, Tuple, Optional
|
| 6 |
import re
|
| 7 |
import json
|
| 8 |
from src.agents.metadata import metadata
|
|
|
|
| 13 |
from src.agents.database.agent import DatabaseAgent
|
| 14 |
from src.agents.default.agent import DefaultAgent
|
| 15 |
from src.agents.swap.agent import SwapAgent
|
| 16 |
+
from src.agents.swap.tools import swap_session
|
| 17 |
+
from src.agents.swap.prompt import SWAP_AGENT_SYSTEM_PROMPT
|
| 18 |
from src.agents.search.agent import SearchAgent
|
| 19 |
from src.agents.database.client import is_database_available
|
| 20 |
|
|
|
|
| 58 |
databaseAgent = None
|
| 59 |
|
| 60 |
swapAgent = SwapAgent(llm)
|
| 61 |
+
self.swap_agent = swapAgent.agent
|
| 62 |
+
agents.append(self.swap_agent)
|
| 63 |
available_agents_text += (
|
| 64 |
"- swap_agent: Handles swap operations on the Avalanche network and any other swap question related.\n"
|
| 65 |
)
|
|
|
|
| 124 |
CryptoConfig.API_ERROR_MESSAGE.lower(),
|
| 125 |
}
|
| 126 |
|
| 127 |
+
self._active_user_id: str | None = None
|
| 128 |
+
self._active_conversation_id: str | None = None
|
| 129 |
+
|
| 130 |
# Prepare database guidance text to avoid backslashes in f-string expressions
|
| 131 |
if databaseAgent:
|
| 132 |
database_instruction = "When a user asks for data analysis, database queries, or information from the database, delegate to the database_agent."
|
|
|
|
| 304 |
return art
|
| 305 |
return {}
|
| 306 |
|
| 307 |
+
def _invoke_swap_agent(self, langchain_messages):
|
| 308 |
+
scoped_messages = [SystemMessage(content=SWAP_AGENT_SYSTEM_PROMPT)]
|
| 309 |
+
scoped_messages.extend(langchain_messages)
|
| 310 |
+
try:
|
| 311 |
+
with swap_session(
|
| 312 |
+
user_id=self._active_user_id,
|
| 313 |
+
conversation_id=self._active_conversation_id,
|
| 314 |
+
):
|
| 315 |
+
response = self.swap_agent.invoke({"messages": scoped_messages})
|
| 316 |
+
except Exception as exc:
|
| 317 |
+
print(f"Error invoking swap agent directly: {exc}")
|
| 318 |
+
return None
|
| 319 |
+
|
| 320 |
+
if not response:
|
| 321 |
+
return None
|
| 322 |
+
|
| 323 |
+
agent, text, messages_out = self._extract_response_from_graph(response)
|
| 324 |
+
return agent, text, messages_out
|
| 325 |
+
|
| 326 |
def _extract_response_from_graph(self, response: Any) -> Tuple[str, str, list]:
|
| 327 |
messages_out = response.get("messages", []) if isinstance(response, dict) else []
|
| 328 |
final_response = None
|
|
|
|
| 415 |
|
| 416 |
def _build_metadata(self, agent_name: str, messages_out) -> dict:
|
| 417 |
if agent_name == "swap_agent":
|
| 418 |
+
swap_meta = metadata.get_swap_agent(
|
| 419 |
+
user_id=self._active_user_id,
|
| 420 |
+
conversation_id=self._active_conversation_id,
|
| 421 |
+
)
|
| 422 |
+
if swap_meta:
|
| 423 |
+
history = metadata.get_swap_history(
|
| 424 |
+
user_id=self._active_user_id,
|
| 425 |
+
conversation_id=self._active_conversation_id,
|
| 426 |
+
)
|
| 427 |
+
if history:
|
| 428 |
+
swap_meta = swap_meta.copy()
|
| 429 |
+
swap_meta.setdefault("history", history)
|
| 430 |
+
else:
|
| 431 |
+
swap_meta = swap_meta.copy()
|
| 432 |
+
return swap_meta if swap_meta else {}
|
| 433 |
if agent_name == "crypto_agent":
|
| 434 |
tool_meta = self._collect_tool_metadata(messages_out)
|
| 435 |
if tool_meta:
|
|
|
|
| 437 |
return metadata.get_crypto_data_agent() or {}
|
| 438 |
return {}
|
| 439 |
|
| 440 |
+
def invoke(
|
| 441 |
+
self,
|
| 442 |
+
messages: List[ChatMessage],
|
| 443 |
+
conversation_id: Optional[str] = None,
|
| 444 |
+
user_id: Optional[str] = None,
|
| 445 |
+
) -> dict:
|
| 446 |
+
self._active_user_id = user_id
|
| 447 |
+
self._active_conversation_id = conversation_id
|
| 448 |
+
swap_state = metadata.get_swap_agent(user_id=user_id, conversation_id=conversation_id)
|
| 449 |
|
| 450 |
langchain_messages = []
|
| 451 |
for msg in messages:
|
|
|
|
| 456 |
elif msg.get("role") == "assistant":
|
| 457 |
langchain_messages.append(AIMessage(content=msg.get("content", "")))
|
| 458 |
|
| 459 |
+
if swap_state and swap_state.get("status") == "collecting":
|
| 460 |
+
swap_result = self._invoke_swap_agent(langchain_messages)
|
| 461 |
+
if swap_result:
|
| 462 |
+
final_agent, cleaned_response, messages_out = swap_result
|
| 463 |
+
meta = self._build_metadata(final_agent, messages_out)
|
| 464 |
+
self._active_user_id = None
|
| 465 |
+
self._active_conversation_id = None
|
| 466 |
+
return {
|
| 467 |
+
"messages": messages_out,
|
| 468 |
+
"agent": final_agent,
|
| 469 |
+
"response": cleaned_response or "Sorry, no meaningful response was returned.",
|
| 470 |
+
"metadata": meta,
|
| 471 |
+
}
|
| 472 |
+
# If direct swap invocation failed, fall through to supervisor graph with hints
|
| 473 |
+
next_field = swap_state.get("next_field")
|
| 474 |
+
pending_question = swap_state.get("pending_question")
|
| 475 |
+
guidance_parts = [
|
| 476 |
+
"There is an in-progress token swap intent for this conversation.",
|
| 477 |
+
"Keep routing messages to the swap_agent until the intent is complete unless the user explicitly cancels or changes topic.",
|
| 478 |
+
]
|
| 479 |
+
if next_field:
|
| 480 |
+
guidance_parts.append(f"The next field to collect is: {next_field}.")
|
| 481 |
+
if pending_question:
|
| 482 |
+
guidance_parts.append(f"Continue the swap flow by asking: {pending_question}")
|
| 483 |
+
guidance_text = " ".join(guidance_parts)
|
| 484 |
+
langchain_messages.insert(0, SystemMessage(content=guidance_text))
|
| 485 |
+
|
| 486 |
try:
|
| 487 |
+
with swap_session(user_id=user_id, conversation_id=conversation_id):
|
| 488 |
+
response = self.app.invoke({"messages": langchain_messages})
|
| 489 |
+
print("DEBUG: response", response)
|
| 490 |
except Exception as e:
|
| 491 |
print(f"Error in Supervisor: {e}")
|
| 492 |
return {
|
|
|
|
| 531 |
print("cleaned_response: ", cleaned_response)
|
| 532 |
print("final_agent: ", final_agent)
|
| 533 |
|
| 534 |
+
self._active_user_id = None
|
| 535 |
+
self._active_conversation_id = None
|
| 536 |
+
|
| 537 |
return {
|
| 538 |
"messages": messages_out,
|
| 539 |
"agent": final_agent,
|
src/agents/swap/config.py
CHANGED
|
@@ -1,153 +1,139 @@
|
|
| 1 |
-
"""Network-aware swap configuration helpers."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class SwapConfig:
|
| 9 |
"""Expose swap metadata so tools can validate user input safely."""
|
| 10 |
|
| 11 |
-
|
| 12 |
-
_NETWORK_TOKENS: Dict[str, Set[str]] = {
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
"UNI",
|
| 17 |
-
"USDC",
|
| 18 |
-
"USDT",
|
| 19 |
-
"AAVE",
|
| 20 |
-
"BTC.B",
|
| 21 |
-
"JOE",
|
| 22 |
-
"MIM",
|
| 23 |
-
},
|
| 24 |
-
"ethereum": {
|
| 25 |
-
"ETH",
|
| 26 |
-
"WETH",
|
| 27 |
-
"USDC",
|
| 28 |
-
"USDT",
|
| 29 |
-
"WBTC",
|
| 30 |
-
"AAVE",
|
| 31 |
-
"UNI",
|
| 32 |
-
"LINK",
|
| 33 |
-
"LDO",
|
| 34 |
-
"USDE",
|
| 35 |
-
"DAI",
|
| 36 |
-
},
|
| 37 |
-
"binance-smart-chain": {
|
| 38 |
-
"USDT",
|
| 39 |
-
"USDC",
|
| 40 |
-
"CAKE",
|
| 41 |
-
"ADA",
|
| 42 |
-
"DOGE",
|
| 43 |
-
"XRP",
|
| 44 |
-
"DOT",
|
| 45 |
-
"TUSD",
|
| 46 |
-
},
|
| 47 |
-
"polygon": {
|
| 48 |
-
"USDT",
|
| 49 |
-
"USDC",
|
| 50 |
-
"WETH",
|
| 51 |
-
"DAI",
|
| 52 |
-
"QUICK",
|
| 53 |
-
"AAVE",
|
| 54 |
-
"SAND",
|
| 55 |
-
},
|
| 56 |
-
"arbitrum": {
|
| 57 |
-
"ARB",
|
| 58 |
-
"USDT",
|
| 59 |
-
"USDC",
|
| 60 |
-
"ETH",
|
| 61 |
-
"GMX",
|
| 62 |
-
},
|
| 63 |
-
"base": {
|
| 64 |
-
"USDC",
|
| 65 |
-
"ETH",
|
| 66 |
-
"CBBTC",
|
| 67 |
-
"AERO",
|
| 68 |
-
},
|
| 69 |
-
"optimism": {
|
| 70 |
-
"USDC",
|
| 71 |
-
"USDT",
|
| 72 |
-
"OP",
|
| 73 |
-
},
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
# Friendly aliases -> canonical keys
|
| 77 |
-
_NETWORK_ALIASES: Dict[str, str] = {
|
| 78 |
-
"avax": "avalanche",
|
| 79 |
-
"avalanche": "avalanche",
|
| 80 |
-
"ethereum": "ethereum",
|
| 81 |
-
"eth": "ethereum",
|
| 82 |
-
"ethereum mainnet": "ethereum",
|
| 83 |
-
"binance smart chain": "binance-smart-chain",
|
| 84 |
-
"binance-smart-chain": "binance-smart-chain",
|
| 85 |
-
"bsc": "binance-smart-chain",
|
| 86 |
-
"bnb": "binance-smart-chain",
|
| 87 |
-
"polygon": "polygon",
|
| 88 |
-
"matic": "polygon",
|
| 89 |
-
"arbitrum": "arbitrum",
|
| 90 |
-
"arbitrum one": "arbitrum",
|
| 91 |
-
"base": "base",
|
| 92 |
-
"optimism": "optimism",
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
_TOKEN_ALIASES: Dict[str, str] = {
|
| 96 |
-
"avax": "AVAX",
|
| 97 |
-
"wavax": "WAVAX",
|
| 98 |
-
"usdc": "USDC",
|
| 99 |
-
"usdt": "USDT",
|
| 100 |
-
"dai": "DAI",
|
| 101 |
-
"btc.b": "BTC.B",
|
| 102 |
-
"btcb": "BTC.B",
|
| 103 |
-
"wbtc": "WBTC",
|
| 104 |
-
"eth": "ETH",
|
| 105 |
-
"weth": "WETH",
|
| 106 |
-
"uni": "UNI",
|
| 107 |
-
"aave": "AAVE",
|
| 108 |
-
"joe": "JOE",
|
| 109 |
-
"mim": "MIM",
|
| 110 |
-
"link": "LINK",
|
| 111 |
-
"chainlink": "LINK",
|
| 112 |
-
"ldo": "LDO",
|
| 113 |
-
"usde": "USDE",
|
| 114 |
-
"cake": "CAKE",
|
| 115 |
-
"ada": "ADA",
|
| 116 |
-
"doge": "DOGE",
|
| 117 |
-
"xrp": "XRP",
|
| 118 |
-
"dot": "DOT",
|
| 119 |
-
"tusd": "TUSD",
|
| 120 |
-
"quick": "QUICK",
|
| 121 |
-
"sand": "SAND",
|
| 122 |
-
"arb": "ARB",
|
| 123 |
-
"gmx": "GMX",
|
| 124 |
-
"cbbtc": "CBBTC",
|
| 125 |
-
"cb-btc": "CBBTC",
|
| 126 |
-
"aero": "AERO",
|
| 127 |
-
"op": "OP",
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
# Optional allow list of directional routes (canonical network names).
|
| 131 |
_SUPPORTED_ROUTES: Set[Tuple[str, str]] = set()
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
# ---------- Public helpers ----------
|
| 137 |
@classmethod
|
| 138 |
def list_networks(cls) -> Iterable[str]:
|
| 139 |
"""Return supported networks in a stable order."""
|
|
|
|
| 140 |
return sorted(cls._NETWORK_TOKENS.keys())
|
| 141 |
|
| 142 |
@classmethod
|
| 143 |
def list_tokens(cls, network: str) -> Iterable[str]:
|
| 144 |
"""Return supported tokens for a given network."""
|
|
|
|
| 145 |
normalized = cls._normalize_network(network)
|
| 146 |
-
|
|
|
|
| 147 |
raise ValueError(
|
| 148 |
f"Unsupported network '{network}'. Available: {sorted(cls._NETWORK_TOKENS)}"
|
| 149 |
)
|
| 150 |
-
return sorted(
|
| 151 |
|
| 152 |
@classmethod
|
| 153 |
def validate_network(cls, network: str) -> str:
|
|
@@ -157,6 +143,7 @@ class SwapConfig:
|
|
| 157 |
@classmethod
|
| 158 |
def validate_or_raise(cls, token: str, network: Optional[str] = None) -> str:
|
| 159 |
"""Validate a token, optionally scoping by network, and return canonical symbol."""
|
|
|
|
| 160 |
canonical = cls._normalize_token(token)
|
| 161 |
if network is not None:
|
| 162 |
normalized_network = cls._normalize_network(network)
|
|
@@ -165,27 +152,39 @@ class SwapConfig:
|
|
| 165 |
raise ValueError(
|
| 166 |
f"Unsupported token '{token}' on {normalized_network}. Available: {sorted(tokens)}"
|
| 167 |
)
|
| 168 |
-
elif canonical not in cls.
|
| 169 |
raise ValueError(
|
| 170 |
-
f"Unsupported token '{token}'. Supported tokens: {sorted(cls.
|
| 171 |
)
|
| 172 |
return canonical
|
| 173 |
|
| 174 |
@classmethod
|
| 175 |
def routes_supported(cls, from_network: str, to_network: str) -> bool:
|
| 176 |
"""Return whether a swap route is supported."""
|
|
|
|
| 177 |
source = cls._normalize_network(from_network)
|
| 178 |
dest = cls._normalize_network(to_network)
|
| 179 |
return (source, dest) in cls._SUPPORTED_ROUTES
|
| 180 |
|
| 181 |
@classmethod
|
| 182 |
def list_supported(cls) -> Iterable[str]:
|
| 183 |
-
"""
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
# ---------- Internal helpers ----------
|
| 187 |
@classmethod
|
| 188 |
def _normalize_network(cls, network: str) -> str:
|
|
|
|
| 189 |
key = (network or "").strip().lower()
|
| 190 |
if not key:
|
| 191 |
raise ValueError("Network is required.")
|
|
@@ -198,14 +197,12 @@ class SwapConfig:
|
|
| 198 |
|
| 199 |
@classmethod
|
| 200 |
def _normalize_token(cls, token: str) -> str:
|
|
|
|
| 201 |
key = (token or "").strip().lower()
|
| 202 |
if not key:
|
| 203 |
raise ValueError("Token is required.")
|
| 204 |
return cls._TOKEN_ALIASES.get(key, key.upper())
|
| 205 |
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
for chain_tokens in cls._NETWORK_TOKENS.values():
|
| 210 |
-
tokens.update(chain_tokens)
|
| 211 |
-
return tokens
|
|
|
|
| 1 |
+
"""Network-aware swap configuration helpers loaded from a registry file."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, Iterable, Optional, Set, Tuple
|
| 8 |
|
| 9 |
|
| 10 |
class SwapConfig:
|
| 11 |
"""Expose swap metadata so tools can validate user input safely."""
|
| 12 |
|
| 13 |
+
_REGISTRY_PATH: Path = Path(__file__).with_name("registry.json")
|
| 14 |
+
_NETWORK_TOKENS: Dict[str, Set[str]] = {}
|
| 15 |
+
_NETWORK_ALIASES: Dict[str, str] = {}
|
| 16 |
+
_TOKEN_ALIASES: Dict[str, str] = {}
|
| 17 |
+
_TOKEN_DETAILS: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
_SUPPORTED_ROUTES: Set[Tuple[str, str]] = set()
|
| 19 |
+
_GLOBAL_TOKENS: Set[str] = set()
|
| 20 |
+
_LOADED: bool = False
|
| 21 |
+
|
| 22 |
+
# ---------- Registry management ----------
|
| 23 |
+
@classmethod
|
| 24 |
+
def reload(cls) -> None:
|
| 25 |
+
data = cls._load_registry()
|
| 26 |
+
cls._rebuild(data)
|
| 27 |
+
|
| 28 |
+
@classmethod
|
| 29 |
+
def _ensure_loaded(cls) -> None:
|
| 30 |
+
if not cls._LOADED:
|
| 31 |
+
cls.reload()
|
| 32 |
+
|
| 33 |
+
@classmethod
|
| 34 |
+
def _load_registry(cls) -> Dict[str, Any]:
|
| 35 |
+
try:
|
| 36 |
+
raw = cls._REGISTRY_PATH.read_text()
|
| 37 |
+
except FileNotFoundError as exc:
|
| 38 |
+
raise RuntimeError(f"Swap registry not found: {cls._REGISTRY_PATH}") from exc
|
| 39 |
+
try:
|
| 40 |
+
data = json.loads(raw)
|
| 41 |
+
except json.JSONDecodeError as exc:
|
| 42 |
+
raise RuntimeError(f"Invalid JSON registry for swap config: {exc}") from exc
|
| 43 |
+
if not isinstance(data, dict):
|
| 44 |
+
raise RuntimeError("Swap registry must be a JSON object with 'networks'.")
|
| 45 |
+
return data
|
| 46 |
+
|
| 47 |
+
@classmethod
|
| 48 |
+
def _rebuild(cls, data: Dict[str, Any]) -> None:
|
| 49 |
+
network_tokens: Dict[str, Set[str]] = {}
|
| 50 |
+
network_aliases: Dict[str, str] = {}
|
| 51 |
+
token_aliases: Dict[str, str] = {}
|
| 52 |
+
token_details: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
| 53 |
+
global_tokens: Set[str] = set()
|
| 54 |
+
|
| 55 |
+
for network in data.get("networks", []):
|
| 56 |
+
if not isinstance(network, dict):
|
| 57 |
+
continue
|
| 58 |
+
name = (network.get("name") or "").strip().lower()
|
| 59 |
+
if not name:
|
| 60 |
+
continue
|
| 61 |
+
aliases = [name, *(network.get("aliases") or [])]
|
| 62 |
+
for alias in aliases:
|
| 63 |
+
alias_key = (alias or "").strip().lower()
|
| 64 |
+
if alias_key:
|
| 65 |
+
network_aliases[alias_key] = name
|
| 66 |
+
|
| 67 |
+
tokens_for_network: Set[str] = set()
|
| 68 |
+
details_for_network: Dict[str, Dict[str, Any]] = {}
|
| 69 |
+
for token in network.get("tokens", []):
|
| 70 |
+
if not isinstance(token, dict):
|
| 71 |
+
continue
|
| 72 |
+
symbol = (token.get("symbol") or "").strip().upper()
|
| 73 |
+
if not symbol:
|
| 74 |
+
continue
|
| 75 |
+
tokens_for_network.add(symbol)
|
| 76 |
+
clean = dict(token)
|
| 77 |
+
clean["symbol"] = symbol
|
| 78 |
+
details_for_network[symbol] = clean
|
| 79 |
+
global_tokens.add(symbol)
|
| 80 |
+
token_aliases[symbol.lower()] = symbol
|
| 81 |
+
for alias in token.get("aliases", []):
|
| 82 |
+
alias_key = (alias or "").strip().lower()
|
| 83 |
+
if alias_key:
|
| 84 |
+
token_aliases[alias_key] = symbol
|
| 85 |
+
|
| 86 |
+
network_tokens[name] = tokens_for_network
|
| 87 |
+
token_details[name] = details_for_network
|
| 88 |
+
|
| 89 |
+
routes = data.get("routes", "all")
|
| 90 |
+
supported_routes: Set[Tuple[str, str]]
|
| 91 |
+
if routes == "all":
|
| 92 |
+
supported_routes = {
|
| 93 |
+
(src, dst) for src in network_tokens for dst in network_tokens
|
| 94 |
+
}
|
| 95 |
+
else:
|
| 96 |
+
supported_routes = set()
|
| 97 |
+
for route in routes or []:
|
| 98 |
+
if isinstance(route, dict):
|
| 99 |
+
src = (route.get("from") or "").strip().lower()
|
| 100 |
+
dst = (route.get("to") or "").strip().lower()
|
| 101 |
+
elif isinstance(route, (list, tuple)) and len(route) == 2:
|
| 102 |
+
src = (route[0] or "").strip().lower()
|
| 103 |
+
dst = (route[1] or "").strip().lower()
|
| 104 |
+
else:
|
| 105 |
+
continue
|
| 106 |
+
src_key = network_aliases.get(src)
|
| 107 |
+
dst_key = network_aliases.get(dst)
|
| 108 |
+
if src_key and dst_key:
|
| 109 |
+
supported_routes.add((src_key, dst_key))
|
| 110 |
+
|
| 111 |
+
cls._NETWORK_TOKENS = network_tokens
|
| 112 |
+
cls._NETWORK_ALIASES = network_aliases
|
| 113 |
+
cls._TOKEN_ALIASES = token_aliases
|
| 114 |
+
cls._TOKEN_DETAILS = token_details
|
| 115 |
+
cls._GLOBAL_TOKENS = global_tokens
|
| 116 |
+
cls._SUPPORTED_ROUTES = supported_routes
|
| 117 |
+
cls._LOADED = True
|
| 118 |
|
| 119 |
# ---------- Public helpers ----------
|
| 120 |
@classmethod
|
| 121 |
def list_networks(cls) -> Iterable[str]:
|
| 122 |
"""Return supported networks in a stable order."""
|
| 123 |
+
cls._ensure_loaded()
|
| 124 |
return sorted(cls._NETWORK_TOKENS.keys())
|
| 125 |
|
| 126 |
@classmethod
|
| 127 |
def list_tokens(cls, network: str) -> Iterable[str]:
|
| 128 |
"""Return supported tokens for a given network."""
|
| 129 |
+
cls._ensure_loaded()
|
| 130 |
normalized = cls._normalize_network(network)
|
| 131 |
+
tokens = cls._NETWORK_TOKENS.get(normalized)
|
| 132 |
+
if tokens is None:
|
| 133 |
raise ValueError(
|
| 134 |
f"Unsupported network '{network}'. Available: {sorted(cls._NETWORK_TOKENS)}"
|
| 135 |
)
|
| 136 |
+
return sorted(tokens)
|
| 137 |
|
| 138 |
@classmethod
|
| 139 |
def validate_network(cls, network: str) -> str:
|
|
|
|
| 143 |
@classmethod
|
| 144 |
def validate_or_raise(cls, token: str, network: Optional[str] = None) -> str:
|
| 145 |
"""Validate a token, optionally scoping by network, and return canonical symbol."""
|
| 146 |
+
cls._ensure_loaded()
|
| 147 |
canonical = cls._normalize_token(token)
|
| 148 |
if network is not None:
|
| 149 |
normalized_network = cls._normalize_network(network)
|
|
|
|
| 152 |
raise ValueError(
|
| 153 |
f"Unsupported token '{token}' on {normalized_network}. Available: {sorted(tokens)}"
|
| 154 |
)
|
| 155 |
+
elif canonical not in cls._GLOBAL_TOKENS:
|
| 156 |
raise ValueError(
|
| 157 |
+
f"Unsupported token '{token}'. Supported tokens: {sorted(cls._GLOBAL_TOKENS)}"
|
| 158 |
)
|
| 159 |
return canonical
|
| 160 |
|
| 161 |
@classmethod
|
| 162 |
def routes_supported(cls, from_network: str, to_network: str) -> bool:
|
| 163 |
"""Return whether a swap route is supported."""
|
| 164 |
+
cls._ensure_loaded()
|
| 165 |
source = cls._normalize_network(from_network)
|
| 166 |
dest = cls._normalize_network(to_network)
|
| 167 |
return (source, dest) in cls._SUPPORTED_ROUTES
|
| 168 |
|
| 169 |
@classmethod
|
| 170 |
def list_supported(cls) -> Iterable[str]:
|
| 171 |
+
"""Return all supported tokens across networks."""
|
| 172 |
+
cls._ensure_loaded()
|
| 173 |
+
return sorted(cls._GLOBAL_TOKENS)
|
| 174 |
+
|
| 175 |
+
@classmethod
|
| 176 |
+
def get_token_policy(cls, network: str, token: str) -> Dict[str, Any]:
|
| 177 |
+
"""Return token metadata (decimals, min/max amounts) for a network/token pair."""
|
| 178 |
+
cls._ensure_loaded()
|
| 179 |
+
normalized_network = cls._normalize_network(network)
|
| 180 |
+
canonical = cls._normalize_token(token)
|
| 181 |
+
policy = cls._TOKEN_DETAILS.get(normalized_network, {}).get(canonical, {})
|
| 182 |
+
return dict(policy)
|
| 183 |
|
| 184 |
# ---------- Internal helpers ----------
|
| 185 |
@classmethod
|
| 186 |
def _normalize_network(cls, network: str) -> str:
|
| 187 |
+
cls._ensure_loaded()
|
| 188 |
key = (network or "").strip().lower()
|
| 189 |
if not key:
|
| 190 |
raise ValueError("Network is required.")
|
|
|
|
| 197 |
|
| 198 |
@classmethod
|
| 199 |
def _normalize_token(cls, token: str) -> str:
|
| 200 |
+
cls._ensure_loaded()
|
| 201 |
key = (token or "").strip().lower()
|
| 202 |
if not key:
|
| 203 |
raise ValueError("Token is required.")
|
| 204 |
return cls._TOKEN_ALIASES.get(key, key.upper())
|
| 205 |
|
| 206 |
+
|
| 207 |
+
# Ensure the registry is warm on import so validation errors surface early.
|
| 208 |
+
SwapConfig._ensure_loaded()
|
|
|
|
|
|
|
|
|
src/agents/swap/prompt.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompt for the specialized swap agent."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
SWAP_AGENT_SYSTEM_PROMPT = """
|
| 5 |
+
You are Zico's token swap orchestrator.
|
| 6 |
+
|
| 7 |
+
Primary responsibilities:
|
| 8 |
+
1. Collect all swap intent fields (`from_network`, `from_token`, `to_network`, `to_token`, `amount`) by invoking the `update_swap_intent` tool.
|
| 9 |
+
2. Use progressive loading: start with the minimum detail you need, then request the remaining fields one at a time.
|
| 10 |
+
3. Validate user inputs via tools. Ask the user to pick from the returned choices whenever a value is invalid or missing.
|
| 11 |
+
4. Never fabricate swap rates, quotes, or execution results. Only confirm that the intent is ready once the tool reports `event == "swap_intent_ready"`.
|
| 12 |
+
5. Preserve context: if the tool response says the intent is still collecting, summarize what you know, ask the next question, and remind the user how to continue.
|
| 13 |
+
6. Reinforce guardrails: warn if the user requests unsupported networks/tokens or amounts outside the allowed range, and guide them back to valid options.
|
| 14 |
+
|
| 15 |
+
Interaction pattern:
|
| 16 |
+
- ALWAYS call `update_swap_intent` when the user provides new swap information.
|
| 17 |
+
- Use `list_networks` or `list_tokens` before suggesting options, so your choices mirror the backend configuration.
|
| 18 |
+
- After each tool call, read the returned `event`, `ask`, and `next_action` fields to decide whether to ask follow-up questions or conclude the intent.
|
| 19 |
+
|
| 20 |
+
Example 1 – progressive collection:
|
| 21 |
+
User: I want to swap some AVAX to USDC.
|
| 22 |
+
Assistant: (call `update_swap_intent` with `from_token="AVAX"`, `to_token="USDC"`)
|
| 23 |
+
Tool: `ask` -> "From which network?"
|
| 24 |
+
Assistant: "Sure — which network will you be swapping from?"
|
| 25 |
+
User: Avalanche, amount is 12.
|
| 26 |
+
Assistant: (call `update_swap_intent` with `from_network="avalanche"`, `amount=12`)
|
| 27 |
+
Tool: `ask` -> "To which network?"
|
| 28 |
+
Assistant: "Got it. Which destination network do you prefer?"
|
| 29 |
+
|
| 30 |
+
Example 2 – validation and completion:
|
| 31 |
+
User: Swap 50 USDC from Ethereum to WBTC on Arbitrum.
|
| 32 |
+
Assistant: (call `update_swap_intent` with all fields)
|
| 33 |
+
Tool: `event` -> `swap_intent_ready`
|
| 34 |
+
Assistant: "All set. Ready to swap 50 USDC on Ethereum for WBTC on Arbitrum. Let me know if you want to execute or adjust values."
|
| 35 |
+
|
| 36 |
+
Keep responses concise, reference the remaining required field explicitly, and never skip the tool call even if you believe all details are already known.
|
| 37 |
+
"""
|
src/agents/swap/registry.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"routes": "all",
|
| 3 |
+
"networks": [
|
| 4 |
+
{
|
| 5 |
+
"name": "avalanche",
|
| 6 |
+
"aliases": ["avalanche", "avax"],
|
| 7 |
+
"tokens": [
|
| 8 |
+
{"symbol": "AVAX", "aliases": ["avax"], "decimals": 18, "min_amount": "0.0001", "max_amount": "100000"},
|
| 9 |
+
{"symbol": "WAVAX", "aliases": ["wavax"], "decimals": 18, "min_amount": "0.0001", "max_amount": "100000"},
|
| 10 |
+
{"symbol": "UNI", "aliases": ["uni"], "decimals": 18, "min_amount": "0.01", "max_amount": "100000"},
|
| 11 |
+
{"symbol": "USDC", "aliases": ["usdc"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 12 |
+
{"symbol": "USDT", "aliases": ["usdt"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 13 |
+
{"symbol": "AAVE", "aliases": ["aave"], "decimals": 18, "min_amount": "0.01", "max_amount": "250000"},
|
| 14 |
+
{"symbol": "BTC.B", "aliases": ["btc.b", "btcb"], "decimals": 8, "min_amount": "0.0001", "max_amount": "100"},
|
| 15 |
+
{"symbol": "JOE", "aliases": ["joe"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"},
|
| 16 |
+
{"symbol": "MIM", "aliases": ["mim"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"}
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"name": "ethereum",
|
| 21 |
+
"aliases": ["ethereum", "eth", "ethereum mainnet"],
|
| 22 |
+
"tokens": [
|
| 23 |
+
{"symbol": "ETH", "aliases": ["eth"], "decimals": 18, "min_amount": "0.0001", "max_amount": "10000"},
|
| 24 |
+
{"symbol": "WETH", "aliases": ["weth"], "decimals": 18, "min_amount": "0.0001", "max_amount": "10000"},
|
| 25 |
+
{"symbol": "USDC", "aliases": ["usdc"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 26 |
+
{"symbol": "USDT", "aliases": ["usdt"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 27 |
+
{"symbol": "WBTC", "aliases": ["wbtc"], "decimals": 8, "min_amount": "0.0001", "max_amount": "100"},
|
| 28 |
+
{"symbol": "AAVE", "aliases": ["aave"], "decimals": 18, "min_amount": "0.01", "max_amount": "250000"},
|
| 29 |
+
{"symbol": "UNI", "aliases": ["uni"], "decimals": 18, "min_amount": "0.01", "max_amount": "250000"},
|
| 30 |
+
{"symbol": "LINK", "aliases": ["link", "chainlink"], "decimals": 18, "min_amount": "0.01", "max_amount": "500000"},
|
| 31 |
+
{"symbol": "LDO", "aliases": ["ldo"], "decimals": 18, "min_amount": "0.01", "max_amount": "500000"},
|
| 32 |
+
{"symbol": "USDE", "aliases": ["usde"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"},
|
| 33 |
+
{"symbol": "DAI", "aliases": ["dai"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"}
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "binance-smart-chain",
|
| 38 |
+
"aliases": ["binance smart chain", "binance-smart-chain", "bsc", "bnb"],
|
| 39 |
+
"tokens": [
|
| 40 |
+
{"symbol": "USDT", "aliases": ["usdt"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"},
|
| 41 |
+
{"symbol": "USDC", "aliases": ["usdc"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"},
|
| 42 |
+
{"symbol": "CAKE", "aliases": ["cake"], "decimals": 18, "min_amount": "0.1", "max_amount": "1000000"},
|
| 43 |
+
{"symbol": "ADA", "aliases": ["ada"], "decimals": 18, "min_amount": "1", "max_amount": "5000000"},
|
| 44 |
+
{"symbol": "DOGE", "aliases": ["doge"], "decimals": 8, "min_amount": "10", "max_amount": "10000000"},
|
| 45 |
+
{"symbol": "XRP", "aliases": ["xrp"], "decimals": 6, "min_amount": "10", "max_amount": "10000000"},
|
| 46 |
+
{"symbol": "DOT", "aliases": ["dot"], "decimals": 10, "min_amount": "0.1", "max_amount": "100000"},
|
| 47 |
+
{"symbol": "TUSD", "aliases": ["tusd"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"}
|
| 48 |
+
]
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"name": "polygon",
|
| 52 |
+
"aliases": ["polygon", "matic"],
|
| 53 |
+
"tokens": [
|
| 54 |
+
{"symbol": "USDT", "aliases": ["usdt"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 55 |
+
{"symbol": "USDC", "aliases": ["usdc"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 56 |
+
{"symbol": "WETH", "aliases": ["weth"], "decimals": 18, "min_amount": "0.0001", "max_amount": "10000"},
|
| 57 |
+
{"symbol": "DAI", "aliases": ["dai"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"},
|
| 58 |
+
{"symbol": "QUICK", "aliases": ["quick"], "decimals": 18, "min_amount": "0.1", "max_amount": "1000000"},
|
| 59 |
+
{"symbol": "AAVE", "aliases": ["aave"], "decimals": 18, "min_amount": "0.01", "max_amount": "250000"},
|
| 60 |
+
{"symbol": "SAND", "aliases": ["sand"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"}
|
| 61 |
+
]
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"name": "arbitrum",
|
| 65 |
+
"aliases": ["arbitrum", "arbitrum one"],
|
| 66 |
+
"tokens": [
|
| 67 |
+
{"symbol": "ARB", "aliases": ["arb"], "decimals": 18, "min_amount": "0.1", "max_amount": "1000000"},
|
| 68 |
+
{"symbol": "USDT", "aliases": ["usdt"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 69 |
+
{"symbol": "USDC", "aliases": ["usdc"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 70 |
+
{"symbol": "ETH", "aliases": ["eth"], "decimals": 18, "min_amount": "0.0001", "max_amount": "10000"},
|
| 71 |
+
{"symbol": "GMX", "aliases": ["gmx"], "decimals": 18, "min_amount": "0.1", "max_amount": "1000000"}
|
| 72 |
+
]
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"name": "base",
|
| 76 |
+
"aliases": ["base"],
|
| 77 |
+
"tokens": [
|
| 78 |
+
{"symbol": "USDC", "aliases": ["usdc"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 79 |
+
{"symbol": "ETH", "aliases": ["eth"], "decimals": 18, "min_amount": "0.0001", "max_amount": "10000"},
|
| 80 |
+
{"symbol": "CBBTC", "aliases": ["cbbtc", "cb-btc"], "decimals": 8, "min_amount": "0.0001", "max_amount": "100"},
|
| 81 |
+
{"symbol": "AERO", "aliases": ["aero"], "decimals": 18, "min_amount": "0.1", "max_amount": "100000"}
|
| 82 |
+
]
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"name": "optimism",
|
| 86 |
+
"aliases": ["optimism", "op"],
|
| 87 |
+
"tokens": [
|
| 88 |
+
{"symbol": "USDC", "aliases": ["usdc"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 89 |
+
{"symbol": "USDT", "aliases": ["usdt"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
|
| 90 |
+
{"symbol": "OP", "aliases": ["op"], "decimals": 18, "min_amount": "0.1", "max_amount": "1000000"}
|
| 91 |
+
]
|
| 92 |
+
}
|
| 93 |
+
]
|
| 94 |
+
}
|
src/agents/swap/storage.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistent storage for swap intents and metadata."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import copy
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from threading import Lock
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
_STATE_TEMPLATE: Dict[str, Dict[str, Any]] = {
|
| 14 |
+
"intents": {},
|
| 15 |
+
"metadata": {},
|
| 16 |
+
"history": {},
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SwapStateRepository:
|
| 21 |
+
"""File-backed storage for swap agent state with TTL and history support."""
|
| 22 |
+
|
| 23 |
+
_instance: "SwapStateRepository" | None = None
|
| 24 |
+
_instance_lock: Lock = Lock()
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
path: Optional[Path] = None,
|
| 29 |
+
ttl_seconds: int = 3600,
|
| 30 |
+
history_limit: int = 10,
|
| 31 |
+
) -> None:
|
| 32 |
+
env_path = os.getenv("SWAP_STATE_PATH")
|
| 33 |
+
default_path = Path(__file__).with_name("swap_state.json")
|
| 34 |
+
self._path = Path(path or env_path or default_path)
|
| 35 |
+
self._ttl_seconds = ttl_seconds
|
| 36 |
+
self._history_limit = history_limit
|
| 37 |
+
self._lock = Lock()
|
| 38 |
+
self._state: Dict[str, Dict[str, Any]] = copy.deepcopy(_STATE_TEMPLATE)
|
| 39 |
+
self._ensure_parent()
|
| 40 |
+
with self._lock:
|
| 41 |
+
self._load_locked()
|
| 42 |
+
|
| 43 |
+
@classmethod
|
| 44 |
+
def instance(cls) -> "SwapStateRepository":
|
| 45 |
+
if cls._instance is None:
|
| 46 |
+
with cls._instance_lock:
|
| 47 |
+
if cls._instance is None:
|
| 48 |
+
cls._instance = cls()
|
| 49 |
+
return cls._instance
|
| 50 |
+
|
| 51 |
+
@classmethod
|
| 52 |
+
def reset(cls) -> None:
|
| 53 |
+
with cls._instance_lock:
|
| 54 |
+
cls._instance = None
|
| 55 |
+
|
| 56 |
+
def _ensure_parent(self) -> None:
|
| 57 |
+
try:
|
| 58 |
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
except OSError:
|
| 60 |
+
pass
|
| 61 |
+
|
| 62 |
+
def _load_locked(self) -> None:
|
| 63 |
+
if not self._path.exists():
|
| 64 |
+
self._state = copy.deepcopy(_STATE_TEMPLATE)
|
| 65 |
+
return
|
| 66 |
+
try:
|
| 67 |
+
data = json.loads(self._path.read_text())
|
| 68 |
+
except Exception:
|
| 69 |
+
self._state = copy.deepcopy(_STATE_TEMPLATE)
|
| 70 |
+
return
|
| 71 |
+
if not isinstance(data, dict):
|
| 72 |
+
self._state = copy.deepcopy(_STATE_TEMPLATE)
|
| 73 |
+
return
|
| 74 |
+
state = copy.deepcopy(_STATE_TEMPLATE)
|
| 75 |
+
for key in state:
|
| 76 |
+
if isinstance(data.get(key), dict):
|
| 77 |
+
state[key] = data[key]
|
| 78 |
+
self._state = state
|
| 79 |
+
|
| 80 |
+
def _persist_locked(self) -> None:
|
| 81 |
+
self._ensure_parent()
|
| 82 |
+
tmp_path = self._path.with_suffix(".tmp")
|
| 83 |
+
payload = json.dumps(self._state, ensure_ascii=False, indent=2)
|
| 84 |
+
try:
|
| 85 |
+
tmp_path.write_text(payload)
|
| 86 |
+
tmp_path.replace(self._path)
|
| 87 |
+
except Exception:
|
| 88 |
+
pass
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
def _normalize_identifier(value: Optional[str], label: str) -> str:
|
| 92 |
+
candidate = (value or "").strip()
|
| 93 |
+
if not candidate:
|
| 94 |
+
raise ValueError(f"{label} is required for swap state operations.")
|
| 95 |
+
return candidate
|
| 96 |
+
|
| 97 |
+
def _key(self, user_id: Optional[str], conversation_id: Optional[str]) -> str:
|
| 98 |
+
user = self._normalize_identifier(user_id, "user_id")
|
| 99 |
+
conversation = self._normalize_identifier(conversation_id, "conversation_id")
|
| 100 |
+
return f"{user}::{conversation}"
|
| 101 |
+
|
| 102 |
+
def _purge_locked(self) -> None:
|
| 103 |
+
if self._ttl_seconds <= 0:
|
| 104 |
+
return
|
| 105 |
+
cutoff = time.time() - self._ttl_seconds
|
| 106 |
+
intents = self._state["intents"]
|
| 107 |
+
metadata = self._state["metadata"]
|
| 108 |
+
stale_keys = [
|
| 109 |
+
key for key, record in intents.items() if record.get("updated_at", 0) < cutoff
|
| 110 |
+
]
|
| 111 |
+
for key in stale_keys:
|
| 112 |
+
intents.pop(key, None)
|
| 113 |
+
metadata.pop(key, None)
|
| 114 |
+
|
| 115 |
+
@staticmethod
|
| 116 |
+
def _format_timestamp(value: float) -> str:
|
| 117 |
+
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
| 118 |
+
|
| 119 |
+
def _history_unlocked(self, key: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
| 120 |
+
history = self._state["history"].get(key, [])
|
| 121 |
+
sorted_history = sorted(history, key=lambda item: item.get("timestamp", 0), reverse=True)
|
| 122 |
+
effective_limit = limit or self._history_limit
|
| 123 |
+
if effective_limit:
|
| 124 |
+
sorted_history = sorted_history[:effective_limit]
|
| 125 |
+
results: List[Dict[str, Any]] = []
|
| 126 |
+
for item in sorted_history:
|
| 127 |
+
entry = copy.deepcopy(item)
|
| 128 |
+
ts = entry.get("timestamp")
|
| 129 |
+
if ts is not None:
|
| 130 |
+
entry["timestamp"] = self._format_timestamp(float(ts))
|
| 131 |
+
results.append(entry)
|
| 132 |
+
return results
|
| 133 |
+
|
| 134 |
+
def load_intent(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
|
| 135 |
+
key = self._key(user_id, conversation_id)
|
| 136 |
+
with self._lock:
|
| 137 |
+
self._purge_locked()
|
| 138 |
+
record = self._state["intents"].get(key)
|
| 139 |
+
if not record:
|
| 140 |
+
return None
|
| 141 |
+
return copy.deepcopy(record.get("intent"))
|
| 142 |
+
|
| 143 |
+
def persist_intent(
|
| 144 |
+
self,
|
| 145 |
+
user_id: str,
|
| 146 |
+
conversation_id: str,
|
| 147 |
+
intent: Dict[str, Any],
|
| 148 |
+
metadata: Dict[str, Any],
|
| 149 |
+
done: bool,
|
| 150 |
+
summary: Optional[Dict[str, Any]] = None,
|
| 151 |
+
) -> List[Dict[str, Any]]:
|
| 152 |
+
key = self._key(user_id, conversation_id)
|
| 153 |
+
with self._lock:
|
| 154 |
+
self._purge_locked()
|
| 155 |
+
now = time.time()
|
| 156 |
+
if done:
|
| 157 |
+
self._state["intents"].pop(key, None)
|
| 158 |
+
else:
|
| 159 |
+
self._state["intents"][key] = {
|
| 160 |
+
"intent": copy.deepcopy(intent),
|
| 161 |
+
"updated_at": now,
|
| 162 |
+
}
|
| 163 |
+
if metadata:
|
| 164 |
+
meta_copy = copy.deepcopy(metadata)
|
| 165 |
+
meta_copy["updated_at"] = now
|
| 166 |
+
self._state["metadata"][key] = meta_copy
|
| 167 |
+
if done and summary:
|
| 168 |
+
history = self._state["history"].get(key, [])
|
| 169 |
+
summary_copy = copy.deepcopy(summary)
|
| 170 |
+
summary_copy.setdefault("timestamp", now)
|
| 171 |
+
history.append(summary_copy)
|
| 172 |
+
self._state["history"][key] = history[-self._history_limit:]
|
| 173 |
+
try:
|
| 174 |
+
self._persist_locked()
|
| 175 |
+
except Exception:
|
| 176 |
+
pass
|
| 177 |
+
return self._history_unlocked(key)
|
| 178 |
+
|
| 179 |
+
def set_metadata(self, user_id: str, conversation_id: str, metadata: Dict[str, Any]) -> None:
|
| 180 |
+
key = self._key(user_id, conversation_id)
|
| 181 |
+
with self._lock:
|
| 182 |
+
self._purge_locked()
|
| 183 |
+
if metadata:
|
| 184 |
+
meta_copy = copy.deepcopy(metadata)
|
| 185 |
+
meta_copy["updated_at"] = time.time()
|
| 186 |
+
self._state["metadata"][key] = meta_copy
|
| 187 |
+
else:
|
| 188 |
+
self._state["metadata"].pop(key, None)
|
| 189 |
+
try:
|
| 190 |
+
self._persist_locked()
|
| 191 |
+
except Exception:
|
| 192 |
+
pass
|
| 193 |
+
|
| 194 |
+
def clear_metadata(self, user_id: str, conversation_id: str) -> None:
|
| 195 |
+
self.set_metadata(user_id, conversation_id, {})
|
| 196 |
+
|
| 197 |
+
def clear_intent(self, user_id: str, conversation_id: str) -> None:
|
| 198 |
+
key = self._key(user_id, conversation_id)
|
| 199 |
+
with self._lock:
|
| 200 |
+
self._state["intents"].pop(key, None)
|
| 201 |
+
try:
|
| 202 |
+
self._persist_locked()
|
| 203 |
+
except Exception:
|
| 204 |
+
pass
|
| 205 |
+
|
| 206 |
+
def get_metadata(self, user_id: str, conversation_id: str) -> Dict[str, Any]:
|
| 207 |
+
key = self._key(user_id, conversation_id)
|
| 208 |
+
with self._lock:
|
| 209 |
+
self._purge_locked()
|
| 210 |
+
meta = self._state["metadata"].get(key)
|
| 211 |
+
if not meta:
|
| 212 |
+
return {}
|
| 213 |
+
entry = copy.deepcopy(meta)
|
| 214 |
+
ts = entry.pop("updated_at", None)
|
| 215 |
+
if ts is not None:
|
| 216 |
+
entry["updated_at"] = self._format_timestamp(float(ts))
|
| 217 |
+
return entry
|
| 218 |
+
|
| 219 |
+
def get_history(
|
| 220 |
+
self,
|
| 221 |
+
user_id: str,
|
| 222 |
+
conversation_id: str,
|
| 223 |
+
limit: Optional[int] = None,
|
| 224 |
+
) -> List[Dict[str, Any]]:
|
| 225 |
+
key = self._key(user_id, conversation_id)
|
| 226 |
+
with self._lock:
|
| 227 |
+
return self._history_unlocked(key, limit)
|
| 228 |
+
|
src/agents/swap/swap_state.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"intents": {
|
| 3 |
+
"<user_id>::<conversation_id>": {
|
| 4 |
+
"intent": {
|
| 5 |
+
"user_id": "<user_id>",
|
| 6 |
+
"conversation_id": "<conversation_id>",
|
| 7 |
+
"from_network": null,
|
| 8 |
+
"from_token": null,
|
| 9 |
+
"to_network": null,
|
| 10 |
+
"to_token": null,
|
| 11 |
+
"amount": null,
|
| 12 |
+
"updated_at": 1760231993.3835566
|
| 13 |
+
},
|
| 14 |
+
"updated_at": 1760231993.3835785
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
"metadata": {
|
| 18 |
+
"<user_id>::<conversation_id>": {
|
| 19 |
+
"event": "swap_intent_pending",
|
| 20 |
+
"status": "collecting",
|
| 21 |
+
"from_network": null,
|
| 22 |
+
"from_token": null,
|
| 23 |
+
"to_network": null,
|
| 24 |
+
"to_token": null,
|
| 25 |
+
"amount": null,
|
| 26 |
+
"user_id": "<user_id>",
|
| 27 |
+
"conversation_id": "<conversation_id>",
|
| 28 |
+
"missing_fields": [
|
| 29 |
+
"from_network",
|
| 30 |
+
"from_token",
|
| 31 |
+
"to_network",
|
| 32 |
+
"to_token",
|
| 33 |
+
"amount"
|
| 34 |
+
],
|
| 35 |
+
"next_field": "from_network",
|
| 36 |
+
"pending_question": "From which network?",
|
| 37 |
+
"choices": [
|
| 38 |
+
"arbitrum",
|
| 39 |
+
"avalanche",
|
| 40 |
+
"base",
|
| 41 |
+
"binance-smart-chain",
|
| 42 |
+
"ethereum",
|
| 43 |
+
"optimism",
|
| 44 |
+
"polygon"
|
| 45 |
+
],
|
| 46 |
+
"error": null,
|
| 47 |
+
"updated_at": 1760231993.3868954
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
"history": {
|
| 51 |
+
"user123::crypto-conv-1": [
|
| 52 |
+
{
|
| 53 |
+
"status": "ready",
|
| 54 |
+
"from_network": "ethereum",
|
| 55 |
+
"from_token": "LINK",
|
| 56 |
+
"to_network": "avalanche",
|
| 57 |
+
"to_token": "AVAX",
|
| 58 |
+
"amount": "1",
|
| 59 |
+
"timestamp": 1760232078.673952
|
| 60 |
+
}
|
| 61 |
+
]
|
| 62 |
+
}
|
| 63 |
+
}
|
src/agents/swap/tools.py
CHANGED
|
@@ -2,29 +2,58 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
|
| 6 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
@dataclass
|
| 21 |
class SwapIntent:
|
| 22 |
-
user_id: str
|
|
|
|
| 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[
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
def is_complete(self) -> bool:
|
| 30 |
return all(
|
|
@@ -33,7 +62,7 @@ class SwapIntent:
|
|
| 33 |
self.from_token,
|
| 34 |
self.to_network,
|
| 35 |
self.to_token,
|
| 36 |
-
self.amount,
|
| 37 |
]
|
| 38 |
)
|
| 39 |
|
|
@@ -51,18 +80,129 @@ class SwapIntent:
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[
|
| 66 |
|
| 67 |
@field_validator("from_network", "to_network", mode="before")
|
| 68 |
@classmethod
|
|
@@ -74,34 +214,15 @@ class UpdateSwapIntentInput(BaseModel):
|
|
| 74 |
def _norm_token(cls, value: Optional[str]) -> Optional[str]:
|
| 75 |
return value.upper() if isinstance(value, str) else value
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 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 ----------
|
|
@@ -132,15 +253,146 @@ def _validate_route(from_network: str, to_network: str) -> None:
|
|
| 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[
|
| 144 |
):
|
| 145 |
"""Update the swap intent and surface the next question or final metadata.
|
| 146 |
|
|
@@ -149,15 +401,21 @@ def update_swap_intent_tool(
|
|
| 149 |
and keep calling it until the response event becomes 'swap_intent_ready'.
|
| 150 |
"""
|
| 151 |
|
| 152 |
-
|
| 153 |
-
intent =
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
_INTENTS[intent_key] = intent
|
| 157 |
|
| 158 |
try:
|
| 159 |
if from_network is not None:
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
if intent.from_network is None and from_token is not None:
|
| 163 |
return _response(
|
|
@@ -170,7 +428,14 @@ def update_swap_intent_tool(
|
|
| 170 |
intent.from_token = _validate_token(from_token, intent.from_network)
|
| 171 |
|
| 172 |
if to_network is not None:
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
if intent.to_network is None and to_token is not None:
|
| 176 |
return _response(
|
|
@@ -183,7 +448,7 @@ def update_swap_intent_tool(
|
|
| 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)
|
|
@@ -205,6 +470,12 @@ def update_swap_intent_tool(
|
|
| 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:
|
|
@@ -235,16 +506,8 @@ def update_swap_intent_tool(
|
|
| 235 |
denom = intent.from_token
|
| 236 |
return _response(intent, f"What is the amount in {denom}?")
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 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):
|
|
@@ -262,9 +525,15 @@ def list_tokens_tool(network: str):
|
|
| 262 |
|
| 263 |
try:
|
| 264 |
canonical = _validate_network(network)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
return {
|
| 266 |
"network": canonical,
|
| 267 |
-
"tokens":
|
|
|
|
| 268 |
}
|
| 269 |
except ValueError as exc:
|
| 270 |
return {
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import time
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
from contextvars import ContextVar
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from decimal import Decimal, InvalidOperation
|
| 10 |
+
from typing import Any, Dict, List, Optional
|
| 11 |
|
| 12 |
from langchain_core.tools import tool
|
| 13 |
from pydantic import BaseModel, Field, field_validator
|
| 14 |
|
| 15 |
from src.agents.metadata import metadata
|
| 16 |
from src.agents.swap.config import SwapConfig
|
| 17 |
+
from src.agents.swap.storage import SwapStateRepository
|
| 18 |
|
| 19 |
+
|
| 20 |
+
# ---------- Helpers ----------
|
| 21 |
+
_STORE = SwapStateRepository.instance()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _format_decimal(value: Decimal) -> str:
|
| 25 |
+
normalized = value.normalize()
|
| 26 |
+
exponent = normalized.as_tuple().exponent
|
| 27 |
+
if exponent > 0:
|
| 28 |
+
normalized = normalized.quantize(Decimal(1))
|
| 29 |
+
text = format(normalized, "f")
|
| 30 |
+
if "." in text:
|
| 31 |
+
text = text.rstrip("0").rstrip(".")
|
| 32 |
+
return text
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _to_decimal(value: Any) -> Optional[Decimal]:
|
| 36 |
+
if value is None:
|
| 37 |
+
return None
|
| 38 |
+
try:
|
| 39 |
+
return Decimal(str(value))
|
| 40 |
+
except (InvalidOperation, TypeError, ValueError):
|
| 41 |
+
return None
|
| 42 |
|
| 43 |
|
| 44 |
@dataclass
|
| 45 |
class SwapIntent:
|
| 46 |
+
user_id: str
|
| 47 |
+
conversation_id: str
|
| 48 |
from_network: Optional[str] = None
|
| 49 |
from_token: Optional[str] = None
|
| 50 |
to_network: Optional[str] = None
|
| 51 |
to_token: Optional[str] = None
|
| 52 |
+
amount: Optional[Decimal] = None
|
| 53 |
+
updated_at: float = field(default_factory=lambda: time.time())
|
| 54 |
+
|
| 55 |
+
def touch(self) -> None:
|
| 56 |
+
self.updated_at = time.time()
|
| 57 |
|
| 58 |
def is_complete(self) -> bool:
|
| 59 |
return all(
|
|
|
|
| 62 |
self.from_token,
|
| 63 |
self.to_network,
|
| 64 |
self.to_token,
|
| 65 |
+
self.amount is not None,
|
| 66 |
]
|
| 67 |
)
|
| 68 |
|
|
|
|
| 80 |
fields.append("amount")
|
| 81 |
return fields
|
| 82 |
|
| 83 |
+
def amount_as_str(self) -> Optional[str]:
|
| 84 |
+
if self.amount is None:
|
| 85 |
+
return None
|
| 86 |
+
return _format_decimal(self.amount)
|
| 87 |
+
|
| 88 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 89 |
+
return {
|
| 90 |
+
"user_id": self.user_id,
|
| 91 |
+
"conversation_id": self.conversation_id,
|
| 92 |
+
"from_network": self.from_network,
|
| 93 |
+
"from_token": self.from_token,
|
| 94 |
+
"to_network": self.to_network,
|
| 95 |
+
"to_token": self.to_token,
|
| 96 |
+
"amount": self.amount_as_str(),
|
| 97 |
+
"updated_at": self.updated_at,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
def to_public(self) -> Dict[str, Optional[str]]:
|
| 101 |
+
public = self.to_dict()
|
| 102 |
+
public["amount"] = self.amount_as_str()
|
| 103 |
+
return public
|
| 104 |
+
|
| 105 |
+
def to_summary(self, status: str, error: Optional[str] = None) -> Dict[str, Any]:
|
| 106 |
+
summary: Dict[str, Any] = {
|
| 107 |
+
"status": status,
|
| 108 |
+
"from_network": self.from_network,
|
| 109 |
+
"from_token": self.from_token,
|
| 110 |
+
"to_network": self.to_network,
|
| 111 |
+
"to_token": self.to_token,
|
| 112 |
+
"amount": self.amount_as_str(),
|
| 113 |
+
}
|
| 114 |
+
if error:
|
| 115 |
+
summary["error"] = error
|
| 116 |
+
return summary
|
| 117 |
+
|
| 118 |
+
@classmethod
|
| 119 |
+
def from_dict(cls, data: Dict[str, Any]) -> "SwapIntent":
|
| 120 |
+
amount = _to_decimal(data.get("amount"))
|
| 121 |
+
intent = cls(
|
| 122 |
+
user_id=(data.get("user_id") or "").strip(),
|
| 123 |
+
conversation_id=(data.get("conversation_id") or "").strip(),
|
| 124 |
+
from_network=data.get("from_network"),
|
| 125 |
+
from_token=data.get("from_token"),
|
| 126 |
+
to_network=data.get("to_network"),
|
| 127 |
+
to_token=data.get("to_token"),
|
| 128 |
+
amount=amount,
|
| 129 |
+
)
|
| 130 |
+
intent.updated_at = float(data.get("updated_at", time.time()))
|
| 131 |
+
return intent
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ---------- Swap session context ----------
|
| 135 |
+
_CURRENT_SESSION: ContextVar[tuple[str, str]] = ContextVar(
|
| 136 |
+
"_current_swap_session",
|
| 137 |
+
default=("", ""),
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def set_current_swap_session(user_id: Optional[str], conversation_id: Optional[str]) -> None:
|
| 142 |
+
"""Store the active swap session for tool calls executed by the agent."""
|
| 143 |
+
|
| 144 |
+
resolved_user = (user_id or "").strip()
|
| 145 |
+
resolved_conversation = (conversation_id or "").strip()
|
| 146 |
+
if not resolved_user:
|
| 147 |
+
raise ValueError("swap_agent requires 'user_id' to identify the swap session.")
|
| 148 |
+
if not resolved_conversation:
|
| 149 |
+
raise ValueError("swap_agent requires 'conversation_id' to identify the swap session.")
|
| 150 |
+
_CURRENT_SESSION.set((resolved_user, resolved_conversation))
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@contextmanager
|
| 154 |
+
def swap_session(user_id: Optional[str], conversation_id: Optional[str]):
|
| 155 |
+
"""Context manager that guarantees session scoping for swap tool calls."""
|
| 156 |
+
|
| 157 |
+
set_current_swap_session(user_id, conversation_id)
|
| 158 |
+
try:
|
| 159 |
+
yield
|
| 160 |
+
finally:
|
| 161 |
+
clear_current_swap_session()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def clear_current_swap_session() -> None:
|
| 165 |
+
"""Reset the active swap session after the agent finishes handling a message."""
|
| 166 |
+
|
| 167 |
+
_CURRENT_SESSION.set(("", ""))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _resolve_session(user_id: Optional[str], conversation_id: Optional[str]) -> tuple[str, str]:
|
| 171 |
+
active_user, active_conversation = _CURRENT_SESSION.get()
|
| 172 |
+
resolved_user = (user_id or active_user or "").strip()
|
| 173 |
+
resolved_conversation = (conversation_id or active_conversation or "").strip()
|
| 174 |
+
if not resolved_user:
|
| 175 |
+
raise ValueError("user_id is required for swap operations.")
|
| 176 |
+
if not resolved_conversation:
|
| 177 |
+
raise ValueError("conversation_id is required for swap operations.")
|
| 178 |
+
return resolved_user, resolved_conversation
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _load_intent(user_id: str, conversation_id: str) -> SwapIntent:
|
| 182 |
+
stored = _STORE.load_intent(user_id, conversation_id)
|
| 183 |
+
if stored:
|
| 184 |
+
intent = SwapIntent.from_dict(stored)
|
| 185 |
+
intent.user_id = user_id
|
| 186 |
+
intent.conversation_id = conversation_id
|
| 187 |
+
return intent
|
| 188 |
+
return SwapIntent(user_id=user_id, conversation_id=conversation_id)
|
| 189 |
+
|
| 190 |
|
| 191 |
# ---------- Pydantic input schema ----------
|
| 192 |
class UpdateSwapIntentInput(BaseModel):
|
| 193 |
user_id: Optional[str] = Field(
|
| 194 |
default=None,
|
| 195 |
+
description="Stable ID for the end user / chat session. Optional if context manager is set.",
|
| 196 |
+
)
|
| 197 |
+
conversation_id: Optional[str] = Field(
|
| 198 |
+
default=None,
|
| 199 |
+
description="Conversation identifier to scope swap intents within a user.",
|
| 200 |
)
|
| 201 |
from_network: Optional[str] = None
|
| 202 |
from_token: Optional[str] = None
|
| 203 |
to_network: Optional[str] = None
|
| 204 |
to_token: Optional[str] = None
|
| 205 |
+
amount: Optional[Decimal] = Field(None, gt=Decimal("0"))
|
| 206 |
|
| 207 |
@field_validator("from_network", "to_network", mode="before")
|
| 208 |
@classmethod
|
|
|
|
| 214 |
def _norm_token(cls, value: Optional[str]) -> Optional[str]:
|
| 215 |
return value.upper() if isinstance(value, str) else value
|
| 216 |
|
| 217 |
+
@field_validator("amount", mode="before")
|
| 218 |
+
@classmethod
|
| 219 |
+
def _norm_amount(cls, value):
|
| 220 |
+
if value is None or isinstance(value, Decimal):
|
| 221 |
+
return value
|
| 222 |
+
decimal_value = _to_decimal(value)
|
| 223 |
+
if decimal_value is None:
|
| 224 |
+
raise ValueError("Amount must be a number.")
|
| 225 |
+
return decimal_value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
|
| 228 |
# ---------- Validation utilities ----------
|
|
|
|
| 253 |
)
|
| 254 |
|
| 255 |
|
| 256 |
+
def _validate_amount(amount: Optional[Decimal], intent: SwapIntent) -> Optional[Decimal]:
|
| 257 |
+
if amount is None:
|
| 258 |
+
return None
|
| 259 |
+
if not intent.from_network or not intent.from_token:
|
| 260 |
+
raise ValueError("Provide the source network and token before specifying an amount.")
|
| 261 |
+
|
| 262 |
+
policy = SwapConfig.get_token_policy(intent.from_network, intent.from_token)
|
| 263 |
+
decimals_value = policy.get("decimals", 18)
|
| 264 |
+
try:
|
| 265 |
+
decimals = int(decimals_value)
|
| 266 |
+
except (TypeError, ValueError):
|
| 267 |
+
decimals = 18
|
| 268 |
+
|
| 269 |
+
if decimals >= 0 and amount.as_tuple().exponent < -decimals:
|
| 270 |
+
raise ValueError(
|
| 271 |
+
f"Amount precision exceeds {decimals} decimal places allowed for {intent.from_token}."
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
minimum = _to_decimal(policy.get("min_amount"))
|
| 275 |
+
maximum = _to_decimal(policy.get("max_amount"))
|
| 276 |
+
|
| 277 |
+
if minimum is not None and amount < minimum:
|
| 278 |
+
raise ValueError(
|
| 279 |
+
f"The minimum amount for {intent.from_token} on {intent.from_network} is {minimum}."
|
| 280 |
+
)
|
| 281 |
+
if maximum is not None and amount > maximum:
|
| 282 |
+
raise ValueError(
|
| 283 |
+
f"The maximum amount for {intent.from_token} on {intent.from_network} is {maximum}."
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
return amount
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ---------- Output helpers ----------
|
| 290 |
+
def _store_swap_metadata(
|
| 291 |
+
intent: SwapIntent,
|
| 292 |
+
ask: Optional[str],
|
| 293 |
+
done: bool,
|
| 294 |
+
error: Optional[str],
|
| 295 |
+
choices: Optional[List[str]] = None,
|
| 296 |
+
) -> Dict[str, Any]:
|
| 297 |
+
intent.touch()
|
| 298 |
+
missing = intent.missing_fields()
|
| 299 |
+
next_field = missing[0] if missing else None
|
| 300 |
+
meta: Dict[str, Any] = {
|
| 301 |
+
"event": "swap_intent_ready" if done else "swap_intent_pending",
|
| 302 |
+
"status": "ready" if done else "collecting",
|
| 303 |
+
"from_network": intent.from_network,
|
| 304 |
+
"from_token": intent.from_token,
|
| 305 |
+
"to_network": intent.to_network,
|
| 306 |
+
"to_token": intent.to_token,
|
| 307 |
+
"amount": intent.amount_as_str(),
|
| 308 |
+
"user_id": intent.user_id,
|
| 309 |
+
"conversation_id": intent.conversation_id,
|
| 310 |
+
"missing_fields": missing,
|
| 311 |
+
"next_field": next_field,
|
| 312 |
+
"pending_question": ask,
|
| 313 |
+
"choices": list(choices or []),
|
| 314 |
+
"error": error,
|
| 315 |
+
}
|
| 316 |
+
summary = intent.to_summary("ready" if done else "collecting", error=error) if done else None
|
| 317 |
+
history = _STORE.persist_intent(
|
| 318 |
+
intent.user_id,
|
| 319 |
+
intent.conversation_id,
|
| 320 |
+
intent.to_dict(),
|
| 321 |
+
meta,
|
| 322 |
+
done=done,
|
| 323 |
+
summary=summary,
|
| 324 |
+
)
|
| 325 |
+
if history:
|
| 326 |
+
meta["history"] = history
|
| 327 |
+
metadata.set_swap_agent(meta, intent.user_id, intent.conversation_id)
|
| 328 |
+
return meta
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _build_next_action(meta: Dict[str, Any]) -> Dict[str, Any]:
|
| 332 |
+
if meta.get("status") == "ready":
|
| 333 |
+
return {
|
| 334 |
+
"type": "complete",
|
| 335 |
+
"prompt": None,
|
| 336 |
+
"field": None,
|
| 337 |
+
"choices": [],
|
| 338 |
+
}
|
| 339 |
+
return {
|
| 340 |
+
"type": "collect_field",
|
| 341 |
+
"prompt": meta.get("pending_question"),
|
| 342 |
+
"field": meta.get("next_field"),
|
| 343 |
+
"choices": meta.get("choices", []),
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def _response(
|
| 348 |
+
intent: SwapIntent,
|
| 349 |
+
ask: Optional[str],
|
| 350 |
+
choices: Optional[List[str]] = None,
|
| 351 |
+
done: bool = False,
|
| 352 |
+
error: Optional[str] = None,
|
| 353 |
+
) -> Dict[str, Any]:
|
| 354 |
+
meta = _store_swap_metadata(intent, ask, done, error, choices)
|
| 355 |
+
|
| 356 |
+
payload: Dict[str, Any] = {
|
| 357 |
+
"event": meta.get("event"),
|
| 358 |
+
"intent": intent.to_public(),
|
| 359 |
+
"ask": ask,
|
| 360 |
+
"choices": choices or [],
|
| 361 |
+
"error": error,
|
| 362 |
+
"next_action": _build_next_action(meta),
|
| 363 |
+
"history": meta.get("history", []),
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
if done:
|
| 367 |
+
payload["metadata"] = {
|
| 368 |
+
key: meta.get(key)
|
| 369 |
+
for key in (
|
| 370 |
+
"event",
|
| 371 |
+
"status",
|
| 372 |
+
"from_network",
|
| 373 |
+
"from_token",
|
| 374 |
+
"to_network",
|
| 375 |
+
"to_token",
|
| 376 |
+
"amount",
|
| 377 |
+
"user_id",
|
| 378 |
+
"conversation_id",
|
| 379 |
+
"history",
|
| 380 |
+
)
|
| 381 |
+
if meta.get(key) is not None
|
| 382 |
+
}
|
| 383 |
+
return payload
|
| 384 |
+
|
| 385 |
+
|
| 386 |
# ---------- Core tool ----------
|
| 387 |
@tool("update_swap_intent", args_schema=UpdateSwapIntentInput)
|
| 388 |
def update_swap_intent_tool(
|
| 389 |
user_id: Optional[str] = None,
|
| 390 |
+
conversation_id: Optional[str] = None,
|
| 391 |
from_network: Optional[str] = None,
|
| 392 |
from_token: Optional[str] = None,
|
| 393 |
to_network: Optional[str] = None,
|
| 394 |
to_token: Optional[str] = None,
|
| 395 |
+
amount: Optional[Decimal] = None,
|
| 396 |
):
|
| 397 |
"""Update the swap intent and surface the next question or final metadata.
|
| 398 |
|
|
|
|
| 401 |
and keep calling it until the response event becomes 'swap_intent_ready'.
|
| 402 |
"""
|
| 403 |
|
| 404 |
+
resolved_user, resolved_conversation = _resolve_session(user_id, conversation_id)
|
| 405 |
+
intent = _load_intent(resolved_user, resolved_conversation)
|
| 406 |
+
intent.user_id = resolved_user
|
| 407 |
+
intent.conversation_id = resolved_conversation
|
|
|
|
| 408 |
|
| 409 |
try:
|
| 410 |
if from_network is not None:
|
| 411 |
+
canonical_from = _validate_network(from_network)
|
| 412 |
+
if canonical_from != intent.from_network:
|
| 413 |
+
intent.from_network = canonical_from
|
| 414 |
+
if intent.from_token:
|
| 415 |
+
try:
|
| 416 |
+
SwapConfig.validate_or_raise(intent.from_token, canonical_from)
|
| 417 |
+
except ValueError:
|
| 418 |
+
intent.from_token = None
|
| 419 |
|
| 420 |
if intent.from_network is None and from_token is not None:
|
| 421 |
return _response(
|
|
|
|
| 428 |
intent.from_token = _validate_token(from_token, intent.from_network)
|
| 429 |
|
| 430 |
if to_network is not None:
|
| 431 |
+
canonical_to = _validate_network(to_network)
|
| 432 |
+
if canonical_to != intent.to_network:
|
| 433 |
+
intent.to_network = canonical_to
|
| 434 |
+
if intent.to_token:
|
| 435 |
+
try:
|
| 436 |
+
SwapConfig.validate_or_raise(intent.to_token, canonical_to)
|
| 437 |
+
except ValueError:
|
| 438 |
+
intent.to_token = None
|
| 439 |
|
| 440 |
if intent.to_network is None and to_token is not None:
|
| 441 |
return _response(
|
|
|
|
| 448 |
intent.to_token = _validate_token(to_token, intent.to_network)
|
| 449 |
|
| 450 |
if amount is not None:
|
| 451 |
+
intent.amount = _validate_amount(amount, intent)
|
| 452 |
|
| 453 |
if intent.from_network and intent.to_network:
|
| 454 |
_validate_route(intent.from_network, intent.to_network)
|
|
|
|
| 470 |
list(SwapConfig.list_tokens(intent.from_network)),
|
| 471 |
error=message,
|
| 472 |
)
|
| 473 |
+
if "amount" in lowered and intent.from_token:
|
| 474 |
+
return _response(
|
| 475 |
+
intent,
|
| 476 |
+
f"Provide a valid amount in {intent.from_token}.",
|
| 477 |
+
error=message,
|
| 478 |
+
)
|
| 479 |
return _response(intent, "Please correct the input.", error=message)
|
| 480 |
|
| 481 |
if intent.from_network is None:
|
|
|
|
| 506 |
denom = intent.from_token
|
| 507 |
return _response(intent, f"What is the amount in {denom}?")
|
| 508 |
|
| 509 |
+
response = _response(intent, ask=None, done=True)
|
| 510 |
+
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
|
| 512 |
|
| 513 |
class ListTokensInput(BaseModel):
|
|
|
|
| 525 |
|
| 526 |
try:
|
| 527 |
canonical = _validate_network(network)
|
| 528 |
+
tokens = list(SwapConfig.list_tokens(canonical))
|
| 529 |
+
policies = {
|
| 530 |
+
token: SwapConfig.get_token_policy(canonical, token)
|
| 531 |
+
for token in tokens
|
| 532 |
+
}
|
| 533 |
return {
|
| 534 |
"network": canonical,
|
| 535 |
+
"tokens": tokens,
|
| 536 |
+
"policies": policies,
|
| 537 |
}
|
| 538 |
except ValueError as exc:
|
| 539 |
return {
|
src/app.py
CHANGED
|
@@ -114,6 +114,24 @@ def _map_agent_type(agent_name: str) -> str:
|
|
| 114 |
}
|
| 115 |
return mapping.get(agent_name, "supervisor")
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
@app.get("/health")
|
| 118 |
def health_check():
|
| 119 |
return {"status": "ok"}
|
|
@@ -135,21 +153,27 @@ def get_conversations(request: Request):
|
|
| 135 |
def chat(request: ChatRequest):
|
| 136 |
print("request: ", request)
|
| 137 |
try:
|
|
|
|
|
|
|
| 138 |
# Add the user message to the conversation
|
| 139 |
chat_manager_instance.add_message(
|
| 140 |
message=request.message.dict(),
|
| 141 |
-
conversation_id=
|
| 142 |
-
user_id=
|
| 143 |
)
|
| 144 |
|
| 145 |
# Get all messages from the conversation to pass to the agent
|
| 146 |
conversation_messages = chat_manager_instance.get_messages(
|
| 147 |
-
conversation_id=
|
| 148 |
-
user_id=
|
| 149 |
)
|
| 150 |
|
| 151 |
# Invoke the supervisor agent with the conversation
|
| 152 |
-
result = supervisor.invoke(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
# Add the agent's response to the conversation
|
| 155 |
if result and isinstance(result, dict):
|
|
@@ -165,7 +189,10 @@ def chat(request: ChatRequest):
|
|
| 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
|
|
@@ -178,8 +205,8 @@ def chat(request: ChatRequest):
|
|
| 178 |
agent_name=agent_name,
|
| 179 |
agent_type=_map_agent_type(agent_name),
|
| 180 |
metadata=result.get("metadata", {}),
|
| 181 |
-
conversation_id=
|
| 182 |
-
user_id=
|
| 183 |
requires_action=True if agent_name == "token swap" else False,
|
| 184 |
action_type="swap" if agent_name == "token swap" else None
|
| 185 |
)
|
|
@@ -187,8 +214,8 @@ def chat(request: ChatRequest):
|
|
| 187 |
# Add the response message to the conversation
|
| 188 |
chat_manager_instance.add_message(
|
| 189 |
message=response_message.dict(),
|
| 190 |
-
conversation_id=
|
| 191 |
-
user_id=
|
| 192 |
)
|
| 193 |
|
| 194 |
# Return only the clean response
|
|
@@ -201,14 +228,26 @@ def chat(request: ChatRequest):
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
return response_payload
|
| 213 |
|
| 214 |
return {"response": "No response available", "agent": "supervisor"}
|
|
|
|
| 114 |
}
|
| 115 |
return mapping.get(agent_name, "supervisor")
|
| 116 |
|
| 117 |
+
|
| 118 |
+
def _resolve_identity(request: ChatRequest) -> tuple[str, str]:
|
| 119 |
+
"""Ensure each request has a stable user and conversation identifier."""
|
| 120 |
+
|
| 121 |
+
user_id = (request.user_id or "").strip()
|
| 122 |
+
if not user_id or user_id.lower() == "anonymous":
|
| 123 |
+
wallet = (request.wallet_address or "").strip()
|
| 124 |
+
if wallet and wallet.lower() != "default":
|
| 125 |
+
user_id = f"wallet::{wallet.lower()}"
|
| 126 |
+
else:
|
| 127 |
+
raise HTTPException(
|
| 128 |
+
status_code=400,
|
| 129 |
+
detail="A stable 'user_id' or wallet_address is required for swap operations.",
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
conversation_id = (request.conversation_id or "").strip() or "default"
|
| 133 |
+
return user_id, conversation_id
|
| 134 |
+
|
| 135 |
@app.get("/health")
|
| 136 |
def health_check():
|
| 137 |
return {"status": "ok"}
|
|
|
|
| 153 |
def chat(request: ChatRequest):
|
| 154 |
print("request: ", request)
|
| 155 |
try:
|
| 156 |
+
user_id, conversation_id = _resolve_identity(request)
|
| 157 |
+
|
| 158 |
# Add the user message to the conversation
|
| 159 |
chat_manager_instance.add_message(
|
| 160 |
message=request.message.dict(),
|
| 161 |
+
conversation_id=conversation_id,
|
| 162 |
+
user_id=user_id
|
| 163 |
)
|
| 164 |
|
| 165 |
# Get all messages from the conversation to pass to the agent
|
| 166 |
conversation_messages = chat_manager_instance.get_messages(
|
| 167 |
+
conversation_id=conversation_id,
|
| 168 |
+
user_id=user_id
|
| 169 |
)
|
| 170 |
|
| 171 |
# Invoke the supervisor agent with the conversation
|
| 172 |
+
result = supervisor.invoke(
|
| 173 |
+
conversation_messages,
|
| 174 |
+
conversation_id=conversation_id,
|
| 175 |
+
user_id=user_id,
|
| 176 |
+
)
|
| 177 |
|
| 178 |
# Add the agent's response to the conversation
|
| 179 |
if result and isinstance(result, dict):
|
|
|
|
| 189 |
if isinstance(result, dict) and result.get("metadata"):
|
| 190 |
response_metadata.update(result.get("metadata") or {})
|
| 191 |
elif agent_name == "token swap":
|
| 192 |
+
swap_meta = metadata.get_swap_agent(
|
| 193 |
+
user_id=user_id,
|
| 194 |
+
conversation_id=conversation_id,
|
| 195 |
+
)
|
| 196 |
if swap_meta:
|
| 197 |
response_metadata.update(swap_meta)
|
| 198 |
swap_meta_snapshot = swap_meta
|
|
|
|
| 205 |
agent_name=agent_name,
|
| 206 |
agent_type=_map_agent_type(agent_name),
|
| 207 |
metadata=result.get("metadata", {}),
|
| 208 |
+
conversation_id=conversation_id,
|
| 209 |
+
user_id=user_id,
|
| 210 |
requires_action=True if agent_name == "token swap" else False,
|
| 211 |
action_type="swap" if agent_name == "token swap" else None
|
| 212 |
)
|
|
|
|
| 214 |
# Add the response message to the conversation
|
| 215 |
chat_manager_instance.add_message(
|
| 216 |
message=response_message.dict(),
|
| 217 |
+
conversation_id=conversation_id,
|
| 218 |
+
user_id=user_id
|
| 219 |
)
|
| 220 |
|
| 221 |
# Return only the clean response
|
|
|
|
| 228 |
if swap_meta_snapshot:
|
| 229 |
response_meta = swap_meta_snapshot
|
| 230 |
else:
|
| 231 |
+
swap_meta = metadata.get_swap_agent(
|
| 232 |
+
user_id=user_id,
|
| 233 |
+
conversation_id=conversation_id,
|
| 234 |
+
)
|
| 235 |
if swap_meta:
|
| 236 |
response_meta = swap_meta
|
|
|
|
| 237 |
if response_meta:
|
| 238 |
response_payload["metadata"] = response_meta
|
| 239 |
if agent_name == "token swap":
|
| 240 |
+
should_clear = False
|
| 241 |
+
if response_meta:
|
| 242 |
+
status = response_meta.get("status") if isinstance(response_meta, dict) else None
|
| 243 |
+
event = response_meta.get("event") if isinstance(response_meta, dict) else None
|
| 244 |
+
should_clear = status == "ready" or event == "swap_intent_ready"
|
| 245 |
+
if should_clear:
|
| 246 |
+
metadata.set_swap_agent(
|
| 247 |
+
{},
|
| 248 |
+
user_id=user_id,
|
| 249 |
+
conversation_id=conversation_id,
|
| 250 |
+
)
|
| 251 |
return response_payload
|
| 252 |
|
| 253 |
return {"response": "No response available", "agent": "supervisor"}
|
src/service/chat_manager.py
CHANGED
|
@@ -101,6 +101,8 @@ class ChatManager:
|
|
| 101 |
conversation_id = self._get_conversation_id(conversation_id)
|
| 102 |
conversation = self._get_or_create_conversation(conversation_id, user_id)
|
| 103 |
chat_message = ChatMessage(**message)
|
|
|
|
|
|
|
| 104 |
if "timestamp" not in message:
|
| 105 |
chat_message.timestamp = datetime.utcnow()
|
| 106 |
conversation.messages.append(chat_message)
|
|
|
|
| 101 |
conversation_id = self._get_conversation_id(conversation_id)
|
| 102 |
conversation = self._get_or_create_conversation(conversation_id, user_id)
|
| 103 |
chat_message = ChatMessage(**message)
|
| 104 |
+
chat_message.conversation_id = conversation_id
|
| 105 |
+
chat_message.user_id = user_id
|
| 106 |
if "timestamp" not in message:
|
| 107 |
chat_message.timestamp = datetime.utcnow()
|
| 108 |
conversation.messages.append(chat_message)
|