Spaces:
Running
Running
add staking + registry swap
Browse files- src/agents/metadata.py +39 -0
- src/agents/staking/__init__.py +1 -0
- src/agents/staking/agent.py +16 -0
- src/agents/staking/config.py +92 -0
- src/agents/staking/intent.py +122 -0
- src/agents/staking/prompt.py +51 -0
- src/agents/staking/storage.py +411 -0
- src/agents/staking/tools.py +354 -0
- src/agents/supervisor/agent.py +129 -5
- src/agents/swap/registry.json +11 -0
- src/app.py +22 -2
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 856 |
-
|
|
|
|
| 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"}
|