ColettoG commited on
Commit
21d8407
·
1 Parent(s): dcdcde9

add staking + registry swap

Browse files
src/agents/metadata.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any, Dict
5
  from src.agents.swap.storage import SwapStateRepository
6
  from src.agents.dca.storage import DcaStateRepository
7
  from src.agents.lending.storage import LendingStateRepository
 
8
 
9
 
10
  class Metadata:
@@ -13,6 +14,7 @@ class Metadata:
13
  self._swap_repo = SwapStateRepository.instance()
14
  self._dca_repo = DcaStateRepository.instance()
15
  self._lending_repo = LendingStateRepository.instance()
 
16
 
17
  def get_crypto_data_agent(self):
18
  return self.crypto_data_agent
@@ -126,5 +128,42 @@ class Metadata:
126
  except ValueError:
127
  return []
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  metadata = Metadata()
 
5
  from src.agents.swap.storage import SwapStateRepository
6
  from src.agents.dca.storage import DcaStateRepository
7
  from src.agents.lending.storage import LendingStateRepository
8
+ from src.agents.staking.storage import StakingStateRepository
9
 
10
 
11
  class Metadata:
 
14
  self._swap_repo = SwapStateRepository.instance()
15
  self._dca_repo = DcaStateRepository.instance()
16
  self._lending_repo = LendingStateRepository.instance()
17
+ self._staking_repo = StakingStateRepository.instance()
18
 
19
  def get_crypto_data_agent(self):
20
  return self.crypto_data_agent
 
128
  except ValueError:
129
  return []
130
 
131
+ def get_staking_agent(self, user_id: str | None = None, conversation_id: str | None = None):
132
+ try:
133
+ return self._staking_repo.get_metadata(user_id, conversation_id)
134
+ except ValueError:
135
+ return {}
136
+
137
+ def set_staking_agent(
138
+ self,
139
+ staking_agent: Dict[str, Any] | None,
140
+ user_id: str | None = None,
141
+ conversation_id: str | None = None,
142
+ ):
143
+ try:
144
+ if staking_agent:
145
+ self._staking_repo.set_metadata(user_id, conversation_id, staking_agent)
146
+ else:
147
+ self._staking_repo.clear_metadata(user_id, conversation_id)
148
+ except ValueError:
149
+ return
150
+
151
+ def clear_staking_agent(self, user_id: str | None = None, conversation_id: str | None = None) -> None:
152
+ try:
153
+ self._staking_repo.clear_metadata(user_id, conversation_id)
154
+ except ValueError:
155
+ return
156
+
157
+ def get_staking_history(
158
+ self,
159
+ user_id: str | None = None,
160
+ conversation_id: str | None = None,
161
+ limit: int | None = None,
162
+ ):
163
+ try:
164
+ return self._staking_repo.get_history(user_id, conversation_id, limit)
165
+ except ValueError:
166
+ return []
167
+
168
 
169
  metadata = Metadata()
