ColettoG commited on
Commit
a64d26e
·
1 Parent(s): 05f5111

add: lending agent

Browse files
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
- swap_state = metadata.get_swap_agent(user_id=user_id, conversation_id=conversation_id)
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
- response = self.app.invoke({"messages": langchain_messages})
496
- print("DEBUG: response", response)
 
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
- """Persistent storage for swap intents and metadata."""
2
  from __future__ import annotations
3
 
4
  import copy
5
- import json
6
- import os
7
  import time
 
8
  from datetime import datetime, timezone
9
- from pathlib import Path
10
  from threading import Lock
11
  from typing import Any, Dict, List, Optional
12
 
13
- _STATE_TEMPLATE: Dict[str, Dict[str, Any]] = {
14
- "intents": {},
15
- "metadata": {},
16
- "history": {},
17
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  class SwapStateRepository:
21
- """File-backed storage for swap agent state with TTL and history support."""
22
 
23
  _instance: "SwapStateRepository" | None = None
24
  _instance_lock: Lock = Lock()
25
 
26
  def __init__(
27
  self,
28
- path: Optional[Path] = None,
29
- ttl_seconds: int = 3600,
 
30
  history_limit: int = 10,
31
  ) -> None:
32
- env_path = os.getenv("SWAP_STATE_PATH")
33
- default_path = Path(__file__).with_name("swap_state.json")
34
- self._path = Path(path or env_path or default_path)
35
- self._ttl_seconds = ttl_seconds
36
  self._history_limit = history_limit
37
- self._lock = Lock()
38
- self._state: Dict[str, Dict[str, Any]] = copy.deepcopy(_STATE_TEMPLATE)
39
- self._ensure_parent()
40
- with self._lock:
41
- self._load_locked()
 
 
 
 
 
 
 
 
 
 
 
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  @classmethod
44
  def instance(cls) -> "SwapStateRepository":
45
  if cls._instance is None:
@@ -53,93 +97,22 @@ class SwapStateRepository:
53
  with cls._instance_lock:
54
  cls._instance = None
55
 
56
- def _ensure_parent(self) -> None:
57
- try:
58
- self._path.parent.mkdir(parents=True, exist_ok=True)
59
- except OSError:
60
- pass
61
-
62
- def _load_locked(self) -> None:
63
- if not self._path.exists():
64
- self._state = copy.deepcopy(_STATE_TEMPLATE)
65
- return
66
- try:
67
- data = json.loads(self._path.read_text())
68
- except Exception:
69
- self._state = copy.deepcopy(_STATE_TEMPLATE)
70
- return
71
- if not isinstance(data, dict):
72
- self._state = copy.deepcopy(_STATE_TEMPLATE)
73
- return
74
- state = copy.deepcopy(_STATE_TEMPLATE)
75
- for key in state:
76
- if isinstance(data.get(key), dict):
77
- state[key] = data[key]
78
- self._state = state
79
-
80
- def _persist_locked(self) -> None:
81
- self._ensure_parent()
82
- tmp_path = self._path.with_suffix(".tmp")
83
- payload = json.dumps(self._state, ensure_ascii=False, indent=2)
84
- try:
85
- tmp_path.write_text(payload)
86
- tmp_path.replace(self._path)
87
- except Exception:
88
- pass
89
-
90
- @staticmethod
91
- def _normalize_identifier(value: Optional[str], label: str) -> str:
92
- candidate = (value or "").strip()
93
- if not candidate:
94
- raise ValueError(f"{label} is required for swap state operations.")
95
- return candidate
96
-
97
- def _key(self, user_id: Optional[str], conversation_id: Optional[str]) -> str:
98
- user = self._normalize_identifier(user_id, "user_id")
99
- conversation = self._normalize_identifier(conversation_id, "conversation_id")
100
- return f"{user}::{conversation}"
101
-
102
- def _purge_locked(self) -> None:
103
- if self._ttl_seconds <= 0:
104
- return
105
- cutoff = time.time() - self._ttl_seconds
106
- intents = self._state["intents"]
107
- metadata = self._state["metadata"]
108
- stale_keys = [
109
- key for key, record in intents.items() if record.get("updated_at", 0) < cutoff
110
- ]
111
- for key in stale_keys:
112
- intents.pop(key, None)
113
- metadata.pop(key, None)
114
-
115
- @staticmethod
116
- def _format_timestamp(value: float) -> str:
117
- return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
118
-
119
- def _history_unlocked(self, key: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
120
- history = self._state["history"].get(key, [])
121
- sorted_history = sorted(history, key=lambda item: item.get("timestamp", 0), reverse=True)
122
- effective_limit = limit or self._history_limit
123
- if effective_limit:
124
- sorted_history = sorted_history[:effective_limit]
125
- results: List[Dict[str, Any]] = []
126
- for item in sorted_history:
127
- entry = copy.deepcopy(item)
128
- ts = entry.get("timestamp")
129
- if ts is not None:
130
- entry["timestamp"] = self._format_timestamp(float(ts))
131
- results.append(entry)
132
- return results
133
-
134
  def load_intent(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
135
- key = self._key(user_id, conversation_id)
136
- with self._lock:
137
- self._purge_locked()
138
- record = self._state["intents"].get(key)
139
  if not record:
140
  return None
141
  return copy.deepcopy(record.get("intent"))
142
 
 
 
 
 
 
 
 
143
  def persist_intent(
144
  self,
145
  user_id: str,
@@ -149,80 +122,287 @@ class SwapStateRepository:
149
  done: bool,
150
  summary: Optional[Dict[str, Any]] = None,
151
  ) -> List[Dict[str, Any]]:
152
- key = self._key(user_id, conversation_id)
153
- with self._lock:
154
- self._purge_locked()
155
  now = time.time()
156
  if done:
157
  self._state["intents"].pop(key, None)
158
  else:
159
- self._state["intents"][key] = {
160
- "intent": copy.deepcopy(intent),
161
- "updated_at": now,
162
- }
163
  if metadata:
164
  meta_copy = copy.deepcopy(metadata)
165
  meta_copy["updated_at"] = now
166
  self._state["metadata"][key] = meta_copy
167
  if done and summary:
168
- history = self._state["history"].get(key, [])
169
  summary_copy = copy.deepcopy(summary)
170
  summary_copy.setdefault("timestamp", now)
171
  history.append(summary_copy)
172
- self._state["history"][key] = history[-self._history_limit:]
173
- try:
174
- self._persist_locked()
175
- except Exception:
176
- pass
177
- return self._history_unlocked(key)
178
-
179
- def set_metadata(self, user_id: str, conversation_id: str, metadata: Dict[str, Any]) -> None:
180
- key = self._key(user_id, conversation_id)
181
- with self._lock:
182
- self._purge_locked()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  if metadata:
184
  meta_copy = copy.deepcopy(metadata)
185
  meta_copy["updated_at"] = time.time()
186
  self._state["metadata"][key] = meta_copy
187
  else:
188
  self._state["metadata"].pop(key, None)
189
- try:
190
- self._persist_locked()
191
- except Exception:
192
- pass
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
  def clear_metadata(self, user_id: str, conversation_id: str) -> None:
195
  self.set_metadata(user_id, conversation_id, {})
196
 
197
  def clear_intent(self, user_id: str, conversation_id: str) -> None:
198
- key = self._key(user_id, conversation_id)
199
- with self._lock:
200
- self._state["intents"].pop(key, None)
201
- try:
202
- self._persist_locked()
203
- except Exception:
204
- pass
 
 
 
205
 
206
  def get_metadata(self, user_id: str, conversation_id: str) -> Dict[str, Any]:
207
- key = self._key(user_id, conversation_id)
208
- with self._lock:
209
- self._purge_locked()
210
- meta = self._state["metadata"].get(key)
211
- if not meta:
212
  return {}
213
- entry = copy.deepcopy(meta)
214
  ts = entry.pop("updated_at", None)
215
  if ts is not None:
216
- entry["updated_at"] = self._format_timestamp(float(ts))
217
  return entry
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  def get_history(
220
  self,
221
  user_id: str,
222
  conversation_id: str,
223
  limit: Optional[int] = None,
224
  ) -> List[Dict[str, Any]]:
225
- key = self._key(user_id, conversation_id)
226
- with self._lock:
227
- return self._history_unlocked(key, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- print("request: ", request)
 
155
  try:
 
156
  user_id, conversation_id = _resolve_identity(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
  # Add the user message to the conversation
159
  chat_manager_instance.add_message(
@@ -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
- print("response_metadata: ", response_metadata)
 
 
 
 
 
 
 
 
 
 
 
 
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 == "token swap" else False,
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 time
3
  from typing import Dict, List, Optional
4
- from src.models.chatMessage import ChatMessage, ConversationState, AgentResponse
5
- from datetime import datetime
 
6
 
7
  logger = logging.getLogger(__name__)
8
 
9
 
10
  class ChatManager:
11
- """
12
- Manages chat conversations and message history.
13
-
14
- This class provides functionality to:
15
- - Create and manage multiple conversations identified by unique IDs
16
- - Associate conversations with specific users
17
- - Add/retrieve messages and responses within conversations
18
- - Clear conversation history
19
- - Get chat history in different formats
20
- - Delete conversations
21
-
22
- Each conversation starts with a default disclaimer message about the experimental nature
23
- of the chatbot.
24
-
25
- Attributes:
26
- user_conversations (Dict[str, Dict[str, ConversationState]]): Dictionary mapping user IDs to their conversations
27
- default_message (ChatMessage): Default disclaimer message added to new conversations
28
-
29
- Example:
30
- >>> chat_manager = ChatManager()
31
- >>> chat_manager.add_message({"role": "user", "content": "Hello"}, "conv1", "user123")
32
- >>> messages = chat_manager.get_messages("conv1", "user123")
33
- """
34
-
35
- def __init__(self) -> None:
36
- self.user_conversations: Dict[str, Dict[str, ConversationState]] = {}
37
- self.default_message = ChatMessage(
38
- role="assistant",
39
- content="""This highly experimental chatbot is not intended for making important decisions. Its
40
- responses are generated using AI models and may not always be accurate.
41
- By using this chatbot, you acknowledge that you use it at your own discretion
42
- and assume all risks associated with its limitations and potential errors.""",
43
- metadata={},
44
- )
45
-
46
- # Backward compatibility - Initialize with default conversation for anonymous user
47
- self._initialize_user("anonymous")
48
- self.user_conversations["anonymous"]["default"] = ConversationState(
49
- conversation_id="default",
50
- user_id="anonymous",
51
- messages=[self.default_message],
52
- context={},
53
- memory={},
54
- agent_history=[],
55
- current_agent=None,
56
- last_message_id=None,
57
- created_at=datetime.utcnow(),
58
- updated_at=datetime.utcnow(),
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
- if "timestamp" not in message:
107
- chat_message.timestamp = datetime.utcnow()
108
- conversation.messages.append(chat_message)
109
- logger.info(f"Added message to conversation {conversation_id} for user {user_id}: {chat_message.content}")
110
-
111
- def add_response(self, response: Dict[str, str], agent_name: str, conversation_id: Optional[str] = None, user_id: Optional[str] = None):
112
- """
113
- Add an agent's response to a conversation.
114
-
115
- Args:
116
- response (Dict[str, str]): Response content
117
- agent_name (str): Name of the responding agent
118
- conversation_id (str, optional): Conversation to add response to. Defaults to "default"
119
- user_id (str, optional): User identifier. Defaults to "anonymous"
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
- Args:
210
- conversation_id (str, optional): ID of conversation to delete. Defaults to "default"
211
- user_id (str, optional): User identifier. Defaults to "anonymous"
212
- """
213
- user_id = self._get_user_id(user_id)
214
- conversation_id = self._get_conversation_id(conversation_id)
215
- self._initialize_user(user_id)
216
- if conversation_id in self.user_conversations[user_id]:
217
- del self.user_conversations[user_id][conversation_id]
218
- logger.info(f"Deleted conversation {conversation_id} for user {user_id}")
 
 
219
 
220
  def create_conversation(self, user_id: Optional[str] = None) -> str:
221
- """
222
- Create a new conversation for a user.
223
-
224
- Args:
225
- user_id (str, optional): User identifier. Defaults to "anonymous"
226
-
227
- Returns:
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
- def _get_or_create_conversation(self, conversation_id: str, user_id: str) -> ConversationState:
252
- """
253
- Get existing conversation or create new one if not exists.
254
-
255
- Args:
256
- conversation_id (str): Conversation ID to get/create
257
- user_id (str): User identifier
258
 
259
- Returns:
260
- ConversationState: Retrieved or created conversation
261
- """
262
- self._initialize_user(user_id)
263
- if conversation_id not in self.user_conversations[user_id]:
264
- self.user_conversations[user_id][conversation_id] = ConversationState(
265
- conversation_id=conversation_id,
266
- user_id=user_id,
267
- messages=[self.default_message],
268
- context={},
269
- memory={},
270
- agent_history=[],
271
- current_agent=None,
272
- last_message_id=None,
273
- created_at=datetime.utcnow(),
274
- updated_at=datetime.utcnow(),
275
- is_active=True
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
- # Create an instance to act as a singleton store
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