Spaces:
Running
Running
add: lending agent
Browse files- requirements.txt +2 -1
- src/agents/dca/agent.py +19 -0
- src/agents/dca/prompt.py +23 -0
- src/agents/dca/storage.py +394 -0
- src/agents/dca/strategy.py +197 -0
- src/agents/dca/strategy_registry.json +81 -0
- src/agents/dca/tools.py +650 -0
- src/agents/lending/config.py +50 -0
- src/agents/lending/intent.py +109 -0
- src/agents/lending/prompt.py +34 -0
- src/agents/lending/storage.py +405 -0
- src/agents/lending/tools.py +388 -0
- src/agents/metadata.py +78 -0
- src/agents/supervisor/agent.py +217 -5
- src/agents/swap/storage.py +323 -143
- src/agents/swap/tools.py +38 -0
- src/app.py +85 -6
- src/integrations/panorama_gateway/client.py +250 -0
- src/integrations/panorama_gateway/config.py +56 -0
- src/service/chat_manager.py +140 -249
- src/service/panorama_store.py +390 -0
requirements.txt
CHANGED
|
@@ -43,6 +43,7 @@ requests>=2.31.0
|
|
| 43 |
# Data validation and serialization
|
| 44 |
marshmallow>=3.20.0
|
| 45 |
jsonschema>=4.19.0
|
|
|
|
| 46 |
|
| 47 |
# Async support
|
| 48 |
asyncio-mqtt>=0.16.0
|
|
@@ -61,4 +62,4 @@ mypy>=1.5.0
|
|
| 61 |
clickhouse-connect>=0.7.0
|
| 62 |
clickhouse-sqlalchemy==0.3.2
|
| 63 |
|
| 64 |
-
langchain-tavily>=0.2.11
|
|
|
|
| 43 |
# Data validation and serialization
|
| 44 |
marshmallow>=3.20.0
|
| 45 |
jsonschema>=4.19.0
|
| 46 |
+
PyJWT>=2.8.0
|
| 47 |
|
| 48 |
# Async support
|
| 49 |
asyncio-mqtt>=0.16.0
|
|
|
|
| 62 |
clickhouse-connect>=0.7.0
|
| 63 |
clickhouse-sqlalchemy==0.3.2
|
| 64 |
|
| 65 |
+
langchain-tavily>=0.2.11
|
src/agents/dca/agent.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from langgraph.prebuilt import create_react_agent
|
| 4 |
+
|
| 5 |
+
from .tools import get_tools
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class DcaAgent:
|
| 11 |
+
"""Agent orchestrating DCA consultations and workflow confirmation."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, llm):
|
| 14 |
+
self.llm = llm
|
| 15 |
+
self.agent = create_react_agent(
|
| 16 |
+
model=llm,
|
| 17 |
+
tools=get_tools(),
|
| 18 |
+
name="dca_agent",
|
| 19 |
+
)
|
src/agents/dca/prompt.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompt for the DCA planning agent."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
DCA_AGENT_SYSTEM_PROMPT = """
|
| 6 |
+
You are Zico's DCA strategist.
|
| 7 |
+
|
| 8 |
+
Always respond in English, regardless of the user's language.
|
| 9 |
+
|
| 10 |
+
Operational stages:
|
| 11 |
+
1. Consulting – retrieve strategy guidance via tools, summarise guardrails and editable defaults, and let the user tailor parameters.
|
| 12 |
+
2. Recommendation – converge on cadence, schedule, budget, and execution venue while respecting strategy limits.
|
| 13 |
+
3. Confirmation – restate the final automation payload and request explicit approval before completing the intent.
|
| 14 |
+
|
| 15 |
+
Core rules:
|
| 16 |
+
- Call `fetch_dca_strategy` when you need strategy context or guardrails.
|
| 17 |
+
- Call `update_dca_intent` every time the user shares new schedule details or confirms adjustments.
|
| 18 |
+
- Never fabricate quotes, prices, or approvals. Only mark the workflow ready when the tool response event becomes `dca_intent_ready`.
|
| 19 |
+
- Surface strategy guardrails and compliance notes whenever the user suggests values outside the allowed bounds.
|
| 20 |
+
- Keep responses concise, cite the remaining fields in plain language, and invite the user to adjust parameters before confirming.
|
| 21 |
+
|
| 22 |
+
Follow the stage progression strictly: consulting → recommendation → confirmation. If the user declines to confirm, offer to adjust the plan and loop back to the relevant stage.
|
| 23 |
+
""".strip()
|
src/agents/dca/storage.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""State management for DCA intents with optional Panorama gateway persistence."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import copy
|
| 6 |
+
import time
|
| 7 |
+
import logging
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from threading import Lock
|
| 10 |
+
from typing import Any, Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
from src.integrations.panorama_gateway import (
|
| 13 |
+
PanoramaGatewayClient,
|
| 14 |
+
PanoramaGatewayError,
|
| 15 |
+
PanoramaGatewaySettings,
|
| 16 |
+
get_panorama_settings,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
DCA_SESSION_ENTITY = "dca-sessions"
|
| 20 |
+
DCA_HISTORY_ENTITY = "dca-histories"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _utc_now_iso() -> str:
|
| 24 |
+
return datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _identifier(user_id: str, conversation_id: str) -> str:
|
| 28 |
+
return f"{user_id}:{conversation_id}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _as_float(value: Any) -> Optional[float]:
|
| 32 |
+
if value is None:
|
| 33 |
+
return None
|
| 34 |
+
try:
|
| 35 |
+
return float(value)
|
| 36 |
+
except (TypeError, ValueError):
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class DcaStateRepository:
|
| 41 |
+
"""Stores DCA agent state via Panorama's gateway with local fallback."""
|
| 42 |
+
|
| 43 |
+
_instance: "DcaStateRepository" | None = None
|
| 44 |
+
_instance_lock: Lock = Lock()
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
*,
|
| 49 |
+
client: PanoramaGatewayClient | None = None,
|
| 50 |
+
settings: PanoramaGatewaySettings | None = None,
|
| 51 |
+
history_limit: int = 10,
|
| 52 |
+
) -> None:
|
| 53 |
+
self._logger = logging.getLogger(__name__)
|
| 54 |
+
self._history_limit = history_limit
|
| 55 |
+
try:
|
| 56 |
+
self._settings = settings or get_panorama_settings()
|
| 57 |
+
self._client = client or PanoramaGatewayClient(self._settings)
|
| 58 |
+
self._use_gateway = True
|
| 59 |
+
except ValueError:
|
| 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 _fallback_to_local_store(self) -> None:
|
| 70 |
+
if self._use_gateway:
|
| 71 |
+
self._logger.warning("Panorama gateway unavailable for DCA state; switching to in-memory fallback.")
|
| 72 |
+
self._use_gateway = False
|
| 73 |
+
self._init_local_store()
|
| 74 |
+
|
| 75 |
+
def _handle_gateway_failure(self, exc: PanoramaGatewayError) -> None:
|
| 76 |
+
self._logger.warning(
|
| 77 |
+
"Panorama gateway error (%s) for DCA repository: %s",
|
| 78 |
+
getattr(exc, "status_code", "unknown"),
|
| 79 |
+
getattr(exc, "payload", exc),
|
| 80 |
+
)
|
| 81 |
+
self._fallback_to_local_store()
|
| 82 |
+
|
| 83 |
+
# ---- Singleton helpers -------------------------------------------------
|
| 84 |
+
@classmethod
|
| 85 |
+
def instance(cls) -> "DcaStateRepository":
|
| 86 |
+
if cls._instance is None:
|
| 87 |
+
with cls._instance_lock:
|
| 88 |
+
if cls._instance is None:
|
| 89 |
+
cls._instance = cls()
|
| 90 |
+
return cls._instance
|
| 91 |
+
|
| 92 |
+
@classmethod
|
| 93 |
+
def reset(cls) -> None:
|
| 94 |
+
with cls._instance_lock:
|
| 95 |
+
cls._instance = None
|
| 96 |
+
|
| 97 |
+
# ---- Core API ----------------------------------------------------------
|
| 98 |
+
def load_intent(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
|
| 99 |
+
if not self._use_gateway:
|
| 100 |
+
self._init_local_store()
|
| 101 |
+
record = self._state["intents"].get(_identifier(user_id, conversation_id))
|
| 102 |
+
if not record:
|
| 103 |
+
return None
|
| 104 |
+
return copy.deepcopy(record.get("intent"))
|
| 105 |
+
|
| 106 |
+
session = self._get_session(user_id, conversation_id)
|
| 107 |
+
if not self._use_gateway:
|
| 108 |
+
return self.load_intent(user_id, conversation_id)
|
| 109 |
+
if not session:
|
| 110 |
+
return None
|
| 111 |
+
return session.get("intent") or None
|
| 112 |
+
|
| 113 |
+
def persist_intent(
|
| 114 |
+
self,
|
| 115 |
+
user_id: str,
|
| 116 |
+
conversation_id: str,
|
| 117 |
+
intent: Dict[str, Any],
|
| 118 |
+
metadata: Dict[str, Any],
|
| 119 |
+
done: bool,
|
| 120 |
+
summary: Optional[Dict[str, Any]] = None,
|
| 121 |
+
) -> List[Dict[str, Any]]:
|
| 122 |
+
if not self._use_gateway:
|
| 123 |
+
self._init_local_store()
|
| 124 |
+
key = _identifier(user_id, conversation_id)
|
| 125 |
+
now = time.time()
|
| 126 |
+
if done:
|
| 127 |
+
self._state["intents"].pop(key, None)
|
| 128 |
+
else:
|
| 129 |
+
self._state["intents"][key] = {"intent": copy.deepcopy(intent), "updated_at": now}
|
| 130 |
+
if metadata:
|
| 131 |
+
meta_copy = copy.deepcopy(metadata)
|
| 132 |
+
meta_copy["updated_at"] = now
|
| 133 |
+
self._state["metadata"][key] = meta_copy
|
| 134 |
+
if done and summary:
|
| 135 |
+
history = self._state["history"].setdefault(key, [])
|
| 136 |
+
summary_copy = copy.deepcopy(summary)
|
| 137 |
+
summary_copy.setdefault("timestamp", now)
|
| 138 |
+
history.append(summary_copy)
|
| 139 |
+
self._state["history"][key] = history[-self._history_limit :]
|
| 140 |
+
return self.get_history(user_id, conversation_id)
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
if done:
|
| 144 |
+
if summary:
|
| 145 |
+
self._create_history_entry(user_id, conversation_id, summary)
|
| 146 |
+
self._delete_session(user_id, conversation_id)
|
| 147 |
+
else:
|
| 148 |
+
payload = self._session_payload(intent, metadata)
|
| 149 |
+
self._upsert_session(user_id, conversation_id, payload)
|
| 150 |
+
return self.get_history(user_id, conversation_id)
|
| 151 |
+
except PanoramaGatewayError as exc:
|
| 152 |
+
self._handle_gateway_failure(exc)
|
| 153 |
+
return self.persist_intent(user_id, conversation_id, intent, metadata, done, summary)
|
| 154 |
+
|
| 155 |
+
def set_metadata(
|
| 156 |
+
self,
|
| 157 |
+
user_id: str,
|
| 158 |
+
conversation_id: str,
|
| 159 |
+
metadata: Dict[str, Any],
|
| 160 |
+
) -> None:
|
| 161 |
+
if not self._use_gateway:
|
| 162 |
+
self._init_local_store()
|
| 163 |
+
key = _identifier(user_id, conversation_id)
|
| 164 |
+
if metadata:
|
| 165 |
+
meta_copy = copy.deepcopy(metadata)
|
| 166 |
+
meta_copy["updated_at"] = time.time()
|
| 167 |
+
self._state["metadata"][key] = meta_copy
|
| 168 |
+
else:
|
| 169 |
+
self._state["metadata"].pop(key, None)
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
if not metadata:
|
| 174 |
+
self._delete_session(user_id, conversation_id)
|
| 175 |
+
return
|
| 176 |
+
|
| 177 |
+
session = self._get_session(user_id, conversation_id)
|
| 178 |
+
if not self._use_gateway:
|
| 179 |
+
return self.set_metadata(user_id, conversation_id, metadata)
|
| 180 |
+
intent = session.get("intent") if session else {}
|
| 181 |
+
payload = self._session_payload(intent or {}, metadata)
|
| 182 |
+
self._upsert_session(user_id, conversation_id, payload)
|
| 183 |
+
except PanoramaGatewayError as exc:
|
| 184 |
+
self._handle_gateway_failure(exc)
|
| 185 |
+
self.set_metadata(user_id, conversation_id, metadata)
|
| 186 |
+
|
| 187 |
+
def clear_metadata(self, user_id: str, conversation_id: str) -> None:
|
| 188 |
+
self.set_metadata(user_id, conversation_id, {})
|
| 189 |
+
|
| 190 |
+
def clear_intent(self, user_id: str, conversation_id: str) -> None:
|
| 191 |
+
if not self._use_gateway:
|
| 192 |
+
self._init_local_store()
|
| 193 |
+
self._state["intents"].pop(_identifier(user_id, conversation_id), None)
|
| 194 |
+
self._state["metadata"].pop(_identifier(user_id, conversation_id), None)
|
| 195 |
+
return
|
| 196 |
+
try:
|
| 197 |
+
self._delete_session(user_id, conversation_id)
|
| 198 |
+
except PanoramaGatewayError as exc:
|
| 199 |
+
self._handle_gateway_failure(exc)
|
| 200 |
+
self.clear_intent(user_id, conversation_id)
|
| 201 |
+
|
| 202 |
+
def get_metadata(self, user_id: str, conversation_id: str) -> Dict[str, Any]:
|
| 203 |
+
if not self._use_gateway:
|
| 204 |
+
self._init_local_store()
|
| 205 |
+
record = self._state["metadata"].get(_identifier(user_id, conversation_id))
|
| 206 |
+
if not record:
|
| 207 |
+
return {}
|
| 208 |
+
entry = copy.deepcopy(record)
|
| 209 |
+
ts = entry.pop("updated_at", None)
|
| 210 |
+
if ts is not None:
|
| 211 |
+
entry["updated_at"] = datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
|
| 212 |
+
return entry
|
| 213 |
+
|
| 214 |
+
session = self._get_session(user_id, conversation_id)
|
| 215 |
+
if not self._use_gateway:
|
| 216 |
+
return self.get_metadata(user_id, conversation_id)
|
| 217 |
+
if not session:
|
| 218 |
+
return {}
|
| 219 |
+
|
| 220 |
+
intent = session.get("intent") or {}
|
| 221 |
+
metadata: Dict[str, Any] = {
|
| 222 |
+
"event": session.get("event"),
|
| 223 |
+
"status": session.get("status"),
|
| 224 |
+
"stage": session.get("stage"),
|
| 225 |
+
"missing_fields": session.get("missingFields") or [],
|
| 226 |
+
"next_field": session.get("nextField"),
|
| 227 |
+
"pending_question": session.get("pendingQuestion"),
|
| 228 |
+
"choices": session.get("choices") or [],
|
| 229 |
+
"error": session.get("errorMessage"),
|
| 230 |
+
"user_id": user_id,
|
| 231 |
+
"conversation_id": conversation_id,
|
| 232 |
+
}
|
| 233 |
+
metadata.update(intent)
|
| 234 |
+
|
| 235 |
+
history = self.get_history(user_id, conversation_id)
|
| 236 |
+
if history:
|
| 237 |
+
metadata["history"] = history
|
| 238 |
+
|
| 239 |
+
updated_at = session.get("updatedAt")
|
| 240 |
+
if updated_at:
|
| 241 |
+
metadata["updated_at"] = updated_at
|
| 242 |
+
return metadata
|
| 243 |
+
|
| 244 |
+
def get_history(
|
| 245 |
+
self,
|
| 246 |
+
user_id: str,
|
| 247 |
+
conversation_id: str,
|
| 248 |
+
limit: Optional[int] = None,
|
| 249 |
+
) -> List[Dict[str, Any]]:
|
| 250 |
+
if not self._use_gateway:
|
| 251 |
+
self._init_local_store()
|
| 252 |
+
history = self._state["history"].get(_identifier(user_id, conversation_id), [])
|
| 253 |
+
if not history:
|
| 254 |
+
return []
|
| 255 |
+
records = copy.deepcopy(history)
|
| 256 |
+
if limit:
|
| 257 |
+
records = records[-limit:]
|
| 258 |
+
for record in records:
|
| 259 |
+
ts = record.get("timestamp")
|
| 260 |
+
if ts is not None:
|
| 261 |
+
record["timestamp"] = datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
|
| 262 |
+
return records
|
| 263 |
+
|
| 264 |
+
effective_limit = limit or self._history_limit
|
| 265 |
+
try:
|
| 266 |
+
result = self._client.list(
|
| 267 |
+
DCA_HISTORY_ENTITY,
|
| 268 |
+
{
|
| 269 |
+
"where": {"userId": user_id, "conversationId": conversation_id},
|
| 270 |
+
"orderBy": {"recordedAt": "desc"},
|
| 271 |
+
"take": effective_limit,
|
| 272 |
+
},
|
| 273 |
+
)
|
| 274 |
+
except PanoramaGatewayError as exc:
|
| 275 |
+
if exc.status_code == 404:
|
| 276 |
+
return []
|
| 277 |
+
self._handle_gateway_failure(exc)
|
| 278 |
+
return self.get_history(user_id, conversation_id, limit)
|
| 279 |
+
except ValueError:
|
| 280 |
+
self._logger.warning("Invalid DCA history response from gateway; falling back to local store.")
|
| 281 |
+
self._fallback_to_local_store()
|
| 282 |
+
return self.get_history(user_id, conversation_id, limit)
|
| 283 |
+
data = result.get("data", []) if isinstance(result, dict) else []
|
| 284 |
+
history: List[Dict[str, Any]] = []
|
| 285 |
+
for entry in data:
|
| 286 |
+
summary_text = entry.get("summary")
|
| 287 |
+
meta_payload = entry.get("metadata") or {}
|
| 288 |
+
if not meta_payload:
|
| 289 |
+
meta_payload = {
|
| 290 |
+
"workflow_type": entry.get("workflowType"),
|
| 291 |
+
"cadence": entry.get("cadence"),
|
| 292 |
+
"tokens": entry.get("tokens"),
|
| 293 |
+
"amounts": entry.get("amounts"),
|
| 294 |
+
"strategy": entry.get("strategy"),
|
| 295 |
+
"venue": entry.get("venue"),
|
| 296 |
+
"slippage_bps": entry.get("slippageBps"),
|
| 297 |
+
"stop_conditions": entry.get("stopConditions"),
|
| 298 |
+
}
|
| 299 |
+
error_message = entry.get("errorMessage")
|
| 300 |
+
if error_message:
|
| 301 |
+
meta_payload = dict(meta_payload)
|
| 302 |
+
meta_payload["error"] = error_message
|
| 303 |
+
history.append(
|
| 304 |
+
{
|
| 305 |
+
"timestamp": entry.get("recordedAt"),
|
| 306 |
+
"summary": summary_text,
|
| 307 |
+
"metadata": meta_payload,
|
| 308 |
+
}
|
| 309 |
+
)
|
| 310 |
+
return history
|
| 311 |
+
|
| 312 |
+
# ---- Gateway helpers ---------------------------------------------------
|
| 313 |
+
def _session_payload(self, intent: Dict[str, Any], metadata: Dict[str, Any]) -> Dict[str, Any]:
|
| 314 |
+
payload = {
|
| 315 |
+
"intent": intent,
|
| 316 |
+
"event": metadata.get("event"),
|
| 317 |
+
"status": metadata.get("status"),
|
| 318 |
+
"stage": metadata.get("stage"),
|
| 319 |
+
"missingFields": metadata.get("missing_fields") or [],
|
| 320 |
+
"nextField": metadata.get("next_field"),
|
| 321 |
+
"pendingQuestion": metadata.get("ask") or metadata.get("pending_question"),
|
| 322 |
+
"choices": metadata.get("choices") or [],
|
| 323 |
+
"errorMessage": metadata.get("error"),
|
| 324 |
+
"updatedAt": metadata.get("updated_at") or _utc_now_iso(),
|
| 325 |
+
}
|
| 326 |
+
return payload
|
| 327 |
+
|
| 328 |
+
def _get_session(self, user_id: str, conversation_id: str) -> Dict[str, Any] | None:
|
| 329 |
+
identifier = _identifier(user_id, conversation_id)
|
| 330 |
+
try:
|
| 331 |
+
return self._client.get(DCA_SESSION_ENTITY, identifier)
|
| 332 |
+
except PanoramaGatewayError as exc:
|
| 333 |
+
if exc.status_code == 404:
|
| 334 |
+
return None
|
| 335 |
+
self._handle_gateway_failure(exc)
|
| 336 |
+
return None
|
| 337 |
+
|
| 338 |
+
def _upsert_session(self, user_id: str, conversation_id: str, payload: Dict[str, Any]) -> None:
|
| 339 |
+
identifier = _identifier(user_id, conversation_id)
|
| 340 |
+
record = {**payload, "updatedAt": _utc_now_iso()}
|
| 341 |
+
try:
|
| 342 |
+
self._client.update(DCA_SESSION_ENTITY, identifier, record)
|
| 343 |
+
except PanoramaGatewayError as exc:
|
| 344 |
+
if exc.status_code != 404:
|
| 345 |
+
raise
|
| 346 |
+
create_payload = {
|
| 347 |
+
"userId": user_id,
|
| 348 |
+
"conversationId": conversation_id,
|
| 349 |
+
"tenantId": self._settings.tenant_id,
|
| 350 |
+
**record,
|
| 351 |
+
}
|
| 352 |
+
try:
|
| 353 |
+
self._client.create(DCA_SESSION_ENTITY, create_payload)
|
| 354 |
+
except PanoramaGatewayError as create_exc:
|
| 355 |
+
if create_exc.status_code == 409:
|
| 356 |
+
return
|
| 357 |
+
if create_exc.status_code == 404:
|
| 358 |
+
raise
|
| 359 |
+
raise
|
| 360 |
+
|
| 361 |
+
def _delete_session(self, user_id: str, conversation_id: str) -> None:
|
| 362 |
+
identifier = _identifier(user_id, conversation_id)
|
| 363 |
+
try:
|
| 364 |
+
self._client.delete(DCA_SESSION_ENTITY, identifier)
|
| 365 |
+
except PanoramaGatewayError as exc:
|
| 366 |
+
if exc.status_code != 404:
|
| 367 |
+
self._handle_gateway_failure(exc)
|
| 368 |
+
raise
|
| 369 |
+
|
| 370 |
+
def _create_history_entry(self, user_id: str, conversation_id: str, summary: Dict[str, Any]) -> None:
|
| 371 |
+
history_payload = {
|
| 372 |
+
"userId": user_id,
|
| 373 |
+
"conversationId": conversation_id,
|
| 374 |
+
"summary": summary.get("summary"),
|
| 375 |
+
"workflowType": summary.get("workflow_type"),
|
| 376 |
+
"cadence": summary.get("cadence"),
|
| 377 |
+
"tokens": summary.get("tokens"),
|
| 378 |
+
"amounts": summary.get("amounts"),
|
| 379 |
+
"strategy": summary.get("strategy"),
|
| 380 |
+
"venue": summary.get("venue"),
|
| 381 |
+
"slippageBps": summary.get("slippage_bps"),
|
| 382 |
+
"stopConditions": summary.get("stop_conditions"),
|
| 383 |
+
"metadata": summary,
|
| 384 |
+
"errorMessage": summary.get("error"),
|
| 385 |
+
"recordedAt": _utc_now_iso(),
|
| 386 |
+
"tenantId": self._settings.tenant_id,
|
| 387 |
+
}
|
| 388 |
+
try:
|
| 389 |
+
self._client.create(DCA_HISTORY_ENTITY, history_payload)
|
| 390 |
+
except PanoramaGatewayError as exc:
|
| 391 |
+
if exc.status_code == 404:
|
| 392 |
+
raise
|
| 393 |
+
elif exc.status_code != 409:
|
| 394 |
+
raise
|
src/agents/dca/strategy.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lightweight RAG wrapper for DCA strategy documents."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
| 9 |
+
|
| 10 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 11 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 12 |
+
|
| 13 |
+
REGISTRY_PATH = Path(__file__).resolve().parent / "strategy_registry.json"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _load_registry(path: Path) -> List[Dict[str, Any]]:
|
| 17 |
+
if not path.exists():
|
| 18 |
+
return []
|
| 19 |
+
try:
|
| 20 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 21 |
+
except (json.JSONDecodeError, OSError):
|
| 22 |
+
return []
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass(slots=True)
|
| 26 |
+
class StrategyDocument:
|
| 27 |
+
strategy_id: str
|
| 28 |
+
version: str
|
| 29 |
+
name: str
|
| 30 |
+
description: str
|
| 31 |
+
tokens_supported: List[Dict[str, Sequence[str]]]
|
| 32 |
+
cadence_options: List[str]
|
| 33 |
+
amount_bounds: Dict[str, Any]
|
| 34 |
+
slippage_bps: Dict[str, Any]
|
| 35 |
+
risk_tier: str
|
| 36 |
+
defaults: Dict[str, Any] = field(default_factory=dict)
|
| 37 |
+
guardrails: List[str] = field(default_factory=list)
|
| 38 |
+
compliance_notes: List[str] = field(default_factory=list)
|
| 39 |
+
context: str = ""
|
| 40 |
+
|
| 41 |
+
def embed_text(self) -> str:
|
| 42 |
+
tokens_text = " ".join(
|
| 43 |
+
f"from:{'|'.join(entry.get('from', []))} to:{'|'.join(entry.get('to', []))}"
|
| 44 |
+
for entry in self.tokens_supported
|
| 45 |
+
)
|
| 46 |
+
guardrails = " ".join(self.guardrails)
|
| 47 |
+
compliance = " ".join(self.compliance_notes)
|
| 48 |
+
cadence = " ".join(self.cadence_options)
|
| 49 |
+
defaults_text = " ".join(f"{key}:{value}" for key, value in self.defaults.items())
|
| 50 |
+
return " ".join(
|
| 51 |
+
[
|
| 52 |
+
self.name,
|
| 53 |
+
self.description,
|
| 54 |
+
self.context,
|
| 55 |
+
tokens_text,
|
| 56 |
+
cadence,
|
| 57 |
+
self.risk_tier,
|
| 58 |
+
guardrails,
|
| 59 |
+
compliance,
|
| 60 |
+
defaults_text,
|
| 61 |
+
]
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def to_consulting_payload(self) -> Dict[str, Any]:
|
| 65 |
+
return {
|
| 66 |
+
"strategy_id": self.strategy_id,
|
| 67 |
+
"version": self.version,
|
| 68 |
+
"name": self.name,
|
| 69 |
+
"summary": self.description,
|
| 70 |
+
"context": self.context,
|
| 71 |
+
"defaults": self.defaults,
|
| 72 |
+
"guardrails": self.guardrails,
|
| 73 |
+
"compliance_notes": self.compliance_notes,
|
| 74 |
+
"cadence_options": self.cadence_options,
|
| 75 |
+
"amount_bounds": self.amount_bounds,
|
| 76 |
+
"slippage_bps": self.slippage_bps,
|
| 77 |
+
"risk_tier": self.risk_tier,
|
| 78 |
+
"tokens_supported": self.tokens_supported,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass(slots=True)
|
| 83 |
+
class StrategyMatch:
|
| 84 |
+
document: StrategyDocument
|
| 85 |
+
confidence: float
|
| 86 |
+
highlights: List[str] = field(default_factory=list)
|
| 87 |
+
|
| 88 |
+
def to_payload(self) -> Dict[str, Any]:
|
| 89 |
+
payload = self.document.to_consulting_payload()
|
| 90 |
+
payload["confidence"] = self.confidence
|
| 91 |
+
if self.highlights:
|
| 92 |
+
payload["highlights"] = self.highlights
|
| 93 |
+
return payload
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class StrategyRetriever:
|
| 97 |
+
"""Simple TF-IDF backed retrieval to simulate strategy RAG behaviour."""
|
| 98 |
+
|
| 99 |
+
def __init__(
|
| 100 |
+
self,
|
| 101 |
+
*,
|
| 102 |
+
registry_path: Path | None = None,
|
| 103 |
+
min_score: float = 0.18,
|
| 104 |
+
) -> None:
|
| 105 |
+
self._registry_path = registry_path or REGISTRY_PATH
|
| 106 |
+
self._min_score = min_score
|
| 107 |
+
self._documents: List[StrategyDocument] = [
|
| 108 |
+
StrategyDocument(**entry) for entry in _load_registry(self._registry_path)
|
| 109 |
+
]
|
| 110 |
+
self._vectorizer: Optional[TfidfVectorizer] = None
|
| 111 |
+
self._matrix = None
|
| 112 |
+
if self._documents:
|
| 113 |
+
self._vectorizer = TfidfVectorizer()
|
| 114 |
+
corpus = [doc.embed_text() for doc in self._documents]
|
| 115 |
+
self._matrix = self._vectorizer.fit_transform(corpus)
|
| 116 |
+
|
| 117 |
+
def refresh(self) -> None:
|
| 118 |
+
"""Reload the registry and refresh embeddings."""
|
| 119 |
+
|
| 120 |
+
self._documents = [StrategyDocument(**entry) for entry in _load_registry(self._registry_path)]
|
| 121 |
+
if not self._documents:
|
| 122 |
+
self._vectorizer = None
|
| 123 |
+
self._matrix = None
|
| 124 |
+
return
|
| 125 |
+
self._vectorizer = TfidfVectorizer()
|
| 126 |
+
corpus = [doc.embed_text() for doc in self._documents]
|
| 127 |
+
self._matrix = self._vectorizer.fit_transform(corpus)
|
| 128 |
+
|
| 129 |
+
def is_ready(self) -> bool:
|
| 130 |
+
return bool(self._vectorizer) and self._matrix is not None
|
| 131 |
+
|
| 132 |
+
def search(
|
| 133 |
+
self,
|
| 134 |
+
*,
|
| 135 |
+
from_token: str | None = None,
|
| 136 |
+
to_token: str | None = None,
|
| 137 |
+
cadence: str | None = None,
|
| 138 |
+
risk_tier: str | None = None,
|
| 139 |
+
text: str | None = None,
|
| 140 |
+
top_k: int = 3,
|
| 141 |
+
) -> List[StrategyMatch]:
|
| 142 |
+
if not self.is_ready():
|
| 143 |
+
return []
|
| 144 |
+
|
| 145 |
+
query_terms: List[str] = []
|
| 146 |
+
if from_token:
|
| 147 |
+
query_terms.append(f"from:{from_token}")
|
| 148 |
+
if to_token:
|
| 149 |
+
query_terms.append(f"to:{to_token}")
|
| 150 |
+
if cadence:
|
| 151 |
+
query_terms.append(f"cadence:{cadence}")
|
| 152 |
+
if risk_tier:
|
| 153 |
+
query_terms.append(f"risk:{risk_tier}")
|
| 154 |
+
if text:
|
| 155 |
+
query_terms.append(text)
|
| 156 |
+
|
| 157 |
+
if not query_terms:
|
| 158 |
+
return []
|
| 159 |
+
|
| 160 |
+
query = " ".join(query_terms)
|
| 161 |
+
vector = self._vectorizer.transform([query])
|
| 162 |
+
scores = cosine_similarity(vector, self._matrix)[0]
|
| 163 |
+
|
| 164 |
+
ranked = sorted(enumerate(scores), key=lambda item: item[1], reverse=True)
|
| 165 |
+
matches: List[StrategyMatch] = []
|
| 166 |
+
for idx, score in ranked[:top_k]:
|
| 167 |
+
if score < self._min_score:
|
| 168 |
+
continue
|
| 169 |
+
doc = self._documents[idx]
|
| 170 |
+
highlights = self._build_highlights(doc, from_token, to_token, cadence)
|
| 171 |
+
matches.append(StrategyMatch(document=doc, confidence=float(round(score, 4)), highlights=highlights))
|
| 172 |
+
return matches
|
| 173 |
+
|
| 174 |
+
@staticmethod
|
| 175 |
+
def _build_highlights(
|
| 176 |
+
doc: StrategyDocument,
|
| 177 |
+
from_token: str | None,
|
| 178 |
+
to_token: str | None,
|
| 179 |
+
cadence: str | None,
|
| 180 |
+
) -> List[str]:
|
| 181 |
+
highlights: List[str] = []
|
| 182 |
+
if from_token and any(from_token.upper() in map(str.upper, entry.get("from", [])) for entry in doc.tokens_supported):
|
| 183 |
+
highlights.append(f"Supports funding token {from_token}.")
|
| 184 |
+
if to_token and any(to_token.upper() in map(str.upper, entry.get("to", [])) for entry in doc.tokens_supported):
|
| 185 |
+
highlights.append(f"Supports target token {to_token}.")
|
| 186 |
+
if cadence and cadence.lower() in {option.lower() for option in doc.cadence_options}:
|
| 187 |
+
highlights.append(f"Includes {cadence} cadence.")
|
| 188 |
+
return highlights
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
_retriever = StrategyRetriever()
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def get_strategy_retriever() -> StrategyRetriever:
|
| 195 |
+
if not _retriever.is_ready():
|
| 196 |
+
_retriever.refresh()
|
| 197 |
+
return _retriever
|
src/agents/dca/strategy_registry.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"strategy_id": "swap_dca_daily_v1",
|
| 4 |
+
"version": "2024-06-01",
|
| 5 |
+
"name": "Daily Stablecoin to Bluechip DCA",
|
| 6 |
+
"description": "Dollar-cost average from USD stablecoins into approved bluechip assets using conservative risk parameters.",
|
| 7 |
+
"tokens_supported": [
|
| 8 |
+
{
|
| 9 |
+
"from": ["USDC", "USDT", "USDt"],
|
| 10 |
+
"to": ["AVAX", "BTC.b", "ETH"]
|
| 11 |
+
}
|
| 12 |
+
],
|
| 13 |
+
"cadence_options": ["daily", "weekly"],
|
| 14 |
+
"amount_bounds": {
|
| 15 |
+
"min_usd": 25,
|
| 16 |
+
"max_usd": 25000,
|
| 17 |
+
"per_cycle_cap": 2500
|
| 18 |
+
},
|
| 19 |
+
"slippage_bps": {
|
| 20 |
+
"max": 75,
|
| 21 |
+
"recommended": 40
|
| 22 |
+
},
|
| 23 |
+
"risk_tier": "conservative",
|
| 24 |
+
"defaults": {
|
| 25 |
+
"cadence": "daily",
|
| 26 |
+
"iterations": 30,
|
| 27 |
+
"start_on": "next_business_day",
|
| 28 |
+
"venue": "pangolin",
|
| 29 |
+
"slippage_bps": 40
|
| 30 |
+
},
|
| 31 |
+
"guardrails": [
|
| 32 |
+
"Never exceed 75 bps max slippage per swap leg.",
|
| 33 |
+
"Pause the schedule if price drops more than 12% intraday."
|
| 34 |
+
],
|
| 35 |
+
"compliance_notes": [
|
| 36 |
+
"KYC required for notional volume above 50k USD in a rolling 30-day window."
|
| 37 |
+
],
|
| 38 |
+
"context": "Applies to swap-based DCA flows where the funding asset is a USD stablecoin and the target asset is on the Avalanche network."
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"strategy_id": "swap_dca_weekly_growth_v1",
|
| 42 |
+
"version": "2024-05-15",
|
| 43 |
+
"name": "Weekly Growth Token Rotation",
|
| 44 |
+
"description": "Rotate stablecoin treasury holdings into approved growth tokens on a weekly cadence with optional stop-loss triggers.",
|
| 45 |
+
"tokens_supported": [
|
| 46 |
+
{
|
| 47 |
+
"from": ["USDC", "USDT"],
|
| 48 |
+
"to": ["JOE", "ARB", "LINK"]
|
| 49 |
+
}
|
| 50 |
+
],
|
| 51 |
+
"cadence_options": ["weekly", "monthly"],
|
| 52 |
+
"amount_bounds": {
|
| 53 |
+
"min_usd": 100,
|
| 54 |
+
"max_usd": 100000,
|
| 55 |
+
"per_cycle_cap": 10000
|
| 56 |
+
},
|
| 57 |
+
"slippage_bps": {
|
| 58 |
+
"max": 120,
|
| 59 |
+
"recommended": 80
|
| 60 |
+
},
|
| 61 |
+
"risk_tier": "moderate",
|
| 62 |
+
"defaults": {
|
| 63 |
+
"cadence": "weekly",
|
| 64 |
+
"iterations": 12,
|
| 65 |
+
"start_on": "next_scheduled_window",
|
| 66 |
+
"venue": "traderjoe",
|
| 67 |
+
"slippage_bps": 80,
|
| 68 |
+
"stop_conditions": [
|
| 69 |
+
"Pause if target asset rallies 20% week-over-week."
|
| 70 |
+
]
|
| 71 |
+
},
|
| 72 |
+
"guardrails": [
|
| 73 |
+
"Pre-trade exposure report must be filed for allocations above 25k USD per cycle.",
|
| 74 |
+
"Respect strategy whitelist; do not route to non-approved pools."
|
| 75 |
+
],
|
| 76 |
+
"compliance_notes": [
|
| 77 |
+
"Requires operations review if cumulative deployed capital exceeds 250k USD."
|
| 78 |
+
],
|
| 79 |
+
"context": "Tailored for teams rotating into growth tokens while maintaining automated stop conditions and compliance hand-offs."
|
| 80 |
+
}
|
| 81 |
+
]
|
src/agents/dca/tools.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DCA tools that orchestrate consulting, recommendation, and confirmation flows."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
from contextvars import ContextVar
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from decimal import Decimal, InvalidOperation
|
| 10 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 11 |
+
|
| 12 |
+
from langchain_core.tools import tool
|
| 13 |
+
from pydantic import BaseModel, Field, field_validator
|
| 14 |
+
|
| 15 |
+
from src.agents.metadata import metadata
|
| 16 |
+
|
| 17 |
+
from .storage import DcaStateRepository
|
| 18 |
+
from .strategy import get_strategy_retriever
|
| 19 |
+
|
| 20 |
+
_STORE = DcaStateRepository.instance()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _decimal_as_str(value: Optional[Decimal]) -> Optional[str]:
|
| 24 |
+
if value is None:
|
| 25 |
+
return None
|
| 26 |
+
normalized = value.normalize()
|
| 27 |
+
text = format(normalized, "f")
|
| 28 |
+
return text.rstrip("0").rstrip(".") if "." in text else text
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _to_decimal(value: Any) -> Optional[Decimal]:
|
| 32 |
+
if value is None:
|
| 33 |
+
return None
|
| 34 |
+
try:
|
| 35 |
+
return Decimal(str(value))
|
| 36 |
+
except (InvalidOperation, TypeError, ValueError):
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
STAGES: Sequence[str] = ("consulting", "recommendation", "confirmation", "ready")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class DcaIntent:
|
| 45 |
+
user_id: str
|
| 46 |
+
conversation_id: str
|
| 47 |
+
stage: str = "consulting"
|
| 48 |
+
strategy_id: Optional[str] = None
|
| 49 |
+
strategy_version: Optional[str] = None
|
| 50 |
+
strategy_name: Optional[str] = None
|
| 51 |
+
strategy_summary: Optional[str] = None
|
| 52 |
+
rag_confidence: Optional[float] = None
|
| 53 |
+
strategy_defaults: Dict[str, Any] = field(default_factory=dict)
|
| 54 |
+
guardrails: List[str] = field(default_factory=list)
|
| 55 |
+
compliance_notes: List[str] = field(default_factory=list)
|
| 56 |
+
from_token: Optional[str] = None
|
| 57 |
+
to_token: Optional[str] = None
|
| 58 |
+
cadence: Optional[str] = None
|
| 59 |
+
start_on: Optional[str] = None
|
| 60 |
+
iterations: Optional[int] = None
|
| 61 |
+
end_on: Optional[str] = None
|
| 62 |
+
total_amount: Optional[Decimal] = None
|
| 63 |
+
per_cycle_amount: Optional[Decimal] = None
|
| 64 |
+
venue: Optional[str] = None
|
| 65 |
+
slippage_bps: Optional[int] = None
|
| 66 |
+
stop_conditions: List[str] = field(default_factory=list)
|
| 67 |
+
notes: Optional[str] = None
|
| 68 |
+
timezone: Optional[str] = None
|
| 69 |
+
confirmed: bool = False
|
| 70 |
+
updated_at: float = field(default_factory=time.time)
|
| 71 |
+
|
| 72 |
+
def touch(self) -> None:
|
| 73 |
+
self.updated_at = time.time()
|
| 74 |
+
|
| 75 |
+
def advance_stage(self, stage: str | None) -> None:
|
| 76 |
+
if not stage or stage == self.stage:
|
| 77 |
+
return
|
| 78 |
+
if stage not in STAGES:
|
| 79 |
+
raise ValueError(f"Unsupported stage '{stage}'. Choose from {', '.join(STAGES)}.")
|
| 80 |
+
current_index = STAGES.index(self.stage if self.stage in STAGES else "consulting")
|
| 81 |
+
target_index = STAGES.index(stage)
|
| 82 |
+
if target_index < current_index:
|
| 83 |
+
self.stage = stage
|
| 84 |
+
return
|
| 85 |
+
if stage == "ready" and not self.confirmed:
|
| 86 |
+
raise ValueError("Cannot mark stage as ready before confirmation.")
|
| 87 |
+
self.stage = stage
|
| 88 |
+
|
| 89 |
+
def missing_fields(self) -> List[str]:
|
| 90 |
+
if self.stage == "ready":
|
| 91 |
+
return []
|
| 92 |
+
missing: List[str] = []
|
| 93 |
+
if self.stage == "consulting":
|
| 94 |
+
if not self.strategy_id:
|
| 95 |
+
missing.append("strategy_id")
|
| 96 |
+
if not self.from_token:
|
| 97 |
+
missing.append("from_token")
|
| 98 |
+
if not self.to_token:
|
| 99 |
+
missing.append("to_token")
|
| 100 |
+
elif self.stage == "recommendation":
|
| 101 |
+
if not self.cadence:
|
| 102 |
+
missing.append("cadence")
|
| 103 |
+
if not self.start_on:
|
| 104 |
+
missing.append("start_on")
|
| 105 |
+
if self.iterations is None and not self.end_on:
|
| 106 |
+
missing.append("iterations_or_end_on")
|
| 107 |
+
if self.total_amount is None and self.per_cycle_amount is None:
|
| 108 |
+
missing.append("total_or_per_cycle_amount")
|
| 109 |
+
if not self.venue:
|
| 110 |
+
missing.append("venue")
|
| 111 |
+
if self.slippage_bps is None:
|
| 112 |
+
missing.append("slippage_bps")
|
| 113 |
+
elif self.stage == "confirmation":
|
| 114 |
+
if not self.confirmed:
|
| 115 |
+
missing.append("confirmation")
|
| 116 |
+
return missing
|
| 117 |
+
|
| 118 |
+
def next_field(self) -> Optional[str]:
|
| 119 |
+
missing = self.missing_fields()
|
| 120 |
+
return missing[0] if missing else None
|
| 121 |
+
|
| 122 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 123 |
+
return {
|
| 124 |
+
"user_id": self.user_id,
|
| 125 |
+
"conversation_id": self.conversation_id,
|
| 126 |
+
"stage": self.stage,
|
| 127 |
+
"strategy_id": self.strategy_id,
|
| 128 |
+
"strategy_version": self.strategy_version,
|
| 129 |
+
"strategy_name": self.strategy_name,
|
| 130 |
+
"strategy_summary": self.strategy_summary,
|
| 131 |
+
"rag_confidence": self.rag_confidence,
|
| 132 |
+
"strategy_defaults": self.strategy_defaults,
|
| 133 |
+
"guardrails": list(self.guardrails),
|
| 134 |
+
"compliance_notes": list(self.compliance_notes),
|
| 135 |
+
"from_token": self.from_token,
|
| 136 |
+
"to_token": self.to_token,
|
| 137 |
+
"cadence": self.cadence,
|
| 138 |
+
"start_on": self.start_on,
|
| 139 |
+
"iterations": self.iterations,
|
| 140 |
+
"end_on": self.end_on,
|
| 141 |
+
"total_amount": _decimal_as_str(self.total_amount),
|
| 142 |
+
"per_cycle_amount": _decimal_as_str(self.per_cycle_amount),
|
| 143 |
+
"venue": self.venue,
|
| 144 |
+
"slippage_bps": self.slippage_bps,
|
| 145 |
+
"stop_conditions": list(self.stop_conditions),
|
| 146 |
+
"notes": self.notes,
|
| 147 |
+
"timezone": self.timezone,
|
| 148 |
+
"confirmed": self.confirmed,
|
| 149 |
+
"updated_at": self.updated_at,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
def to_public(self) -> Dict[str, Any]:
|
| 153 |
+
data = self.to_dict()
|
| 154 |
+
data["updated_at"] = datetime_from_timestamp(self.updated_at)
|
| 155 |
+
return data
|
| 156 |
+
|
| 157 |
+
def to_summary(self, error: Optional[str] = None) -> Dict[str, Any]:
|
| 158 |
+
summary = {
|
| 159 |
+
"summary": (
|
| 160 |
+
f"DCA from {self.from_token} to {self.to_token} "
|
| 161 |
+
f"({self.cadence}) starting {self.start_on}"
|
| 162 |
+
),
|
| 163 |
+
"workflow_type": "dca_swap",
|
| 164 |
+
"cadence": {"interval": self.cadence, "start_on": self.start_on, "iterations": self.iterations, "end_on": self.end_on},
|
| 165 |
+
"tokens": {"from": self.from_token, "to": self.to_token},
|
| 166 |
+
"amounts": {
|
| 167 |
+
"total": _decimal_as_str(self.total_amount),
|
| 168 |
+
"per_cycle": _decimal_as_str(self.per_cycle_amount),
|
| 169 |
+
},
|
| 170 |
+
"notes": self.notes,
|
| 171 |
+
"strategy": {
|
| 172 |
+
"strategy_id": self.strategy_id,
|
| 173 |
+
"strategy_version": self.strategy_version,
|
| 174 |
+
"confidence": self.rag_confidence,
|
| 175 |
+
},
|
| 176 |
+
"venue": self.venue,
|
| 177 |
+
"slippage_bps": self.slippage_bps,
|
| 178 |
+
"stop_conditions": list(self.stop_conditions),
|
| 179 |
+
}
|
| 180 |
+
if error:
|
| 181 |
+
summary["error"] = error
|
| 182 |
+
return summary
|
| 183 |
+
|
| 184 |
+
def to_workflow_payload(self) -> Dict[str, Any]:
|
| 185 |
+
return {
|
| 186 |
+
"workflow_type": "dca_swap",
|
| 187 |
+
"strategy_id": self.strategy_id,
|
| 188 |
+
"strategy_version": self.strategy_version,
|
| 189 |
+
"tokens": {"from": self.from_token, "to": self.to_token},
|
| 190 |
+
"cadence": {
|
| 191 |
+
"interval": self.cadence,
|
| 192 |
+
"start_on": self.start_on,
|
| 193 |
+
"iterations": self.iterations,
|
| 194 |
+
"end_on": self.end_on,
|
| 195 |
+
},
|
| 196 |
+
"amounts": {
|
| 197 |
+
"total": _decimal_as_str(self.total_amount),
|
| 198 |
+
"per_cycle": _decimal_as_str(self.per_cycle_amount),
|
| 199 |
+
},
|
| 200 |
+
"venue": self.venue,
|
| 201 |
+
"slippage_bps": self.slippage_bps,
|
| 202 |
+
"stop_conditions": list(self.stop_conditions),
|
| 203 |
+
"notes": self.notes,
|
| 204 |
+
"strategy_defaults": self.strategy_defaults,
|
| 205 |
+
"guardrails": list(self.guardrails),
|
| 206 |
+
"compliance_notes": list(self.compliance_notes),
|
| 207 |
+
"rag_confidence": self.rag_confidence,
|
| 208 |
+
"metadata": {
|
| 209 |
+
"timezone": self.timezone,
|
| 210 |
+
},
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
@classmethod
|
| 214 |
+
def from_dict(cls, data: Dict[str, Any]) -> "DcaIntent":
|
| 215 |
+
intent = cls(
|
| 216 |
+
user_id=data.get("user_id", ""),
|
| 217 |
+
conversation_id=data.get("conversation_id", ""),
|
| 218 |
+
stage=data.get("stage", "consulting"),
|
| 219 |
+
)
|
| 220 |
+
intent.strategy_id = data.get("strategy_id")
|
| 221 |
+
intent.strategy_version = data.get("strategy_version")
|
| 222 |
+
intent.strategy_name = data.get("strategy_name")
|
| 223 |
+
intent.strategy_summary = data.get("strategy_summary")
|
| 224 |
+
intent.rag_confidence = data.get("rag_confidence")
|
| 225 |
+
intent.strategy_defaults = data.get("strategy_defaults") or {}
|
| 226 |
+
intent.guardrails = list(data.get("guardrails") or [])
|
| 227 |
+
intent.compliance_notes = list(data.get("compliance_notes") or [])
|
| 228 |
+
intent.from_token = data.get("from_token")
|
| 229 |
+
intent.to_token = data.get("to_token")
|
| 230 |
+
intent.cadence = data.get("cadence")
|
| 231 |
+
intent.start_on = data.get("start_on")
|
| 232 |
+
intent.iterations = data.get("iterations")
|
| 233 |
+
intent.end_on = data.get("end_on")
|
| 234 |
+
intent.total_amount = _to_decimal(data.get("total_amount"))
|
| 235 |
+
intent.per_cycle_amount = _to_decimal(data.get("per_cycle_amount"))
|
| 236 |
+
intent.venue = data.get("venue")
|
| 237 |
+
intent.slippage_bps = data.get("slippage_bps")
|
| 238 |
+
intent.stop_conditions = list(data.get("stop_conditions") or [])
|
| 239 |
+
intent.notes = data.get("notes")
|
| 240 |
+
intent.timezone = data.get("timezone")
|
| 241 |
+
intent.confirmed = bool(data.get("confirmed"))
|
| 242 |
+
intent.updated_at = float(data.get("updated_at", time.time()))
|
| 243 |
+
return intent
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def datetime_from_timestamp(value: float) -> str:
|
| 247 |
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(value))
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ---------- Session context ----------
|
| 251 |
+
_CURRENT_SESSION: ContextVar[tuple[str, str]] = ContextVar("_current_dca_session", default=("", ""))
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def set_current_dca_session(user_id: Optional[str], conversation_id: Optional[str]) -> None:
|
| 255 |
+
resolved_user = (user_id or "").strip()
|
| 256 |
+
resolved_conversation = (conversation_id or "").strip()
|
| 257 |
+
if not resolved_user:
|
| 258 |
+
raise ValueError("dca_agent requires 'user_id' to identify the session.")
|
| 259 |
+
if not resolved_conversation:
|
| 260 |
+
raise ValueError("dca_agent requires 'conversation_id' to identify the session.")
|
| 261 |
+
_CURRENT_SESSION.set((resolved_user, resolved_conversation))
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@contextmanager
|
| 265 |
+
def dca_session(user_id: Optional[str], conversation_id: Optional[str]):
|
| 266 |
+
set_current_dca_session(user_id, conversation_id)
|
| 267 |
+
try:
|
| 268 |
+
yield
|
| 269 |
+
finally:
|
| 270 |
+
clear_current_dca_session()
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def clear_current_dca_session() -> None:
|
| 274 |
+
_CURRENT_SESSION.set(("", ""))
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _resolve_session(user_id: Optional[str], conversation_id: Optional[str]) -> tuple[str, str]:
|
| 278 |
+
active_user, active_conversation = _CURRENT_SESSION.get()
|
| 279 |
+
resolved_user = (user_id or active_user or "").strip()
|
| 280 |
+
resolved_conversation = (conversation_id or active_conversation or "").strip()
|
| 281 |
+
if not resolved_user:
|
| 282 |
+
raise ValueError("user_id is required for DCA operations.")
|
| 283 |
+
if not resolved_conversation:
|
| 284 |
+
raise ValueError("conversation_id is required for DCA operations.")
|
| 285 |
+
return resolved_user, resolved_conversation
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _load_intent(user_id: str, conversation_id: str) -> DcaIntent:
|
| 289 |
+
stored = _STORE.load_intent(user_id, conversation_id)
|
| 290 |
+
if stored:
|
| 291 |
+
intent = DcaIntent.from_dict(stored)
|
| 292 |
+
intent.user_id = user_id
|
| 293 |
+
intent.conversation_id = conversation_id
|
| 294 |
+
return intent
|
| 295 |
+
return DcaIntent(user_id=user_id, conversation_id=conversation_id)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ---------- Metadata helpers ----------
|
| 299 |
+
def _store_dca_metadata(
|
| 300 |
+
intent: DcaIntent,
|
| 301 |
+
ask: Optional[str],
|
| 302 |
+
done: bool,
|
| 303 |
+
error: Optional[str],
|
| 304 |
+
choices: Optional[List[str]] = None,
|
| 305 |
+
) -> Dict[str, Any]:
|
| 306 |
+
intent.touch()
|
| 307 |
+
missing = intent.missing_fields()
|
| 308 |
+
next_field = intent.next_field()
|
| 309 |
+
meta: Dict[str, Any] = {
|
| 310 |
+
"event": "dca_intent_ready" if done else "dca_intent_collecting",
|
| 311 |
+
"status": "ready" if done else intent.stage,
|
| 312 |
+
"stage": intent.stage,
|
| 313 |
+
"missing_fields": missing,
|
| 314 |
+
"next_field": next_field,
|
| 315 |
+
"pending_question": ask,
|
| 316 |
+
"choices": list(choices or []),
|
| 317 |
+
"error": error,
|
| 318 |
+
"user_id": intent.user_id,
|
| 319 |
+
"conversation_id": intent.conversation_id,
|
| 320 |
+
}
|
| 321 |
+
payload = intent.to_dict()
|
| 322 |
+
meta.update(payload)
|
| 323 |
+
|
| 324 |
+
summary = intent.to_summary(error=error) if done else None
|
| 325 |
+
history = _STORE.persist_intent(
|
| 326 |
+
intent.user_id,
|
| 327 |
+
intent.conversation_id,
|
| 328 |
+
payload,
|
| 329 |
+
meta,
|
| 330 |
+
done=done,
|
| 331 |
+
summary=summary,
|
| 332 |
+
)
|
| 333 |
+
if history:
|
| 334 |
+
meta["history"] = history
|
| 335 |
+
metadata.set_dca_agent(meta, intent.user_id, intent.conversation_id)
|
| 336 |
+
return meta
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _build_next_action(meta: Dict[str, Any]) -> Dict[str, Any]:
|
| 340 |
+
if meta.get("status") == "ready":
|
| 341 |
+
return {"type": "complete", "prompt": None, "field": None, "choices": []}
|
| 342 |
+
return {
|
| 343 |
+
"type": "collect_field",
|
| 344 |
+
"prompt": meta.get("pending_question"),
|
| 345 |
+
"field": meta.get("next_field"),
|
| 346 |
+
"choices": meta.get("choices", []),
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _response(
|
| 351 |
+
intent: DcaIntent,
|
| 352 |
+
ask: Optional[str],
|
| 353 |
+
choices: Optional[List[str]] = None,
|
| 354 |
+
done: bool = False,
|
| 355 |
+
error: Optional[str] = None,
|
| 356 |
+
) -> Dict[str, Any]:
|
| 357 |
+
meta = _store_dca_metadata(intent, ask, done, error, choices)
|
| 358 |
+
response: Dict[str, Any] = {
|
| 359 |
+
"event": meta.get("event"),
|
| 360 |
+
"intent": intent.to_dict(),
|
| 361 |
+
"ask": ask,
|
| 362 |
+
"choices": choices or [],
|
| 363 |
+
"error": error,
|
| 364 |
+
"next_action": _build_next_action(meta),
|
| 365 |
+
"history": meta.get("history", []),
|
| 366 |
+
"stage": meta.get("stage"),
|
| 367 |
+
"status": meta.get("status"),
|
| 368 |
+
}
|
| 369 |
+
if done:
|
| 370 |
+
response["metadata"] = intent.to_workflow_payload()
|
| 371 |
+
return response
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
# ---------- Tool Schemas ----------
|
| 375 |
+
class FetchStrategyInput(BaseModel):
|
| 376 |
+
user_id: Optional[str] = Field(default=None, description="Stable user identifier.")
|
| 377 |
+
conversation_id: Optional[str] = Field(default=None, description="Conversation identifier.")
|
| 378 |
+
from_token: Optional[str] = Field(default=None, description="Funding token for the DCA.")
|
| 379 |
+
to_token: Optional[str] = Field(default=None, description="Target asset for accumulation.")
|
| 380 |
+
cadence: Optional[str] = Field(default=None, description="Desired cadence cue (daily/weekly/monthly).")
|
| 381 |
+
risk_tier: Optional[str] = Field(default=None, description="Risk tier preference.")
|
| 382 |
+
text: Optional[str] = Field(default=None, description="Additional free-form context to seed retrieval.")
|
| 383 |
+
top_k: int = Field(default=3, ge=1, le=5, description="Maximum number of strategy suggestions to return.")
|
| 384 |
+
|
| 385 |
+
@field_validator("cadence", mode="before")
|
| 386 |
+
@classmethod
|
| 387 |
+
def _normalize_cadence(cls, value: Any) -> Any:
|
| 388 |
+
if isinstance(value, str):
|
| 389 |
+
return value.lower().strip()
|
| 390 |
+
return value
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
class UpdateDcaIntentInput(BaseModel):
|
| 394 |
+
user_id: Optional[str] = None
|
| 395 |
+
conversation_id: Optional[str] = None
|
| 396 |
+
stage: Optional[str] = Field(default=None, description="Explicit stage override (consulting/recommendation/confirmation).")
|
| 397 |
+
strategy_id: Optional[str] = None
|
| 398 |
+
strategy_version: Optional[str] = None
|
| 399 |
+
strategy_name: Optional[str] = None
|
| 400 |
+
strategy_summary: Optional[str] = None
|
| 401 |
+
rag_confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
| 402 |
+
strategy_defaults: Optional[Dict[str, Any]] = None
|
| 403 |
+
guardrails: Optional[List[str]] = None
|
| 404 |
+
compliance_notes: Optional[List[str]] = None
|
| 405 |
+
from_token: Optional[str] = None
|
| 406 |
+
to_token: Optional[str] = None
|
| 407 |
+
cadence: Optional[str] = None
|
| 408 |
+
start_on: Optional[str] = None
|
| 409 |
+
iterations: Optional[int] = Field(default=None, ge=0)
|
| 410 |
+
end_on: Optional[str] = None
|
| 411 |
+
total_amount: Optional[Decimal] = None
|
| 412 |
+
per_cycle_amount: Optional[Decimal] = None
|
| 413 |
+
venue: Optional[str] = None
|
| 414 |
+
slippage_bps: Optional[int] = Field(default=None, ge=0)
|
| 415 |
+
stop_conditions: Optional[List[str]] = None
|
| 416 |
+
notes: Optional[str] = None
|
| 417 |
+
timezone: Optional[str] = None
|
| 418 |
+
confirm: Optional[bool] = None
|
| 419 |
+
reset: bool = Field(default=False, description="When true, clears the current intent.")
|
| 420 |
+
|
| 421 |
+
@field_validator("cadence", mode="before")
|
| 422 |
+
@classmethod
|
| 423 |
+
def _norm_cadence(cls, value: Any) -> Any:
|
| 424 |
+
if isinstance(value, str):
|
| 425 |
+
return value.lower().strip()
|
| 426 |
+
return value
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# ---------- Strategy retrieval tool ----------
|
| 430 |
+
@tool("fetch_dca_strategy", args_schema=FetchStrategyInput)
|
| 431 |
+
def fetch_dca_strategy_tool(**kwargs) -> Dict[str, Any]:
|
| 432 |
+
"""Retrieve strategy recommendations from the registry-backed RAG index."""
|
| 433 |
+
|
| 434 |
+
top_k = kwargs.pop("top_k", 3)
|
| 435 |
+
resolved_user, resolved_conversation = _resolve_session(kwargs.get("user_id"), kwargs.get("conversation_id"))
|
| 436 |
+
retriever = get_strategy_retriever()
|
| 437 |
+
matches = retriever.search(
|
| 438 |
+
from_token=kwargs.get("from_token"),
|
| 439 |
+
to_token=kwargs.get("to_token"),
|
| 440 |
+
cadence=kwargs.get("cadence"),
|
| 441 |
+
risk_tier=kwargs.get("risk_tier"),
|
| 442 |
+
text=kwargs.get("text"),
|
| 443 |
+
top_k=top_k,
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
suggestions = [match.to_payload() for match in matches]
|
| 447 |
+
intent = _load_intent(resolved_user, resolved_conversation)
|
| 448 |
+
if suggestions:
|
| 449 |
+
best = suggestions[0]
|
| 450 |
+
defaults = dict(best.get("defaults") or {})
|
| 451 |
+
cadence_options = best.get("cadence_options")
|
| 452 |
+
amount_bounds = best.get("amount_bounds")
|
| 453 |
+
slippage_policy = best.get("slippage_bps")
|
| 454 |
+
|
| 455 |
+
merged = dict(defaults)
|
| 456 |
+
if cadence_options:
|
| 457 |
+
merged["cadence_options"] = cadence_options
|
| 458 |
+
if amount_bounds:
|
| 459 |
+
merged["amount_bounds"] = amount_bounds
|
| 460 |
+
if slippage_policy:
|
| 461 |
+
merged["slippage_policy"] = slippage_policy
|
| 462 |
+
if "slippage_bps" not in merged and isinstance(slippage_policy, dict):
|
| 463 |
+
merged["slippage_bps"] = slippage_policy.get("recommended")
|
| 464 |
+
intent.strategy_defaults = merged
|
| 465 |
+
intent.guardrails = best.get("guardrails", intent.guardrails)
|
| 466 |
+
intent.compliance_notes = best.get("compliance_notes", intent.compliance_notes)
|
| 467 |
+
meta = _store_dca_metadata(intent, ask=None, done=False, error=None, choices=None)
|
| 468 |
+
return {
|
| 469 |
+
"event": "dca_strategy_suggestions",
|
| 470 |
+
"suggestions": suggestions,
|
| 471 |
+
"query": {
|
| 472 |
+
"from_token": kwargs.get("from_token"),
|
| 473 |
+
"to_token": kwargs.get("to_token"),
|
| 474 |
+
"cadence": kwargs.get("cadence"),
|
| 475 |
+
"risk_tier": kwargs.get("risk_tier"),
|
| 476 |
+
"text": kwargs.get("text"),
|
| 477 |
+
},
|
| 478 |
+
"metadata": meta,
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
# ---------- Intent update tool ----------
|
| 483 |
+
@tool("update_dca_intent", args_schema=UpdateDcaIntentInput)
|
| 484 |
+
def update_dca_intent_tool(
|
| 485 |
+
user_id: Optional[str] = None,
|
| 486 |
+
conversation_id: Optional[str] = None,
|
| 487 |
+
stage: Optional[str] = None,
|
| 488 |
+
strategy_id: Optional[str] = None,
|
| 489 |
+
strategy_version: Optional[str] = None,
|
| 490 |
+
strategy_name: Optional[str] = None,
|
| 491 |
+
strategy_summary: Optional[str] = None,
|
| 492 |
+
rag_confidence: Optional[float] = None,
|
| 493 |
+
strategy_defaults: Optional[Dict[str, Any]] = None,
|
| 494 |
+
guardrails: Optional[List[str]] = None,
|
| 495 |
+
compliance_notes: Optional[List[str]] = None,
|
| 496 |
+
from_token: Optional[str] = None,
|
| 497 |
+
to_token: Optional[str] = None,
|
| 498 |
+
cadence: Optional[str] = None,
|
| 499 |
+
start_on: Optional[str] = None,
|
| 500 |
+
iterations: Optional[int] = None,
|
| 501 |
+
end_on: Optional[str] = None,
|
| 502 |
+
total_amount: Optional[Decimal] = None,
|
| 503 |
+
per_cycle_amount: Optional[Decimal] = None,
|
| 504 |
+
venue: Optional[str] = None,
|
| 505 |
+
slippage_bps: Optional[int] = None,
|
| 506 |
+
stop_conditions: Optional[List[str]] = None,
|
| 507 |
+
notes: Optional[str] = None,
|
| 508 |
+
timezone: Optional[str] = None,
|
| 509 |
+
confirm: Optional[bool] = None,
|
| 510 |
+
reset: bool = False,
|
| 511 |
+
):
|
| 512 |
+
"""Update the DCA intent. Supply only freshly provided fields each call."""
|
| 513 |
+
|
| 514 |
+
resolved_user, resolved_conversation = _resolve_session(user_id, conversation_id)
|
| 515 |
+
if reset:
|
| 516 |
+
_STORE.clear_intent(resolved_user, resolved_conversation)
|
| 517 |
+
metadata.clear_dca_agent(resolved_user, resolved_conversation)
|
| 518 |
+
intent = DcaIntent(user_id=resolved_user, conversation_id=resolved_conversation)
|
| 519 |
+
return _response(intent, ask="Let's revisit your DCA preferences.", choices=[])
|
| 520 |
+
|
| 521 |
+
intent = _load_intent(resolved_user, resolved_conversation)
|
| 522 |
+
intent.user_id = resolved_user
|
| 523 |
+
intent.conversation_id = resolved_conversation
|
| 524 |
+
|
| 525 |
+
try:
|
| 526 |
+
if stage:
|
| 527 |
+
intent.advance_stage(stage)
|
| 528 |
+
if strategy_id is not None:
|
| 529 |
+
intent.strategy_id = strategy_id
|
| 530 |
+
if strategy_version is not None:
|
| 531 |
+
intent.strategy_version = strategy_version
|
| 532 |
+
if strategy_name is not None:
|
| 533 |
+
intent.strategy_name = strategy_name
|
| 534 |
+
if strategy_summary is not None:
|
| 535 |
+
intent.strategy_summary = strategy_summary
|
| 536 |
+
if rag_confidence is not None:
|
| 537 |
+
intent.rag_confidence = rag_confidence
|
| 538 |
+
if strategy_defaults is not None:
|
| 539 |
+
intent.strategy_defaults = strategy_defaults
|
| 540 |
+
if guardrails is not None:
|
| 541 |
+
intent.guardrails = list(guardrails)
|
| 542 |
+
if compliance_notes is not None:
|
| 543 |
+
intent.compliance_notes = list(compliance_notes)
|
| 544 |
+
if from_token is not None:
|
| 545 |
+
intent.from_token = from_token
|
| 546 |
+
if to_token is not None:
|
| 547 |
+
intent.to_token = to_token
|
| 548 |
+
if cadence is not None:
|
| 549 |
+
intent.cadence = cadence
|
| 550 |
+
if start_on is not None:
|
| 551 |
+
intent.start_on = start_on
|
| 552 |
+
if iterations is not None:
|
| 553 |
+
intent.iterations = iterations
|
| 554 |
+
if end_on is not None:
|
| 555 |
+
intent.end_on = end_on
|
| 556 |
+
if total_amount is not None:
|
| 557 |
+
intent.total_amount = total_amount
|
| 558 |
+
if per_cycle_amount is not None:
|
| 559 |
+
intent.per_cycle_amount = per_cycle_amount
|
| 560 |
+
if venue is not None:
|
| 561 |
+
intent.venue = venue
|
| 562 |
+
if slippage_bps is not None:
|
| 563 |
+
intent.slippage_bps = slippage_bps
|
| 564 |
+
if stop_conditions is not None:
|
| 565 |
+
intent.stop_conditions = list(stop_conditions)
|
| 566 |
+
if notes is not None:
|
| 567 |
+
intent.notes = notes
|
| 568 |
+
if timezone is not None:
|
| 569 |
+
intent.timezone = timezone
|
| 570 |
+
|
| 571 |
+
if confirm is not None:
|
| 572 |
+
intent.confirmed = bool(confirm)
|
| 573 |
+
if intent.confirmed:
|
| 574 |
+
intent.advance_stage("ready")
|
| 575 |
+
except ValueError as exc:
|
| 576 |
+
return _response(intent, ask=intent.next_field() or "Please review the input.", error=str(exc))
|
| 577 |
+
|
| 578 |
+
# Stage auto-advancement
|
| 579 |
+
if intent.stage == "consulting" and not intent.missing_fields():
|
| 580 |
+
intent.advance_stage("recommendation")
|
| 581 |
+
if intent.stage == "recommendation" and not intent.missing_fields():
|
| 582 |
+
intent.advance_stage("confirmation")
|
| 583 |
+
|
| 584 |
+
missing = intent.missing_fields()
|
| 585 |
+
if intent.stage == "confirmation" and not missing and intent.confirmed:
|
| 586 |
+
return _response(intent, ask=None, done=True)
|
| 587 |
+
|
| 588 |
+
ask = _build_prompt_for_field(intent.next_field(), intent)
|
| 589 |
+
choices = _build_choices_for_field(intent.next_field(), intent)
|
| 590 |
+
return _response(intent, ask=ask, choices=choices)
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def _build_prompt_for_field(field: Optional[str], intent: DcaIntent) -> Optional[str]:
|
| 594 |
+
prompts = {
|
| 595 |
+
"strategy_id": "Which strategy from the playbook should we base this DCA on?",
|
| 596 |
+
"from_token": "Which token will fund the DCA swaps?",
|
| 597 |
+
"to_token": "Which token should we accumulate?",
|
| 598 |
+
"cadence": "What cadence works best (daily, weekly, monthly)?",
|
| 599 |
+
"start_on": "When should we start the schedule?",
|
| 600 |
+
"iterations_or_end_on": "Provide number of cycles or a target end date.",
|
| 601 |
+
"total_or_per_cycle_amount": "Do you have a total budget or per-cycle amount?",
|
| 602 |
+
"venue": "Where should we route the swaps?",
|
| 603 |
+
"slippage_bps": "Set the maximum slippage tolerance in basis points.",
|
| 604 |
+
"confirmation": "Ready to confirm this workflow?",
|
| 605 |
+
}
|
| 606 |
+
return prompts.get(field)
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def _build_choices_for_field(field: Optional[str], intent: DcaIntent) -> List[str]:
|
| 610 |
+
if field == "cadence":
|
| 611 |
+
cadences = intent.strategy_defaults.get("cadence_options") if intent.strategy_defaults else None
|
| 612 |
+
if isinstance(cadences, list):
|
| 613 |
+
return cadences
|
| 614 |
+
cadence_default = intent.strategy_defaults.get("cadence") if intent.strategy_defaults else None
|
| 615 |
+
if cadence_default and isinstance(cadence_default, str):
|
| 616 |
+
return [cadence_default]
|
| 617 |
+
if field == "slippage_bps":
|
| 618 |
+
rec = intent.strategy_defaults.get("slippage_bps") if intent.strategy_defaults else None
|
| 619 |
+
policy = intent.strategy_defaults.get("slippage_policy") if intent.strategy_defaults else None
|
| 620 |
+
if isinstance(rec, dict):
|
| 621 |
+
recommended = rec.get("recommended") or rec.get("default") or rec.get("max")
|
| 622 |
+
if recommended is not None:
|
| 623 |
+
return [str(recommended)]
|
| 624 |
+
if isinstance(rec, (int, float, str)):
|
| 625 |
+
return [str(rec)]
|
| 626 |
+
if isinstance(policy, dict):
|
| 627 |
+
recommended = policy.get("recommended") or policy.get("default") or policy.get("max")
|
| 628 |
+
if recommended is not None:
|
| 629 |
+
return [str(recommended)]
|
| 630 |
+
if field == "iterations_or_end_on":
|
| 631 |
+
defaults = []
|
| 632 |
+
iteration_default = intent.strategy_defaults.get("iterations") if intent.strategy_defaults else None
|
| 633 |
+
end_default = intent.strategy_defaults.get("end_on") if intent.strategy_defaults else None
|
| 634 |
+
if iteration_default is not None:
|
| 635 |
+
defaults.append(f"iterations:{iteration_default}")
|
| 636 |
+
if end_default:
|
| 637 |
+
defaults.append(f"end_on:{end_default}")
|
| 638 |
+
return defaults
|
| 639 |
+
if field == "total_or_per_cycle_amount":
|
| 640 |
+
defaults = []
|
| 641 |
+
if intent.strategy_defaults.get("total_amount"):
|
| 642 |
+
defaults.append(f"total:{intent.strategy_defaults['total_amount']}")
|
| 643 |
+
if intent.strategy_defaults.get("per_cycle_amount"):
|
| 644 |
+
defaults.append(f"per_cycle:{intent.strategy_defaults['per_cycle_amount']}")
|
| 645 |
+
return defaults
|
| 646 |
+
return []
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def get_tools():
|
| 650 |
+
return [fetch_dca_strategy_tool, update_dca_intent_tool]
|
src/agents/lending/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration for the Lending Agent."""
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
|
| 4 |
+
class LendingConfig:
|
| 5 |
+
"""Static configuration for supported lending assets and networks."""
|
| 6 |
+
|
| 7 |
+
# TODO: Fetch this from the Lending Service API dynamically if possible.
|
| 8 |
+
SUPPORTED_NETWORKS = ["ethereum", "arbitrum", "optimism", "base", "polygon", "avalanche"]
|
| 9 |
+
|
| 10 |
+
SUPPORTED_ASSETS = {
|
| 11 |
+
"ethereum": ["USDC", "USDT", "DAI", "WBTC", "WETH", "AAVE", "LINK"],
|
| 12 |
+
"arbitrum": ["USDC", "USDT", "DAI", "WBTC", "WETH", "ARB"],
|
| 13 |
+
"optimism": ["USDC", "USDT", "DAI", "WBTC", "WETH", "OP"],
|
| 14 |
+
"base": ["USDC", "WETH", "CBETH"],
|
| 15 |
+
"polygon": ["USDC", "USDT", "DAI", "WBTC", "WETH", "MATIC"],
|
| 16 |
+
"avalanche": ["USDC", "USDT", "DAI", "WBTC", "WETH", "AVAX"],
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
SUPPORTED_ACTIONS = ["supply", "borrow", "repay", "withdraw"]
|
| 20 |
+
|
| 21 |
+
@classmethod
|
| 22 |
+
def list_networks(cls) -> List[str]:
|
| 23 |
+
return cls.SUPPORTED_NETWORKS
|
| 24 |
+
|
| 25 |
+
@classmethod
|
| 26 |
+
def list_assets(cls, network: str) -> List[str]:
|
| 27 |
+
return cls.SUPPORTED_ASSETS.get(network.lower(), [])
|
| 28 |
+
|
| 29 |
+
@classmethod
|
| 30 |
+
def validate_network(cls, network: str) -> str:
|
| 31 |
+
net = network.lower().strip()
|
| 32 |
+
if net not in cls.SUPPORTED_NETWORKS:
|
| 33 |
+
raise ValueError(f"Network '{network}' is not supported. Supported: {cls.SUPPORTED_NETWORKS}")
|
| 34 |
+
return net
|
| 35 |
+
|
| 36 |
+
@classmethod
|
| 37 |
+
def validate_asset(cls, asset: str, network: str) -> str:
|
| 38 |
+
net = cls.validate_network(network)
|
| 39 |
+
symbol = asset.upper().strip()
|
| 40 |
+
supported = cls.list_assets(net)
|
| 41 |
+
if symbol not in supported:
|
| 42 |
+
raise ValueError(f"Asset '{asset}' is not supported on {net}. Supported: {supported}")
|
| 43 |
+
return symbol
|
| 44 |
+
|
| 45 |
+
@classmethod
|
| 46 |
+
def validate_action(cls, action: str) -> str:
|
| 47 |
+
act = action.lower().strip()
|
| 48 |
+
if act not in cls.SUPPORTED_ACTIONS:
|
| 49 |
+
raise ValueError(f"Action '{action}' is not supported. Supported: {cls.SUPPORTED_ACTIONS}")
|
| 50 |
+
return act
|
src/agents/lending/intent.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lending 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.lending.config import LendingConfig
|
| 10 |
+
|
| 11 |
+
def _format_decimal(value: Decimal) -> str:
|
| 12 |
+
normalized = value.normalize()
|
| 13 |
+
exponent = normalized.as_tuple().exponent
|
| 14 |
+
if exponent > 0:
|
| 15 |
+
normalized = normalized.quantize(Decimal(1))
|
| 16 |
+
text = format(normalized, "f")
|
| 17 |
+
if "." in text:
|
| 18 |
+
text = text.rstrip("0").rstrip(".")
|
| 19 |
+
return text
|
| 20 |
+
|
| 21 |
+
def _to_decimal(value: Any) -> Optional[Decimal]:
|
| 22 |
+
if value is None:
|
| 23 |
+
return None
|
| 24 |
+
try:
|
| 25 |
+
return Decimal(str(value))
|
| 26 |
+
except (InvalidOperation, TypeError, ValueError):
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class LendingIntent:
|
| 31 |
+
user_id: str
|
| 32 |
+
conversation_id: str
|
| 33 |
+
action: Optional[str] = None
|
| 34 |
+
network: Optional[str] = None
|
| 35 |
+
asset: Optional[str] = None
|
| 36 |
+
amount: Optional[Decimal] = None
|
| 37 |
+
updated_at: float = field(default_factory=lambda: time.time())
|
| 38 |
+
|
| 39 |
+
def touch(self) -> None:
|
| 40 |
+
self.updated_at = time.time()
|
| 41 |
+
|
| 42 |
+
def is_complete(self) -> bool:
|
| 43 |
+
return all(
|
| 44 |
+
[
|
| 45 |
+
self.action,
|
| 46 |
+
self.network,
|
| 47 |
+
self.asset,
|
| 48 |
+
self.amount is not None,
|
| 49 |
+
]
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def missing_fields(self) -> List[str]:
|
| 53 |
+
fields: List[str] = []
|
| 54 |
+
if not self.action:
|
| 55 |
+
fields.append("action")
|
| 56 |
+
if not self.network:
|
| 57 |
+
fields.append("network")
|
| 58 |
+
if not self.asset:
|
| 59 |
+
fields.append("asset")
|
| 60 |
+
if self.amount is None:
|
| 61 |
+
fields.append("amount")
|
| 62 |
+
return fields
|
| 63 |
+
|
| 64 |
+
def amount_as_str(self) -> Optional[str]:
|
| 65 |
+
if self.amount is None:
|
| 66 |
+
return None
|
| 67 |
+
return _format_decimal(self.amount)
|
| 68 |
+
|
| 69 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 70 |
+
return {
|
| 71 |
+
"user_id": self.user_id,
|
| 72 |
+
"conversation_id": self.conversation_id,
|
| 73 |
+
"action": self.action,
|
| 74 |
+
"network": self.network,
|
| 75 |
+
"asset": self.asset,
|
| 76 |
+
"amount": self.amount_as_str(),
|
| 77 |
+
"updated_at": self.updated_at,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
def to_public(self) -> Dict[str, Optional[str]]:
|
| 81 |
+
public = self.to_dict()
|
| 82 |
+
public["amount"] = self.amount_as_str()
|
| 83 |
+
return public
|
| 84 |
+
|
| 85 |
+
def to_summary(self, status: str, error: Optional[str] = None) -> Dict[str, Any]:
|
| 86 |
+
summary: Dict[str, Any] = {
|
| 87 |
+
"status": status,
|
| 88 |
+
"action": self.action,
|
| 89 |
+
"network": self.network,
|
| 90 |
+
"asset": self.asset,
|
| 91 |
+
"amount": self.amount_as_str(),
|
| 92 |
+
}
|
| 93 |
+
if error:
|
| 94 |
+
summary["error"] = error
|
| 95 |
+
return summary
|
| 96 |
+
|
| 97 |
+
@classmethod
|
| 98 |
+
def from_dict(cls, data: Dict[str, Any]) -> "LendingIntent":
|
| 99 |
+
amount = _to_decimal(data.get("amount"))
|
| 100 |
+
intent = cls(
|
| 101 |
+
user_id=(data.get("user_id") or "").strip(),
|
| 102 |
+
conversation_id=(data.get("conversation_id") or "").strip(),
|
| 103 |
+
action=data.get("action"),
|
| 104 |
+
network=data.get("network"),
|
| 105 |
+
asset=data.get("asset"),
|
| 106 |
+
amount=amount,
|
| 107 |
+
)
|
| 108 |
+
intent.updated_at = float(data.get("updated_at", time.time()))
|
| 109 |
+
return intent
|
src/agents/lending/prompt.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompt for the specialized lending agent."""
|
| 2 |
+
|
| 3 |
+
LENDING_AGENT_SYSTEM_PROMPT = \"\"\"
|
| 4 |
+
You are Zico's lending orchestrator.
|
| 5 |
+
Your goal is to help the user define a lending operation (supply, borrow, repay, withdraw) by collecting the necessary details.
|
| 6 |
+
|
| 7 |
+
# Responsibilities
|
| 8 |
+
1. Collect all lending intent fields (`action`, `network`, `asset`, `amount`) by invoking the `update_lending_intent` tool.
|
| 9 |
+
2. If the tool returns a question (`ask`), present it to the user clearly.
|
| 10 |
+
3. If the tool returns an error, explain it and ask for correction.
|
| 11 |
+
4. Only confirm that the intent is ready once the tool reports `event == "lending_intent_ready"`.
|
| 12 |
+
|
| 13 |
+
# Rules
|
| 14 |
+
- ALWAYS call `update_lending_intent` when the user provides new lending information.
|
| 15 |
+
- Do NOT ask for all fields at once if the user only provided some. Let the tool guide the flow.
|
| 16 |
+
- When the intent is ready, summarize the operation and ask for confirmation (or just state it's ready for execution).
|
| 17 |
+
|
| 18 |
+
# Examples
|
| 19 |
+
|
| 20 |
+
User: I want to supply USDC.
|
| 21 |
+
Assistant: (call `update_lending_intent` with `action="supply"`, `asset="USDC"`)
|
| 22 |
+
Tool: `ask` -> "On which network?"
|
| 23 |
+
Assistant: "Sure. On which network would you like to supply USDC?"
|
| 24 |
+
|
| 25 |
+
User: On Arbitrum.
|
| 26 |
+
Assistant: (call `update_lending_intent` with `network="arbitrum"`)
|
| 27 |
+
Tool: `ask` -> "How much USDC?"
|
| 28 |
+
Assistant: "How much USDC do you want to supply?"
|
| 29 |
+
|
| 30 |
+
User: 100.
|
| 31 |
+
Assistant: (call `update_lending_intent` with `amount=100`)
|
| 32 |
+
Tool: `event` -> `lending_intent_ready`
|
| 33 |
+
Assistant: "All set. Ready to supply 100 USDC on Arbitrum."
|
| 34 |
+
\"\"\"
|
src/agents/lending/storage.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gateway-backed storage for lending 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 |
+
LENDING_SESSION_ENTITY = "lending-sessions"
|
| 19 |
+
LENDING_HISTORY_ENTITY = "lending-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 LendingStateRepository:
|
| 40 |
+
"""Stores lending agent state via Panorama's gateway or an in-memory fallback."""
|
| 41 |
+
|
| 42 |
+
_instance: "LendingStateRepository" | 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 lending 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 lending 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) -> "LendingStateRepository":
|
| 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["network"] = intent.get("network")
|
| 237 |
+
metadata["asset"] = intent.get("asset")
|
| 238 |
+
metadata["amount"] = intent.get("amount")
|
| 239 |
+
|
| 240 |
+
history = self.get_history(user_id, conversation_id)
|
| 241 |
+
if history:
|
| 242 |
+
metadata["history"] = history
|
| 243 |
+
|
| 244 |
+
updated_at = session.get("updatedAt")
|
| 245 |
+
if updated_at:
|
| 246 |
+
metadata["updated_at"] = updated_at
|
| 247 |
+
|
| 248 |
+
return metadata
|
| 249 |
+
|
| 250 |
+
def get_history(
|
| 251 |
+
self,
|
| 252 |
+
user_id: str,
|
| 253 |
+
conversation_id: str,
|
| 254 |
+
limit: Optional[int] = None,
|
| 255 |
+
) -> List[Dict[str, Any]]:
|
| 256 |
+
if not self._use_gateway:
|
| 257 |
+
key = _identifier(user_id, conversation_id)
|
| 258 |
+
history = self._state["history"].get(key, [])
|
| 259 |
+
effective = limit or self._history_limit
|
| 260 |
+
result: List[Dict[str, Any]] = []
|
| 261 |
+
for item in sorted(history, key=lambda entry: entry.get("timestamp", 0), reverse=True)[:effective]:
|
| 262 |
+
entry = copy.deepcopy(item)
|
| 263 |
+
ts = entry.get("timestamp")
|
| 264 |
+
if ts is not None:
|
| 265 |
+
entry["timestamp"] = datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
|
| 266 |
+
result.append(entry)
|
| 267 |
+
return result
|
| 268 |
+
|
| 269 |
+
effective_limit = limit or self._history_limit
|
| 270 |
+
try:
|
| 271 |
+
result = self._client.list(
|
| 272 |
+
LENDING_HISTORY_ENTITY,
|
| 273 |
+
{
|
| 274 |
+
"where": {"userId": user_id, "conversationId": conversation_id},
|
| 275 |
+
"orderBy": {"recordedAt": "desc"},
|
| 276 |
+
"take": effective_limit,
|
| 277 |
+
},
|
| 278 |
+
)
|
| 279 |
+
except PanoramaGatewayError as exc:
|
| 280 |
+
if exc.status_code == 404:
|
| 281 |
+
return []
|
| 282 |
+
self._handle_gateway_failure(exc)
|
| 283 |
+
return self.get_history(user_id, conversation_id, limit)
|
| 284 |
+
except ValueError:
|
| 285 |
+
self._logger.warning("Invalid lending history response from gateway; falling back to local store.")
|
| 286 |
+
self._fallback_to_local_store()
|
| 287 |
+
return self.get_history(user_id, conversation_id, limit)
|
| 288 |
+
data = result.get("data", []) if isinstance(result, dict) else []
|
| 289 |
+
history: List[Dict[str, Any]] = []
|
| 290 |
+
for entry in data:
|
| 291 |
+
history.append(
|
| 292 |
+
{
|
| 293 |
+
"status": entry.get("status"),
|
| 294 |
+
"action": entry.get("action"),
|
| 295 |
+
"network": entry.get("network"),
|
| 296 |
+
"asset": entry.get("asset"),
|
| 297 |
+
"amount": entry.get("amount"),
|
| 298 |
+
"error": entry.get("errorMessage"),
|
| 299 |
+
"timestamp": entry.get("recordedAt"),
|
| 300 |
+
}
|
| 301 |
+
)
|
| 302 |
+
return history
|
| 303 |
+
|
| 304 |
+
# ---- Gateway helpers --------------------------------------------------
|
| 305 |
+
def _get_session(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
|
| 306 |
+
identifier = _identifier(user_id, conversation_id)
|
| 307 |
+
try:
|
| 308 |
+
return self._client.get(LENDING_SESSION_ENTITY, identifier)
|
| 309 |
+
except PanoramaGatewayError as exc:
|
| 310 |
+
if exc.status_code == 404:
|
| 311 |
+
return None
|
| 312 |
+
self._handle_gateway_failure(exc)
|
| 313 |
+
return None
|
| 314 |
+
|
| 315 |
+
def _delete_session(self, user_id: str, conversation_id: str) -> None:
|
| 316 |
+
identifier = _identifier(user_id, conversation_id)
|
| 317 |
+
try:
|
| 318 |
+
self._client.delete(LENDING_SESSION_ENTITY, identifier)
|
| 319 |
+
except PanoramaGatewayError as exc:
|
| 320 |
+
if exc.status_code != 404:
|
| 321 |
+
self._handle_gateway_failure(exc)
|
| 322 |
+
raise
|
| 323 |
+
|
| 324 |
+
def _upsert_session(
|
| 325 |
+
self,
|
| 326 |
+
user_id: str,
|
| 327 |
+
conversation_id: str,
|
| 328 |
+
data: Dict[str, Any],
|
| 329 |
+
) -> None:
|
| 330 |
+
identifier = _identifier(user_id, conversation_id)
|
| 331 |
+
payload = {**data, "updatedAt": _utc_now_iso()}
|
| 332 |
+
try:
|
| 333 |
+
self._client.update(LENDING_SESSION_ENTITY, identifier, payload)
|
| 334 |
+
except PanoramaGatewayError as exc:
|
| 335 |
+
if exc.status_code != 404:
|
| 336 |
+
self._handle_gateway_failure(exc)
|
| 337 |
+
raise
|
| 338 |
+
create_payload = {
|
| 339 |
+
"userId": user_id,
|
| 340 |
+
"conversationId": conversation_id,
|
| 341 |
+
"tenantId": self._tenant_id(),
|
| 342 |
+
**payload,
|
| 343 |
+
}
|
| 344 |
+
try:
|
| 345 |
+
self._client.create(LENDING_SESSION_ENTITY, create_payload)
|
| 346 |
+
except PanoramaGatewayError as create_exc:
|
| 347 |
+
if create_exc.status_code == 409:
|
| 348 |
+
return
|
| 349 |
+
if create_exc.status_code == 404:
|
| 350 |
+
self._handle_gateway_failure(create_exc)
|
| 351 |
+
raise
|
| 352 |
+
self._handle_gateway_failure(create_exc)
|
| 353 |
+
raise
|
| 354 |
+
|
| 355 |
+
def _create_history_entry(
|
| 356 |
+
self,
|
| 357 |
+
user_id: str,
|
| 358 |
+
conversation_id: str,
|
| 359 |
+
summary: Dict[str, Any],
|
| 360 |
+
) -> None:
|
| 361 |
+
history_payload = {
|
| 362 |
+
"userId": user_id,
|
| 363 |
+
"conversationId": conversation_id,
|
| 364 |
+
"status": summary.get("status"),
|
| 365 |
+
"action": summary.get("action"),
|
| 366 |
+
"network": summary.get("network"),
|
| 367 |
+
"asset": summary.get("asset"),
|
| 368 |
+
"amount": _as_float(summary.get("amount")),
|
| 369 |
+
"errorMessage": summary.get("error"),
|
| 370 |
+
"recordedAt": _utc_now_iso(),
|
| 371 |
+
"tenantId": self._tenant_id(),
|
| 372 |
+
}
|
| 373 |
+
if self._logger.isEnabledFor(logging.DEBUG):
|
| 374 |
+
self._logger.debug(
|
| 375 |
+
"Persisting lending history for user=%s conversation=%s payload=%s",
|
| 376 |
+
user_id,
|
| 377 |
+
conversation_id,
|
| 378 |
+
history_payload,
|
| 379 |
+
)
|
| 380 |
+
try:
|
| 381 |
+
self._client.create(LENDING_HISTORY_ENTITY, history_payload)
|
| 382 |
+
except PanoramaGatewayError as exc:
|
| 383 |
+
if exc.status_code == 404:
|
| 384 |
+
self._handle_gateway_failure(exc)
|
| 385 |
+
raise
|
| 386 |
+
elif exc.status_code != 409:
|
| 387 |
+
self._handle_gateway_failure(exc)
|
| 388 |
+
raise
|
| 389 |
+
|
| 390 |
+
@staticmethod
|
| 391 |
+
def _session_payload(intent: Dict[str, Any], metadata: Dict[str, Any]) -> Dict[str, Any]:
|
| 392 |
+
missing = metadata.get("missing_fields") or []
|
| 393 |
+
if not isinstance(missing, list):
|
| 394 |
+
missing = list(missing)
|
| 395 |
+
return {
|
| 396 |
+
"status": metadata.get("status"),
|
| 397 |
+
"event": metadata.get("event"),
|
| 398 |
+
"intent": intent,
|
| 399 |
+
"missingFields": missing,
|
| 400 |
+
"nextField": metadata.get("next_field"),
|
| 401 |
+
"pendingQuestion": metadata.get("pending_question"),
|
| 402 |
+
"choices": metadata.get("choices"),
|
| 403 |
+
"errorMessage": metadata.get("error"),
|
| 404 |
+
"historyCursor": metadata.get("history_cursor") or 0,
|
| 405 |
+
}
|
src/agents/lending/tools.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lending tools that manage a conversational lending intent."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import time
|
| 7 |
+
from contextlib import contextmanager
|
| 8 |
+
from contextvars import ContextVar
|
| 9 |
+
from decimal import Decimal, InvalidOperation
|
| 10 |
+
from typing import Any, Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
from langchain_core.tools import tool
|
| 13 |
+
from pydantic import BaseModel, Field, field_validator
|
| 14 |
+
|
| 15 |
+
from src.agents.metadata import metadata
|
| 16 |
+
from src.agents.lending.config import LendingConfig
|
| 17 |
+
from src.agents.lending.intent import LendingIntent, _to_decimal
|
| 18 |
+
from src.agents.lending.storage import LendingStateRepository
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------- Helpers ----------
|
| 22 |
+
_STORE = LendingStateRepository.instance()
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ---------- Lending session context ----------
|
| 27 |
+
_CURRENT_SESSION: ContextVar[tuple[str, str]] = ContextVar(
|
| 28 |
+
"_current_lending_session",
|
| 29 |
+
default=("", ""),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def set_current_lending_session(user_id: Optional[str], conversation_id: Optional[str]) -> None:
|
| 34 |
+
"""Store the active lending session for tool calls executed by the agent."""
|
| 35 |
+
|
| 36 |
+
resolved_user = (user_id or "").strip()
|
| 37 |
+
resolved_conversation = (conversation_id or "").strip()
|
| 38 |
+
if not resolved_user:
|
| 39 |
+
raise ValueError("lending_agent requires 'user_id' to identify the lending session.")
|
| 40 |
+
if not resolved_conversation:
|
| 41 |
+
raise ValueError("lending_agent requires 'conversation_id' to identify the lending session.")
|
| 42 |
+
_CURRENT_SESSION.set((resolved_user, resolved_conversation))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@contextmanager
|
| 46 |
+
def lending_session(user_id: Optional[str], conversation_id: Optional[str]):
|
| 47 |
+
"""Context manager that guarantees session scoping for lending tool calls."""
|
| 48 |
+
|
| 49 |
+
set_current_lending_session(user_id, conversation_id)
|
| 50 |
+
try:
|
| 51 |
+
yield
|
| 52 |
+
finally:
|
| 53 |
+
clear_current_lending_session()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def clear_current_lending_session() -> None:
|
| 57 |
+
"""Reset the active lending session after the agent finishes handling a message."""
|
| 58 |
+
|
| 59 |
+
_CURRENT_SESSION.set(("", ""))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _resolve_session(user_id: Optional[str], conversation_id: Optional[str]) -> tuple[str, str]:
|
| 63 |
+
active_user, active_conversation = _CURRENT_SESSION.get()
|
| 64 |
+
resolved_user = (user_id or active_user or "").strip()
|
| 65 |
+
resolved_conversation = (conversation_id or active_conversation or "").strip()
|
| 66 |
+
if not resolved_user:
|
| 67 |
+
raise ValueError("user_id is required for lending operations.")
|
| 68 |
+
if not resolved_conversation:
|
| 69 |
+
raise ValueError("conversation_id is required for lending operations.")
|
| 70 |
+
return resolved_user, resolved_conversation
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _load_intent(user_id: str, conversation_id: str) -> LendingIntent:
|
| 74 |
+
stored = _STORE.load_intent(user_id, conversation_id)
|
| 75 |
+
if stored:
|
| 76 |
+
intent = LendingIntent.from_dict(stored)
|
| 77 |
+
intent.user_id = user_id
|
| 78 |
+
intent.conversation_id = conversation_id
|
| 79 |
+
return intent
|
| 80 |
+
return LendingIntent(user_id=user_id, conversation_id=conversation_id)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------- Pydantic input schema ----------
|
| 84 |
+
class UpdateLendingIntentInput(BaseModel):
|
| 85 |
+
user_id: Optional[str] = Field(
|
| 86 |
+
default=None,
|
| 87 |
+
description="Stable ID for the end user / chat session. Optional if context manager is set.",
|
| 88 |
+
)
|
| 89 |
+
conversation_id: Optional[str] = Field(
|
| 90 |
+
default=None,
|
| 91 |
+
description="Conversation identifier to scope lending intents within a user.",
|
| 92 |
+
)
|
| 93 |
+
action: Optional[str] = None
|
| 94 |
+
network: Optional[str] = None
|
| 95 |
+
asset: Optional[str] = None
|
| 96 |
+
amount: Optional[Decimal] = Field(None, gt=Decimal("0"))
|
| 97 |
+
|
| 98 |
+
@field_validator("network", mode="before")
|
| 99 |
+
@classmethod
|
| 100 |
+
def _norm_network(cls, value: Optional[str]) -> Optional[str]:
|
| 101 |
+
return value.lower() if isinstance(value, str) else value
|
| 102 |
+
|
| 103 |
+
@field_validator("asset", mode="before")
|
| 104 |
+
@classmethod
|
| 105 |
+
def _norm_asset(cls, value: Optional[str]) -> Optional[str]:
|
| 106 |
+
return value.upper() if isinstance(value, str) else value
|
| 107 |
+
|
| 108 |
+
@field_validator("action", mode="before")
|
| 109 |
+
@classmethod
|
| 110 |
+
def _norm_action(cls, value: Optional[str]) -> Optional[str]:
|
| 111 |
+
return value.lower() if isinstance(value, str) else value
|
| 112 |
+
|
| 113 |
+
@field_validator("amount", mode="before")
|
| 114 |
+
@classmethod
|
| 115 |
+
def _norm_amount(cls, value):
|
| 116 |
+
if value is None or isinstance(value, Decimal):
|
| 117 |
+
return value
|
| 118 |
+
decimal_value = _to_decimal(value)
|
| 119 |
+
if decimal_value is None:
|
| 120 |
+
raise ValueError("Amount must be a number.")
|
| 121 |
+
return decimal_value
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ---------- Validation utilities ----------
|
| 125 |
+
def _validate_network(network: Optional[str]) -> Optional[str]:
|
| 126 |
+
if network is None:
|
| 127 |
+
return None
|
| 128 |
+
return LendingConfig.validate_network(network)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _validate_asset(asset: Optional[str], network: Optional[str]) -> Optional[str]:
|
| 132 |
+
if asset is None:
|
| 133 |
+
return None
|
| 134 |
+
if network is None:
|
| 135 |
+
raise ValueError("Please provide the network before choosing an asset.")
|
| 136 |
+
return LendingConfig.validate_asset(asset, network)
|
| 137 |
+
|
| 138 |
+
def _validate_action(action: Optional[str]) -> Optional[str]:
|
| 139 |
+
if action is None:
|
| 140 |
+
return None
|
| 141 |
+
return LendingConfig.validate_action(action)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ---------- Output helpers ----------
|
| 145 |
+
def _store_lending_metadata(
|
| 146 |
+
intent: LendingIntent,
|
| 147 |
+
ask: Optional[str],
|
| 148 |
+
done: bool,
|
| 149 |
+
error: Optional[str],
|
| 150 |
+
choices: Optional[List[str]] = None,
|
| 151 |
+
) -> Dict[str, Any]:
|
| 152 |
+
intent.touch()
|
| 153 |
+
missing = intent.missing_fields()
|
| 154 |
+
next_field = missing[0] if missing else None
|
| 155 |
+
meta: Dict[str, Any] = {
|
| 156 |
+
"event": "lending_intent_ready" if done else "lending_intent_pending",
|
| 157 |
+
"status": "ready" if done else "collecting",
|
| 158 |
+
"action": intent.action,
|
| 159 |
+
"network": intent.network,
|
| 160 |
+
"asset": intent.asset,
|
| 161 |
+
"amount": intent.amount_as_str(),
|
| 162 |
+
"user_id": intent.user_id,
|
| 163 |
+
"conversation_id": intent.conversation_id,
|
| 164 |
+
"missing_fields": missing,
|
| 165 |
+
"next_field": next_field,
|
| 166 |
+
"pending_question": ask,
|
| 167 |
+
"choices": list(choices or []),
|
| 168 |
+
"error": error,
|
| 169 |
+
}
|
| 170 |
+
summary = intent.to_summary("ready" if done else "collecting", error=error) if done else None
|
| 171 |
+
history = _STORE.persist_intent(
|
| 172 |
+
intent.user_id,
|
| 173 |
+
intent.conversation_id,
|
| 174 |
+
intent.to_dict(),
|
| 175 |
+
meta,
|
| 176 |
+
done=done,
|
| 177 |
+
summary=summary,
|
| 178 |
+
)
|
| 179 |
+
if history:
|
| 180 |
+
meta["history"] = history
|
| 181 |
+
metadata.set_lending_agent(meta, intent.user_id, intent.conversation_id)
|
| 182 |
+
if logger.isEnabledFor(logging.DEBUG):
|
| 183 |
+
logger.debug(
|
| 184 |
+
"Lending metadata stored for user=%s conversation=%s done=%s error=%s meta=%s",
|
| 185 |
+
intent.user_id,
|
| 186 |
+
intent.conversation_id,
|
| 187 |
+
done,
|
| 188 |
+
error,
|
| 189 |
+
meta,
|
| 190 |
+
)
|
| 191 |
+
return meta
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _build_next_action(meta: Dict[str, Any]) -> Dict[str, Any]:
|
| 195 |
+
if meta.get("status") == "ready":
|
| 196 |
+
return {
|
| 197 |
+
"type": "complete",
|
| 198 |
+
"prompt": None,
|
| 199 |
+
"field": None,
|
| 200 |
+
"choices": [],
|
| 201 |
+
}
|
| 202 |
+
return {
|
| 203 |
+
"type": "collect_field",
|
| 204 |
+
"prompt": meta.get("pending_question"),
|
| 205 |
+
"field": meta.get("next_field"),
|
| 206 |
+
"choices": meta.get("choices", []),
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _response(
|
| 211 |
+
intent: LendingIntent,
|
| 212 |
+
ask: Optional[str],
|
| 213 |
+
choices: Optional[List[str]] = None,
|
| 214 |
+
done: bool = False,
|
| 215 |
+
error: Optional[str] = None,
|
| 216 |
+
) -> Dict[str, Any]:
|
| 217 |
+
meta = _store_lending_metadata(intent, ask, done, error, choices)
|
| 218 |
+
|
| 219 |
+
payload: Dict[str, Any] = {
|
| 220 |
+
"event": meta.get("event"),
|
| 221 |
+
"intent": intent.to_public(),
|
| 222 |
+
"ask": ask,
|
| 223 |
+
"choices": choices or [],
|
| 224 |
+
"error": error,
|
| 225 |
+
"next_action": _build_next_action(meta),
|
| 226 |
+
"history": meta.get("history", []),
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
if done:
|
| 230 |
+
payload["metadata"] = {
|
| 231 |
+
key: meta.get(key)
|
| 232 |
+
for key in (
|
| 233 |
+
"event",
|
| 234 |
+
"status",
|
| 235 |
+
"action",
|
| 236 |
+
"network",
|
| 237 |
+
"asset",
|
| 238 |
+
"amount",
|
| 239 |
+
"user_id",
|
| 240 |
+
"conversation_id",
|
| 241 |
+
"history",
|
| 242 |
+
)
|
| 243 |
+
if meta.get(key) is not None
|
| 244 |
+
}
|
| 245 |
+
return payload
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ---------- Core tool ----------
|
| 249 |
+
@tool("update_lending_intent", args_schema=UpdateLendingIntentInput)
|
| 250 |
+
def update_lending_intent_tool(
|
| 251 |
+
user_id: Optional[str] = None,
|
| 252 |
+
conversation_id: Optional[str] = None,
|
| 253 |
+
action: Optional[str] = None,
|
| 254 |
+
network: Optional[str] = None,
|
| 255 |
+
asset: Optional[str] = None,
|
| 256 |
+
amount: Optional[Decimal] = None,
|
| 257 |
+
):
|
| 258 |
+
"""Update the lending intent and surface the next question or final metadata.
|
| 259 |
+
|
| 260 |
+
Call this tool whenever the user provides new lending details. Supply only the
|
| 261 |
+
fields that were mentioned in the latest message (leave the others as None)
|
| 262 |
+
and keep calling it until the response event becomes 'lending_intent_ready'.
|
| 263 |
+
"""
|
| 264 |
+
|
| 265 |
+
resolved_user, resolved_conversation = _resolve_session(user_id, conversation_id)
|
| 266 |
+
intent = _load_intent(resolved_user, resolved_conversation)
|
| 267 |
+
intent.user_id = resolved_user
|
| 268 |
+
intent.conversation_id = resolved_conversation
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
if logger.isEnabledFor(logging.DEBUG):
|
| 272 |
+
logger.debug(
|
| 273 |
+
"update_lending_intent_tool input user=%s conversation=%s action=%s network=%s "
|
| 274 |
+
"asset=%s amount=%s",
|
| 275 |
+
user_id,
|
| 276 |
+
conversation_id,
|
| 277 |
+
action,
|
| 278 |
+
network,
|
| 279 |
+
asset,
|
| 280 |
+
amount,
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
if action is not None:
|
| 284 |
+
intent.action = _validate_action(action)
|
| 285 |
+
|
| 286 |
+
if network is not None:
|
| 287 |
+
canonical_net = _validate_network(network)
|
| 288 |
+
if canonical_net != intent.network:
|
| 289 |
+
intent.network = canonical_net
|
| 290 |
+
# Re-validate asset if network changed
|
| 291 |
+
if intent.asset:
|
| 292 |
+
try:
|
| 293 |
+
LendingConfig.validate_asset(intent.asset, canonical_net)
|
| 294 |
+
except ValueError:
|
| 295 |
+
intent.asset = None
|
| 296 |
+
|
| 297 |
+
if intent.action is None:
|
| 298 |
+
return _response(
|
| 299 |
+
intent,
|
| 300 |
+
"What would you like to do? (supply, borrow, repay, withdraw)",
|
| 301 |
+
LendingConfig.SUPPORTED_ACTIONS,
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
if intent.network is None and network is not None:
|
| 305 |
+
# User tried to set network but maybe it was invalid or just checking
|
| 306 |
+
pass
|
| 307 |
+
|
| 308 |
+
if intent.network is None:
|
| 309 |
+
return _response(
|
| 310 |
+
intent,
|
| 311 |
+
"On which network?",
|
| 312 |
+
LendingConfig.list_networks(),
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
if asset is not None:
|
| 316 |
+
intent.asset = _validate_asset(asset, intent.network)
|
| 317 |
+
|
| 318 |
+
if intent.asset is None:
|
| 319 |
+
return _response(
|
| 320 |
+
intent,
|
| 321 |
+
f"Which asset on {intent.network}?",
|
| 322 |
+
LendingConfig.list_assets(intent.network),
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
if amount is not None:
|
| 326 |
+
intent.amount = amount # Basic validation done in pydantic
|
| 327 |
+
|
| 328 |
+
if intent.amount is None:
|
| 329 |
+
return _response(intent, f"How much {intent.asset}?")
|
| 330 |
+
|
| 331 |
+
except ValueError as exc:
|
| 332 |
+
message = str(exc)
|
| 333 |
+
if logger.isEnabledFor(logging.INFO):
|
| 334 |
+
logger.info(
|
| 335 |
+
"Lending intent validation issue for user=%s conversation=%s: %s",
|
| 336 |
+
intent.user_id,
|
| 337 |
+
intent.conversation_id,
|
| 338 |
+
message,
|
| 339 |
+
)
|
| 340 |
+
return _response(intent, "Please correct the input.", error=message)
|
| 341 |
+
except Exception as exc:
|
| 342 |
+
logger.exception(
|
| 343 |
+
"Unexpected error updating lending intent for user=%s conversation=%s",
|
| 344 |
+
intent.user_id,
|
| 345 |
+
intent.conversation_id,
|
| 346 |
+
)
|
| 347 |
+
return _response(intent, "Please try again with the lending details.", error=str(exc))
|
| 348 |
+
|
| 349 |
+
response = _response(intent, ask=None, done=True)
|
| 350 |
+
return response
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
class ListLendingAssetsInput(BaseModel):
|
| 354 |
+
network: str
|
| 355 |
+
|
| 356 |
+
@field_validator("network", mode="before")
|
| 357 |
+
@classmethod
|
| 358 |
+
def _norm_network(cls, value: str) -> str:
|
| 359 |
+
return value.lower() if isinstance(value, str) else value
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@tool("list_lending_assets", args_schema=ListLendingAssetsInput)
|
| 363 |
+
def list_lending_assets_tool(network: str):
|
| 364 |
+
"""List the supported lending assets for a given network."""
|
| 365 |
+
|
| 366 |
+
try:
|
| 367 |
+
canonical = _validate_network(network)
|
| 368 |
+
assets = LendingConfig.list_assets(canonical)
|
| 369 |
+
return {
|
| 370 |
+
"network": canonical,
|
| 371 |
+
"assets": assets,
|
| 372 |
+
}
|
| 373 |
+
except ValueError as exc:
|
| 374 |
+
return {
|
| 375 |
+
"error": str(exc),
|
| 376 |
+
"choices": LendingConfig.list_networks(),
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@tool("list_lending_networks")
|
| 381 |
+
def list_lending_networks_tool():
|
| 382 |
+
"""List supported lending networks."""
|
| 383 |
+
|
| 384 |
+
return {"networks": LendingConfig.list_networks()}
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def get_tools():
|
| 388 |
+
return [update_lending_intent_tool, list_lending_assets_tool, list_lending_networks_tool]
|
src/agents/metadata.py
CHANGED
|
@@ -3,12 +3,16 @@ from __future__ import annotations
|
|
| 3 |
from typing import Any, Dict
|
| 4 |
|
| 5 |
from src.agents.swap.storage import SwapStateRepository
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class Metadata:
|
| 9 |
def __init__(self):
|
| 10 |
self.crypto_data_agent: Dict[str, Any] = {}
|
| 11 |
self._swap_repo = SwapStateRepository.instance()
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def get_crypto_data_agent(self):
|
| 14 |
return self.crypto_data_agent
|
|
@@ -37,6 +41,43 @@ class Metadata:
|
|
| 37 |
# Ignore clears when identity is missing; no actionable state to update.
|
| 38 |
return
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
def get_swap_history(
|
| 41 |
self,
|
| 42 |
user_id: str | None = None,
|
|
@@ -48,5 +89,42 @@ class Metadata:
|
|
| 48 |
except ValueError:
|
| 49 |
return []
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
metadata = Metadata()
|
|
|
|
| 3 |
from typing import Any, Dict
|
| 4 |
|
| 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:
|
| 11 |
def __init__(self):
|
| 12 |
self.crypto_data_agent: Dict[str, Any] = {}
|
| 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
|
|
|
|
| 41 |
# Ignore clears when identity is missing; no actionable state to update.
|
| 42 |
return
|
| 43 |
|
| 44 |
+
def get_dca_agent(self, user_id: str | None = None, conversation_id: str | None = None):
|
| 45 |
+
try:
|
| 46 |
+
return self._dca_repo.get_metadata(user_id, conversation_id)
|
| 47 |
+
except ValueError:
|
| 48 |
+
return {}
|
| 49 |
+
|
| 50 |
+
def set_dca_agent(
|
| 51 |
+
self,
|
| 52 |
+
dca_agent: Dict[str, Any] | None,
|
| 53 |
+
user_id: str | None = None,
|
| 54 |
+
conversation_id: str | None = None,
|
| 55 |
+
):
|
| 56 |
+
try:
|
| 57 |
+
if dca_agent:
|
| 58 |
+
self._dca_repo.set_metadata(user_id, conversation_id, dca_agent)
|
| 59 |
+
else:
|
| 60 |
+
self._dca_repo.clear_metadata(user_id, conversation_id)
|
| 61 |
+
except ValueError:
|
| 62 |
+
return
|
| 63 |
+
|
| 64 |
+
def clear_dca_agent(self, user_id: str | None = None, conversation_id: str | None = None) -> None:
|
| 65 |
+
try:
|
| 66 |
+
self._dca_repo.clear_metadata(user_id, conversation_id)
|
| 67 |
+
except ValueError:
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
def get_dca_history(
|
| 71 |
+
self,
|
| 72 |
+
user_id: str | None = None,
|
| 73 |
+
conversation_id: str | None = None,
|
| 74 |
+
limit: int | None = None,
|
| 75 |
+
):
|
| 76 |
+
try:
|
| 77 |
+
return self._dca_repo.get_history(user_id, conversation_id, limit)
|
| 78 |
+
except ValueError:
|
| 79 |
+
return []
|
| 80 |
+
|
| 81 |
def get_swap_history(
|
| 82 |
self,
|
| 83 |
user_id: str | None = None,
|
|
|
|
| 89 |
except ValueError:
|
| 90 |
return []
|
| 91 |
|
| 92 |
+
def get_lending_agent(self, user_id: str | None = None, conversation_id: str | None = None):
|
| 93 |
+
try:
|
| 94 |
+
return self._lending_repo.get_metadata(user_id, conversation_id)
|
| 95 |
+
except ValueError:
|
| 96 |
+
return {}
|
| 97 |
+
|
| 98 |
+
def set_lending_agent(
|
| 99 |
+
self,
|
| 100 |
+
lending_agent: Dict[str, Any] | None,
|
| 101 |
+
user_id: str | None = None,
|
| 102 |
+
conversation_id: str | None = None,
|
| 103 |
+
):
|
| 104 |
+
try:
|
| 105 |
+
if lending_agent:
|
| 106 |
+
self._lending_repo.set_metadata(user_id, conversation_id, lending_agent)
|
| 107 |
+
else:
|
| 108 |
+
self._lending_repo.clear_metadata(user_id, conversation_id)
|
| 109 |
+
except ValueError:
|
| 110 |
+
return
|
| 111 |
+
|
| 112 |
+
def clear_lending_agent(self, user_id: str | None = None, conversation_id: str | None = None) -> None:
|
| 113 |
+
try:
|
| 114 |
+
self._lending_repo.clear_metadata(user_id, conversation_id)
|
| 115 |
+
except ValueError:
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
def get_lending_history(
|
| 119 |
+
self,
|
| 120 |
+
user_id: str | None = None,
|
| 121 |
+
conversation_id: str | None = None,
|
| 122 |
+
limit: int | None = None,
|
| 123 |
+
):
|
| 124 |
+
try:
|
| 125 |
+
return self._lending_repo.get_history(user_id, conversation_id, limit)
|
| 126 |
+
except ValueError:
|
| 127 |
+
return []
|
| 128 |
+
|
| 129 |
|
| 130 |
metadata = Metadata()
|
src/agents/supervisor/agent.py
CHANGED
|
@@ -13,8 +13,12 @@ from src.agents.crypto_data.agent import CryptoDataAgent
|
|
| 13 |
from src.agents.database.agent import DatabaseAgent
|
| 14 |
from src.agents.default.agent import DefaultAgent
|
| 15 |
from src.agents.swap.agent import SwapAgent
|
|
|
|
| 16 |
from src.agents.swap.tools import swap_session
|
| 17 |
from src.agents.swap.prompt import SWAP_AGENT_SYSTEM_PROMPT
|
|
|
|
|
|
|
|
|
|
| 18 |
from src.agents.search.agent import SearchAgent
|
| 19 |
from src.agents.database.client import is_database_available
|
| 20 |
|
|
@@ -64,6 +68,13 @@ class Supervisor:
|
|
| 64 |
"- swap_agent: Handles swap operations on the Avalanche network and any other swap question related.\n"
|
| 65 |
)
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
searchAgent = SearchAgent(llm)
|
| 68 |
self.search_agent = searchAgent.agent
|
| 69 |
agents.append(self.search_agent)
|
|
@@ -76,8 +87,8 @@ class Supervisor:
|
|
| 76 |
agents.append(self.default_agent)
|
| 77 |
|
| 78 |
# Track known agent names for response extraction
|
| 79 |
-
self.known_agent_names = {"crypto_agent", "database_agent", "swap_agent", "search_agent", "default_agent"}
|
| 80 |
-
self.specialized_agents = {"crypto_agent", "database_agent", "swap_agent", "search_agent"}
|
| 81 |
self.failure_markers = (
|
| 82 |
"cannot fulfill",
|
| 83 |
"can't fulfill",
|
|
@@ -177,11 +188,18 @@ Examples of swap queries to delegate:
|
|
| 177 |
- What are the available tokens for swapping?
|
| 178 |
- I want to swap 100 USD for AVAX
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
When a swap conversation is already underway (the user is still providing swap
|
| 181 |
details or the swap_agent requested follow-up information), keep routing those
|
| 182 |
messages to the swap_agent until it has gathered every field and signals the
|
| 183 |
swap intent is ready.
|
| 184 |
|
|
|
|
|
|
|
| 185 |
{database_examples}
|
| 186 |
|
| 187 |
{search_examples}
|
|
@@ -202,6 +220,8 @@ Examples of general queries to handle directly:
|
|
| 202 |
|
| 203 |
self.app = self.supervisor.compile()
|
| 204 |
|
|
|
|
|
|
|
| 205 |
def _is_handoff_text(self, text: str) -> bool:
|
| 206 |
if not text:
|
| 207 |
return False
|
|
@@ -325,6 +345,25 @@ Examples of general queries to handle directly:
|
|
| 325 |
agent, text, messages_out = self._extract_response_from_graph(response)
|
| 326 |
return agent, text, messages_out
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
def _extract_response_from_graph(self, response: Any) -> Tuple[str, str, list]:
|
| 329 |
messages_out = response.get("messages", []) if isinstance(response, dict) else []
|
| 330 |
final_response = None
|
|
@@ -415,6 +454,45 @@ Examples of general queries to handle directly:
|
|
| 415 |
fallback_agent = "search_agent"
|
| 416 |
return fallback_agent, fallback_text, fallback_messages
|
| 417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
def _build_metadata(self, agent_name: str, messages_out) -> dict:
|
| 419 |
if agent_name == "swap_agent":
|
| 420 |
swap_meta = metadata.get_swap_agent(
|
|
@@ -432,6 +510,22 @@ Examples of general queries to handle directly:
|
|
| 432 |
else:
|
| 433 |
swap_meta = swap_meta.copy()
|
| 434 |
return swap_meta if swap_meta else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
if agent_name == "crypto_agent":
|
| 436 |
tool_meta = self._collect_tool_metadata(messages_out)
|
| 437 |
if tool_meta:
|
|
@@ -439,6 +533,50 @@ Examples of general queries to handle directly:
|
|
| 439 |
return metadata.get_crypto_data_agent() or {}
|
| 440 |
return {}
|
| 441 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
def invoke(
|
| 443 |
self,
|
| 444 |
messages: List[ChatMessage],
|
|
@@ -447,7 +585,7 @@ Examples of general queries to handle directly:
|
|
| 447 |
) -> dict:
|
| 448 |
self._active_user_id = user_id
|
| 449 |
self._active_conversation_id = conversation_id
|
| 450 |
-
|
| 451 |
|
| 452 |
langchain_messages = []
|
| 453 |
for msg in messages:
|
|
@@ -463,6 +601,66 @@ Examples of general queries to handle directly:
|
|
| 463 |
SystemMessage(content="Always respond in English, regardless of the user's language."),
|
| 464 |
)
|
| 465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
if swap_state and swap_state.get("status") == "collecting":
|
| 467 |
swap_result = self._invoke_swap_agent(langchain_messages)
|
| 468 |
if swap_result:
|
|
@@ -489,11 +687,25 @@ Examples of general queries to handle directly:
|
|
| 489 |
guidance_parts.append(f"Continue the swap flow by asking: {pending_question}")
|
| 490 |
guidance_text = " ".join(guidance_parts)
|
| 491 |
langchain_messages.insert(0, SystemMessage(content=guidance_text))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
|
| 493 |
try:
|
| 494 |
with swap_session(user_id=user_id, conversation_id=conversation_id):
|
| 495 |
-
|
| 496 |
-
|
|
|
|
| 497 |
except Exception as e:
|
| 498 |
print(f"Error in Supervisor: {e}")
|
| 499 |
return {
|
|
|
|
| 13 |
from src.agents.database.agent import DatabaseAgent
|
| 14 |
from src.agents.default.agent import DefaultAgent
|
| 15 |
from src.agents.swap.agent import SwapAgent
|
| 16 |
+
from src.agents.swap.config import SwapConfig
|
| 17 |
from src.agents.swap.tools import swap_session
|
| 18 |
from src.agents.swap.prompt import SWAP_AGENT_SYSTEM_PROMPT
|
| 19 |
+
from src.agents.dca.agent import DcaAgent
|
| 20 |
+
from src.agents.dca.tools import dca_session
|
| 21 |
+
from src.agents.dca.prompt import DCA_AGENT_SYSTEM_PROMPT
|
| 22 |
from src.agents.search.agent import SearchAgent
|
| 23 |
from src.agents.database.client import is_database_available
|
| 24 |
|
|
|
|
| 68 |
"- swap_agent: Handles swap operations on the Avalanche network and any other swap question related.\n"
|
| 69 |
)
|
| 70 |
|
| 71 |
+
dcaAgent = DcaAgent(llm)
|
| 72 |
+
self.dca_agent = dcaAgent.agent
|
| 73 |
+
agents.append(self.dca_agent)
|
| 74 |
+
available_agents_text += (
|
| 75 |
+
"- dca_agent: Plans DCA swap workflows, consulting strategy docs, validating parameters, and confirming automation metadata.\n"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
searchAgent = SearchAgent(llm)
|
| 79 |
self.search_agent = searchAgent.agent
|
| 80 |
agents.append(self.search_agent)
|
|
|
|
| 87 |
agents.append(self.default_agent)
|
| 88 |
|
| 89 |
# Track known agent names for response extraction
|
| 90 |
+
self.known_agent_names = {"crypto_agent", "database_agent", "swap_agent", "dca_agent", "search_agent", "default_agent"}
|
| 91 |
+
self.specialized_agents = {"crypto_agent", "database_agent", "swap_agent", "dca_agent", "search_agent"}
|
| 92 |
self.failure_markers = (
|
| 93 |
"cannot fulfill",
|
| 94 |
"can't fulfill",
|
|
|
|
| 188 |
- What are the available tokens for swapping?
|
| 189 |
- I want to swap 100 USD for AVAX
|
| 190 |
|
| 191 |
+
Examples of dca queries to delegate:
|
| 192 |
+
- Help me schedule a daily DCA from USDC to AVAX
|
| 193 |
+
- Suggest a weekly swap DCA strategy
|
| 194 |
+
- I want to automate a monthly swap from token A to token B
|
| 195 |
+
|
| 196 |
When a swap conversation is already underway (the user is still providing swap
|
| 197 |
details or the swap_agent requested follow-up information), keep routing those
|
| 198 |
messages to the swap_agent until it has gathered every field and signals the
|
| 199 |
swap intent is ready.
|
| 200 |
|
| 201 |
+
When a DCA conversation is already underway (the user is reviewing strategy recommendations or adjusting schedule parameters), keep routing messages to the dca_agent until the workflow is confirmed or cancelled.
|
| 202 |
+
|
| 203 |
{database_examples}
|
| 204 |
|
| 205 |
{search_examples}
|
|
|
|
| 220 |
|
| 221 |
self.app = self.supervisor.compile()
|
| 222 |
|
| 223 |
+
self._swap_network_terms, self._swap_token_terms = self._build_swap_detection_terms()
|
| 224 |
+
|
| 225 |
def _is_handoff_text(self, text: str) -> bool:
|
| 226 |
if not text:
|
| 227 |
return False
|
|
|
|
| 345 |
agent, text, messages_out = self._extract_response_from_graph(response)
|
| 346 |
return agent, text, messages_out
|
| 347 |
|
| 348 |
+
def _invoke_dca_agent(self, langchain_messages):
|
| 349 |
+
scoped_messages = [SystemMessage(content=DCA_AGENT_SYSTEM_PROMPT)]
|
| 350 |
+
scoped_messages.extend(langchain_messages)
|
| 351 |
+
try:
|
| 352 |
+
with dca_session(
|
| 353 |
+
user_id=self._active_user_id,
|
| 354 |
+
conversation_id=self._active_conversation_id,
|
| 355 |
+
):
|
| 356 |
+
response = self.dca_agent.invoke({"messages": scoped_messages})
|
| 357 |
+
except Exception as exc:
|
| 358 |
+
print(f"Error invoking dca agent directly: {exc}")
|
| 359 |
+
return None
|
| 360 |
+
|
| 361 |
+
if not response:
|
| 362 |
+
return None
|
| 363 |
+
|
| 364 |
+
agent, text, messages_out = self._extract_response_from_graph(response)
|
| 365 |
+
return agent, text, messages_out
|
| 366 |
+
|
| 367 |
def _extract_response_from_graph(self, response: Any) -> Tuple[str, str, list]:
|
| 368 |
messages_out = response.get("messages", []) if isinstance(response, dict) else []
|
| 369 |
final_response = None
|
|
|
|
| 454 |
fallback_agent = "search_agent"
|
| 455 |
return fallback_agent, fallback_text, fallback_messages
|
| 456 |
|
| 457 |
+
def _detect_pending_followups(self, messages: List[Any]) -> tuple[bool, bool]:
|
| 458 |
+
awaiting_swap = False
|
| 459 |
+
awaiting_dca = False
|
| 460 |
+
def _get_entry_value(entry: Any, dict_keys: tuple[str, ...], attr_name: str) -> Any:
|
| 461 |
+
"""Support both camelCase (gateway) and snake_case (local) message fields."""
|
| 462 |
+
if isinstance(entry, dict):
|
| 463 |
+
for key in dict_keys:
|
| 464 |
+
if key in entry:
|
| 465 |
+
return entry.get(key)
|
| 466 |
+
return None
|
| 467 |
+
return getattr(entry, attr_name, None)
|
| 468 |
+
|
| 469 |
+
for entry in reversed(messages):
|
| 470 |
+
role_raw = _get_entry_value(entry, ("role", "Role"), "role")
|
| 471 |
+
agent_label_raw = _get_entry_value(entry, ("agent_name", "agentName"), "agent_name")
|
| 472 |
+
action_type_raw = _get_entry_value(entry, ("action_type", "actionType"), "action_type")
|
| 473 |
+
requires_action_raw = _get_entry_value(
|
| 474 |
+
entry,
|
| 475 |
+
("requires_action", "requiresAction"),
|
| 476 |
+
"requires_action",
|
| 477 |
+
)
|
| 478 |
+
metadata_payload = _get_entry_value(entry, ("metadata",), "metadata") or {}
|
| 479 |
+
|
| 480 |
+
role = str(role_raw or "").lower()
|
| 481 |
+
if role != "assistant":
|
| 482 |
+
continue
|
| 483 |
+
agent_label = str(agent_label_raw or "").lower()
|
| 484 |
+
action_type = str(action_type_raw or "").lower()
|
| 485 |
+
requires_action = bool(requires_action_raw)
|
| 486 |
+
status = str((metadata_payload.get("status") if isinstance(metadata_payload, dict) else "") or "").lower()
|
| 487 |
+
|
| 488 |
+
if requires_action and status != "ready":
|
| 489 |
+
if action_type == "swap" or "swap" in agent_label:
|
| 490 |
+
awaiting_swap = True
|
| 491 |
+
if action_type == "dca" or "dca" in agent_label:
|
| 492 |
+
awaiting_dca = True
|
| 493 |
+
break
|
| 494 |
+
return awaiting_swap, awaiting_dca
|
| 495 |
+
|
| 496 |
def _build_metadata(self, agent_name: str, messages_out) -> dict:
|
| 497 |
if agent_name == "swap_agent":
|
| 498 |
swap_meta = metadata.get_swap_agent(
|
|
|
|
| 510 |
else:
|
| 511 |
swap_meta = swap_meta.copy()
|
| 512 |
return swap_meta if swap_meta else {}
|
| 513 |
+
if agent_name == "dca_agent":
|
| 514 |
+
dca_meta = metadata.get_dca_agent(
|
| 515 |
+
user_id=self._active_user_id,
|
| 516 |
+
conversation_id=self._active_conversation_id,
|
| 517 |
+
)
|
| 518 |
+
if dca_meta:
|
| 519 |
+
history = metadata.get_dca_history(
|
| 520 |
+
user_id=self._active_user_id,
|
| 521 |
+
conversation_id=self._active_conversation_id,
|
| 522 |
+
)
|
| 523 |
+
if history:
|
| 524 |
+
dca_meta = dca_meta.copy()
|
| 525 |
+
dca_meta.setdefault("history", history)
|
| 526 |
+
else:
|
| 527 |
+
dca_meta = dca_meta.copy()
|
| 528 |
+
return dca_meta if dca_meta else {}
|
| 529 |
if agent_name == "crypto_agent":
|
| 530 |
tool_meta = self._collect_tool_metadata(messages_out)
|
| 531 |
if tool_meta:
|
|
|
|
| 533 |
return metadata.get_crypto_data_agent() or {}
|
| 534 |
return {}
|
| 535 |
|
| 536 |
+
def _build_swap_detection_terms(self) -> tuple[set[str], set[str]]:
|
| 537 |
+
networks: set[str] = set()
|
| 538 |
+
tokens: set[str] = set()
|
| 539 |
+
try:
|
| 540 |
+
for net in SwapConfig.list_networks():
|
| 541 |
+
lowered = net.lower()
|
| 542 |
+
networks.add(lowered)
|
| 543 |
+
try:
|
| 544 |
+
for token in SwapConfig.list_tokens(net):
|
| 545 |
+
tokens.add(token.lower())
|
| 546 |
+
except ValueError:
|
| 547 |
+
continue
|
| 548 |
+
except Exception:
|
| 549 |
+
return set(), set()
|
| 550 |
+
return networks, tokens
|
| 551 |
+
|
| 552 |
+
def _is_swap_like_request(self, messages: List[ChatMessage]) -> bool:
|
| 553 |
+
for msg in reversed(messages):
|
| 554 |
+
if msg.get("role") != "user":
|
| 555 |
+
continue
|
| 556 |
+
content = (msg.get("content") or "").strip()
|
| 557 |
+
if not content:
|
| 558 |
+
continue
|
| 559 |
+
lowered = content.lower()
|
| 560 |
+
swap_keywords = (
|
| 561 |
+
"swap",
|
| 562 |
+
"swapping",
|
| 563 |
+
"exchange",
|
| 564 |
+
"convert",
|
| 565 |
+
"trade",
|
| 566 |
+
)
|
| 567 |
+
if not any(keyword in lowered for keyword in swap_keywords):
|
| 568 |
+
return False
|
| 569 |
+
if any(term and term in lowered for term in self._swap_network_terms):
|
| 570 |
+
return True
|
| 571 |
+
if any(term and term in lowered for term in self._swap_token_terms):
|
| 572 |
+
return True
|
| 573 |
+
if "token" in lowered or any(ch.isdigit() for ch in lowered):
|
| 574 |
+
return True
|
| 575 |
+
# Default to True if the user explicitly mentioned swap-related keywords,
|
| 576 |
+
# even if they haven't provided networks/tokens yet.
|
| 577 |
+
return True
|
| 578 |
+
return False
|
| 579 |
+
|
| 580 |
def invoke(
|
| 581 |
self,
|
| 582 |
messages: List[ChatMessage],
|
|
|
|
| 585 |
) -> dict:
|
| 586 |
self._active_user_id = user_id
|
| 587 |
self._active_conversation_id = conversation_id
|
| 588 |
+
awaiting_swap, awaiting_dca = self._detect_pending_followups(messages)
|
| 589 |
|
| 590 |
langchain_messages = []
|
| 591 |
for msg in messages:
|
|
|
|
| 601 |
SystemMessage(content="Always respond in English, regardless of the user's language."),
|
| 602 |
)
|
| 603 |
|
| 604 |
+
dca_state = metadata.get_dca_agent(user_id=user_id, conversation_id=conversation_id)
|
| 605 |
+
swap_state = metadata.get_swap_agent(user_id=user_id, conversation_id=conversation_id)
|
| 606 |
+
|
| 607 |
+
if not swap_state and self._is_swap_like_request(messages):
|
| 608 |
+
swap_result = self._invoke_swap_agent(langchain_messages)
|
| 609 |
+
if swap_result:
|
| 610 |
+
final_agent, cleaned_response, messages_out = swap_result
|
| 611 |
+
meta = self._build_metadata(final_agent, messages_out)
|
| 612 |
+
self._active_user_id = None
|
| 613 |
+
self._active_conversation_id = None
|
| 614 |
+
return {
|
| 615 |
+
"messages": messages_out,
|
| 616 |
+
"agent": final_agent,
|
| 617 |
+
"response": cleaned_response or "Sorry, no meaningful response was returned.",
|
| 618 |
+
"metadata": meta,
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
in_progress_statuses = {"consulting", "recommendation", "confirmation"}
|
| 622 |
+
|
| 623 |
+
if dca_state and dca_state.get("status") in in_progress_statuses:
|
| 624 |
+
dca_result = self._invoke_dca_agent(langchain_messages)
|
| 625 |
+
if dca_result:
|
| 626 |
+
final_agent, cleaned_response, messages_out = dca_result
|
| 627 |
+
meta = self._build_metadata(final_agent, messages_out)
|
| 628 |
+
self._active_user_id = None
|
| 629 |
+
self._active_conversation_id = None
|
| 630 |
+
return {
|
| 631 |
+
"messages": messages_out,
|
| 632 |
+
"agent": final_agent,
|
| 633 |
+
"response": cleaned_response or "Sorry, no meaningful response was returned.",
|
| 634 |
+
"metadata": meta,
|
| 635 |
+
}
|
| 636 |
+
stage = dca_state.get("status")
|
| 637 |
+
next_field = dca_state.get("next_field")
|
| 638 |
+
pending_question = dca_state.get("pending_question")
|
| 639 |
+
guidance_parts = [
|
| 640 |
+
"There is an in-progress DCA planning session for this conversation.",
|
| 641 |
+
"Keep routing messages to the dca_agent until the workflow is confirmed or the user cancels.",
|
| 642 |
+
]
|
| 643 |
+
if stage:
|
| 644 |
+
guidance_parts.append(f"The current stage is: {stage}.")
|
| 645 |
+
if next_field:
|
| 646 |
+
guidance_parts.append(f"The next field to collect is: {next_field}.")
|
| 647 |
+
if pending_question:
|
| 648 |
+
guidance_parts.append(f"Continue the DCA flow by asking: {pending_question}")
|
| 649 |
+
langchain_messages.insert(0, SystemMessage(content=" ".join(guidance_parts)))
|
| 650 |
+
elif awaiting_dca:
|
| 651 |
+
dca_result = self._invoke_dca_agent(langchain_messages)
|
| 652 |
+
if dca_result:
|
| 653 |
+
final_agent, cleaned_response, messages_out = dca_result
|
| 654 |
+
meta = self._build_metadata(final_agent, messages_out)
|
| 655 |
+
self._active_user_id = None
|
| 656 |
+
self._active_conversation_id = None
|
| 657 |
+
return {
|
| 658 |
+
"messages": messages_out,
|
| 659 |
+
"agent": final_agent,
|
| 660 |
+
"response": cleaned_response or "Sorry, no meaningful response was returned.",
|
| 661 |
+
"metadata": meta,
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
if swap_state and swap_state.get("status") == "collecting":
|
| 665 |
swap_result = self._invoke_swap_agent(langchain_messages)
|
| 666 |
if swap_result:
|
|
|
|
| 687 |
guidance_parts.append(f"Continue the swap flow by asking: {pending_question}")
|
| 688 |
guidance_text = " ".join(guidance_parts)
|
| 689 |
langchain_messages.insert(0, SystemMessage(content=guidance_text))
|
| 690 |
+
elif awaiting_swap:
|
| 691 |
+
swap_result = self._invoke_swap_agent(langchain_messages)
|
| 692 |
+
if swap_result:
|
| 693 |
+
final_agent, cleaned_response, messages_out = swap_result
|
| 694 |
+
meta = self._build_metadata(final_agent, messages_out)
|
| 695 |
+
self._active_user_id = None
|
| 696 |
+
self._active_conversation_id = None
|
| 697 |
+
return {
|
| 698 |
+
"messages": messages_out,
|
| 699 |
+
"agent": final_agent,
|
| 700 |
+
"response": cleaned_response or "Sorry, no meaningful response was returned.",
|
| 701 |
+
"metadata": meta,
|
| 702 |
+
}
|
| 703 |
|
| 704 |
try:
|
| 705 |
with swap_session(user_id=user_id, conversation_id=conversation_id):
|
| 706 |
+
with dca_session(user_id=user_id, conversation_id=conversation_id):
|
| 707 |
+
response = self.app.invoke({"messages": langchain_messages})
|
| 708 |
+
print("DEBUG: response", response)
|
| 709 |
except Exception as e:
|
| 710 |
print(f"Error in Supervisor: {e}")
|
| 711 |
return {
|
src/agents/swap/storage.py
CHANGED
|
@@ -1,45 +1,89 @@
|
|
| 1 |
-
"""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import copy
|
| 5 |
-
import json
|
| 6 |
-
import os
|
| 7 |
import time
|
|
|
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
-
from pathlib import Path
|
| 10 |
from threading import Lock
|
| 11 |
from typing import Any, Dict, List, Optional
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class SwapStateRepository:
|
| 21 |
-
"""
|
| 22 |
|
| 23 |
_instance: "SwapStateRepository" | None = None
|
| 24 |
_instance_lock: Lock = Lock()
|
| 25 |
|
| 26 |
def __init__(
|
| 27 |
self,
|
| 28 |
-
|
| 29 |
-
|
|
|
|
| 30 |
history_limit: int = 10,
|
| 31 |
) -> None:
|
| 32 |
-
|
| 33 |
-
default_path = Path(__file__).with_name("swap_state.json")
|
| 34 |
-
self._path = Path(path or env_path or default_path)
|
| 35 |
-
self._ttl_seconds = ttl_seconds
|
| 36 |
self._history_limit = history_limit
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
@classmethod
|
| 44 |
def instance(cls) -> "SwapStateRepository":
|
| 45 |
if cls._instance is None:
|
|
@@ -53,93 +97,22 @@ class SwapStateRepository:
|
|
| 53 |
with cls._instance_lock:
|
| 54 |
cls._instance = None
|
| 55 |
|
| 56 |
-
|
| 57 |
-
try:
|
| 58 |
-
self._path.parent.mkdir(parents=True, exist_ok=True)
|
| 59 |
-
except OSError:
|
| 60 |
-
pass
|
| 61 |
-
|
| 62 |
-
def _load_locked(self) -> None:
|
| 63 |
-
if not self._path.exists():
|
| 64 |
-
self._state = copy.deepcopy(_STATE_TEMPLATE)
|
| 65 |
-
return
|
| 66 |
-
try:
|
| 67 |
-
data = json.loads(self._path.read_text())
|
| 68 |
-
except Exception:
|
| 69 |
-
self._state = copy.deepcopy(_STATE_TEMPLATE)
|
| 70 |
-
return
|
| 71 |
-
if not isinstance(data, dict):
|
| 72 |
-
self._state = copy.deepcopy(_STATE_TEMPLATE)
|
| 73 |
-
return
|
| 74 |
-
state = copy.deepcopy(_STATE_TEMPLATE)
|
| 75 |
-
for key in state:
|
| 76 |
-
if isinstance(data.get(key), dict):
|
| 77 |
-
state[key] = data[key]
|
| 78 |
-
self._state = state
|
| 79 |
-
|
| 80 |
-
def _persist_locked(self) -> None:
|
| 81 |
-
self._ensure_parent()
|
| 82 |
-
tmp_path = self._path.with_suffix(".tmp")
|
| 83 |
-
payload = json.dumps(self._state, ensure_ascii=False, indent=2)
|
| 84 |
-
try:
|
| 85 |
-
tmp_path.write_text(payload)
|
| 86 |
-
tmp_path.replace(self._path)
|
| 87 |
-
except Exception:
|
| 88 |
-
pass
|
| 89 |
-
|
| 90 |
-
@staticmethod
|
| 91 |
-
def _normalize_identifier(value: Optional[str], label: str) -> str:
|
| 92 |
-
candidate = (value or "").strip()
|
| 93 |
-
if not candidate:
|
| 94 |
-
raise ValueError(f"{label} is required for swap state operations.")
|
| 95 |
-
return candidate
|
| 96 |
-
|
| 97 |
-
def _key(self, user_id: Optional[str], conversation_id: Optional[str]) -> str:
|
| 98 |
-
user = self._normalize_identifier(user_id, "user_id")
|
| 99 |
-
conversation = self._normalize_identifier(conversation_id, "conversation_id")
|
| 100 |
-
return f"{user}::{conversation}"
|
| 101 |
-
|
| 102 |
-
def _purge_locked(self) -> None:
|
| 103 |
-
if self._ttl_seconds <= 0:
|
| 104 |
-
return
|
| 105 |
-
cutoff = time.time() - self._ttl_seconds
|
| 106 |
-
intents = self._state["intents"]
|
| 107 |
-
metadata = self._state["metadata"]
|
| 108 |
-
stale_keys = [
|
| 109 |
-
key for key, record in intents.items() if record.get("updated_at", 0) < cutoff
|
| 110 |
-
]
|
| 111 |
-
for key in stale_keys:
|
| 112 |
-
intents.pop(key, None)
|
| 113 |
-
metadata.pop(key, None)
|
| 114 |
-
|
| 115 |
-
@staticmethod
|
| 116 |
-
def _format_timestamp(value: float) -> str:
|
| 117 |
-
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
| 118 |
-
|
| 119 |
-
def _history_unlocked(self, key: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
| 120 |
-
history = self._state["history"].get(key, [])
|
| 121 |
-
sorted_history = sorted(history, key=lambda item: item.get("timestamp", 0), reverse=True)
|
| 122 |
-
effective_limit = limit or self._history_limit
|
| 123 |
-
if effective_limit:
|
| 124 |
-
sorted_history = sorted_history[:effective_limit]
|
| 125 |
-
results: List[Dict[str, Any]] = []
|
| 126 |
-
for item in sorted_history:
|
| 127 |
-
entry = copy.deepcopy(item)
|
| 128 |
-
ts = entry.get("timestamp")
|
| 129 |
-
if ts is not None:
|
| 130 |
-
entry["timestamp"] = self._format_timestamp(float(ts))
|
| 131 |
-
results.append(entry)
|
| 132 |
-
return results
|
| 133 |
-
|
| 134 |
def load_intent(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
self.
|
| 138 |
-
record = self._state["intents"].get(key)
|
| 139 |
if not record:
|
| 140 |
return None
|
| 141 |
return copy.deepcopy(record.get("intent"))
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
def persist_intent(
|
| 144 |
self,
|
| 145 |
user_id: str,
|
|
@@ -149,80 +122,287 @@ class SwapStateRepository:
|
|
| 149 |
done: bool,
|
| 150 |
summary: Optional[Dict[str, Any]] = None,
|
| 151 |
) -> List[Dict[str, Any]]:
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
now = time.time()
|
| 156 |
if done:
|
| 157 |
self._state["intents"].pop(key, None)
|
| 158 |
else:
|
| 159 |
-
self._state["intents"][key] = {
|
| 160 |
-
"intent": copy.deepcopy(intent),
|
| 161 |
-
"updated_at": now,
|
| 162 |
-
}
|
| 163 |
if metadata:
|
| 164 |
meta_copy = copy.deepcopy(metadata)
|
| 165 |
meta_copy["updated_at"] = now
|
| 166 |
self._state["metadata"][key] = meta_copy
|
| 167 |
if done and summary:
|
| 168 |
-
history = self._state["history"].
|
| 169 |
summary_copy = copy.deepcopy(summary)
|
| 170 |
summary_copy.setdefault("timestamp", now)
|
| 171 |
history.append(summary_copy)
|
| 172 |
-
self._state["history"][key] = history[-self._history_limit:]
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
if metadata:
|
| 184 |
meta_copy = copy.deepcopy(metadata)
|
| 185 |
meta_copy["updated_at"] = time.time()
|
| 186 |
self._state["metadata"][key] = meta_copy
|
| 187 |
else:
|
| 188 |
self._state["metadata"].pop(key, None)
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
def clear_metadata(self, user_id: str, conversation_id: str) -> None:
|
| 195 |
self.set_metadata(user_id, conversation_id, {})
|
| 196 |
|
| 197 |
def clear_intent(self, user_id: str, conversation_id: str) -> None:
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
self._state["intents"].pop(
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
def get_metadata(self, user_id: str, conversation_id: str) -> Dict[str, Any]:
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
self.
|
| 210 |
-
|
| 211 |
-
if not meta:
|
| 212 |
return {}
|
| 213 |
-
entry = copy.deepcopy(
|
| 214 |
ts = entry.pop("updated_at", None)
|
| 215 |
if ts is not None:
|
| 216 |
-
entry["updated_at"] =
|
| 217 |
return entry
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
def get_history(
|
| 220 |
self,
|
| 221 |
user_id: str,
|
| 222 |
conversation_id: str,
|
| 223 |
limit: Optional[int] = None,
|
| 224 |
) -> List[Dict[str, Any]]:
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gateway-backed storage for swap 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 |
+
SWAP_SESSION_ENTITY = "swap-sessions"
|
| 19 |
+
SWAP_HISTORY_ENTITY = "swap-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 SwapStateRepository:
|
| 40 |
+
"""Stores swap agent state via Panorama's gateway or an in-memory fallback."""
|
| 41 |
|
| 42 |
_instance: "SwapStateRepository" | 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 swap 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 swap 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) -> "SwapStateRepository":
|
| 89 |
if cls._instance is 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,
|
|
|
|
| 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["from_network"] = intent.get("from_network")
|
| 236 |
+
metadata["from_token"] = intent.get("from_token")
|
| 237 |
+
metadata["to_network"] = intent.get("to_network")
|
| 238 |
+
metadata["to_token"] = intent.get("to_token")
|
| 239 |
+
metadata["amount"] = intent.get("amount")
|
| 240 |
+
|
| 241 |
+
history = self.get_history(user_id, conversation_id)
|
| 242 |
+
if history:
|
| 243 |
+
metadata["history"] = history
|
| 244 |
+
|
| 245 |
+
updated_at = session.get("updatedAt")
|
| 246 |
+
if updated_at:
|
| 247 |
+
metadata["updated_at"] = updated_at
|
| 248 |
+
|
| 249 |
+
return metadata
|
| 250 |
+
|
| 251 |
def get_history(
|
| 252 |
self,
|
| 253 |
user_id: str,
|
| 254 |
conversation_id: str,
|
| 255 |
limit: Optional[int] = None,
|
| 256 |
) -> List[Dict[str, Any]]:
|
| 257 |
+
if not self._use_gateway:
|
| 258 |
+
key = _identifier(user_id, conversation_id)
|
| 259 |
+
history = self._state["history"].get(key, [])
|
| 260 |
+
effective = limit or self._history_limit
|
| 261 |
+
result: List[Dict[str, Any]] = []
|
| 262 |
+
for item in sorted(history, key=lambda entry: entry.get("timestamp", 0), reverse=True)[:effective]:
|
| 263 |
+
entry = copy.deepcopy(item)
|
| 264 |
+
ts = entry.get("timestamp")
|
| 265 |
+
if ts is not None:
|
| 266 |
+
entry["timestamp"] = datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
|
| 267 |
+
result.append(entry)
|
| 268 |
+
return result
|
| 269 |
+
|
| 270 |
+
effective_limit = limit or self._history_limit
|
| 271 |
+
try:
|
| 272 |
+
result = self._client.list(
|
| 273 |
+
SWAP_HISTORY_ENTITY,
|
| 274 |
+
{
|
| 275 |
+
"where": {"userId": user_id, "conversationId": conversation_id},
|
| 276 |
+
"orderBy": {"recordedAt": "desc"},
|
| 277 |
+
"take": effective_limit,
|
| 278 |
+
},
|
| 279 |
+
)
|
| 280 |
+
except PanoramaGatewayError as exc:
|
| 281 |
+
if exc.status_code == 404:
|
| 282 |
+
return []
|
| 283 |
+
self._handle_gateway_failure(exc)
|
| 284 |
+
return self.get_history(user_id, conversation_id, limit)
|
| 285 |
+
except ValueError:
|
| 286 |
+
self._logger.warning("Invalid swap history response from gateway; falling back to local store.")
|
| 287 |
+
self._fallback_to_local_store()
|
| 288 |
+
return self.get_history(user_id, conversation_id, limit)
|
| 289 |
+
data = result.get("data", []) if isinstance(result, dict) else []
|
| 290 |
+
history: List[Dict[str, Any]] = []
|
| 291 |
+
for entry in data:
|
| 292 |
+
history.append(
|
| 293 |
+
{
|
| 294 |
+
"status": entry.get("status"),
|
| 295 |
+
"from_network": entry.get("fromNetwork"),
|
| 296 |
+
"from_token": entry.get("fromToken"),
|
| 297 |
+
"to_network": entry.get("toNetwork"),
|
| 298 |
+
"to_token": entry.get("toToken"),
|
| 299 |
+
"amount": entry.get("amount"),
|
| 300 |
+
"error": entry.get("errorMessage"),
|
| 301 |
+
"timestamp": entry.get("recordedAt"),
|
| 302 |
+
}
|
| 303 |
+
)
|
| 304 |
+
return history
|
| 305 |
|
| 306 |
+
# ---- Gateway helpers --------------------------------------------------
|
| 307 |
+
def _get_session(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
|
| 308 |
+
identifier = _identifier(user_id, conversation_id)
|
| 309 |
+
try:
|
| 310 |
+
return self._client.get(SWAP_SESSION_ENTITY, identifier)
|
| 311 |
+
except PanoramaGatewayError as exc:
|
| 312 |
+
if exc.status_code == 404:
|
| 313 |
+
return None
|
| 314 |
+
self._handle_gateway_failure(exc)
|
| 315 |
+
return None
|
| 316 |
+
|
| 317 |
+
def _delete_session(self, user_id: str, conversation_id: str) -> None:
|
| 318 |
+
identifier = _identifier(user_id, conversation_id)
|
| 319 |
+
try:
|
| 320 |
+
self._client.delete(SWAP_SESSION_ENTITY, identifier)
|
| 321 |
+
except PanoramaGatewayError as exc:
|
| 322 |
+
if exc.status_code != 404:
|
| 323 |
+
self._handle_gateway_failure(exc)
|
| 324 |
+
raise
|
| 325 |
+
|
| 326 |
+
def _upsert_session(
|
| 327 |
+
self,
|
| 328 |
+
user_id: str,
|
| 329 |
+
conversation_id: str,
|
| 330 |
+
data: Dict[str, Any],
|
| 331 |
+
) -> None:
|
| 332 |
+
identifier = _identifier(user_id, conversation_id)
|
| 333 |
+
payload = {**data, "updatedAt": _utc_now_iso()}
|
| 334 |
+
try:
|
| 335 |
+
self._client.update(SWAP_SESSION_ENTITY, identifier, payload)
|
| 336 |
+
except PanoramaGatewayError as exc:
|
| 337 |
+
if exc.status_code != 404:
|
| 338 |
+
self._handle_gateway_failure(exc)
|
| 339 |
+
raise
|
| 340 |
+
create_payload = {
|
| 341 |
+
"userId": user_id,
|
| 342 |
+
"conversationId": conversation_id,
|
| 343 |
+
"tenantId": self._tenant_id(),
|
| 344 |
+
**payload,
|
| 345 |
+
}
|
| 346 |
+
try:
|
| 347 |
+
self._client.create(SWAP_SESSION_ENTITY, create_payload)
|
| 348 |
+
except PanoramaGatewayError as create_exc:
|
| 349 |
+
if create_exc.status_code == 409:
|
| 350 |
+
return
|
| 351 |
+
if create_exc.status_code == 404:
|
| 352 |
+
self._handle_gateway_failure(create_exc)
|
| 353 |
+
raise
|
| 354 |
+
self._handle_gateway_failure(create_exc)
|
| 355 |
+
raise
|
| 356 |
+
|
| 357 |
+
def _create_history_entry(
|
| 358 |
+
self,
|
| 359 |
+
user_id: str,
|
| 360 |
+
conversation_id: str,
|
| 361 |
+
summary: Dict[str, Any],
|
| 362 |
+
) -> None:
|
| 363 |
+
history_payload = {
|
| 364 |
+
"userId": user_id,
|
| 365 |
+
"conversationId": conversation_id,
|
| 366 |
+
"status": summary.get("status"),
|
| 367 |
+
"fromNetwork": summary.get("from_network"),
|
| 368 |
+
"fromToken": summary.get("from_token"),
|
| 369 |
+
"toNetwork": summary.get("to_network"),
|
| 370 |
+
"toToken": summary.get("to_token"),
|
| 371 |
+
"amount": _as_float(summary.get("amount")),
|
| 372 |
+
"errorMessage": summary.get("error"),
|
| 373 |
+
"recordedAt": _utc_now_iso(),
|
| 374 |
+
"tenantId": self._tenant_id(),
|
| 375 |
+
}
|
| 376 |
+
if self._logger.isEnabledFor(logging.DEBUG):
|
| 377 |
+
self._logger.debug(
|
| 378 |
+
"Persisting swap history for user=%s conversation=%s payload=%s",
|
| 379 |
+
user_id,
|
| 380 |
+
conversation_id,
|
| 381 |
+
history_payload,
|
| 382 |
+
)
|
| 383 |
+
try:
|
| 384 |
+
self._client.create(SWAP_HISTORY_ENTITY, history_payload)
|
| 385 |
+
except PanoramaGatewayError as exc:
|
| 386 |
+
if exc.status_code == 404:
|
| 387 |
+
self._handle_gateway_failure(exc)
|
| 388 |
+
raise
|
| 389 |
+
elif exc.status_code != 409:
|
| 390 |
+
self._handle_gateway_failure(exc)
|
| 391 |
+
raise
|
| 392 |
+
|
| 393 |
+
@staticmethod
|
| 394 |
+
def _session_payload(intent: Dict[str, Any], metadata: Dict[str, Any]) -> Dict[str, Any]:
|
| 395 |
+
missing = metadata.get("missing_fields") or []
|
| 396 |
+
if not isinstance(missing, list):
|
| 397 |
+
missing = list(missing)
|
| 398 |
+
return {
|
| 399 |
+
"status": metadata.get("status"),
|
| 400 |
+
"event": metadata.get("event"),
|
| 401 |
+
"intent": intent,
|
| 402 |
+
"missingFields": missing,
|
| 403 |
+
"nextField": metadata.get("next_field"),
|
| 404 |
+
"pendingQuestion": metadata.get("pending_question"),
|
| 405 |
+
"choices": metadata.get("choices"),
|
| 406 |
+
"errorMessage": metadata.get("error"),
|
| 407 |
+
"historyCursor": metadata.get("history_cursor") or 0,
|
| 408 |
+
}
|
src/agents/swap/tools.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import time
|
| 6 |
from contextlib import contextmanager
|
| 7 |
from contextvars import ContextVar
|
|
@@ -19,6 +20,7 @@ from src.agents.swap.storage import SwapStateRepository
|
|
| 19 |
|
| 20 |
# ---------- Helpers ----------
|
| 21 |
_STORE = SwapStateRepository.instance()
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def _format_decimal(value: Decimal) -> str:
|
|
@@ -325,6 +327,15 @@ def _store_swap_metadata(
|
|
| 325 |
if history:
|
| 326 |
meta["history"] = history
|
| 327 |
metadata.set_swap_agent(meta, intent.user_id, intent.conversation_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
return meta
|
| 329 |
|
| 330 |
|
|
@@ -407,6 +418,19 @@ def update_swap_intent_tool(
|
|
| 407 |
intent.conversation_id = resolved_conversation
|
| 408 |
|
| 409 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
if from_network is not None:
|
| 411 |
canonical_from = _validate_network(from_network)
|
| 412 |
if canonical_from != intent.from_network:
|
|
@@ -456,6 +480,13 @@ def update_swap_intent_tool(
|
|
| 456 |
except ValueError as exc:
|
| 457 |
message = str(exc)
|
| 458 |
lowered = message.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
if "network" in lowered:
|
| 460 |
return _response(
|
| 461 |
intent,
|
|
@@ -477,6 +508,13 @@ def update_swap_intent_tool(
|
|
| 477 |
error=message,
|
| 478 |
)
|
| 479 |
return _response(intent, "Please correct the input.", error=message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
if intent.from_network is None:
|
| 482 |
return _response(
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import logging
|
| 6 |
import time
|
| 7 |
from contextlib import contextmanager
|
| 8 |
from contextvars import ContextVar
|
|
|
|
| 20 |
|
| 21 |
# ---------- Helpers ----------
|
| 22 |
_STORE = SwapStateRepository.instance()
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
|
| 26 |
def _format_decimal(value: Decimal) -> str:
|
|
|
|
| 327 |
if history:
|
| 328 |
meta["history"] = history
|
| 329 |
metadata.set_swap_agent(meta, intent.user_id, intent.conversation_id)
|
| 330 |
+
if logger.isEnabledFor(logging.DEBUG):
|
| 331 |
+
logger.debug(
|
| 332 |
+
"Swap metadata stored for user=%s conversation=%s done=%s error=%s meta=%s",
|
| 333 |
+
intent.user_id,
|
| 334 |
+
intent.conversation_id,
|
| 335 |
+
done,
|
| 336 |
+
error,
|
| 337 |
+
meta,
|
| 338 |
+
)
|
| 339 |
return meta
|
| 340 |
|
| 341 |
|
|
|
|
| 418 |
intent.conversation_id = resolved_conversation
|
| 419 |
|
| 420 |
try:
|
| 421 |
+
if logger.isEnabledFor(logging.DEBUG):
|
| 422 |
+
logger.debug(
|
| 423 |
+
"update_swap_intent_tool input user=%s conversation=%s from_network=%s "
|
| 424 |
+
"from_token=%s to_network=%s to_token=%s amount=%s",
|
| 425 |
+
user_id,
|
| 426 |
+
conversation_id,
|
| 427 |
+
from_network,
|
| 428 |
+
from_token,
|
| 429 |
+
to_network,
|
| 430 |
+
to_token,
|
| 431 |
+
amount,
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
if from_network is not None:
|
| 435 |
canonical_from = _validate_network(from_network)
|
| 436 |
if canonical_from != intent.from_network:
|
|
|
|
| 480 |
except ValueError as exc:
|
| 481 |
message = str(exc)
|
| 482 |
lowered = message.lower()
|
| 483 |
+
if logger.isEnabledFor(logging.INFO):
|
| 484 |
+
logger.info(
|
| 485 |
+
"Swap intent validation issue for user=%s conversation=%s: %s",
|
| 486 |
+
intent.user_id,
|
| 487 |
+
intent.conversation_id,
|
| 488 |
+
message,
|
| 489 |
+
)
|
| 490 |
if "network" in lowered:
|
| 491 |
return _response(
|
| 492 |
intent,
|
|
|
|
| 508 |
error=message,
|
| 509 |
)
|
| 510 |
return _response(intent, "Please correct the input.", error=message)
|
| 511 |
+
except Exception as exc:
|
| 512 |
+
logger.exception(
|
| 513 |
+
"Unexpected error updating swap intent for user=%s conversation=%s",
|
| 514 |
+
intent.user_id,
|
| 515 |
+
intent.conversation_id,
|
| 516 |
+
)
|
| 517 |
+
return _response(intent, "Please try again with the swap details.", error=str(exc))
|
| 518 |
|
| 519 |
if intent.from_network is None:
|
| 520 |
return _response(
|
src/app.py
CHANGED
|
@@ -33,6 +33,7 @@ app.add_middleware(
|
|
| 33 |
|
| 34 |
# Instantiate Supervisor agent (singleton LLM)
|
| 35 |
supervisor = Supervisor(Config.get_llm())
|
|
|
|
| 36 |
|
| 37 |
class ChatRequest(BaseModel):
|
| 38 |
message: ChatMessage
|
|
@@ -56,6 +57,7 @@ AVAILABLE_AGENTS = [
|
|
| 56 |
{"name": "base", "human_readable_name": "Base Transaction Manager", "description": "Handle transactions on Base network."},
|
| 57 |
{"name": "mor rewards", "human_readable_name": "MOR Rewards Tracker", "description": "Track MOR rewards and balances."},
|
| 58 |
{"name": "mor claims", "human_readable_name": "MOR Claims Agent", "description": "Claim MOR tokens."},
|
|
|
|
| 59 |
]
|
| 60 |
|
| 61 |
# Default to a small, reasonable subset
|
|
@@ -73,6 +75,7 @@ AGENT_COMMANDS = [
|
|
| 73 |
{"command": "dca", "name": "DCA Strategy Manager", "description": "Plan a dollar-cost averaging strategy."},
|
| 74 |
{"command": "base", "name": "Base Transaction Manager", "description": "Send tokens and swap on Base."},
|
| 75 |
{"command": "rewards", "name": "MOR Rewards Tracker", "description": "Check rewards balance and accrual."},
|
|
|
|
| 76 |
]
|
| 77 |
|
| 78 |
# Agents endpoints expected by the frontend
|
|
@@ -110,11 +113,27 @@ def _map_agent_type(agent_name: str) -> str:
|
|
| 110 |
"database_agent": "analysis",
|
| 111 |
"search_agent": "realtime search",
|
| 112 |
"swap_agent": "token swap",
|
|
|
|
| 113 |
"supervisor": "supervisor",
|
| 114 |
}
|
| 115 |
return mapping.get(agent_name, "supervisor")
|
| 116 |
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
def _resolve_identity(request: ChatRequest) -> tuple[str, str]:
|
| 119 |
"""Ensure each request has a stable user and conversation identifier."""
|
| 120 |
|
|
@@ -151,9 +170,36 @@ def get_conversations(request: Request):
|
|
| 151 |
|
| 152 |
@app.post("/chat")
|
| 153 |
def chat(request: ChatRequest):
|
| 154 |
-
|
|
|
|
| 155 |
try:
|
|
|
|
| 156 |
user_id, conversation_id = _resolve_identity(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
# Add the user message to the conversation
|
| 159 |
chat_manager_instance.add_message(
|
|
@@ -174,12 +220,16 @@ def chat(request: ChatRequest):
|
|
| 174 |
conversation_id=conversation_id,
|
| 175 |
user_id=user_id,
|
| 176 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
# Add the agent's response to the conversation
|
| 179 |
if result and isinstance(result, dict):
|
| 180 |
-
print("result: ", result)
|
| 181 |
agent_name = result.get("agent", "supervisor")
|
| 182 |
-
print("agent_name: ", agent_name)
|
| 183 |
agent_name = _map_agent_type(agent_name)
|
| 184 |
|
| 185 |
# Build response metadata and enrich with coin info for crypto price queries
|
|
@@ -196,7 +246,19 @@ def chat(request: ChatRequest):
|
|
| 196 |
if swap_meta:
|
| 197 |
response_metadata.update(swap_meta)
|
| 198 |
swap_meta_snapshot = swap_meta
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
# Create a ChatMessage from the supervisor response
|
| 202 |
response_message = ChatMessage(
|
|
@@ -207,8 +269,8 @@ def chat(request: ChatRequest):
|
|
| 207 |
metadata=result.get("metadata", {}),
|
| 208 |
conversation_id=conversation_id,
|
| 209 |
user_id=user_id,
|
| 210 |
-
requires_action=True if agent_name
|
| 211 |
-
action_type="swap" if agent_name == "token swap" else None
|
| 212 |
)
|
| 213 |
|
| 214 |
# Add the response message to the conversation
|
|
@@ -248,10 +310,27 @@ def chat(request: ChatRequest):
|
|
| 248 |
user_id=user_id,
|
| 249 |
conversation_id=conversation_id,
|
| 250 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
return response_payload
|
| 252 |
|
| 253 |
return {"response": "No response available", "agent": "supervisor"}
|
| 254 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
raise HTTPException(status_code=500, detail=str(e))
|
| 256 |
|
| 257 |
# Include chat manager router
|
|
|
|
| 33 |
|
| 34 |
# Instantiate Supervisor agent (singleton LLM)
|
| 35 |
supervisor = Supervisor(Config.get_llm())
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
|
| 38 |
class ChatRequest(BaseModel):
|
| 39 |
message: ChatMessage
|
|
|
|
| 57 |
{"name": "base", "human_readable_name": "Base Transaction Manager", "description": "Handle transactions on Base network."},
|
| 58 |
{"name": "mor rewards", "human_readable_name": "MOR Rewards Tracker", "description": "Track MOR rewards and balances."},
|
| 59 |
{"name": "mor claims", "human_readable_name": "MOR Claims Agent", "description": "Claim MOR tokens."},
|
| 60 |
+
{"name": "lending", "human_readable_name": "Lending Agent", "description": "Supply, borrow, repay, or withdraw assets."},
|
| 61 |
]
|
| 62 |
|
| 63 |
# Default to a small, reasonable subset
|
|
|
|
| 75 |
{"command": "dca", "name": "DCA Strategy Manager", "description": "Plan a dollar-cost averaging strategy."},
|
| 76 |
{"command": "base", "name": "Base Transaction Manager", "description": "Send tokens and swap on Base."},
|
| 77 |
{"command": "rewards", "name": "MOR Rewards Tracker", "description": "Check rewards balance and accrual."},
|
| 78 |
+
{"command": "lending", "name": "Lending Agent", "description": "Supply, borrow, repay, or withdraw assets."},
|
| 79 |
]
|
| 80 |
|
| 81 |
# Agents endpoints expected by the frontend
|
|
|
|
| 113 |
"database_agent": "analysis",
|
| 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")
|
| 120 |
|
| 121 |
|
| 122 |
+
def _sanitize_user_message_content(content: str | None) -> str | None:
|
| 123 |
+
"""Strip wrapper prompts (e.g., 'User Message: ...') the frontend might send."""
|
| 124 |
+
if not content:
|
| 125 |
+
return content
|
| 126 |
+
text = content.strip()
|
| 127 |
+
marker = "user message:"
|
| 128 |
+
lowered = text.lower()
|
| 129 |
+
idx = lowered.rfind(marker)
|
| 130 |
+
if idx != -1:
|
| 131 |
+
candidate = text[idx + len(marker):].strip()
|
| 132 |
+
if candidate:
|
| 133 |
+
return candidate
|
| 134 |
+
return text
|
| 135 |
+
|
| 136 |
+
|
| 137 |
def _resolve_identity(request: ChatRequest) -> tuple[str, str]:
|
| 138 |
"""Ensure each request has a stable user and conversation identifier."""
|
| 139 |
|
|
|
|
| 170 |
|
| 171 |
@app.post("/chat")
|
| 172 |
def chat(request: ChatRequest):
|
| 173 |
+
user_id: str | None = None
|
| 174 |
+
conversation_id: str | None = None
|
| 175 |
try:
|
| 176 |
+
logger.debug("Received chat payload: %s", request.model_dump())
|
| 177 |
user_id, conversation_id = _resolve_identity(request)
|
| 178 |
+
logger.debug(
|
| 179 |
+
"Resolved chat identity user=%s conversation=%s wallet=%s",
|
| 180 |
+
user_id,
|
| 181 |
+
conversation_id,
|
| 182 |
+
(request.wallet_address or "").strip() if request.wallet_address else None,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
wallet = request.wallet_address.strip() if request.wallet_address else None
|
| 186 |
+
if wallet and wallet.lower() == "default":
|
| 187 |
+
wallet = None
|
| 188 |
+
display_name = None
|
| 189 |
+
if isinstance(request.message.metadata, dict):
|
| 190 |
+
display_name = request.message.metadata.get("display_name")
|
| 191 |
+
|
| 192 |
+
chat_manager_instance.ensure_session(
|
| 193 |
+
user_id,
|
| 194 |
+
conversation_id,
|
| 195 |
+
wallet_address=wallet,
|
| 196 |
+
display_name=display_name,
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
if request.message.role == "user":
|
| 200 |
+
clean_content = _sanitize_user_message_content(request.message.content)
|
| 201 |
+
if clean_content is not None:
|
| 202 |
+
request.message.content = clean_content
|
| 203 |
|
| 204 |
# Add the user message to the conversation
|
| 205 |
chat_manager_instance.add_message(
|
|
|
|
| 220 |
conversation_id=conversation_id,
|
| 221 |
user_id=user_id,
|
| 222 |
)
|
| 223 |
+
logger.debug(
|
| 224 |
+
"Supervisor returned result for user=%s conversation=%s: %s",
|
| 225 |
+
user_id,
|
| 226 |
+
conversation_id,
|
| 227 |
+
result,
|
| 228 |
+
)
|
| 229 |
|
| 230 |
# Add the agent's response to the conversation
|
| 231 |
if result and isinstance(result, dict):
|
|
|
|
| 232 |
agent_name = result.get("agent", "supervisor")
|
|
|
|
| 233 |
agent_name = _map_agent_type(agent_name)
|
| 234 |
|
| 235 |
# Build response metadata and enrich with coin info for crypto price queries
|
|
|
|
| 246 |
if swap_meta:
|
| 247 |
response_metadata.update(swap_meta)
|
| 248 |
swap_meta_snapshot = swap_meta
|
| 249 |
+
elif agent_name == "lending":
|
| 250 |
+
lending_meta = metadata.get_lending_agent(
|
| 251 |
+
user_id=user_id,
|
| 252 |
+
conversation_id=conversation_id,
|
| 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,
|
| 259 |
+
conversation_id,
|
| 260 |
+
response_metadata,
|
| 261 |
+
)
|
| 262 |
|
| 263 |
# Create a ChatMessage from the supervisor response
|
| 264 |
response_message = ChatMessage(
|
|
|
|
| 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
|
|
|
|
| 310 |
user_id=user_id,
|
| 311 |
conversation_id=conversation_id,
|
| 312 |
)
|
| 313 |
+
if agent_name == "lending":
|
| 314 |
+
should_clear = False
|
| 315 |
+
if response_meta:
|
| 316 |
+
status = response_meta.get("status") if isinstance(response_meta, dict) else None
|
| 317 |
+
event = response_meta.get("event") if isinstance(response_meta, dict) else None
|
| 318 |
+
should_clear = status == "ready" or event == "lending_intent_ready"
|
| 319 |
+
if should_clear:
|
| 320 |
+
metadata.set_lending_agent(
|
| 321 |
+
{},
|
| 322 |
+
user_id=user_id,
|
| 323 |
+
conversation_id=conversation_id,
|
| 324 |
+
)
|
| 325 |
return response_payload
|
| 326 |
|
| 327 |
return {"response": "No response available", "agent": "supervisor"}
|
| 328 |
except Exception as e:
|
| 329 |
+
logger.exception(
|
| 330 |
+
"Chat handler failed for user=%s conversation=%s",
|
| 331 |
+
user_id,
|
| 332 |
+
conversation_id,
|
| 333 |
+
)
|
| 334 |
raise HTTPException(status_code=500, detail=str(e))
|
| 335 |
|
| 336 |
# Include chat manager router
|
src/integrations/panorama_gateway/client.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from dataclasses import asdict
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Any, Dict, Iterable, Optional
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
import jwt
|
| 12 |
+
|
| 13 |
+
from .config import PanoramaGatewaySettings, get_panorama_settings
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PanoramaGatewayError(RuntimeError):
|
| 19 |
+
"""Raised when the Panorama gateway returns an error response."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, message: str, status_code: int, payload: Any | None = None) -> None:
|
| 22 |
+
super().__init__(message)
|
| 23 |
+
self.status_code = status_code
|
| 24 |
+
self.payload = payload
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _encode_identifier(identifier: Any) -> str:
|
| 28 |
+
"""Coerce identifiers into the colon-delimited format expected by the gateway."""
|
| 29 |
+
|
| 30 |
+
if isinstance(identifier, str):
|
| 31 |
+
return identifier
|
| 32 |
+
if isinstance(identifier, Iterable):
|
| 33 |
+
parts: Iterable[str] = (str(part) for part in identifier)
|
| 34 |
+
return ":".join(parts)
|
| 35 |
+
if isinstance(identifier, dict):
|
| 36 |
+
return ":".join(str(value) for value in identifier.values())
|
| 37 |
+
raise ValueError(f"Unsupported identifier type: {type(identifier)}")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class PanoramaGatewayClient:
|
| 41 |
+
"""HTTP client wrapper for Panorama's data gateway."""
|
| 42 |
+
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
settings: PanoramaGatewaySettings | None = None,
|
| 46 |
+
*,
|
| 47 |
+
client: httpx.Client | None = None,
|
| 48 |
+
) -> None:
|
| 49 |
+
self._settings = settings or get_panorama_settings()
|
| 50 |
+
self._client = client or httpx.Client(
|
| 51 |
+
base_url=self._settings.base_url,
|
| 52 |
+
timeout=self._settings.request_timeout,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def __enter__(self) -> "PanoramaGatewayClient":
|
| 56 |
+
return self
|
| 57 |
+
|
| 58 |
+
def __exit__(self, exc_type, exc, tb) -> None:
|
| 59 |
+
self.close()
|
| 60 |
+
|
| 61 |
+
def close(self) -> None:
|
| 62 |
+
self._client.close()
|
| 63 |
+
|
| 64 |
+
# ---- low-level helpers -------------------------------------------------
|
| 65 |
+
def _build_token(self) -> str:
|
| 66 |
+
now = int(time.time())
|
| 67 |
+
payload: Dict[str, Any] = {
|
| 68 |
+
"iat": now,
|
| 69 |
+
"exp": now + 300,
|
| 70 |
+
"service": self._settings.service_name,
|
| 71 |
+
"roles": self._settings.roles,
|
| 72 |
+
"tenant": self._settings.tenant_id,
|
| 73 |
+
}
|
| 74 |
+
if self._settings.jwt_audience:
|
| 75 |
+
payload["aud"] = self._settings.jwt_audience
|
| 76 |
+
if self._settings.jwt_issuer:
|
| 77 |
+
payload["iss"] = self._settings.jwt_issuer
|
| 78 |
+
|
| 79 |
+
return jwt.encode(payload, self._settings.jwt_secret, algorithm="HS256")
|
| 80 |
+
|
| 81 |
+
def _default_headers(self) -> Dict[str, str]:
|
| 82 |
+
return {
|
| 83 |
+
"Authorization": f"Bearer {self._build_token()}",
|
| 84 |
+
"x-tenant-id": self._settings.tenant_id,
|
| 85 |
+
"Accept": "application/json",
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
@staticmethod
|
| 89 |
+
def _truncate_payload(payload: Any, limit: int = 512) -> Any:
|
| 90 |
+
if payload is None:
|
| 91 |
+
return None
|
| 92 |
+
try:
|
| 93 |
+
text = json.dumps(payload)
|
| 94 |
+
except (TypeError, ValueError):
|
| 95 |
+
text = str(payload)
|
| 96 |
+
if len(text) <= limit:
|
| 97 |
+
return payload
|
| 98 |
+
return text[:limit] + "...<truncated>"
|
| 99 |
+
|
| 100 |
+
def _request(
|
| 101 |
+
self,
|
| 102 |
+
method: str,
|
| 103 |
+
path: str,
|
| 104 |
+
*,
|
| 105 |
+
params: Dict[str, Any] | None = None,
|
| 106 |
+
json_body: Any | None = None,
|
| 107 |
+
idempotency_key: str | None = None,
|
| 108 |
+
) -> Any:
|
| 109 |
+
headers = self._default_headers()
|
| 110 |
+
if method.upper() in {"POST", "PATCH", "PUT", "DELETE"}:
|
| 111 |
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
| 112 |
+
|
| 113 |
+
if logger.isEnabledFor(logging.DEBUG):
|
| 114 |
+
logger.debug(
|
| 115 |
+
"Panorama %s %s params=%s body=%s",
|
| 116 |
+
method,
|
| 117 |
+
path,
|
| 118 |
+
params,
|
| 119 |
+
self._truncate_payload(json_body),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
response = self._client.request(
|
| 123 |
+
method=method,
|
| 124 |
+
url=path,
|
| 125 |
+
headers=headers,
|
| 126 |
+
params=params,
|
| 127 |
+
json=json_body,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
if response.status_code >= 400:
|
| 131 |
+
message = f"Gateway request failed ({response.status_code})"
|
| 132 |
+
try:
|
| 133 |
+
payload = response.json()
|
| 134 |
+
except ValueError:
|
| 135 |
+
payload = response.text
|
| 136 |
+
logger.warning(
|
| 137 |
+
"Panorama error %s %s status=%s payload=%s",
|
| 138 |
+
method,
|
| 139 |
+
path,
|
| 140 |
+
response.status_code,
|
| 141 |
+
payload,
|
| 142 |
+
)
|
| 143 |
+
raise PanoramaGatewayError(message, response.status_code, payload)
|
| 144 |
+
|
| 145 |
+
if response.status_code == 204:
|
| 146 |
+
if logger.isEnabledFor(logging.DEBUG):
|
| 147 |
+
logger.debug("Panorama %s %s status=204 no-content", method, path)
|
| 148 |
+
return None
|
| 149 |
+
|
| 150 |
+
if response.headers.get("content-type", "").startswith("application/json"):
|
| 151 |
+
body = response.json()
|
| 152 |
+
if logger.isEnabledFor(logging.DEBUG):
|
| 153 |
+
logger.debug(
|
| 154 |
+
"Panorama %s %s status=%s body=%s",
|
| 155 |
+
method,
|
| 156 |
+
path,
|
| 157 |
+
response.status_code,
|
| 158 |
+
self._truncate_payload(body),
|
| 159 |
+
)
|
| 160 |
+
return body
|
| 161 |
+
|
| 162 |
+
text = response.text
|
| 163 |
+
if logger.isEnabledFor(logging.DEBUG):
|
| 164 |
+
logger.debug(
|
| 165 |
+
"Panorama %s %s status=%s body=%s",
|
| 166 |
+
method,
|
| 167 |
+
path,
|
| 168 |
+
response.status_code,
|
| 169 |
+
text[:512] + ("...<truncated>" if len(text) > 512 else ""),
|
| 170 |
+
)
|
| 171 |
+
return text
|
| 172 |
+
|
| 173 |
+
# ---- CRUD facades ------------------------------------------------------
|
| 174 |
+
def list(self, entity: str, query: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
| 175 |
+
params = None
|
| 176 |
+
if query:
|
| 177 |
+
params = {}
|
| 178 |
+
for key, value in query.items():
|
| 179 |
+
if isinstance(value, (dict, list)):
|
| 180 |
+
params[key] = json.dumps(value)
|
| 181 |
+
else:
|
| 182 |
+
params[key] = value
|
| 183 |
+
return self._request("GET", f"/v1/{entity}", params=params)
|
| 184 |
+
|
| 185 |
+
def get(self, entity: str, identifier: Any) -> Any:
|
| 186 |
+
encoded_id = _encode_identifier(identifier)
|
| 187 |
+
return self._request("GET", f"/v1/{entity}/{encoded_id}")
|
| 188 |
+
|
| 189 |
+
def create(
|
| 190 |
+
self,
|
| 191 |
+
entity: str,
|
| 192 |
+
payload: Dict[str, Any],
|
| 193 |
+
*,
|
| 194 |
+
idempotency_key: str | None = None,
|
| 195 |
+
) -> Any:
|
| 196 |
+
return self._request(
|
| 197 |
+
"POST",
|
| 198 |
+
f"/v1/{entity}",
|
| 199 |
+
json_body=payload,
|
| 200 |
+
idempotency_key=idempotency_key,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
def update(
|
| 204 |
+
self,
|
| 205 |
+
entity: str,
|
| 206 |
+
identifier: Any,
|
| 207 |
+
payload: Dict[str, Any],
|
| 208 |
+
*,
|
| 209 |
+
idempotency_key: str | None = None,
|
| 210 |
+
) -> Any:
|
| 211 |
+
encoded_id = _encode_identifier(identifier)
|
| 212 |
+
return self._request(
|
| 213 |
+
"PATCH",
|
| 214 |
+
f"/v1/{entity}/{encoded_id}",
|
| 215 |
+
json_body=payload,
|
| 216 |
+
idempotency_key=idempotency_key,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
def delete(
|
| 220 |
+
self,
|
| 221 |
+
entity: str,
|
| 222 |
+
identifier: Any,
|
| 223 |
+
*,
|
| 224 |
+
idempotency_key: str | None = None,
|
| 225 |
+
) -> None:
|
| 226 |
+
encoded_id = _encode_identifier(identifier)
|
| 227 |
+
self._request(
|
| 228 |
+
"DELETE",
|
| 229 |
+
f"/v1/{entity}/{encoded_id}",
|
| 230 |
+
idempotency_key=idempotency_key,
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
def transact(
|
| 234 |
+
self,
|
| 235 |
+
operations: Iterable[Dict[str, Any]],
|
| 236 |
+
*,
|
| 237 |
+
idempotency_key: str | None = None,
|
| 238 |
+
) -> Any:
|
| 239 |
+
payload = {"ops": list(operations)}
|
| 240 |
+
return self._request(
|
| 241 |
+
"POST",
|
| 242 |
+
"/v1/_transact",
|
| 243 |
+
json_body=payload,
|
| 244 |
+
idempotency_key=idempotency_key,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 248 |
+
"""Return a serialisable snapshot of the current settings (useful for debugging)."""
|
| 249 |
+
|
| 250 |
+
return asdict(self._settings)
|
src/integrations/panorama_gateway/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _split_roles(raw: str | None) -> List[str]:
|
| 10 |
+
if not raw:
|
| 11 |
+
return ["agent"]
|
| 12 |
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(slots=True)
|
| 16 |
+
class PanoramaGatewaySettings:
|
| 17 |
+
"""Runtime configuration for the Panorama data gateway client."""
|
| 18 |
+
|
| 19 |
+
base_url: str
|
| 20 |
+
jwt_secret: str
|
| 21 |
+
tenant_id: str = "tenant-agent"
|
| 22 |
+
service_name: str = "zico-agent"
|
| 23 |
+
roles: List[str] = field(default_factory=lambda: ["agent"])
|
| 24 |
+
jwt_audience: Optional[str] = None
|
| 25 |
+
jwt_issuer: Optional[str] = None
|
| 26 |
+
request_timeout: float = 10.0
|
| 27 |
+
|
| 28 |
+
@classmethod
|
| 29 |
+
def load(cls) -> "PanoramaGatewaySettings":
|
| 30 |
+
base_url = os.getenv("PANORAMA_GATEWAY_URL")
|
| 31 |
+
if not base_url:
|
| 32 |
+
raise ValueError("PANORAMA_GATEWAY_URL environment variable is required.")
|
| 33 |
+
|
| 34 |
+
secret = os.getenv("PANORAMA_GATEWAY_JWT_SECRET") or os.getenv("JWT_SECRET")
|
| 35 |
+
if not secret:
|
| 36 |
+
raise ValueError(
|
| 37 |
+
"Set PANORAMA_GATEWAY_JWT_SECRET or reuse JWT_SECRET for Panorama gateway auth."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
return cls(
|
| 41 |
+
base_url=base_url.rstrip("/"),
|
| 42 |
+
jwt_secret=secret,
|
| 43 |
+
tenant_id=os.getenv("PANORAMA_GATEWAY_TENANT", "tenant-agent"),
|
| 44 |
+
service_name=os.getenv("PANORAMA_GATEWAY_SERVICE", "zico-agent"),
|
| 45 |
+
roles=_split_roles(os.getenv("PANORAMA_GATEWAY_ROLES", "agent")),
|
| 46 |
+
jwt_audience=os.getenv("PANORAMA_GATEWAY_JWT_AUDIENCE"),
|
| 47 |
+
jwt_issuer=os.getenv("PANORAMA_GATEWAY_JWT_ISSUER"),
|
| 48 |
+
request_timeout=float(os.getenv("PANORAMA_GATEWAY_TIMEOUT", "10")),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@lru_cache(maxsize=1)
|
| 53 |
+
def get_panorama_settings() -> PanoramaGatewaySettings:
|
| 54 |
+
"""Memoized accessor so callers share a single settings instance."""
|
| 55 |
+
|
| 56 |
+
return PanoramaGatewaySettings.load()
|
src/service/chat_manager.py
CHANGED
|
@@ -1,125 +1,86 @@
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
-
import
|
| 3 |
from typing import Dict, List, Optional
|
| 4 |
-
|
| 5 |
-
from
|
|
|
|
| 6 |
|
| 7 |
logger = logging.getLogger(__name__)
|
| 8 |
|
| 9 |
|
| 10 |
class ChatManager:
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
self.
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
is_active=True
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
def _get_conversation_id(self, conversation_id: Optional[str] = None) -> str:
|
| 63 |
-
"""Helper method to get conversation ID, defaulting to 'default' if None provided"""
|
| 64 |
-
return conversation_id or "default"
|
| 65 |
-
|
| 66 |
-
def _get_user_id(self, user_id: Optional[str] = None) -> str:
|
| 67 |
-
"""Helper method to get user ID, defaulting to 'anonymous' if None provided"""
|
| 68 |
-
return user_id or "anonymous"
|
| 69 |
-
|
| 70 |
-
def _initialize_user(self, user_id: str) -> None:
|
| 71 |
-
"""Initialize conversations dictionary for a new user"""
|
| 72 |
-
if user_id not in self.user_conversations:
|
| 73 |
-
self.user_conversations[user_id] = {}
|
| 74 |
-
logger.info(f"Initialized conversations for user {user_id}")
|
| 75 |
-
|
| 76 |
-
def get_messages(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None) -> List[Dict[str, str]]:
|
| 77 |
-
"""
|
| 78 |
-
Get all messages for a specific conversation.
|
| 79 |
-
|
| 80 |
-
Args:
|
| 81 |
-
conversation_id (str, optional): Unique identifier for the conversation. Defaults to "default"
|
| 82 |
-
user_id (str, optional): User identifier. Defaults to "anonymous"
|
| 83 |
-
|
| 84 |
-
Returns:
|
| 85 |
-
List[Dict[str, str]]: List of messages as dictionaries
|
| 86 |
-
"""
|
| 87 |
-
user_id = self._get_user_id(user_id)
|
| 88 |
-
conversation = self._get_or_create_conversation(self._get_conversation_id(conversation_id), user_id)
|
| 89 |
-
return [msg.dict() for msg in conversation.messages]
|
| 90 |
-
|
| 91 |
-
def add_message(self, message: Dict[str, str], conversation_id: Optional[str] = None, user_id: Optional[str] = None):
|
| 92 |
-
"""
|
| 93 |
-
Add a new message to a conversation.
|
| 94 |
-
|
| 95 |
-
Args:
|
| 96 |
-
message (Dict[str, str]): Message to add
|
| 97 |
-
conversation_id (str, optional): Conversation to add message to. Defaults to "default"
|
| 98 |
-
user_id (str, optional): User identifier. Defaults to "anonymous"
|
| 99 |
-
"""
|
| 100 |
-
user_id = self._get_user_id(user_id)
|
| 101 |
-
conversation_id = self._get_conversation_id(conversation_id)
|
| 102 |
-
conversation = self._get_or_create_conversation(conversation_id, user_id)
|
| 103 |
chat_message = ChatMessage(**message)
|
|
|
|
|
|
|
| 104 |
chat_message.conversation_id = conversation_id
|
| 105 |
chat_message.user_id = user_id
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
| 121 |
agent_response = AgentResponse(**response)
|
| 122 |
-
# You may need to implement to_chat_message if not present
|
| 123 |
chat_message = ChatMessage(
|
| 124 |
role="assistant",
|
| 125 |
content=agent_response.content,
|
|
@@ -131,152 +92,82 @@ class ChatManager:
|
|
| 131 |
next_agent=agent_response.next_agent,
|
| 132 |
requires_followup=agent_response.requires_followup,
|
| 133 |
status="completed" if agent_response.success else "failed",
|
| 134 |
-
error_message=agent_response.error_message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
)
|
| 136 |
-
self.add_message(chat_message.dict(), self._get_conversation_id(conversation_id), self._get_user_id(user_id))
|
| 137 |
-
logger.info(f"Added response from agent {agent_name} to conversation {conversation_id} for user {user_id}")
|
| 138 |
-
|
| 139 |
-
def clear_messages(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None):
|
| 140 |
-
"""
|
| 141 |
-
Clear all messages in a conversation except the default message.
|
| 142 |
-
|
| 143 |
-
Args:
|
| 144 |
-
conversation_id (str, optional): Conversation to clear. Defaults to "default"
|
| 145 |
-
user_id (str, optional): User identifier. Defaults to "anonymous"
|
| 146 |
-
"""
|
| 147 |
-
user_id = self._get_user_id(user_id)
|
| 148 |
-
conversation = self._get_or_create_conversation(self._get_conversation_id(conversation_id), user_id)
|
| 149 |
-
conversation.messages = [self.default_message] # Keep the initial message
|
| 150 |
-
logger.info(f"Cleared message history for conversation {conversation_id} for user {user_id}")
|
| 151 |
-
|
| 152 |
-
def get_last_message(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None) -> Dict[str, str]:
|
| 153 |
-
"""
|
| 154 |
-
Get the most recent message from a conversation.
|
| 155 |
-
|
| 156 |
-
Args:
|
| 157 |
-
conversation_id (str, optional): Conversation to get message from. Defaults to "default"
|
| 158 |
-
user_id (str, optional): User identifier. Defaults to "anonymous"
|
| 159 |
-
|
| 160 |
-
Returns:
|
| 161 |
-
Dict[str, str]: Last message or empty dict if no messages
|
| 162 |
-
"""
|
| 163 |
-
user_id = self._get_user_id(user_id)
|
| 164 |
-
conversation = self._get_or_create_conversation(self._get_conversation_id(conversation_id), user_id)
|
| 165 |
-
return conversation.messages[-1].dict() if conversation.messages else {}
|
| 166 |
-
|
| 167 |
-
def get_chat_history(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None) -> str:
|
| 168 |
-
"""
|
| 169 |
-
Get formatted chat history for a conversation.
|
| 170 |
-
|
| 171 |
-
Args:
|
| 172 |
-
conversation_id (str, optional): Conversation to get history for. Defaults to "default"
|
| 173 |
-
user_id (str, optional): User identifier. Defaults to "anonymous"
|
| 174 |
-
|
| 175 |
-
Returns:
|
| 176 |
-
str: Formatted chat history as string
|
| 177 |
-
"""
|
| 178 |
-
user_id = self._get_user_id(user_id)
|
| 179 |
-
conversation = self._get_or_create_conversation(self._get_conversation_id(conversation_id), user_id)
|
| 180 |
-
return "\n".join([f"{msg.role}: {msg.content}" for msg in conversation.messages])
|
| 181 |
-
|
| 182 |
-
def get_all_conversation_ids(self, user_id: Optional[str] = None) -> List[str]:
|
| 183 |
-
"""
|
| 184 |
-
Get a list of all conversation IDs for a specific user.
|
| 185 |
-
|
| 186 |
-
Args:
|
| 187 |
-
user_id (str, optional): User identifier. Defaults to "anonymous"
|
| 188 |
-
|
| 189 |
-
Returns:
|
| 190 |
-
List[str]: List of conversation IDs for the user
|
| 191 |
-
"""
|
| 192 |
-
user_id = self._get_user_id(user_id)
|
| 193 |
-
self._initialize_user(user_id)
|
| 194 |
-
return list(self.user_conversations[user_id].keys())
|
| 195 |
-
|
| 196 |
-
def get_all_user_ids(self) -> List[str]:
|
| 197 |
-
"""
|
| 198 |
-
Get a list of all user IDs.
|
| 199 |
-
|
| 200 |
-
Returns:
|
| 201 |
-
List[str]: List of user IDs
|
| 202 |
-
"""
|
| 203 |
-
return list(self.user_conversations.keys())
|
| 204 |
-
|
| 205 |
-
def delete_conversation(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None):
|
| 206 |
-
"""
|
| 207 |
-
Delete a conversation by ID.
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
conversation_id = self.
|
| 215 |
-
self.
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
| 219 |
|
| 220 |
def create_conversation(self, user_id: Optional[str] = None) -> str:
|
| 221 |
-
""
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
str: ID of the created conversation
|
| 229 |
-
"""
|
| 230 |
-
user_id = self._get_user_id(user_id)
|
| 231 |
-
self._initialize_user(user_id)
|
| 232 |
-
conversation_id = f"conversation_{len(self.user_conversations[user_id])}"
|
| 233 |
-
while conversation_id in self.user_conversations[user_id]:
|
| 234 |
-
conversation_id = f"conversation_{len(self.user_conversations[user_id])}_{int(time.time())}"
|
| 235 |
-
self.user_conversations[user_id][conversation_id] = ConversationState(
|
| 236 |
-
conversation_id=conversation_id,
|
| 237 |
-
user_id=user_id,
|
| 238 |
-
messages=[self.default_message],
|
| 239 |
-
context={},
|
| 240 |
-
memory={},
|
| 241 |
-
agent_history=[],
|
| 242 |
-
current_agent=None,
|
| 243 |
-
last_message_id=None,
|
| 244 |
-
created_at=datetime.utcnow(),
|
| 245 |
-
updated_at=datetime.utcnow(),
|
| 246 |
-
is_active=True
|
| 247 |
)
|
| 248 |
-
logger.info(f"Created new conversation {conversation_id} for user {user_id}")
|
| 249 |
return conversation_id
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
Args:
|
| 256 |
-
conversation_id (str): Conversation ID to get/create
|
| 257 |
-
user_id (str): User identifier
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
)
|
| 277 |
-
logger.info(f"Created new conversation {conversation_id} for user {user_id}")
|
| 278 |
-
return self.user_conversations[user_id][conversation_id]
|
| 279 |
|
| 280 |
|
| 281 |
-
#
|
| 282 |
chat_manager_instance = ChatManager()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
import logging
|
| 4 |
+
import uuid
|
| 5 |
from typing import Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
from src.models.chatMessage import AgentResponse, ChatMessage
|
| 8 |
+
from src.service.panorama_store import PanoramaStore
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
|
| 13 |
class ChatManager:
|
| 14 |
+
"""Facade that delegates chat persistence to the Panorama data gateway."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, store: PanoramaStore | None = None) -> None:
|
| 17 |
+
self._store = store or PanoramaStore()
|
| 18 |
+
|
| 19 |
+
@staticmethod
|
| 20 |
+
def _resolve_ids(
|
| 21 |
+
conversation_id: Optional[str],
|
| 22 |
+
user_id: Optional[str],
|
| 23 |
+
) -> tuple[str, str]:
|
| 24 |
+
return (conversation_id or "default", user_id or "anonymous")
|
| 25 |
+
|
| 26 |
+
# ---- Message access ---------------------------------------------------
|
| 27 |
+
def get_messages(
|
| 28 |
+
self,
|
| 29 |
+
conversation_id: Optional[str] = None,
|
| 30 |
+
user_id: Optional[str] = None,
|
| 31 |
+
) -> List[Dict[str, str]]:
|
| 32 |
+
conversation_id, user_id = self._resolve_ids(conversation_id, user_id)
|
| 33 |
+
self._store.ensure_conversation(user_id, conversation_id)
|
| 34 |
+
messages = self._store.list_messages(user_id, conversation_id)
|
| 35 |
+
return messages
|
| 36 |
+
|
| 37 |
+
def get_last_message(
|
| 38 |
+
self,
|
| 39 |
+
conversation_id: Optional[str] = None,
|
| 40 |
+
user_id: Optional[str] = None,
|
| 41 |
+
) -> Dict[str, str]:
|
| 42 |
+
messages = self.get_messages(conversation_id, user_id)
|
| 43 |
+
return messages[-1] if messages else {}
|
| 44 |
+
|
| 45 |
+
def get_chat_history(
|
| 46 |
+
self,
|
| 47 |
+
conversation_id: Optional[str] = None,
|
| 48 |
+
user_id: Optional[str] = None,
|
| 49 |
+
) -> str:
|
| 50 |
+
messages = self.get_messages(conversation_id, user_id)
|
| 51 |
+
return "\n".join(f"{msg.get('role')}: {msg.get('content')}" for msg in messages)
|
| 52 |
+
|
| 53 |
+
# ---- Mutations --------------------------------------------------------
|
| 54 |
+
def add_message(
|
| 55 |
+
self,
|
| 56 |
+
message: Dict[str, str],
|
| 57 |
+
conversation_id: Optional[str] = None,
|
| 58 |
+
user_id: Optional[str] = None,
|
| 59 |
+
) -> Dict[str, str]:
|
| 60 |
+
conversation_id, user_id = self._resolve_ids(conversation_id, user_id)
|
| 61 |
+
self._store.ensure_user_and_conversation(user_id, conversation_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
chat_message = ChatMessage(**message)
|
| 63 |
+
if not chat_message.message_id:
|
| 64 |
+
chat_message.message_id = str(uuid.uuid4())
|
| 65 |
chat_message.conversation_id = conversation_id
|
| 66 |
chat_message.user_id = user_id
|
| 67 |
+
stored = self._store.add_message(user_id, conversation_id, chat_message)
|
| 68 |
+
logger.info(
|
| 69 |
+
"Persisted message for user=%s conversation=%s role=%s",
|
| 70 |
+
user_id,
|
| 71 |
+
conversation_id,
|
| 72 |
+
chat_message.role,
|
| 73 |
+
)
|
| 74 |
+
return stored
|
| 75 |
+
|
| 76 |
+
def add_response(
|
| 77 |
+
self,
|
| 78 |
+
response: Dict[str, str],
|
| 79 |
+
agent_name: str,
|
| 80 |
+
conversation_id: Optional[str] = None,
|
| 81 |
+
user_id: Optional[str] = None,
|
| 82 |
+
) -> Dict[str, str]:
|
| 83 |
agent_response = AgentResponse(**response)
|
|
|
|
| 84 |
chat_message = ChatMessage(
|
| 85 |
role="assistant",
|
| 86 |
content=agent_response.content,
|
|
|
|
| 92 |
next_agent=agent_response.next_agent,
|
| 93 |
requires_followup=agent_response.requires_followup,
|
| 94 |
status="completed" if agent_response.success else "failed",
|
| 95 |
+
error_message=agent_response.error_message,
|
| 96 |
+
)
|
| 97 |
+
stored = self.add_message(
|
| 98 |
+
chat_message.dict(),
|
| 99 |
+
conversation_id=conversation_id,
|
| 100 |
+
user_id=user_id,
|
| 101 |
+
)
|
| 102 |
+
logger.info(
|
| 103 |
+
"Persisted agent response from %s for user=%s conversation=%s",
|
| 104 |
+
agent_name,
|
| 105 |
+
user_id,
|
| 106 |
+
conversation_id,
|
| 107 |
+
)
|
| 108 |
+
return stored
|
| 109 |
+
|
| 110 |
+
def clear_messages(
|
| 111 |
+
self,
|
| 112 |
+
conversation_id: Optional[str] = None,
|
| 113 |
+
user_id: Optional[str] = None,
|
| 114 |
+
) -> None:
|
| 115 |
+
conversation_id, user_id = self._resolve_ids(conversation_id, user_id)
|
| 116 |
+
self._store.ensure_conversation(user_id, conversation_id)
|
| 117 |
+
self._store.reset_conversation(user_id, conversation_id)
|
| 118 |
+
logger.info(
|
| 119 |
+
"Cleared messages for user=%s conversation=%s",
|
| 120 |
+
user_id,
|
| 121 |
+
conversation_id,
|
| 122 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
+
def delete_conversation(
|
| 125 |
+
self,
|
| 126 |
+
conversation_id: Optional[str] = None,
|
| 127 |
+
user_id: Optional[str] = None,
|
| 128 |
+
) -> None:
|
| 129 |
+
conversation_id, user_id = self._resolve_ids(conversation_id, user_id)
|
| 130 |
+
self._store.delete_conversation(user_id, conversation_id)
|
| 131 |
+
logger.info(
|
| 132 |
+
"Deleted conversation for user=%s conversation=%s",
|
| 133 |
+
user_id,
|
| 134 |
+
conversation_id,
|
| 135 |
+
)
|
| 136 |
|
| 137 |
def create_conversation(self, user_id: Optional[str] = None) -> str:
|
| 138 |
+
user_id = (user_id or "anonymous")
|
| 139 |
+
conversation_id = f"conversation-{uuid.uuid4().hex[:8]}"
|
| 140 |
+
self._store.ensure_user_and_conversation(user_id, conversation_id)
|
| 141 |
+
logger.info(
|
| 142 |
+
"Created new conversation for user=%s conversation=%s",
|
| 143 |
+
user_id,
|
| 144 |
+
conversation_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
)
|
|
|
|
| 146 |
return conversation_id
|
| 147 |
|
| 148 |
+
# ---- Discovery helpers ------------------------------------------------
|
| 149 |
+
def get_all_conversation_ids(self, user_id: Optional[str] = None) -> List[str]:
|
| 150 |
+
user_id = (user_id or "anonymous")
|
| 151 |
+
return self._store.list_conversations(user_id)
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
+
def get_all_user_ids(self) -> List[str]:
|
| 154 |
+
return self._store.list_users()
|
| 155 |
+
|
| 156 |
+
def ensure_session(
|
| 157 |
+
self,
|
| 158 |
+
user_id: str,
|
| 159 |
+
conversation_id: str,
|
| 160 |
+
*,
|
| 161 |
+
wallet_address: Optional[str] = None,
|
| 162 |
+
display_name: Optional[str] = None,
|
| 163 |
+
) -> None:
|
| 164 |
+
self._store.ensure_user_and_conversation(
|
| 165 |
+
user_id,
|
| 166 |
+
conversation_id,
|
| 167 |
+
wallet_address=wallet_address,
|
| 168 |
+
display_name=display_name,
|
| 169 |
+
)
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
|
| 172 |
+
# Singleton-style accessor for the FastAPI routes
|
| 173 |
chat_manager_instance = ChatManager()
|
src/service/panorama_store.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
from src.models.chatMessage import ChatMessage
|
| 9 |
+
|
| 10 |
+
from src.integrations.panorama_gateway import (
|
| 11 |
+
PanoramaGatewayClient,
|
| 12 |
+
PanoramaGatewayError,
|
| 13 |
+
PanoramaGatewaySettings,
|
| 14 |
+
get_panorama_settings,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
DEFAULT_DISCLAIMER = (
|
| 19 |
+
"This highly experimental chatbot is not intended for making important decisions. "
|
| 20 |
+
"Its responses are generated using AI models and may not always be accurate. "
|
| 21 |
+
"By using this chatbot, you acknowledge that you use it at your own discretion "
|
| 22 |
+
"and assume all risks associated with its limitations and potential errors."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _utc_now_iso() -> str:
|
| 27 |
+
"""Return an RFC3339 timestamp with millisecond precision and Z suffix."""
|
| 28 |
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _normalize_datetime(value: Any | None) -> Optional[str]:
|
| 32 |
+
"""Convert datetime-like inputs to gateway-friendly RFC3339 strings."""
|
| 33 |
+
if value is None:
|
| 34 |
+
return None
|
| 35 |
+
if isinstance(value, datetime):
|
| 36 |
+
dt = value
|
| 37 |
+
elif isinstance(value, str):
|
| 38 |
+
if not value:
|
| 39 |
+
return None
|
| 40 |
+
try:
|
| 41 |
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
| 42 |
+
except ValueError:
|
| 43 |
+
return value
|
| 44 |
+
else:
|
| 45 |
+
return value
|
| 46 |
+
|
| 47 |
+
if dt.tzinfo is None:
|
| 48 |
+
dt = dt.replace(tzinfo=timezone.utc)
|
| 49 |
+
else:
|
| 50 |
+
dt = dt.astimezone(timezone.utc)
|
| 51 |
+
return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _conversation_key(user_id: str, conversation_id: str) -> str:
|
| 55 |
+
return f"{user_id}:{conversation_id}"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _drop_none(data: Dict[str, Any]) -> Dict[str, Any]:
|
| 59 |
+
"""Remove keys with None values so the gateway never receives JSON null."""
|
| 60 |
+
return {key: value for key, value in data.items() if value is not None}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class PanoramaStore:
|
| 64 |
+
"""Bridges chat persistence to the Panorama data gateway."""
|
| 65 |
+
|
| 66 |
+
def __init__(
|
| 67 |
+
self,
|
| 68 |
+
*,
|
| 69 |
+
client: PanoramaGatewayClient | None = None,
|
| 70 |
+
settings: PanoramaGatewaySettings | None = None,
|
| 71 |
+
) -> None:
|
| 72 |
+
self._settings = settings or get_panorama_settings()
|
| 73 |
+
self._client = client or PanoramaGatewayClient(self._settings)
|
| 74 |
+
self._logger = logging.getLogger(__name__)
|
| 75 |
+
|
| 76 |
+
# ---- user helpers -----------------------------------------------------
|
| 77 |
+
def ensure_user(
|
| 78 |
+
self,
|
| 79 |
+
user_id: str,
|
| 80 |
+
*,
|
| 81 |
+
wallet_address: Optional[str] = None,
|
| 82 |
+
display_name: Optional[str] = None,
|
| 83 |
+
attributes: Optional[Dict[str, Any]] = None,
|
| 84 |
+
) -> Dict[str, Any]:
|
| 85 |
+
try:
|
| 86 |
+
user = self._client.get("users", user_id)
|
| 87 |
+
except PanoramaGatewayError as exc:
|
| 88 |
+
if exc.status_code != 404:
|
| 89 |
+
raise
|
| 90 |
+
user = None
|
| 91 |
+
|
| 92 |
+
if user:
|
| 93 |
+
# Update last seen without failing the request if the patch raises.
|
| 94 |
+
try:
|
| 95 |
+
payload: Dict[str, Any] = {"lastSeenAt": _utc_now_iso()}
|
| 96 |
+
if display_name:
|
| 97 |
+
payload["displayName"] = display_name
|
| 98 |
+
if wallet_address:
|
| 99 |
+
payload["walletAddress"] = wallet_address
|
| 100 |
+
if attributes:
|
| 101 |
+
payload["attributes"] = attributes
|
| 102 |
+
self._client.update("users", user_id, payload)
|
| 103 |
+
except PanoramaGatewayError:
|
| 104 |
+
pass
|
| 105 |
+
return user
|
| 106 |
+
|
| 107 |
+
payload = _drop_none(
|
| 108 |
+
{
|
| 109 |
+
"userId": user_id,
|
| 110 |
+
"walletAddress": wallet_address,
|
| 111 |
+
"displayName": display_name,
|
| 112 |
+
"attributes": attributes or {},
|
| 113 |
+
"tenantId": self._settings.tenant_id,
|
| 114 |
+
"createdAt": _utc_now_iso(),
|
| 115 |
+
"lastSeenAt": _utc_now_iso(),
|
| 116 |
+
}
|
| 117 |
+
)
|
| 118 |
+
try:
|
| 119 |
+
return self._client.create("users", payload)
|
| 120 |
+
except PanoramaGatewayError as exc:
|
| 121 |
+
self._logger.error(
|
| 122 |
+
"Failed to create user %s via gateway: status=%s payload=%s request=%s",
|
| 123 |
+
user_id,
|
| 124 |
+
exc.status_code,
|
| 125 |
+
exc.payload,
|
| 126 |
+
payload,
|
| 127 |
+
)
|
| 128 |
+
raise
|
| 129 |
+
|
| 130 |
+
# ---- conversation helpers ---------------------------------------------
|
| 131 |
+
def ensure_conversation(
|
| 132 |
+
self,
|
| 133 |
+
user_id: str,
|
| 134 |
+
conversation_id: str,
|
| 135 |
+
*,
|
| 136 |
+
title: Optional[str] = None,
|
| 137 |
+
) -> Dict[str, Any]:
|
| 138 |
+
conv_key = _conversation_key(user_id, conversation_id)
|
| 139 |
+
try:
|
| 140 |
+
conversation = self._client.get("conversations", conv_key)
|
| 141 |
+
except PanoramaGatewayError as exc:
|
| 142 |
+
if exc.status_code != 404:
|
| 143 |
+
raise
|
| 144 |
+
conversation = None
|
| 145 |
+
|
| 146 |
+
if conversation:
|
| 147 |
+
return conversation
|
| 148 |
+
|
| 149 |
+
payload = _drop_none(
|
| 150 |
+
{
|
| 151 |
+
"id": conv_key,
|
| 152 |
+
"userId": user_id,
|
| 153 |
+
"conversationId": conversation_id,
|
| 154 |
+
"title": title,
|
| 155 |
+
"status": "active",
|
| 156 |
+
"messageCount": 0,
|
| 157 |
+
"tenantId": self._settings.tenant_id,
|
| 158 |
+
"contextState": {},
|
| 159 |
+
"memoryState": {},
|
| 160 |
+
"createdAt": _utc_now_iso(),
|
| 161 |
+
"updatedAt": _utc_now_iso(),
|
| 162 |
+
}
|
| 163 |
+
)
|
| 164 |
+
try:
|
| 165 |
+
conversation = self._client.create("conversations", payload)
|
| 166 |
+
except PanoramaGatewayError as exc:
|
| 167 |
+
self._logger.error(
|
| 168 |
+
"Failed to create conversation %s for user %s: status=%s payload=%s request=%s",
|
| 169 |
+
conversation_id,
|
| 170 |
+
user_id,
|
| 171 |
+
exc.status_code,
|
| 172 |
+
exc.payload,
|
| 173 |
+
payload,
|
| 174 |
+
)
|
| 175 |
+
raise
|
| 176 |
+
self._create_disclaimer_message(user_id, conversation_id)
|
| 177 |
+
return conversation
|
| 178 |
+
|
| 179 |
+
def list_conversations(self, user_id: str) -> List[str]:
|
| 180 |
+
result = self._client.list(
|
| 181 |
+
"conversations",
|
| 182 |
+
{
|
| 183 |
+
"where": {"userId": user_id},
|
| 184 |
+
"orderBy": {"updatedAt": "desc"},
|
| 185 |
+
},
|
| 186 |
+
)
|
| 187 |
+
data = result.get("data", []) if isinstance(result, dict) else []
|
| 188 |
+
return [item.get("conversationId") for item in data if item.get("conversationId")]
|
| 189 |
+
|
| 190 |
+
def list_users(self, limit: int = 1000) -> List[str]:
|
| 191 |
+
result = self._client.list(
|
| 192 |
+
"users",
|
| 193 |
+
{
|
| 194 |
+
"orderBy": {"createdAt": "desc"},
|
| 195 |
+
"take": limit,
|
| 196 |
+
},
|
| 197 |
+
)
|
| 198 |
+
data = result.get("data", []) if isinstance(result, dict) else []
|
| 199 |
+
return [item.get("userId") for item in data if item.get("userId")]
|
| 200 |
+
|
| 201 |
+
def delete_conversation(self, user_id: str, conversation_id: str) -> None:
|
| 202 |
+
messages = self.list_messages(user_id, conversation_id)
|
| 203 |
+
deletes: List[Dict[str, Any]] = [
|
| 204 |
+
{
|
| 205 |
+
"op": "delete",
|
| 206 |
+
"entity": "messages",
|
| 207 |
+
"args": {"id": message["messageId"]},
|
| 208 |
+
}
|
| 209 |
+
for message in messages
|
| 210 |
+
if message.get("messageId")
|
| 211 |
+
]
|
| 212 |
+
if deletes:
|
| 213 |
+
self._client.transact(deletes)
|
| 214 |
+
|
| 215 |
+
conv_key = _conversation_key(user_id, conversation_id)
|
| 216 |
+
try:
|
| 217 |
+
self._client.delete("conversations", conv_key)
|
| 218 |
+
except PanoramaGatewayError as exc:
|
| 219 |
+
if exc.status_code != 404:
|
| 220 |
+
raise
|
| 221 |
+
|
| 222 |
+
def reset_conversation(self, user_id: str, conversation_id: str) -> None:
|
| 223 |
+
messages = self.list_messages(user_id, conversation_id)
|
| 224 |
+
deletes = [
|
| 225 |
+
{
|
| 226 |
+
"op": "delete",
|
| 227 |
+
"entity": "messages",
|
| 228 |
+
"args": {"id": message["messageId"]},
|
| 229 |
+
}
|
| 230 |
+
for message in messages
|
| 231 |
+
if message.get("messageId")
|
| 232 |
+
]
|
| 233 |
+
if deletes:
|
| 234 |
+
self._client.transact(deletes)
|
| 235 |
+
|
| 236 |
+
conv_key = _conversation_key(user_id, conversation_id)
|
| 237 |
+
self._client.update(
|
| 238 |
+
"conversations",
|
| 239 |
+
conv_key,
|
| 240 |
+
{"messageCount": 0, "updatedAt": _utc_now_iso()},
|
| 241 |
+
)
|
| 242 |
+
self._create_disclaimer_message(user_id, conversation_id)
|
| 243 |
+
|
| 244 |
+
# ---- message helpers ---------------------------------------------------
|
| 245 |
+
def list_messages(self, user_id: str, conversation_id: str) -> List[Dict[str, Any]]:
|
| 246 |
+
result = self._client.list(
|
| 247 |
+
"messages",
|
| 248 |
+
{
|
| 249 |
+
"where": {"userId": user_id, "conversationId": conversation_id},
|
| 250 |
+
"orderBy": {"timestamp": "asc"},
|
| 251 |
+
},
|
| 252 |
+
)
|
| 253 |
+
if isinstance(result, dict):
|
| 254 |
+
return result.get("data", [])
|
| 255 |
+
return []
|
| 256 |
+
|
| 257 |
+
def add_message(
|
| 258 |
+
self,
|
| 259 |
+
user_id: str,
|
| 260 |
+
conversation_id: str,
|
| 261 |
+
message: ChatMessage,
|
| 262 |
+
) -> Dict[str, Any]:
|
| 263 |
+
conversation = self.ensure_conversation(user_id, conversation_id)
|
| 264 |
+
message_dict = message.dict()
|
| 265 |
+
message_id = message_dict.get("message_id") or message_dict.get("messageId")
|
| 266 |
+
if not message_id:
|
| 267 |
+
message_id = str(uuid.uuid4())
|
| 268 |
+
timestamp = _normalize_datetime(message_dict.get("timestamp")) or _utc_now_iso()
|
| 269 |
+
message_payload = _drop_none(
|
| 270 |
+
{
|
| 271 |
+
"messageId": message_id,
|
| 272 |
+
"userId": user_id,
|
| 273 |
+
"conversationId": conversation_id,
|
| 274 |
+
"role": message_dict.get("role"),
|
| 275 |
+
"content": message_dict.get("content"),
|
| 276 |
+
"agentName": message_dict.get("agent_name"),
|
| 277 |
+
"agentType": message_dict.get("agent_type"),
|
| 278 |
+
"requiresAction": message_dict.get("requires_action", False),
|
| 279 |
+
"actionType": message_dict.get("action_type"),
|
| 280 |
+
"metadata": message_dict.get("metadata") or {},
|
| 281 |
+
"status": message_dict.get("status"),
|
| 282 |
+
"errorMessage": message_dict.get("error_message"),
|
| 283 |
+
"toolCalls": message_dict.get("tool_calls"),
|
| 284 |
+
"toolResults": message_dict.get("tool_results"),
|
| 285 |
+
"nextAgent": message_dict.get("next_agent"),
|
| 286 |
+
"requiresFollowup": message_dict.get("requires_followup", False),
|
| 287 |
+
"timestamp": timestamp,
|
| 288 |
+
"tenantId": self._settings.tenant_id,
|
| 289 |
+
}
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
operations = [
|
| 293 |
+
{"op": "create", "entity": "messages", "args": {"data": message_payload}},
|
| 294 |
+
{
|
| 295 |
+
"op": "update",
|
| 296 |
+
"entity": "conversations",
|
| 297 |
+
"args": {
|
| 298 |
+
"id": conversation["id"],
|
| 299 |
+
"data": {
|
| 300 |
+
"lastMessageId": message_payload["messageId"],
|
| 301 |
+
"messageCount": (conversation.get("messageCount") or 0) + 1,
|
| 302 |
+
"updatedAt": _utc_now_iso(),
|
| 303 |
+
},
|
| 304 |
+
},
|
| 305 |
+
},
|
| 306 |
+
]
|
| 307 |
+
self._client.transact(operations)
|
| 308 |
+
|
| 309 |
+
memory_payload = _drop_none(
|
| 310 |
+
{
|
| 311 |
+
"messageId": message_payload["messageId"],
|
| 312 |
+
"role": message_payload["role"],
|
| 313 |
+
"content": message_payload["content"],
|
| 314 |
+
"agentName": message_payload.get("agentName"),
|
| 315 |
+
"agentType": message_payload.get("agentType"),
|
| 316 |
+
"metadata": message_payload.get("metadata"),
|
| 317 |
+
"requiresAction": message_payload.get("requiresAction"),
|
| 318 |
+
"timestamp": message_payload.get("timestamp"),
|
| 319 |
+
}
|
| 320 |
+
)
|
| 321 |
+
try:
|
| 322 |
+
self.create_conversation_memory(
|
| 323 |
+
user_id,
|
| 324 |
+
conversation_id,
|
| 325 |
+
scope="conversation",
|
| 326 |
+
memory_type=message_payload["role"],
|
| 327 |
+
payload=memory_payload,
|
| 328 |
+
)
|
| 329 |
+
except PanoramaGatewayError:
|
| 330 |
+
# Memory writes should never block the main chat flow.
|
| 331 |
+
pass
|
| 332 |
+
|
| 333 |
+
return message_payload
|
| 334 |
+
|
| 335 |
+
def _create_disclaimer_message(self, user_id: str, conversation_id: str) -> None:
|
| 336 |
+
disclaimer = ChatMessage(
|
| 337 |
+
role="assistant",
|
| 338 |
+
content=DEFAULT_DISCLAIMER,
|
| 339 |
+
metadata={},
|
| 340 |
+
user_id=user_id,
|
| 341 |
+
conversation_id=conversation_id,
|
| 342 |
+
)
|
| 343 |
+
self.add_message(user_id, conversation_id, disclaimer)
|
| 344 |
+
|
| 345 |
+
# ---- conversation memory ----------------------------------------------
|
| 346 |
+
def create_conversation_memory(
|
| 347 |
+
self,
|
| 348 |
+
user_id: str,
|
| 349 |
+
conversation_id: Optional[str],
|
| 350 |
+
scope: str,
|
| 351 |
+
memory_type: str,
|
| 352 |
+
payload: Dict[str, Any],
|
| 353 |
+
*,
|
| 354 |
+
label: Optional[str] = None,
|
| 355 |
+
importance_score: Optional[float] = None,
|
| 356 |
+
expires_at: Optional[Any] = None,
|
| 357 |
+
) -> Dict[str, Any]:
|
| 358 |
+
memory_payload = _drop_none(
|
| 359 |
+
{
|
| 360 |
+
"userId": user_id,
|
| 361 |
+
"conversationId": conversation_id,
|
| 362 |
+
"scope": scope,
|
| 363 |
+
"memoryType": memory_type,
|
| 364 |
+
"payload": payload,
|
| 365 |
+
"tenantId": self._settings.tenant_id,
|
| 366 |
+
"label": label,
|
| 367 |
+
"importanceScore": importance_score,
|
| 368 |
+
"expiresAt": _normalize_datetime(expires_at),
|
| 369 |
+
"createdAt": _utc_now_iso(),
|
| 370 |
+
"updatedAt": _utc_now_iso(),
|
| 371 |
+
}
|
| 372 |
+
)
|
| 373 |
+
return self._client.create("conversation-memories", memory_payload)
|
| 374 |
+
|
| 375 |
+
# ---- utility -----------------------------------------------------------
|
| 376 |
+
def ensure_user_and_conversation(
|
| 377 |
+
self,
|
| 378 |
+
user_id: str,
|
| 379 |
+
conversation_id: str,
|
| 380 |
+
*,
|
| 381 |
+
wallet_address: Optional[str] = None,
|
| 382 |
+
display_name: Optional[str] = None,
|
| 383 |
+
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
| 384 |
+
user = self.ensure_user(
|
| 385 |
+
user_id,
|
| 386 |
+
wallet_address=wallet_address,
|
| 387 |
+
display_name=display_name,
|
| 388 |
+
)
|
| 389 |
+
conversation = self.ensure_conversation(user_id, conversation_id)
|
| 390 |
+
return user, conversation
|