src/agents/staking/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Staking agent for Lido on Ethereum."""
src/agents/staking/agent.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from src.agents.staking.tools import get_tools
3
+ from langgraph.prebuilt import create_react_agent
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class StakingAgent:
9
+ """Agent for handling staking operations (stake ETH, unstake stETH) via Lido on Ethereum."""
10
+ def __init__(self, llm):
11
+ self.llm = llm
12
+ self.agent = create_react_agent(
13
+ model=llm,
14
+ tools=get_tools(),
15
+ name="staking_agent"
16
+ )
src/agents/staking/config.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for the Staking Agent (Lido on Ethereum)."""
2
+ from typing import List
3
+
4
+
5
+ class StakingConfig:
6
+ """Static configuration for Lido staking on Ethereum Mainnet."""
7
+
8
+ # Fixed network - Lido staking is only on Ethereum Mainnet
9
+ NETWORK = "ethereum"
10
+ CHAIN_ID = 1
11
+ PROTOCOL = "lido"
12
+
13
+ # Supported actions
14
+ SUPPORTED_ACTIONS = ["stake", "unstake"]
15
+
16
+ # Token addresses on Ethereum Mainnet
17
+ ETH_ADDRESS = "0x0000000000000000000000000000000000000000"
18
+ STETH_ADDRESS = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"
19
+ WSTETH_ADDRESS = "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"
20
+
21
+ # Token configuration
22
+ TOKENS = {
23
+ "ETH": {
24
+ "symbol": "ETH",
25
+ "address": ETH_ADDRESS,
26
+ "decimals": 18,
27
+ },
28
+ "stETH": {
29
+ "symbol": "stETH",
30
+ "address": STETH_ADDRESS,
31
+ "decimals": 18,
32
+ },
33
+ "wstETH": {
34
+ "symbol": "wstETH",
35
+ "address": WSTETH_ADDRESS,
36
+ "decimals": 18,
37
+ },
38
+ }
39
+
40
+ # Minimum amounts (in ETH)
41
+ MIN_STAKE_AMOUNT = "0.0001"
42
+ MIN_UNSTAKE_AMOUNT = "0.0001"
43
+
44
+ @classmethod
45
+ def get_network(cls) -> str:
46
+ return cls.NETWORK
47
+
48
+ @classmethod
49
+ def get_chain_id(cls) -> int:
50
+ return cls.CHAIN_ID
51
+
52
+ @classmethod
53
+ def get_protocol(cls) -> str:
54
+ return cls.PROTOCOL
55
+
56
+ @classmethod
57
+ def list_actions(cls) -> List[str]:
58
+ return cls.SUPPORTED_ACTIONS
59
+
60
+ @classmethod
61
+ def validate_action(cls, action: str) -> str:
62
+ act = action.lower().strip()
63
+ if act not in cls.SUPPORTED_ACTIONS:
64
+ raise ValueError(f"Action '{action}' is not supported. Supported: {cls.SUPPORTED_ACTIONS}")
65
+ return act
66
+
67
+ @classmethod
68
+ def get_input_token(cls, action: str) -> str:
69
+ """Get the input token based on action."""
70
+ if action == "stake":
71
+ return "ETH"
72
+ return "stETH"
73
+
74
+ @classmethod
75
+ def get_output_token(cls, action: str) -> str:
76
+ """Get the output token based on action."""
77
+ if action == "stake":
78
+ return "stETH"
79
+ return "ETH"
80
+
81
+ @classmethod
82
+ def get_token_decimals(cls, token: str) -> int:
83
+ token_info = cls.TOKENS.get(token.upper()) or cls.TOKENS.get(token)
84
+ if token_info:
85
+ return token_info.get("decimals", 18)
86
+ return 18
87
+
88
+ @classmethod
89
+ def get_min_amount(cls, action: str) -> str:
90
+ if action == "stake":
91
+ return cls.MIN_STAKE_AMOUNT
92
+ return cls.MIN_UNSTAKE_AMOUNT
src/agents/staking/intent.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Staking intent definition and validation."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from decimal import Decimal, InvalidOperation
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from src.agents.staking.config import StakingConfig
10
+
11
+
12
+ def _format_decimal(value: Decimal) -> str:
13
+ normalized = value.normalize()
14
+ exponent = normalized.as_tuple().exponent
15
+ if exponent > 0:
16
+ normalized = normalized.quantize(Decimal(1))
17
+ text = format(normalized, "f")
18
+ if "." in text:
19
+ text = text.rstrip("0").rstrip(".")
20
+ return text
21
+
22
+
23
+ def _to_decimal(value: Any) -> Optional[Decimal]:
24
+ if value is None:
25
+ return None
26
+ try:
27
+ return Decimal(str(value))
28
+ except (InvalidOperation, TypeError, ValueError):
29
+ return None
30
+
31
+
32
+ @dataclass
33
+ class StakingIntent:
34
+ user_id: str
35
+ conversation_id: str
36
+ action: Optional[str] = None # stake or unstake
37
+ amount: Optional[Decimal] = None
38
+ updated_at: float = field(default_factory=lambda: time.time())
39
+
40
+ # Fixed values for Lido on Ethereum
41
+ network: str = field(default_factory=lambda: StakingConfig.NETWORK)
42
+ protocol: str = field(default_factory=lambda: StakingConfig.PROTOCOL)
43
+ chain_id: int = field(default_factory=lambda: StakingConfig.CHAIN_ID)
44
+
45
+ def touch(self) -> None:
46
+ self.updated_at = time.time()
47
+
48
+ def is_complete(self) -> bool:
49
+ return all([
50
+ self.action,
51
+ self.amount is not None,
52
+ ])
53
+
54
+ def missing_fields(self) -> List[str]:
55
+ fields: List[str] = []
56
+ if not self.action:
57
+ fields.append("action")
58
+ if self.amount is None:
59
+ fields.append("amount")
60
+ return fields
61
+
62
+ def amount_as_str(self) -> Optional[str]:
63
+ if self.amount is None:
64
+ return None
65
+ return _format_decimal(self.amount)
66
+
67
+ def get_input_token(self) -> str:
68
+ """Get the token being sent based on action."""
69
+ if self.action == "stake":
70
+ return "ETH"
71
+ return "stETH"
72
+
73
+ def get_output_token(self) -> str:
74
+ """Get the token being received based on action."""
75
+ if self.action == "stake":
76
+ return "stETH"
77
+ return "ETH"
78
+
79
+ def to_dict(self) -> Dict[str, Any]:
80
+ return {
81
+ "user_id": self.user_id,
82
+ "conversation_id": self.conversation_id,
83
+ "action": self.action,
84
+ "amount": self.amount_as_str(),
85
+ "network": self.network,
86
+ "protocol": self.protocol,
87
+ "chain_id": self.chain_id,
88
+ "input_token": self.get_input_token() if self.action else None,
89
+ "output_token": self.get_output_token() if self.action else None,
90
+ "updated_at": self.updated_at,
91
+ }
92
+
93
+ def to_public(self) -> Dict[str, Optional[str]]:
94
+ public = self.to_dict()
95
+ public["amount"] = self.amount_as_str()
96
+ return public
97
+
98
+ def to_summary(self, status: str, error: Optional[str] = None) -> Dict[str, Any]:
99
+ summary: Dict[str, Any] = {
100
+ "status": status,
101
+ "action": self.action,
102
+ "amount": self.amount_as_str(),
103
+ "network": self.network,
104
+ "protocol": self.protocol,
105
+ "input_token": self.get_input_token() if self.action else None,
106
+ "output_token": self.get_output_token() if self.action else None,
107
+ }
108
+ if error:
109
+ summary["error"] = error
110
+ return summary
111
+
112
+ @classmethod
113
+ def from_dict(cls, data: Dict[str, Any]) -> "StakingIntent":
114
+ amount = _to_decimal(data.get("amount"))
115
+ intent = cls(
116
+ user_id=(data.get("user_id") or "").strip(),
117
+ conversation_id=(data.get("conversation_id") or "").strip(),
118
+ action=data.get("action"),
119
+ amount=amount,
120
+ )
121
+ intent.updated_at = float(data.get("updated_at", time.time()))
122
+ return intent
src/agents/staking/prompt.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System prompt for the specialized staking agent."""
2
+
3
+ STAKING_AGENT_SYSTEM_PROMPT = """
4
+ You are Zico's staking orchestrator for Lido on Ethereum.
5
+ Your goal is to help the user stake ETH to earn rewards (receiving stETH) or unstake stETH back to ETH.
6
+
7
+ Always respond in English, regardless of the user's language.
8
+
9
+ # Protocol Information
10
+ - Protocol: Lido (liquid staking)
11
+ - Network: Ethereum Mainnet
12
+ - Stake: ETH -> stETH (start earning rewards)
13
+ - Unstake: stETH -> ETH (stop earning rewards)
14
+
15
+ # Responsibilities
16
+ 1. Collect all staking intent fields (`action`, `amount`) by invoking the `update_staking_intent` tool.
17
+ 2. If the tool returns a question (`ask`), present it to the user clearly.
18
+ 3. If the tool returns an error, explain it and ask for correction.
19
+ 4. Only confirm that the intent is ready once the tool reports `event == "staking_intent_ready"`.
20
+
21
+ # Rules
22
+ - ALWAYS call `update_staking_intent` when the user provides new staking information.
23
+ - Do NOT ask for all fields at once if the user only provided some. Let the tool guide the flow.
24
+ - When the intent is ready, summarize the operation and confirm it's ready for execution.
25
+ - Use `get_staking_info` if the user asks about how staking works or wants more information.
26
+
27
+ # Examples
28
+
29
+ Example 1 - User wants to stake:
30
+ User: I want to stake some ETH
31
+ Assistant: (call `update_staking_intent` with `action="stake"`)
32
+ Tool: `ask` -> "How much ETH do you want to stake?"
33
+ Assistant: "Sure! How much ETH would you like to stake?"
34
+
35
+ User: 2 ETH
36
+ Assistant: (call `update_staking_intent` with `amount=2`)
37
+ Tool: `event` -> `staking_intent_ready`
38
+ Assistant: "All set! Ready to stake 2 ETH on Lido. You will receive stETH in return and start earning staking rewards."
39
+
40
+ Example 2 - User wants to unstake:
41
+ User: I want to unstake 1.5 stETH
42
+ Assistant: (call `update_staking_intent` with `action="unstake"`, `amount=1.5`)
43
+ Tool: `event` -> `staking_intent_ready`
44
+ Assistant: "All set! Ready to unstake 1.5 stETH. You will receive ETH in return."
45
+
46
+ Example 3 - User asks about staking:
47
+ User: How does staking work?
48
+ Assistant: (call `get_staking_info`)
49
+ Tool: Returns staking information
50
+ Assistant: "Lido is a liquid staking solution for Ethereum. When you stake ETH, you receive stETH which automatically accrues staking rewards. You can unstake anytime to convert your stETH back to ETH. Would you like to stake or unstake?"
51
+ """
src/agents/staking/storage.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gateway-backed storage for staking intents with local fallback."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import time
6
+ import logging
7
+ from datetime import datetime, timezone
8
+ from threading import Lock
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from src.integrations.panorama_gateway import (
12
+ PanoramaGatewayClient,
13
+ PanoramaGatewayError,
14
+ PanoramaGatewaySettings,
15
+ get_panorama_settings,
16
+ )
17
+
18
+ STAKING_SESSION_ENTITY = "staking-sessions"
19
+ STAKING_HISTORY_ENTITY = "staking-histories"
20
+
21
+
22
+ def _utc_now_iso() -> str:
23
+ return datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
24
+
25
+
26
+ def _identifier(user_id: str, conversation_id: str) -> str:
27
+ return f"{user_id}:{conversation_id}"
28
+
29
+
30
+ def _as_float(value: Any) -> Optional[float]:
31
+ if value is None:
32
+ return None
33
+ try:
34
+ return float(value)
35
+ except (TypeError, ValueError):
36
+ return None
37
+
38
+
39
+ class StakingStateRepository:
40
+ """Stores staking agent state via Panorama's gateway or an in-memory fallback."""
41
+
42
+ _instance: "StakingStateRepository" | None = None
43
+ _instance_lock: Lock = Lock()
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ client: PanoramaGatewayClient | None = None,
49
+ settings: PanoramaGatewaySettings | None = None,
50
+ history_limit: int = 10,
51
+ ) -> None:
52
+ self._logger = logging.getLogger(__name__)
53
+ self._history_limit = history_limit
54
+ try:
55
+ self._settings = settings or get_panorama_settings()
56
+ self._client = client or PanoramaGatewayClient(self._settings)
57
+ self._use_gateway = True
58
+ except ValueError:
59
+ # PANORAMA_GATEWAY_URL or JWT secrets not configured - fall back to local store.
60
+ self._settings = None
61
+ self._client = None
62
+ self._use_gateway = False
63
+ self._init_local_store()
64
+
65
+ def _init_local_store(self) -> None:
66
+ if not hasattr(self, "_state"):
67
+ self._state = {"intents": {}, "metadata": {}, "history": {}}
68
+
69
+ def _tenant_id(self) -> str:
70
+ return self._settings.tenant_id if self._settings else "tenant-agent"
71
+
72
+ def _fallback_to_local_store(self) -> None:
73
+ if self._use_gateway:
74
+ self._logger.warning("Panorama gateway unavailable for staking state; switching to in-memory fallback.")
75
+ self._use_gateway = False
76
+ self._init_local_store()
77
+
78
+ def _handle_gateway_failure(self, exc: PanoramaGatewayError) -> None:
79
+ self._logger.warning(
80
+ "Panorama gateway error (%s) for staking repository: %s",
81
+ getattr(exc, "status_code", "unknown"),
82
+ getattr(exc, "payload", exc),
83
+ )
84
+ self._fallback_to_local_store()
85
+
86
+ # ---- Singleton helpers -----------------------------------------------
87
+ @classmethod
88
+ def instance(cls) -> "StakingStateRepository":
89
+ if cls._instance is None:
90
+ with cls._instance_lock:
91
+ if cls._instance is None:
92
+ cls._instance = cls()
93
+ return cls._instance
94
+
95
+ @classmethod
96
+ def reset(cls) -> None:
97
+ with cls._instance_lock:
98
+ cls._instance = None
99
+
100
+ # ---- Core API ---------------------------------------------------------
101
+ def load_intent(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
102
+ if not self._use_gateway:
103
+ self._init_local_store()
104
+ record = self._state["intents"].get(_identifier(user_id, conversation_id))
105
+ if not record:
106
+ return None
107
+ return copy.deepcopy(record.get("intent"))
108
+
109
+ session = self._get_session(user_id, conversation_id)
110
+ if not self._use_gateway:
111
+ return self.load_intent(user_id, conversation_id)
112
+ if not session:
113
+ return None
114
+ return session.get("intent") or None
115
+
116
+ def persist_intent(
117
+ self,
118
+ user_id: str,
119
+ conversation_id: str,
120
+ intent: Dict[str, Any],
121
+ metadata: Dict[str, Any],
122
+ done: bool,
123
+ summary: Optional[Dict[str, Any]] = None,
124
+ ) -> List[Dict[str, Any]]:
125
+ if not self._use_gateway:
126
+ self._init_local_store()
127
+ key = _identifier(user_id, conversation_id)
128
+ now = time.time()
129
+ if done:
130
+ self._state["intents"].pop(key, None)
131
+ else:
132
+ self._state["intents"][key] = {"intent": copy.deepcopy(intent), "updated_at": now}
133
+ if metadata:
134
+ meta_copy = copy.deepcopy(metadata)
135
+ meta_copy["updated_at"] = now
136
+ self._state["metadata"][key] = meta_copy
137
+ if done and summary:
138
+ history = self._state["history"].setdefault(key, [])
139
+ summary_copy = copy.deepcopy(summary)
140
+ summary_copy.setdefault("timestamp", now)
141
+ history.append(summary_copy)
142
+ self._state["history"][key] = history[-self._history_limit:]
143
+ return self.get_history(user_id, conversation_id)
144
+
145
+ try:
146
+ if done:
147
+ if summary:
148
+ self._create_history_entry(user_id, conversation_id, summary)
149
+ self._delete_session(user_id, conversation_id)
150
+ else:
151
+ payload = self._session_payload(intent, metadata)
152
+ self._upsert_session(user_id, conversation_id, payload)
153
+ return self.get_history(user_id, conversation_id)
154
+ except PanoramaGatewayError as exc:
155
+ self._handle_gateway_failure(exc)
156
+ return self.persist_intent(user_id, conversation_id, intent, metadata, done, summary)
157
+
158
+ def set_metadata(
159
+ self,
160
+ user_id: str,
161
+ conversation_id: str,
162
+ metadata: Dict[str, Any],
163
+ ) -> None:
164
+ if not self._use_gateway:
165
+ self._init_local_store()
166
+ key = _identifier(user_id, conversation_id)
167
+ if metadata:
168
+ meta_copy = copy.deepcopy(metadata)
169
+ meta_copy["updated_at"] = time.time()
170
+ self._state["metadata"][key] = meta_copy
171
+ else:
172
+ self._state["metadata"].pop(key, None)
173
+ return
174
+
175
+ try:
176
+ if not metadata:
177
+ self._delete_session(user_id, conversation_id)
178
+ return
179
+
180
+ session = self._get_session(user_id, conversation_id)
181
+ if not self._use_gateway:
182
+ return self.set_metadata(user_id, conversation_id, metadata)
183
+ intent = session.get("intent") if session else {}
184
+ payload = self._session_payload(intent or {}, metadata)
185
+ self._upsert_session(user_id, conversation_id, payload)
186
+ except PanoramaGatewayError as exc:
187
+ self._handle_gateway_failure(exc)
188
+ self.set_metadata(user_id, conversation_id, metadata)
189
+
190
+ def clear_metadata(self, user_id: str, conversation_id: str) -> None:
191
+ self.set_metadata(user_id, conversation_id, {})
192
+
193
+ def clear_intent(self, user_id: str, conversation_id: str) -> None:
194
+ if not self._use_gateway:
195
+ self._init_local_store()
196
+ self._state["intents"].pop(_identifier(user_id, conversation_id), None)
197
+ self._state["metadata"].pop(_identifier(user_id, conversation_id), None)
198
+ return
199
+ try:
200
+ self._delete_session(user_id, conversation_id)
201
+ except PanoramaGatewayError as exc:
202
+ self._handle_gateway_failure(exc)
203
+ self.clear_intent(user_id, conversation_id)
204
+
205
+ def get_metadata(self, user_id: str, conversation_id: str) -> Dict[str, Any]:
206
+ if not self._use_gateway:
207
+ self._init_local_store()
208
+ record = self._state["metadata"].get(_identifier(user_id, conversation_id))
209
+ if not record:
210
+ return {}
211
+ entry = copy.deepcopy(record)
212
+ ts = entry.pop("updated_at", None)
213
+ if ts is not None:
214
+ entry["updated_at"] = datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
215
+ return entry
216
+
217
+ session = self._get_session(user_id, conversation_id)
218
+ if not self._use_gateway:
219
+ return self.get_metadata(user_id, conversation_id)
220
+ if not session:
221
+ return {}
222
+
223
+ intent = session.get("intent") or {}
224
+ metadata: Dict[str, Any] = {
225
+ "event": session.get("event"),
226
+ "status": session.get("status"),
227
+ "missing_fields": session.get("missingFields") or [],
228
+ "next_field": session.get("nextField"),
229
+ "pending_question": session.get("pendingQuestion"),
230
+ "choices": session.get("choices") or [],
231
+ "error": session.get("errorMessage"),
232
+ "user_id": user_id,
233
+ "conversation_id": conversation_id,
234
+ }
235
+ metadata["action"] = intent.get("action")
236
+ metadata["amount"] = intent.get("amount")
237
+ metadata["network"] = intent.get("network")
238
+ metadata["protocol"] = intent.get("protocol")
239
+ metadata["input_token"] = intent.get("input_token")
240
+ metadata["output_token"] = intent.get("output_token")
241
+
242
+ history = self.get_history(user_id, conversation_id)
243
+ if history:
244
+ metadata["history"] = history
245
+
246
+ updated_at = session.get("updatedAt")
247
+ if updated_at:
248
+ metadata["updated_at"] = updated_at
249
+
250
+ return metadata
251
+
252
+ def get_history(
253
+ self,
254
+ user_id: str,
255
+ conversation_id: str,
256
+ limit: Optional[int] = None,
257
+ ) -> List[Dict[str, Any]]:
258
+ if not self._use_gateway:
259
+ key = _identifier(user_id, conversation_id)
260
+ history = self._state["history"].get(key, [])
261
+ effective = limit or self._history_limit
262
+ result: List[Dict[str, Any]] = []
263
+ for item in sorted(history, key=lambda entry: entry.get("timestamp", 0), reverse=True)[:effective]:
264
+ entry = copy.deepcopy(item)
265
+ ts = entry.get("timestamp")
266
+ if ts is not None:
267
+ entry["timestamp"] = datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
268
+ result.append(entry)
269
+ return result
270
+
271
+ effective_limit = limit or self._history_limit
272
+ try:
273
+ result = self._client.list(
274
+ STAKING_HISTORY_ENTITY,
275
+ {
276
+ "where": {"userId": user_id, "conversationId": conversation_id},
277
+ "orderBy": {"recordedAt": "desc"},
278
+ "take": effective_limit,
279
+ },
280
+ )
281
+ except PanoramaGatewayError as exc:
282
+ if exc.status_code == 404:
283
+ return []
284
+ self._handle_gateway_failure(exc)
285
+ return self.get_history(user_id, conversation_id, limit)
286
+ except ValueError:
287
+ self._logger.warning("Invalid staking history response from gateway; falling back to local store.")
288
+ self._fallback_to_local_store()
289
+ return self.get_history(user_id, conversation_id, limit)
290
+ data = result.get("data", []) if isinstance(result, dict) else []
291
+ history: List[Dict[str, Any]] = []
292
+ for entry in data:
293
+ history.append(
294
+ {
295
+ "status": entry.get("status"),
296
+ "action": entry.get("action"),
297
+ "amount": entry.get("amount"),
298
+ "network": entry.get("network"),
299
+ "protocol": entry.get("protocol"),
300
+ "input_token": entry.get("inputToken"),
301
+ "output_token": entry.get("outputToken"),
302
+ "error": entry.get("errorMessage"),
303
+ "timestamp": entry.get("recordedAt"),
304
+ }
305
+ )
306
+ return history
307
+
308
+ # ---- Gateway helpers --------------------------------------------------
309
+ def _get_session(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
310
+ identifier = _identifier(user_id, conversation_id)
311
+ try:
312
+ return self._client.get(STAKING_SESSION_ENTITY, identifier)
313
+ except PanoramaGatewayError as exc:
314
+ if exc.status_code == 404:
315
+ return None
316
+ self._handle_gateway_failure(exc)
317
+ return None
318
+
319
+ def _delete_session(self, user_id: str, conversation_id: str) -> None:
320
+ identifier = _identifier(user_id, conversation_id)
321
+ try:
322
+ self._client.delete(STAKING_SESSION_ENTITY, identifier)
323
+ except PanoramaGatewayError as exc:
324
+ if exc.status_code != 404:
325
+ self._handle_gateway_failure(exc)
326
+ raise
327
+
328
+ def _upsert_session(
329
+ self,
330
+ user_id: str,
331
+ conversation_id: str,
332
+ data: Dict[str, Any],
333
+ ) -> None:
334
+ identifier = _identifier(user_id, conversation_id)
335
+ payload = {**data, "updatedAt": _utc_now_iso()}
336
+ try:
337
+ self._client.update(STAKING_SESSION_ENTITY, identifier, payload)
338
+ except PanoramaGatewayError as exc:
339
+ if exc.status_code != 404:
340
+ self._handle_gateway_failure(exc)
341
+ raise
342
+ create_payload = {
343
+ "userId": user_id,
344
+ "conversationId": conversation_id,
345
+ "tenantId": self._tenant_id(),
346
+ **payload,
347
+ }
348
+ try:
349
+ self._client.create(STAKING_SESSION_ENTITY, create_payload)
350
+ except PanoramaGatewayError as create_exc:
351
+ if create_exc.status_code == 409:
352
+ return
353
+ if create_exc.status_code == 404:
354
+ self._handle_gateway_failure(create_exc)
355
+ raise
356
+ self._handle_gateway_failure(create_exc)
357
+ raise
358
+
359
+ def _create_history_entry(
360
+ self,
361
+ user_id: str,
362
+ conversation_id: str,
363
+ summary: Dict[str, Any],
364
+ ) -> None:
365
+ history_payload = {
366
+ "userId": user_id,
367
+ "conversationId": conversation_id,
368
+ "status": summary.get("status"),
369
+ "action": summary.get("action"),
370
+ "amount": _as_float(summary.get("amount")),
371
+ "network": summary.get("network"),
372
+ "protocol": summary.get("protocol"),
373
+ "inputToken": summary.get("input_token"),
374
+ "outputToken": summary.get("output_token"),
375
+ "errorMessage": summary.get("error"),
376
+ "recordedAt": _utc_now_iso(),
377
+ "tenantId": self._tenant_id(),
378
+ }
379
+ if self._logger.isEnabledFor(logging.DEBUG):
380
+ self._logger.debug(
381
+ "Persisting staking history for user=%s conversation=%s payload=%s",
382
+ user_id,
383
+ conversation_id,
384
+ history_payload,
385
+ )
386
+ try:
387
+ self._client.create(STAKING_HISTORY_ENTITY, history_payload)
388
+ except PanoramaGatewayError as exc:
389
+ if exc.status_code == 404:
390
+ self._handle_gateway_failure(exc)
391
+ raise
392
+ elif exc.status_code != 409:
393
+ self._handle_gateway_failure(exc)
394
+ raise
395
+
396
+ @staticmethod
397
+ def _session_payload(intent: Dict[str, Any], metadata: Dict[str, Any]) -> Dict[str, Any]:
398
+ missing = metadata.get("missing_fields") or []
399
+ if not isinstance(missing, list):
400
+ missing = list(missing)
401
+ return {
402
+ "status": metadata.get("status"),
403
+ "event": metadata.get("event"),
404
+ "intent": intent,
405
+ "missingFields": missing,
406
+ "nextField": metadata.get("next_field"),
407
+ "pendingQuestion": metadata.get("pending_question"),
408
+ "choices": metadata.get("choices"),
409
+ "errorMessage": metadata.get("error"),
410
+ "historyCursor": metadata.get("history_cursor") or 0,
411
+ }
src/agents/staking/tools.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Staking tools that manage a conversational staking intent for Lido on Ethereum."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import contextmanager
7
+ from contextvars import ContextVar
8
+ from decimal import Decimal, InvalidOperation
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from langchain_core.tools import tool
12
+ from pydantic import BaseModel, Field, field_validator
13
+
14
+ from src.agents.metadata import metadata
15
+ from src.agents.staking.config import StakingConfig
16
+ from src.agents.staking.intent import StakingIntent, _to_decimal
17
+ from src.agents.staking.storage import StakingStateRepository
18
+
19
+
20
+ # ---------- Helpers ----------
21
+ _STORE = StakingStateRepository.instance()
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ---------- Staking session context ----------
26
+ _CURRENT_SESSION: ContextVar[tuple[str, str]] = ContextVar(
27
+ "_current_staking_session",
28
+ default=("", ""),
29
+ )
30
+
31
+
32
+ def set_current_staking_session(user_id: Optional[str], conversation_id: Optional[str]) -> None:
33
+ """Store the active staking session for tool calls executed by the agent."""
34
+
35
+ resolved_user = (user_id or "").strip()
36
+ resolved_conversation = (conversation_id or "").strip()
37
+ if not resolved_user:
38
+ raise ValueError("staking_agent requires 'user_id' to identify the staking session.")
39
+ if not resolved_conversation:
40
+ raise ValueError("staking_agent requires 'conversation_id' to identify the staking session.")
41
+ _CURRENT_SESSION.set((resolved_user, resolved_conversation))
42
+
43
+
44
+ @contextmanager
45
+ def staking_session(user_id: Optional[str], conversation_id: Optional[str]):
46
+ """Context manager that guarantees session scoping for staking tool calls."""
47
+
48
+ set_current_staking_session(user_id, conversation_id)
49
+ try:
50
+ yield
51
+ finally:
52
+ clear_current_staking_session()
53
+
54
+
55
+ def clear_current_staking_session() -> None:
56
+ """Reset the active staking session after the agent finishes handling a message."""
57
+
58
+ _CURRENT_SESSION.set(("", ""))
59
+
60
+
61
+ def _resolve_session(user_id: Optional[str], conversation_id: Optional[str]) -> tuple[str, str]:
62
+ active_user, active_conversation = _CURRENT_SESSION.get()
63
+ resolved_user = (user_id or active_user or "").strip()
64
+ resolved_conversation = (conversation_id or active_conversation or "").strip()
65
+ if not resolved_user:
66
+ raise ValueError("user_id is required for staking operations.")
67
+ if not resolved_conversation:
68
+ raise ValueError("conversation_id is required for staking operations.")
69
+ return resolved_user, resolved_conversation
70
+
71
+
72
+ def _load_intent(user_id: str, conversation_id: str) -> StakingIntent:
73
+ stored = _STORE.load_intent(user_id, conversation_id)
74
+ if stored:
75
+ intent = StakingIntent.from_dict(stored)
76
+ intent.user_id = user_id
77
+ intent.conversation_id = conversation_id
78
+ return intent
79
+ return StakingIntent(user_id=user_id, conversation_id=conversation_id)
80
+
81
+
82
+ # ---------- Pydantic input schema ----------
83
+ class UpdateStakingIntentInput(BaseModel):
84
+ user_id: Optional[str] = Field(
85
+ default=None,
86
+ description="Stable ID for the end user / chat session. Optional if context manager is set.",
87
+ )
88
+ conversation_id: Optional[str] = Field(
89
+ default=None,
90
+ description="Conversation identifier to scope staking intents within a user.",
91
+ )
92
+ action: Optional[str] = Field(
93
+ default=None,
94
+ description="The staking action: 'stake' (ETH -> stETH) or 'unstake' (stETH -> ETH)",
95
+ )
96
+ amount: Optional[Decimal] = Field(
97
+ default=None,
98
+ gt=Decimal("0"),
99
+ description="The amount to stake or unstake",
100
+ )
101
+
102
+ @field_validator("action", mode="before")
103
+ @classmethod
104
+ def _norm_action(cls, value: Optional[str]) -> Optional[str]:
105
+ return value.lower() if isinstance(value, str) else value
106
+
107
+ @field_validator("amount", mode="before")
108
+ @classmethod
109
+ def _norm_amount(cls, value):
110
+ if value is None or isinstance(value, Decimal):
111
+ return value
112
+ decimal_value = _to_decimal(value)
113
+ if decimal_value is None:
114
+ raise ValueError("Amount must be a number.")
115
+ return decimal_value
116
+
117
+
118
+ # ---------- Validation utilities ----------
119
+ def _validate_action(action: Optional[str]) -> Optional[str]:
120
+ if action is None:
121
+ return None
122
+ return StakingConfig.validate_action(action)
123
+
124
+
125
+ def _validate_amount(amount: Optional[Decimal], action: Optional[str]) -> Optional[Decimal]:
126
+ if amount is None:
127
+ return None
128
+ if action is None:
129
+ raise ValueError("Please specify the action (stake or unstake) before providing an amount.")
130
+
131
+ min_amount = Decimal(StakingConfig.get_min_amount(action))
132
+ if amount < min_amount:
133
+ input_token = StakingConfig.get_input_token(action)
134
+ raise ValueError(f"Minimum amount for {action} is {min_amount} {input_token}.")
135
+
136
+ return amount
137
+
138
+
139
+ # ---------- Output helpers ----------
140
+ def _store_staking_metadata(
141
+ intent: StakingIntent,
142
+ ask: Optional[str],
143
+ done: bool,
144
+ error: Optional[str],
145
+ choices: Optional[List[str]] = None,
146
+ ) -> Dict[str, Any]:
147
+ intent.touch()
148
+ missing = intent.missing_fields()
149
+ next_field = missing[0] if missing else None
150
+ meta: Dict[str, Any] = {
151
+ "event": "staking_intent_ready" if done else "staking_intent_pending",
152
+ "status": "ready" if done else "collecting",
153
+ "action": intent.action,
154
+ "amount": intent.amount_as_str(),
155
+ "network": intent.network,
156
+ "protocol": intent.protocol,
157
+ "chain_id": intent.chain_id,
158
+ "input_token": intent.get_input_token() if intent.action else None,
159
+ "output_token": intent.get_output_token() if intent.action else None,
160
+ "user_id": intent.user_id,
161
+ "conversation_id": intent.conversation_id,
162
+ "missing_fields": missing,
163
+ "next_field": next_field,
164
+ "pending_question": ask,
165
+ "choices": list(choices or []),
166
+ "error": error,
167
+ }
168
+ summary = intent.to_summary("ready" if done else "collecting", error=error) if done else None
169
+ history = _STORE.persist_intent(
170
+ intent.user_id,
171
+ intent.conversation_id,
172
+ intent.to_dict(),
173
+ meta,
174
+ done=done,
175
+ summary=summary,
176
+ )
177
+ if history:
178
+ meta["history"] = history
179
+ metadata.set_staking_agent(meta, intent.user_id, intent.conversation_id)
180
+ if logger.isEnabledFor(logging.DEBUG):
181
+ logger.debug(
182
+ "Staking metadata stored for user=%s conversation=%s done=%s error=%s meta=%s",
183
+ intent.user_id,
184
+ intent.conversation_id,
185
+ done,
186
+ error,
187
+ meta,
188
+ )
189
+ return meta
190
+
191
+
192
+ def _build_next_action(meta: Dict[str, Any]) -> Dict[str, Any]:
193
+ if meta.get("status") == "ready":
194
+ return {
195
+ "type": "complete",
196
+ "prompt": None,
197
+ "field": None,
198
+ "choices": [],
199
+ }
200
+ return {
201
+ "type": "collect_field",
202
+ "prompt": meta.get("pending_question"),
203
+ "field": meta.get("next_field"),
204
+ "choices": meta.get("choices", []),
205
+ }
206
+
207
+
208
+ def _response(
209
+ intent: StakingIntent,
210
+ ask: Optional[str],
211
+ choices: Optional[List[str]] = None,
212
+ done: bool = False,
213
+ error: Optional[str] = None,
214
+ ) -> Dict[str, Any]:
215
+ meta = _store_staking_metadata(intent, ask, done, error, choices)
216
+
217
+ payload: Dict[str, Any] = {
218
+ "event": meta.get("event"),
219
+ "intent": intent.to_public(),
220
+ "ask": ask,
221
+ "choices": choices or [],
222
+ "error": error,
223
+ "next_action": _build_next_action(meta),
224
+ "history": meta.get("history", []),
225
+ }
226
+
227
+ if done:
228
+ payload["metadata"] = {
229
+ key: meta.get(key)
230
+ for key in (
231
+ "event",
232
+ "status",
233
+ "action",
234
+ "amount",
235
+ "network",
236
+ "protocol",
237
+ "chain_id",
238
+ "input_token",
239
+ "output_token",
240
+ "user_id",
241
+ "conversation_id",
242
+ "history",
243
+ )
244
+ if meta.get(key) is not None
245
+ }
246
+ return payload
247
+
248
+
249
+ # ---------- Core tool ----------
250
+ @tool("update_staking_intent", args_schema=UpdateStakingIntentInput)
251
+ def update_staking_intent_tool(
252
+ user_id: Optional[str] = None,
253
+ conversation_id: Optional[str] = None,
254
+ action: Optional[str] = None,
255
+ amount: Optional[Decimal] = None,
256
+ ):
257
+ """Update the staking intent and surface the next question or final metadata.
258
+
259
+ Call this tool whenever the user provides new staking details. Supply only the
260
+ fields that were mentioned in the latest message (leave the others as None)
261
+ and keep calling it until the response event becomes 'staking_intent_ready'.
262
+
263
+ Staking is done via Lido protocol on Ethereum Mainnet:
264
+ - stake: Convert ETH to stETH and start earning rewards
265
+ - unstake: Convert stETH back to ETH
266
+ """
267
+
268
+ resolved_user, resolved_conversation = _resolve_session(user_id, conversation_id)
269
+ intent = _load_intent(resolved_user, resolved_conversation)
270
+ intent.user_id = resolved_user
271
+ intent.conversation_id = resolved_conversation
272
+
273
+ try:
274
+ if logger.isEnabledFor(logging.DEBUG):
275
+ logger.debug(
276
+ "update_staking_intent_tool input user=%s conversation=%s action=%s amount=%s",
277
+ user_id,
278
+ conversation_id,
279
+ action,
280
+ amount,
281
+ )
282
+
283
+ if action is not None:
284
+ intent.action = _validate_action(action)
285
+
286
+ if intent.action is None:
287
+ return _response(
288
+ intent,
289
+ "What would you like to do? Stake ETH to earn rewards, or unstake stETH back to ETH?",
290
+ StakingConfig.SUPPORTED_ACTIONS,
291
+ )
292
+
293
+ if amount is not None:
294
+ intent.amount = _validate_amount(amount, intent.action)
295
+
296
+ if intent.amount is None:
297
+ input_token = intent.get_input_token()
298
+ return _response(intent, f"How much {input_token} do you want to {intent.action}?")
299
+
300
+ except ValueError as exc:
301
+ message = str(exc)
302
+ if logger.isEnabledFor(logging.INFO):
303
+ logger.info(
304
+ "Staking intent validation issue for user=%s conversation=%s: %s",
305
+ intent.user_id,
306
+ intent.conversation_id,
307
+ message,
308
+ )
309
+ return _response(intent, "Please correct the input.", error=message)
310
+ except Exception as exc:
311
+ logger.exception(
312
+ "Unexpected error updating staking intent for user=%s conversation=%s",
313
+ intent.user_id,
314
+ intent.conversation_id,
315
+ )
316
+ return _response(intent, "Please try again with the staking details.", error=str(exc))
317
+
318
+ response = _response(intent, ask=None, done=True)
319
+ return response
320
+
321
+
322
+ @tool("get_staking_info")
323
+ def get_staking_info_tool():
324
+ """Get information about the staking service (Lido on Ethereum).
325
+
326
+ Returns details about the supported staking protocol, network, and tokens.
327
+ """
328
+ return {
329
+ "protocol": StakingConfig.PROTOCOL,
330
+ "network": StakingConfig.NETWORK,
331
+ "chain_id": StakingConfig.CHAIN_ID,
332
+ "supported_actions": StakingConfig.SUPPORTED_ACTIONS,
333
+ "tokens": {
334
+ "stake": {
335
+ "input": "ETH",
336
+ "output": "stETH",
337
+ "description": "Stake ETH to receive stETH and earn rewards",
338
+ },
339
+ "unstake": {
340
+ "input": "stETH",
341
+ "output": "ETH",
342
+ "description": "Unstake stETH to receive ETH back",
343
+ },
344
+ },
345
+ "min_amounts": {
346
+ "stake": StakingConfig.MIN_STAKE_AMOUNT,
347
+ "unstake": StakingConfig.MIN_UNSTAKE_AMOUNT,
348
+ },
349
+ "info": "Lido is a liquid staking solution for Ethereum. When you stake ETH, you receive stETH which accrues staking rewards automatically.",
350
+ }
351
+
352
+
353
+ def get_tools():
354
+ return [update_staking_intent_tool, get_staking_info_tool]
src/agents/supervisor/agent.py CHANGED
@@ -23,6 +23,9 @@ from src.agents.lending.agent import LendingAgent
23
  from src.agents.lending.tools import lending_session
24
  from src.agents.lending.prompt import LENDING_AGENT_SYSTEM_PROMPT
25
  from src.agents.lending.config import LendingConfig
 
 
 
26
  from src.agents.search.agent import SearchAgent
27
  from src.agents.database.client import is_database_available
28
 
@@ -86,6 +89,13 @@ class Supervisor:
86
  "- lending_agent: Handles lending operations (supply, borrow, repay, withdraw) on DeFi protocols like Aave.\n"
87
  )
88
 
 
 
 
 
 
 
 
89
  searchAgent = SearchAgent(llm)
90
  self.search_agent = searchAgent.agent
91
  agents.append(self.search_agent)
@@ -98,8 +108,8 @@ class Supervisor:
98
  agents.append(self.default_agent)
99
 
100
  # Track known agent names for response extraction
101
- self.known_agent_names = {"crypto_agent", "database_agent", "swap_agent", "dca_agent", "lending_agent", "search_agent", "default_agent"}
102
- self.specialized_agents = {"crypto_agent", "database_agent", "swap_agent", "dca_agent", "lending_agent", "search_agent"}
103
  self.failure_markers = (
104
  "cannot fulfill",
105
  "can't fulfill",
@@ -211,6 +221,13 @@ Examples of lending queries to delegate:
211
  - I want to make a supply of 100 USDC
212
  - Withdraw my WETH from the lending protocol
213
 
 
 
 
 
 
 
 
214
  When a swap conversation is already underway (the user is still providing swap
215
  details or the swap_agent requested follow-up information), keep routing those
216
  messages to the swap_agent until it has gathered every field and signals the
@@ -220,6 +237,8 @@ When a DCA conversation is already underway (the user is reviewing strategy reco
220
 
221
  When a lending conversation is already underway (the user is providing lending details like asset, amount, network), keep routing messages to the lending_agent until the lending intent is ready or cancelled.
222
 
 
 
223
  {database_examples}
224
 
225
  {search_examples}
@@ -404,6 +423,25 @@ Examples of general queries to handle directly:
404
  agent, text, messages_out = self._extract_response_from_graph(response)
405
  return agent, text, messages_out
406
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  def _extract_response_from_graph(self, response: Any) -> Tuple[str, str, list]:
408
  messages_out = response.get("messages", []) if isinstance(response, dict) else []
409
  final_response = None
@@ -582,6 +620,22 @@ Examples of general queries to handle directly:
582
  else:
583
  lending_meta = lending_meta.copy()
584
  return lending_meta if lending_meta else {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  if agent_name == "crypto_agent":
586
  tool_meta = self._collect_tool_metadata(messages_out)
587
  if tool_meta:
@@ -680,6 +734,31 @@ Examples of general queries to handle directly:
680
  return True
681
  return False
682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  def invoke(
684
  self,
685
  messages: List[ChatMessage],
@@ -707,8 +786,24 @@ Examples of general queries to handle directly:
707
  dca_state = metadata.get_dca_agent(user_id=user_id, conversation_id=conversation_id)
708
  swap_state = metadata.get_swap_agent(user_id=user_id, conversation_id=conversation_id)
709
  lending_state = metadata.get_lending_agent(user_id=user_id, conversation_id=conversation_id)
 
710
 
711
- # Check for new lending request first
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  if not lending_state and self._is_lending_like_request(messages):
713
  lending_result = self._invoke_lending_agent(langchain_messages)
714
  if lending_result:
@@ -848,12 +943,41 @@ Examples of general queries to handle directly:
848
  guidance_text = " ".join(guidance_parts)
849
  langchain_messages.insert(0, SystemMessage(content=guidance_text))
850
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
851
  try:
852
  with swap_session(user_id=user_id, conversation_id=conversation_id):
853
  with dca_session(user_id=user_id, conversation_id=conversation_id):
854
  with lending_session(user_id=user_id, conversation_id=conversation_id):
855
- response = self.app.invoke({"messages": langchain_messages})
856
- print("DEBUG: response", response)
 
857
  except Exception as e:
858
  print(f"Error in Supervisor: {e}")
859
  return {
 
23
  from src.agents.lending.tools import lending_session
24
  from src.agents.lending.prompt import LENDING_AGENT_SYSTEM_PROMPT
25
  from src.agents.lending.config import LendingConfig
26
+ from src.agents.staking.agent import StakingAgent
27
+ from src.agents.staking.tools import staking_session
28
+ from src.agents.staking.prompt import STAKING_AGENT_SYSTEM_PROMPT
29
  from src.agents.search.agent import SearchAgent
30
  from src.agents.database.client import is_database_available
31
 
 
89
  "- lending_agent: Handles lending operations (supply, borrow, repay, withdraw) on DeFi protocols like Aave.\n"
90
  )
91
 
92
+ stakingAgent = StakingAgent(llm)
93
+ self.staking_agent = stakingAgent.agent
94
+ agents.append(self.staking_agent)
95
+ available_agents_text += (
96
+ "- staking_agent: Handles staking operations (stake ETH, unstake stETH) via Lido on Ethereum.\n"
97
+ )
98
+
99
  searchAgent = SearchAgent(llm)
100
  self.search_agent = searchAgent.agent
101
  agents.append(self.search_agent)
 
108
  agents.append(self.default_agent)
109
 
110
  # Track known agent names for response extraction
111
+ self.known_agent_names = {"crypto_agent", "database_agent", "swap_agent", "dca_agent", "lending_agent", "staking_agent", "search_agent", "default_agent"}
112
+ self.specialized_agents = {"crypto_agent", "database_agent", "swap_agent", "dca_agent", "lending_agent", "staking_agent", "search_agent"}
113
  self.failure_markers = (
114
  "cannot fulfill",
115
  "can't fulfill",
 
221
  - I want to make a supply of 100 USDC
222
  - Withdraw my WETH from the lending protocol
223
 
224
+ Examples of staking queries to delegate:
225
+ - I want to stake ETH
226
+ - I want to stake 2 ETH on Lido
227
+ - Help me unstake my stETH
228
+ - I want to earn staking rewards
229
+ - Convert my ETH to stETH
230
+
231
  When a swap conversation is already underway (the user is still providing swap
232
  details or the swap_agent requested follow-up information), keep routing those
233
  messages to the swap_agent until it has gathered every field and signals the
 
237
 
238
  When a lending conversation is already underway (the user is providing lending details like asset, amount, network), keep routing messages to the lending_agent until the lending intent is ready or cancelled.
239
 
240
+ When a staking conversation is already underway (the user is providing staking details like action or amount), keep routing messages to the staking_agent until the staking intent is ready or cancelled.
241
+
242
  {database_examples}
243
 
244
  {search_examples}
 
423
  agent, text, messages_out = self._extract_response_from_graph(response)
424
  return agent, text, messages_out
425
 
426
+ def _invoke_staking_agent(self, langchain_messages):
427
+ scoped_messages = [SystemMessage(content=STAKING_AGENT_SYSTEM_PROMPT)]
428
+ scoped_messages.extend(langchain_messages)
429
+ try:
430
+ with staking_session(
431
+ user_id=self._active_user_id,
432
+ conversation_id=self._active_conversation_id,
433
+ ):
434
+ response = self.staking_agent.invoke({"messages": scoped_messages})
435
+ except Exception as exc:
436
+ print(f"Error invoking staking agent directly: {exc}")
437
+ return None
438
+
439
+ if not response:
440
+ return None
441
+
442
+ agent, text, messages_out = self._extract_response_from_graph(response)
443
+ return agent, text, messages_out
444
+
445
  def _extract_response_from_graph(self, response: Any) -> Tuple[str, str, list]:
446
  messages_out = response.get("messages", []) if isinstance(response, dict) else []
447
  final_response = None
 
620
  else:
621
  lending_meta = lending_meta.copy()
622
  return lending_meta if lending_meta else {}
623
+ if agent_name == "staking_agent":
624
+ staking_meta = metadata.get_staking_agent(
625
+ user_id=self._active_user_id,
626
+ conversation_id=self._active_conversation_id,
627
+ )
628
+ if staking_meta:
629
+ history = metadata.get_staking_history(
630
+ user_id=self._active_user_id,
631
+ conversation_id=self._active_conversation_id,
632
+ )
633
+ if history:
634
+ staking_meta = staking_meta.copy()
635
+ staking_meta.setdefault("history", history)
636
+ else:
637
+ staking_meta = staking_meta.copy()
638
+ return staking_meta if staking_meta else {}
639
  if agent_name == "crypto_agent":
640
  tool_meta = self._collect_tool_metadata(messages_out)
641
  if tool_meta:
 
734
  return True
735
  return False
736
 
737
+ def _is_staking_like_request(self, messages: List[ChatMessage]) -> bool:
738
+ for msg in reversed(messages):
739
+ if msg.get("role") != "user":
740
+ continue
741
+ content = (msg.get("content") or "").strip()
742
+ if not content:
743
+ continue
744
+ lowered = content.lower()
745
+ staking_keywords = (
746
+ "stake",
747
+ "staking",
748
+ "unstake",
749
+ "unstaking",
750
+ "steth",
751
+ "lido",
752
+ "liquid staking",
753
+ "staking rewards",
754
+ "eth staking",
755
+ )
756
+ if not any(keyword in lowered for keyword in staking_keywords):
757
+ return False
758
+ # Default to True if the user explicitly mentioned staking-related keywords
759
+ return True
760
+ return False
761
+
762
  def invoke(
763
  self,
764
  messages: List[ChatMessage],
 
786
  dca_state = metadata.get_dca_agent(user_id=user_id, conversation_id=conversation_id)
787
  swap_state = metadata.get_swap_agent(user_id=user_id, conversation_id=conversation_id)
788
  lending_state = metadata.get_lending_agent(user_id=user_id, conversation_id=conversation_id)
789
+ staking_state = metadata.get_staking_agent(user_id=user_id, conversation_id=conversation_id)
790
 
791
+ # Check for new staking request first
792
+ if not staking_state and self._is_staking_like_request(messages):
793
+ staking_result = self._invoke_staking_agent(langchain_messages)
794
+ if staking_result:
795
+ final_agent, cleaned_response, messages_out = staking_result
796
+ meta = self._build_metadata(final_agent, messages_out)
797
+ self._active_user_id = None
798
+ self._active_conversation_id = None
799
+ return {
800
+ "messages": messages_out,
801
+ "agent": final_agent,
802
+ "response": cleaned_response or "Sorry, no meaningful response was returned.",
803
+ "metadata": meta,
804
+ }
805
+
806
+ # Check for new lending request
807
  if not lending_state and self._is_lending_like_request(messages):
808
  lending_result = self._invoke_lending_agent(langchain_messages)
809
  if lending_result:
 
943
  guidance_text = " ".join(guidance_parts)
944
  langchain_messages.insert(0, SystemMessage(content=guidance_text))
945
 
946
+ # Handle in-progress staking flow
947
+ if staking_state and staking_state.get("status") == "collecting":
948
+ staking_result = self._invoke_staking_agent(langchain_messages)
949
+ if staking_result:
950
+ final_agent, cleaned_response, messages_out = staking_result
951
+ meta = self._build_metadata(final_agent, messages_out)
952
+ self._active_user_id = None
953
+ self._active_conversation_id = None
954
+ return {
955
+ "messages": messages_out,
956
+ "agent": final_agent,
957
+ "response": cleaned_response or "Sorry, no meaningful response was returned.",
958
+ "metadata": meta,
959
+ }
960
+ # If direct staking invocation failed, fall through to supervisor graph with hints
961
+ next_field = staking_state.get("next_field")
962
+ pending_question = staking_state.get("pending_question")
963
+ guidance_parts = [
964
+ "There is an in-progress staking intent for this conversation.",
965
+ "Keep routing messages to the staking_agent until the intent is complete unless the user explicitly cancels or changes topic.",
966
+ ]
967
+ if next_field:
968
+ guidance_parts.append(f"The next field to collect is: {next_field}.")
969
+ if pending_question:
970
+ guidance_parts.append(f"Continue the staking flow by asking: {pending_question}")
971
+ guidance_text = " ".join(guidance_parts)
972
+ langchain_messages.insert(0, SystemMessage(content=guidance_text))
973
+
974
  try:
975
  with swap_session(user_id=user_id, conversation_id=conversation_id):
976
  with dca_session(user_id=user_id, conversation_id=conversation_id):
977
  with lending_session(user_id=user_id, conversation_id=conversation_id):
978
+ with staking_session(user_id=user_id, conversation_id=conversation_id):
979
+ response = self.app.invoke({"messages": langchain_messages})
980
+ print("DEBUG: response", response)
981
  except Exception as e:
982
  print(f"Error in Supervisor: {e}")
983
  return {
src/agents/swap/registry.json CHANGED
@@ -89,6 +89,17 @@
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
  }
 
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
+ "name": "world-chain",
95
+ "aliases": ["world-chain", "world chain", "worldchain", "wld"],
96
+ "tokens": [
97
+ {"symbol": "ETH", "aliases": ["eth"], "decimals": 18, "min_amount": "0.0001", "max_amount": "10000"},
98
+ {"symbol": "WETH", "aliases": ["weth"], "decimals": 18, "min_amount": "0.0001", "max_amount": "10000"},
99
+ {"symbol": "WLD", "aliases": ["wld", "worldcoin"], "decimals": 18, "min_amount": "0.1", "max_amount": "1000000"},
100
+ {"symbol": "USDC", "aliases": ["usdc"], "decimals": 6, "min_amount": "1", "max_amount": "1000000"},
101
+ {"symbol": "CONFRARIA", "aliases": ["confraria", "mcw", "milhas da confra"], "decimals": 18, "min_amount": "1", "max_amount": "1000000"}
102
+ ]
103
  }
104
  ]
105
  }
src/app.py CHANGED
@@ -114,6 +114,7 @@ def _map_agent_type(agent_name: str) -> str:
114
  "search_agent": "realtime search",
115
  "swap_agent": "token swap",
116
  "lending_agent": "lending",
 
117
  "supervisor": "supervisor",
118
  }
119
  return mapping.get(agent_name, "supervisor")
@@ -253,6 +254,13 @@ def chat(request: ChatRequest):
253
  )
254
  if lending_meta:
255
  response_metadata.update(lending_meta)
 
 
 
 
 
 
 
256
  logger.debug(
257
  "Response metadata for user=%s conversation=%s: %s",
258
  user_id,
@@ -269,8 +277,8 @@ def chat(request: ChatRequest):
269
  metadata=result.get("metadata", {}),
270
  conversation_id=conversation_id,
271
  user_id=user_id,
272
- requires_action=True if agent_name in ["token swap", "lending"] else False,
273
- action_type="swap" if agent_name == "token swap" else "lending" if agent_name == "lending" else None
274
  )
275
 
276
  # Add the response message to the conversation
@@ -322,6 +330,18 @@ def chat(request: ChatRequest):
322
  user_id=user_id,
323
  conversation_id=conversation_id,
324
  )
 
 
 
 
 
 
 
 
 
 
 
 
325
  return response_payload
326
 
327
  return {"response": "No response available", "agent": "supervisor"}
 
114
  "search_agent": "realtime search",
115
  "swap_agent": "token swap",
116
  "lending_agent": "lending",
117
+ "staking_agent": "staking",
118
  "supervisor": "supervisor",
119
  }
