ColettoG commited on
Commit
715acff
·
1 Parent(s): 7a5d90a

add: improvements swap agent deploy

Browse files
src/agents/metadata.py CHANGED
@@ -1,18 +1,52 @@
 
 
 
 
 
 
 
1
  class Metadata:
2
  def __init__(self):
3
- self.crypto_data_agent = {}
4
- self.swap_agent = {}
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
- return self.swap_agent
14
-
15
- def set_swap_agent(self, swap_agent):
16
- self.swap_agent = swap_agent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- 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
  )
@@ -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
- 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:
@@ -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(self, messages: List[ChatMessage]) -> dict:
402
- from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
 
 
 
 
 
 
 
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
- 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 {
@@ -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
- 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
- "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
- for _src in _NETWORK_TOKENS.keys():
133
- for _dst in _NETWORK_TOKENS.keys():
134
- _SUPPORTED_ROUTES.add((_src, _dst))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if normalized not in cls._NETWORK_TOKENS:
 
147
  raise ValueError(
148
  f"Unsupported network '{network}'. Available: {sorted(cls._NETWORK_TOKENS)}"
149
  )
150
- return sorted(cls._NETWORK_TOKENS[normalized])
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._all_tokens():
169
  raise ValueError(
170
- f"Unsupported token '{token}'. Supported tokens: {sorted(cls._all_tokens())}"
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
- """Backwards compatible helper returning all tokens across networks."""
184
- return sorted(cls._all_tokens())
 
 
 
 
 
 
 
 
 
 
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
- @classmethod
207
- def _all_tokens(cls) -> Set[str]:
208
- tokens: Set[str] = set()
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
- 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(
@@ -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, 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
@@ -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
- # ---------- 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 ----------
@@ -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[float] = None,
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
- 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(
@@ -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
- intent.to_network = _validate_network(to_network)
 
 
 
 
 
 
 
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
- 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):
@@ -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": list(SwapConfig.list_tokens(canonical)),
 
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=request.conversation_id,
142
- user_id=request.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=request.conversation_id,
148
- user_id=request.user_id
149
  )
150
 
151
  # Invoke the supervisor agent with the conversation
152
- result = supervisor.invoke(conversation_messages)
 
 
 
 
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=request.conversation_id,
182
- user_id=request.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=request.conversation_id,
191
- user_id=request.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
- metadata.set_swap_agent({})
 
 
 
 
 
 
 
 
 
 
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)