120
  return mapping.get(agent_name, "supervisor")
 
254
  )
255
  if lending_meta:
256
  response_metadata.update(lending_meta)
257
+ elif agent_name == "staking":
258
+ staking_meta = metadata.get_staking_agent(
259
+ user_id=user_id,
260
+ conversation_id=conversation_id,
261
+ )
262
+ if staking_meta:
263
+ response_metadata.update(staking_meta)
264
  logger.debug(
265
  "Response metadata for user=%s conversation=%s: %s",
266
  user_id,
 
277
  metadata=result.get("metadata", {}),
278
  conversation_id=conversation_id,
279
  user_id=user_id,
280
+ requires_action=True if agent_name in ["token swap", "lending", "staking"] else False,
281
+ action_type="swap" if agent_name == "token swap" else "lending" if agent_name == "lending" else "staking" if agent_name == "staking" else None
282
  )
283
 
284
  # Add the response message to the conversation
 
330
  user_id=user_id,
331
  conversation_id=conversation_id,
332
  )
333
+ if agent_name == "staking":
334
+ should_clear = False
335
+ if response_meta:
336
+ status = response_meta.get("status") if isinstance(response_meta, dict) else None
337
+ event = response_meta.get("event") if isinstance(response_meta, dict) else None
338
+ should_clear = status == "ready" or event == "staking_intent_ready"
339
+ if should_clear:
340
+ metadata.set_staking_agent(
341
+ {},
342
+ user_id=user_id,
343
+ conversation_id=conversation_id,
344
+ )
345
  return response_payload
346
 
347
  return {"response": "No response available", "agent": "supervisor"}