github-actions[bot] commited on
Commit
bc3dd51
·
1 Parent(s): 48bf8be

Deploy from GitHub Actions: a7d40a05e1c8e0f277a3e01ef50ae8f13f98ee0a

Browse files
src/agents/portfolio/__init__.py ADDED
File without changes
src/agents/portfolio/agent.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Portfolio Advisor agent — reads wallet holdings and cross-references with
3
+ web search to produce risk analysis and swap recommendations.
4
+ """
5
+
6
+ import logging
7
+
8
+ from langgraph.prebuilt import create_react_agent
9
+
10
+ from src.agents.portfolio.prompt import PORTFOLIO_ADVISOR_SYSTEM_PROMPT
11
+ from src.agents.portfolio.tools import get_tools as get_portfolio_tools
12
+ from src.agents.search.tools import get_tools as get_search_tools
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class PortfolioAdvisorAgent:
18
+ """Agent that analyses on-chain portfolio data and recommends actions."""
19
+
20
+ def __init__(self, llm):
21
+ self.llm = llm
22
+
23
+ # Combine portfolio tools + search tools so the agent can
24
+ # both read the wallet AND look up recent news for held tokens.
25
+ tools = get_portfolio_tools() + get_search_tools()
26
+
27
+ self.agent = create_react_agent(
28
+ model=llm,
29
+ tools=tools,
30
+ name="portfolio_advisor",
31
+ prompt=PORTFOLIO_ADVISOR_SYSTEM_PROMPT,
32
+ )
src/agents/portfolio/config.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration for the Portfolio Advisor agent.
3
+
4
+ Uses free, keyless APIs:
5
+ • Blockscout — ETH, Polygon, Arbitrum, Base, Optimism
6
+ • Routescan — Avalanche
7
+ """
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Blockscout instances (no API key required)
11
+ # ---------------------------------------------------------------------------
12
+ BLOCKSCOUT_CHAINS: dict[str, str] = {
13
+ "ethereum": "https://eth.blockscout.com",
14
+ "polygon": "https://polygon.blockscout.com",
15
+ "arbitrum": "https://arbitrum.blockscout.com",
16
+ "base": "https://base.blockscout.com",
17
+ "optimism": "https://optimism.blockscout.com",
18
+ }
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Routescan (no API key required) — chain name → EVM chain ID
22
+ # ---------------------------------------------------------------------------
23
+ ROUTESCAN_CHAINS: dict[str, int] = {
24
+ "avalanche": 43114,
25
+ }
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Native token metadata per chain
29
+ # ---------------------------------------------------------------------------
30
+ NATIVE_SYMBOL: dict[str, str] = {
31
+ "ethereum": "ETH",
32
+ "polygon": "POL",
33
+ "arbitrum": "ETH",
34
+ "base": "ETH",
35
+ "optimism": "ETH",
36
+ "avalanche": "AVAX",
37
+ }
38
+
39
+ NATIVE_DECIMALS: int = 18
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Classification sets
43
+ # ---------------------------------------------------------------------------
44
+ STABLECOIN_SYMBOLS: set[str] = {
45
+ "USDC", "USDT", "DAI", "TUSD", "BUSD", "FRAX", "MIM", "USDe",
46
+ }
47
+
48
+ BLUE_CHIP_SYMBOLS: set[str] = {
49
+ "ETH", "WETH", "BTC", "WBTC", "BNB", "AVAX", "WAVAX", "MATIC", "POL",
50
+ }
51
+
52
+ # Minimum USD value for a position to be included in the report.
53
+ MIN_VALUE_USD: float = 0.01
src/agents/portfolio/prompt.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ System prompt for the Portfolio Advisor agent.
3
+
4
+ Combines detailed per-token analysis, risk assessment, market context,
5
+ and proactive Swap upselling.
6
+ """
7
+
8
+ from src.agents.markdown_instructions import MARKDOWN_INSTRUCTIONS
9
+
10
+ PORTFOLIO_ADVISOR_SYSTEM_PROMPT = f"""You are Zico's **Senior Portfolio & Risk Analyst**.
11
+ Your mission is to help users understand their on-chain portfolio in full detail — every single token, its value, its risk, and what action to take.
12
+
13
+ Always respond in English, regardless of the user's language.
14
+
15
+ ## Workflow
16
+
17
+ 1. **Fetch the portfolio** — Always call `get_user_portfolio` first to get real, live data. Never guess or fabricate holdings.
18
+ 2. **Present EVERY token** — List ALL tokens from the tool result in a detailed table. Never omit or summarize tokens.
19
+ 3. **Analyze each position** — For every token, assess the risk level based on its category and market position.
20
+ 4. **Research** — For the top 3 holdings or any position that raises concern, use the search tool to look up recent news, hacks, governance issues, or market sentiment.
21
+ 5. **Advise** — Provide concrete recommendations with specific swap actions.
22
+
23
+ ## CRITICAL: Always Show the Complete Token Table
24
+
25
+ When presenting the portfolio, you MUST include a full table with ALL tokens. Use this exact format:
26
+
27
+ | Token | Chain | Balance | Value (USD) | % of Portfolio | Risk Level |
28
+ |-------|-------|---------|-------------|----------------|------------|
29
+ | ETH | ethereum | 0.0026 | $5.04 | 58.1% | 🟢 Low |
30
+ | STETH | ethereum | 0.001 | $2.03 | 23.4% | 🟢 Low |
31
+ | ... | ... | ... | ... | ... | ... |
32
+
33
+ List EVERY token returned by the tool. Do NOT skip any token, even those with tiny values. The user wants to see their complete inventory.
34
+
35
+ ## Risk Level Classification
36
+
37
+ Assign risk levels to each token based on these rules:
38
+
39
+ - **🟢 Low Risk**: BTC, ETH, WETH, WBTC — established, battle-tested protocols with massive liquidity. Users can comfortably hold large positions.
40
+ - **🟡 Medium Risk**: stETH, MATIC/POL, BNB, AVAX, WAVAX — top-tier L1/L2 tokens or major liquid staking derivatives. Solid but more volatile than BTC/ETH.
41
+ - **🟠 High Risk**: Smaller-cap altcoins, meme tokens, newer DeFi tokens — these can lose 50-90% of value quickly. Position sizes should be small.
42
+ - **🔴 Critical Risk**: Unknown tokens, tokens with very low liquidity, tokens that appeared via airdrop without the user buying them — may be scam tokens.
43
+ - **⚪ Stable**: USDC, USDT, DAI — stablecoins. Low risk but capital is not growing.
44
+
45
+ ## Market Perspective (Use This for Analysis)
46
+
47
+ When advising, provide market-informed perspective:
48
+ - **BTC/ETH** are considered the "blue chips" of crypto — higher exposure is generally acceptable for long-term holders
49
+ - **Layer 2 tokens** (ARB, OP, BASE) are higher risk than ETH but still quality projects
50
+ - **Stablecoins** are safe but idle capital — suggest yield opportunities (lending, staking) when appropriate
51
+ - **Altcoins/Meme coins** — warn about concentration risk, suggest keeping these under 10-20% of total portfolio
52
+ - Always consider: is the user over-concentrated in one asset? Under-diversified? Holding idle capital?
53
+
54
+ ## Risk Identification Rules
55
+
56
+ - **Over-concentration**: Flag any single non-stablecoin asset that represents ≥ 30% of total portfolio value.
57
+ - **Low diversification**: Flag if the portfolio has < 3 distinct assets.
58
+ - **High altcoin exposure**: Flag if altcoins represent > 50% of total value.
59
+ - **Stablecoin dominance**: Note if stablecoins are > 70% (capital may not be working for the user).
60
+ - **News-driven risk**: If search reveals negative news (hacks, exploits, depegs, regulatory action) for a held asset, escalate the alert.
61
+
62
+ ## Swap Upsell Guidelines
63
+
64
+ Whenever you identify a risk or improvement opportunity, suggest a **specific Swap action** the user can perform through our platform:
65
+
66
+ - Be specific: mention exact tokens, approximate amounts, and the reasoning.
67
+ - Frame swaps as protective or opportunistic moves, not sales pitches.
68
+ - Use the pattern: "I can help you **swap X% of [Token] to [Token]** right now to [reason]."
69
+ - Suggest diversifying into ETH, stablecoins (USDC), or other blue chips when reducing risk.
70
+ - If the portfolio looks healthy, acknowledge it and suggest yield opportunities (staking, DCA).
71
+
72
+ ## Response Structure
73
+
74
+ Always structure your response with these sections:
75
+
76
+ ### 1. Portfolio Overview
77
+ - Total value, total asset count, chains with holdings
78
+
79
+ ### 2. Complete Holdings Table
80
+ - Full table with ALL tokens (see format above) — NEVER skip tokens
81
+
82
+ ### 3. Allocation Breakdown
83
+ - Blue chips %, Stablecoins %, Altcoins %
84
+ - Visual summary of where the money is
85
+
86
+ ### 4. Risk Assessment
87
+ - Per-token risk analysis with severity
88
+ - Overall portfolio health score
89
+
90
+ ### 5. Market Context & News
91
+ - Relevant findings from web search for key holdings
92
+ - Current market sentiment for major positions
93
+
94
+ ### 6. Recommendations
95
+ - Specific swap or rebalancing actions with exact amounts
96
+ - Priority-ordered: most important action first
97
+
98
+ {MARKDOWN_INSTRUCTIONS}"""
src/agents/portfolio/tools.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Portfolio tools — fetches multi-chain wallet balances via free, keyless APIs.
3
+
4
+ Data sources:
5
+ • Blockscout API v2 — ETH, Polygon, Arbitrum, Base, Optimism
6
+ • Routescan API v2 — Avalanche
7
+
8
+ Both APIs return token balances with USD pricing included, so no separate
9
+ price-feed is needed.
10
+
11
+ Session management follows the same ContextVar pattern used by the swap,
12
+ lending, and staking agents so the wallet_address is injected by the graph
13
+ node rather than asked from the user.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import time
21
+ from concurrent.futures import ThreadPoolExecutor, as_completed
22
+ from contextlib import contextmanager
23
+ from contextvars import ContextVar
24
+ from threading import Lock
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ import requests
28
+ from langchain_core.tools import tool
29
+ from pydantic import BaseModel
30
+
31
+ from src.agents.portfolio.config import (
32
+ BLOCKSCOUT_CHAINS,
33
+ BLUE_CHIP_SYMBOLS,
34
+ MIN_VALUE_USD,
35
+ NATIVE_DECIMALS,
36
+ NATIVE_SYMBOL,
37
+ ROUTESCAN_CHAINS,
38
+ STABLECOIN_SYMBOLS,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ _HTTP_TIMEOUT = 15 # seconds per request
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Session context (user_id, conversation_id, wallet_address)
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _CURRENT_SESSION: ContextVar[tuple[str, str, str]] = ContextVar(
50
+ "_current_portfolio_session",
51
+ default=("", "", ""),
52
+ )
53
+
54
+
55
+ def set_current_portfolio_session(
56
+ user_id: Optional[str],
57
+ conversation_id: Optional[str],
58
+ wallet_address: Optional[str],
59
+ ) -> None:
60
+ _CURRENT_SESSION.set((
61
+ (user_id or "").strip(),
62
+ (conversation_id or "").strip(),
63
+ (wallet_address or "").strip(),
64
+ ))
65
+
66
+
67
+ def clear_current_portfolio_session() -> None:
68
+ _CURRENT_SESSION.set(("", "", ""))
69
+
70
+
71
+ @contextmanager
72
+ def portfolio_session(
73
+ user_id: Optional[str],
74
+ conversation_id: Optional[str],
75
+ wallet_address: Optional[str],
76
+ ):
77
+ """Context manager that guarantees session scoping for portfolio tool calls."""
78
+ set_current_portfolio_session(user_id, conversation_id, wallet_address)
79
+ try:
80
+ yield
81
+ finally:
82
+ clear_current_portfolio_session()
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Simple TTL cache (avoids hammering explorers on follow-up questions)
87
+ # ---------------------------------------------------------------------------
88
+
89
+ _PORTFOLIO_CACHE: Dict[str, tuple[Any, float]] = {}
90
+ _CACHE_TTL = 60 # seconds
91
+ _cache_lock = Lock()
92
+
93
+
94
+ def _get_cached(key: str) -> Optional[Any]:
95
+ with _cache_lock:
96
+ entry = _PORTFOLIO_CACHE.get(key)
97
+ if entry and (time.time() - entry[1]) < _CACHE_TTL:
98
+ return entry[0]
99
+ return None
100
+
101
+
102
+ def _set_cached(key: str, value: Any) -> None:
103
+ with _cache_lock:
104
+ _PORTFOLIO_CACHE[key] = (value, time.time())
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Blockscout helpers
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _blockscout_fetch_address(base_url: str, address: str) -> Dict[str, Any]:
112
+ """GET /api/v2/addresses/{addr} — native balance + coin exchange rate."""
113
+ try:
114
+ resp = requests.get(
115
+ f"{base_url}/api/v2/addresses/{address}",
116
+ timeout=_HTTP_TIMEOUT,
117
+ )
118
+ resp.raise_for_status()
119
+ return resp.json()
120
+ except Exception as exc:
121
+ logger.warning("Blockscout address fetch failed (%s): %s", base_url, exc)
122
+ return {}
123
+
124
+
125
+ def _blockscout_fetch_tokens(base_url: str, address: str) -> List[Dict[str, Any]]:
126
+ """GET /api/v2/addresses/{addr}/token-balances — ERC-20 holdings with price."""
127
+ try:
128
+ resp = requests.get(
129
+ f"{base_url}/api/v2/addresses/{address}/token-balances",
130
+ timeout=_HTTP_TIMEOUT,
131
+ )
132
+ resp.raise_for_status()
133
+ return resp.json() # list of token objects
134
+ except Exception as exc:
135
+ logger.warning("Blockscout token-balances fetch failed (%s): %s", base_url, exc)
136
+ return []
137
+
138
+
139
+ def _process_blockscout_chain(
140
+ chain_name: str,
141
+ base_url: str,
142
+ address: str,
143
+ ) -> List[Dict[str, Any]]:
144
+ """Fetch and normalise all holdings on a single Blockscout-indexed chain."""
145
+ assets: List[Dict[str, Any]] = []
146
+
147
+ # ── Native token ──
148
+ addr_data = _blockscout_fetch_address(base_url, address)
149
+ if addr_data:
150
+ try:
151
+ raw_balance = int(addr_data.get("coin_balance") or "0")
152
+ balance = raw_balance / (10 ** NATIVE_DECIMALS)
153
+ exchange_rate = float(addr_data.get("exchange_rate") or 0)
154
+ value_usd = balance * exchange_rate
155
+ symbol = NATIVE_SYMBOL.get(chain_name, "ETH")
156
+ if value_usd >= MIN_VALUE_USD or balance >= 0.000001:
157
+ assets.append({
158
+ "symbol": symbol,
159
+ "name": symbol,
160
+ "balance": round(balance, 6),
161
+ "value_usd": round(value_usd, 2),
162
+ "chain": chain_name,
163
+ "contract_address": "native",
164
+ "category": _classify(symbol),
165
+ })
166
+ except (ValueError, TypeError):
167
+ pass
168
+
169
+ # ── ERC-20 tokens ──
170
+ tokens = _blockscout_fetch_tokens(base_url, address)
171
+ for tok in tokens:
172
+ try:
173
+ token_info = tok.get("token", {})
174
+ decimals = int(token_info.get("decimals") or 18)
175
+ raw_value = int(tok.get("value") or "0")
176
+ balance = raw_value / (10 ** decimals)
177
+ if balance <= 0:
178
+ continue
179
+
180
+ symbol = (token_info.get("symbol") or "???").upper()
181
+ exchange_rate = float(token_info.get("exchange_rate") or 0)
182
+ value_usd = balance * exchange_rate
183
+
184
+ if value_usd < MIN_VALUE_USD and balance < 0.000001:
185
+ continue
186
+
187
+ assets.append({
188
+ "symbol": symbol,
189
+ "name": token_info.get("name", symbol),
190
+ "balance": round(balance, 6),
191
+ "value_usd": round(value_usd, 2),
192
+ "chain": chain_name,
193
+ "contract_address": token_info.get("address_hash", ""),
194
+ "category": _classify(symbol),
195
+ })
196
+ except (ValueError, TypeError):
197
+ continue
198
+
199
+ return assets
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Routescan helpers
204
+ # ---------------------------------------------------------------------------
205
+
206
+ _ROUTESCAN_BASE = "https://api.routescan.io/v2/network/mainnet/evm"
207
+
208
+
209
+ def _routescan_fetch_tokens(chain_id: int, address: str) -> List[Dict[str, Any]]:
210
+ """GET /v2/network/mainnet/evm/{chainId}/address/{addr}/erc20-holdings."""
211
+ try:
212
+ resp = requests.get(
213
+ f"{_ROUTESCAN_BASE}/{chain_id}/address/{address}/erc20-holdings",
214
+ timeout=_HTTP_TIMEOUT,
215
+ )
216
+ resp.raise_for_status()
217
+ data = resp.json()
218
+ return data.get("items", [])
219
+ except Exception as exc:
220
+ logger.warning("Routescan fetch failed (chain %s): %s", chain_id, exc)
221
+ return []
222
+
223
+
224
+ def _routescan_fetch_native(chain_id: int, address: str) -> Dict[str, Any]:
225
+ """GET /v2/network/mainnet/evm/{chainId}/address/{addr}."""
226
+ try:
227
+ resp = requests.get(
228
+ f"{_ROUTESCAN_BASE}/{chain_id}/address/{address}",
229
+ timeout=_HTTP_TIMEOUT,
230
+ )
231
+ resp.raise_for_status()
232
+ return resp.json()
233
+ except Exception as exc:
234
+ logger.warning("Routescan native fetch failed (chain %s): %s", chain_id, exc)
235
+ return {}
236
+
237
+
238
+ def _process_routescan_chain(
239
+ chain_name: str,
240
+ chain_id: int,
241
+ address: str,
242
+ ) -> List[Dict[str, Any]]:
243
+ """Fetch and normalise all holdings on a single Routescan-indexed chain."""
244
+ assets: List[Dict[str, Any]] = []
245
+
246
+ # ── Native token ──
247
+ native_data = _routescan_fetch_native(chain_id, address)
248
+ if native_data:
249
+ try:
250
+ raw_balance = int(native_data.get("balance") or "0")
251
+ balance = raw_balance / (10 ** NATIVE_DECIMALS)
252
+ # Routescan native endpoint may not include price;
253
+ # use CoinGecko-style fallback only for AVAX
254
+ native_price = float(native_data.get("tokenPriceUsd") or 0)
255
+ value_usd = balance * native_price
256
+ symbol = NATIVE_SYMBOL.get(chain_name, "AVAX")
257
+ if value_usd >= MIN_VALUE_USD or balance >= 0.000001:
258
+ assets.append({
259
+ "symbol": symbol,
260
+ "name": symbol,
261
+ "balance": round(balance, 6),
262
+ "value_usd": round(value_usd, 2),
263
+ "chain": chain_name,
264
+ "contract_address": "native",
265
+ "category": _classify(symbol),
266
+ })
267
+ except (ValueError, TypeError):
268
+ pass
269
+
270
+ # ── ERC-20 tokens ──
271
+ tokens = _routescan_fetch_tokens(chain_id, address)
272
+ for tok in tokens:
273
+ try:
274
+ decimals = int(tok.get("tokenDecimals") or 18)
275
+ raw_qty = int(tok.get("tokenQuantity") or "0")
276
+ balance = raw_qty / (10 ** decimals)
277
+ if balance <= 0:
278
+ continue
279
+
280
+ symbol = (tok.get("tokenSymbol") or "???").upper()
281
+ value_usd = float(tok.get("tokenValueInUsd") or 0)
282
+
283
+ if value_usd < MIN_VALUE_USD and balance < 0.000001:
284
+ continue
285
+
286
+ assets.append({
287
+ "symbol": symbol,
288
+ "name": tok.get("tokenName", symbol),
289
+ "balance": round(balance, 6),
290
+ "value_usd": round(value_usd, 2),
291
+ "chain": chain_name,
292
+ "contract_address": tok.get("tokenAddress", ""),
293
+ "category": _classify(symbol),
294
+ })
295
+ except (ValueError, TypeError):
296
+ continue
297
+
298
+ return assets
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Classification
303
+ # ---------------------------------------------------------------------------
304
+
305
+ def _classify(symbol: str) -> str:
306
+ upper = symbol.upper()
307
+ if upper in STABLECOIN_SYMBOLS:
308
+ return "stablecoin"
309
+ if upper in BLUE_CHIP_SYMBOLS:
310
+ return "blue_chip"
311
+ return "altcoin"
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # The Tool
316
+ # ---------------------------------------------------------------------------
317
+
318
+ class _GetPortfolioArgs(BaseModel):
319
+ """No user-facing args — wallet_address comes from the session context."""
320
+ pass
321
+
322
+
323
+ @tool("get_user_portfolio", args_schema=_GetPortfolioArgs)
324
+ def get_user_portfolio_tool() -> str:
325
+ """Fetch the connected user's multi-chain token holdings with USD values.
326
+
327
+ Returns a JSON object with total_value_usd, top_holdings, allocation
328
+ breakdown (stablecoins %, blue_chips %, altcoins %), and the full
329
+ asset list. Use this data to analyze portfolio risk and concentration.
330
+ """
331
+ _, _, wallet_address = _CURRENT_SESSION.get()
332
+
333
+ if not wallet_address:
334
+ return json.dumps({"error": "No wallet address available. Ask the user to connect their wallet."})
335
+
336
+ # Check cache
337
+ cache_key = f"portfolio:{wallet_address.lower()}"
338
+ cached = _get_cached(cache_key)
339
+ if cached:
340
+ return cached
341
+
342
+ # ── Fetch all chains in parallel ──
343
+ all_assets: List[Dict[str, Any]] = []
344
+
345
+ with ThreadPoolExecutor(max_workers=6) as pool:
346
+ futures = {}
347
+
348
+ # Blockscout chains
349
+ for chain_name, base_url in BLOCKSCOUT_CHAINS.items():
350
+ fut = pool.submit(_process_blockscout_chain, chain_name, base_url, wallet_address)
351
+ futures[fut] = chain_name
352
+
353
+ # Routescan chains
354
+ for chain_name, chain_id in ROUTESCAN_CHAINS.items():
355
+ fut = pool.submit(_process_routescan_chain, chain_name, chain_id, wallet_address)
356
+ futures[fut] = chain_name
357
+
358
+ for fut in as_completed(futures):
359
+ chain = futures[fut]
360
+ try:
361
+ all_assets.extend(fut.result())
362
+ except Exception as exc:
363
+ logger.warning("Failed to fetch %s: %s", chain, exc)
364
+
365
+ if not all_assets:
366
+ return json.dumps({
367
+ "wallet_address": wallet_address,
368
+ "total_value_usd": 0,
369
+ "asset_count": 0,
370
+ "chains_checked": list(BLOCKSCOUT_CHAINS.keys()) + list(ROUTESCAN_CHAINS.keys()),
371
+ "top_holdings": [],
372
+ "allocation": {"stablecoins_pct": 0, "blue_chips_pct": 0, "altcoins_pct": 0},
373
+ "all_assets": [],
374
+ "note": "No token balances found. The wallet may be empty or the APIs may be temporarily unavailable.",
375
+ })
376
+
377
+ # ── Aggregate ──
378
+ total_value = sum(a["value_usd"] for a in all_assets)
379
+ for a in all_assets:
380
+ a["percentage"] = round((a["value_usd"] / total_value * 100) if total_value > 0 else 0, 2)
381
+
382
+ all_assets.sort(key=lambda a: a["value_usd"], reverse=True)
383
+
384
+ stablecoins_usd = sum(a["value_usd"] for a in all_assets if a["category"] == "stablecoin")
385
+ blue_chips_usd = sum(a["value_usd"] for a in all_assets if a["category"] == "blue_chip")
386
+ altcoins_usd = sum(a["value_usd"] for a in all_assets if a["category"] == "altcoin")
387
+
388
+ result = json.dumps({
389
+ "wallet_address": wallet_address,
390
+ "total_value_usd": round(total_value, 2),
391
+ "asset_count": len(all_assets),
392
+ "chains_checked": list(BLOCKSCOUT_CHAINS.keys()) + list(ROUTESCAN_CHAINS.keys()),
393
+ "top_holdings": all_assets[:5],
394
+ "allocation": {
395
+ "stablecoins_pct": round((stablecoins_usd / total_value * 100) if total_value > 0 else 0, 1),
396
+ "blue_chips_pct": round((blue_chips_usd / total_value * 100) if total_value > 0 else 0, 1),
397
+ "altcoins_pct": round((altcoins_usd / total_value * 100) if total_value > 0 else 0, 1),
398
+ },
399
+ "all_assets": all_assets,
400
+ })
401
+
402
+ _set_cached(cache_key, result)
403
+ return result
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Public API
408
+ # ---------------------------------------------------------------------------
409
+
410
+ def get_tools() -> list:
411
+ """Return the toolset for the portfolio advisor agent."""
412
+ return [get_user_portfolio_tool]
src/agents/routing/semantic_router.py CHANGED
@@ -27,6 +27,7 @@ class IntentCategory(str, Enum):
27
  STAKING = "staking"
28
  DCA = "dca"
29
  MARKET_DATA = "market_data"
 
30
  EDUCATION = "education"
31
  SEARCH = "search"
32
  GENERAL = "general"
@@ -39,6 +40,7 @@ _INTENT_AGENT_MAP: Dict[IntentCategory, str] = {
39
  IntentCategory.STAKING: "staking_agent",
40
  IntentCategory.DCA: "dca_agent",
41
  IntentCategory.MARKET_DATA: "crypto_agent",
 
42
  IntentCategory.EDUCATION: "default_agent",
43
  IntentCategory.SEARCH: "search_agent",
44
  IntentCategory.GENERAL: "default_agent",
@@ -114,6 +116,32 @@ INTENT_EXEMPLARS: Dict[IntentCategory, List[str]] = {
114
  "Bitcoin market cap",
115
  "What is the TVL of Avalanche?",
116
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  IntentCategory.EDUCATION: [
118
  "What is DeFi?",
119
  "How does liquid staking work?",
 
27
  STAKING = "staking"
28
  DCA = "dca"
29
  MARKET_DATA = "market_data"
30
+ PORTFOLIO = "portfolio"
31
  EDUCATION = "education"
32
  SEARCH = "search"
33
  GENERAL = "general"
 
40
  IntentCategory.STAKING: "staking_agent",
41
  IntentCategory.DCA: "dca_agent",
42
  IntentCategory.MARKET_DATA: "crypto_agent",
43
+ IntentCategory.PORTFOLIO: "portfolio_advisor",
44
  IntentCategory.EDUCATION: "default_agent",
45
  IntentCategory.SEARCH: "search_agent",
46
  IntentCategory.GENERAL: "default_agent",
 
116
  "Bitcoin market cap",
117
  "What is the TVL of Avalanche?",
118
  ],
119
+ IntentCategory.PORTFOLIO: [
120
+ "Analyze my portfolio",
121
+ "What's my biggest risk?",
122
+ "How should I rebalance my wallet?",
123
+ "Show me my token holdings",
124
+ "What tokens do I have?",
125
+ "Am I too exposed to any single token?",
126
+ "Give me a risk assessment of my wallet",
127
+ "How diversified is my portfolio?",
128
+ "What should I do with my tokens?",
129
+ "What is the amount of each token?",
130
+ "How much of each token do I hold?",
131
+ "Show me the value of each position",
132
+ "List all my tokens with balances",
133
+ "Where am I most exposed?",
134
+ "Which tokens should I sell?",
135
+ "Where should I take more risk?",
136
+ "Where should I be more careful?",
137
+ "Is my wallet safe?",
138
+ "Analise minha carteira",
139
+ "Qual meu maior risco?",
140
+ "Como balancear minha carteira?",
141
+ "Quais tokens eu tenho?",
142
+ "Quanto tenho de cada token?",
143
+ "Onde posso arriscar mais?",
144
+ ],
145
  IntentCategory.EDUCATION: [
146
  "What is DeFi?",
147
  "How does liquid staking work?",
src/app.py CHANGED
@@ -2,10 +2,12 @@ import asyncio
2
  import base64
3
  import json
4
  import os
 
5
  from typing import Any, Dict, List, Optional
6
 
7
  from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
8
  from fastapi.middleware.cors import CORSMiddleware
 
9
  from langchain_core.messages import HumanMessage
10
  from pydantic import BaseModel
11
 
@@ -135,6 +137,7 @@ def _map_agent_type(agent_name: str) -> str:
135
  "swap_agent": "token swap",
136
  "lending_agent": "lending",
137
  "staking_agent": "staking",
 
138
  "supervisor": "supervisor",
139
  }
140
  return mapping.get(agent_name, "supervisor")
@@ -177,6 +180,7 @@ def _invoke_graph(
177
  user_id,
178
  conversation_id,
179
  *,
 
180
  pre_classified: Dict[str, Any] | None = None,
181
  ):
182
  """Invoke the StateGraph and return the result state.
@@ -189,6 +193,7 @@ def _invoke_graph(
189
  "messages": conversation_messages,
190
  "user_id": user_id,
191
  "conversation_id": conversation_id,
 
192
  }
193
  if pre_classified:
194
  initial_state.update(pre_classified)
@@ -408,7 +413,7 @@ def chat(request: ChatRequest):
408
  cost_snapshot = cost_tracker.get_snapshot()
409
 
410
  # Invoke the StateGraph
411
- result = _invoke_graph(conversation_messages, user_id, conversation_id)
412
 
413
  # Calculate and save cost delta
414
  cost_delta = cost_tracker.calculate_delta(cost_snapshot)
@@ -441,6 +446,318 @@ def chat(request: ChatRequest):
441
  raise HTTPException(status_code=500, detail=str(e))
442
 
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  # Supported audio MIME types
445
  AUDIO_MIME_TYPES = {
446
  ".mp3": "audio/mpeg",
@@ -481,6 +798,11 @@ Return ONLY a JSON object (no markdown fences) with these fields:
481
  {"transcription": "<exact transcription>", "intent": "<category>", "confidence": <0.0-1.0>}
482
  """
483
 
 
 
 
 
 
484
  # Maps audio classification intents to agent runtime names
485
  _AUDIO_INTENT_AGENT_MAP: Dict[str, str] = {
486
  "swap": "swap_agent",
@@ -488,6 +810,7 @@ _AUDIO_INTENT_AGENT_MAP: Dict[str, str] = {
488
  "staking": "staking_agent",
489
  "dca": "dca_agent",
490
  "market_data": "crypto_agent",
 
491
  "search": "search_agent",
492
  "education": "default_agent",
493
  "general": "default_agent",
@@ -697,6 +1020,7 @@ async def chat_audio(
697
  conversation_messages,
698
  request_user_id,
699
  request_conversation_id,
 
700
  pre_classified=pre_classified,
701
  )
702
 
@@ -742,5 +1066,65 @@ async def chat_audio(
742
  raise HTTPException(status_code=500, detail=str(e))
743
 
744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
  # Include chat manager router
746
  app.include_router(chat_manager_router)
 
2
  import base64
3
  import json
4
  import os
5
+ import time
6
  from typing import Any, Dict, List, Optional
7
 
8
  from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
9
  from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import StreamingResponse
11
  from langchain_core.messages import HumanMessage
12
  from pydantic import BaseModel
13
 
 
137
  "swap_agent": "token swap",
138
  "lending_agent": "lending",
139
  "staking_agent": "staking",
140
+ "portfolio_advisor": "portfolio analysis",
141
  "supervisor": "supervisor",
142
  }
143
  return mapping.get(agent_name, "supervisor")
 
180
  user_id,
181
  conversation_id,
182
  *,
183
+ wallet_address: str | None = None,
184
  pre_classified: Dict[str, Any] | None = None,
185
  ):
186
  """Invoke the StateGraph and return the result state.
 
193
  "messages": conversation_messages,
194
  "user_id": user_id,
195
  "conversation_id": conversation_id,
196
+ "wallet_address": wallet_address,
197
  }
198
  if pre_classified:
199
  initial_state.update(pre_classified)
 
413
  cost_snapshot = cost_tracker.get_snapshot()
414
 
415
  # Invoke the StateGraph
416
+ result = _invoke_graph(conversation_messages, user_id, conversation_id, wallet_address=wallet)
417
 
418
  # Calculate and save cost delta
419
  cost_delta = cost_tracker.calculate_delta(cost_snapshot)
 
446
  raise HTTPException(status_code=500, detail=str(e))
447
 
448
 
449
+ # ---------------------------------------------------------------------------
450
+ # SSE Streaming endpoint
451
+ # ---------------------------------------------------------------------------
452
+
453
+ # Human-readable labels for each graph node
454
+ _NODE_LABELS: Dict[str, str] = {
455
+ "entry_node": "Preparing context...",
456
+ "semantic_router_node": "Routing your request...",
457
+ "llm_router_node": "Analyzing intent...",
458
+ "swap_agent_node": "Consulting swap protocols...",
459
+ "lending_agent_node": "Checking lending markets...",
460
+ "staking_agent_node": "Reviewing staking options...",
461
+ "dca_agent_node": "Planning DCA strategy...",
462
+ "crypto_agent_node": "Fetching market data...",
463
+ "search_agent_node": "Searching the web...",
464
+ "default_agent_node": "Thinking...",
465
+ "database_agent_node": "Querying portfolio...",
466
+ "portfolio_advisor_node": "Analyzing your portfolio...",
467
+ "formatter_node": "Formatting response...",
468
+ "error_node": "Validating parameters...",
469
+ }
470
+
471
+
472
+ def _sse(event_type: str, data: dict) -> str:
473
+ """Format a Server-Sent Event string."""
474
+ return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
475
+
476
+
477
+ async def _persist_response_bg(
478
+ full_response: str,
479
+ response_agent: str,
480
+ response_metadata: dict,
481
+ user_id: str,
482
+ conversation_id: str,
483
+ cost_delta: dict,
484
+ ) -> None:
485
+ """Background task: persist assistant message and update costs."""
486
+ try:
487
+ agent_name = _map_agent_type(response_agent)
488
+ response_message = ChatMessage(
489
+ role="assistant",
490
+ content=full_response,
491
+ agent_name=agent_name,
492
+ agent_type=_map_agent_type(agent_name),
493
+ metadata=response_metadata,
494
+ conversation_id=conversation_id,
495
+ user_id=user_id,
496
+ requires_action=(
497
+ True if agent_name in ("token swap", "lending", "staking") else False
498
+ ),
499
+ action_type=(
500
+ "swap"
501
+ if agent_name == "token swap"
502
+ else "lending"
503
+ if agent_name == "lending"
504
+ else "staking"
505
+ if agent_name == "staking"
506
+ else None
507
+ ),
508
+ )
509
+ await asyncio.to_thread(
510
+ chat_manager_instance.add_message,
511
+ response_message.dict(),
512
+ conversation_id,
513
+ user_id,
514
+ )
515
+
516
+ if cost_delta.get("cost", 0) > 0 or cost_delta.get("calls", 0) > 0:
517
+ await asyncio.to_thread(
518
+ chat_manager_instance.update_conversation_costs,
519
+ cost_delta,
520
+ conversation_id,
521
+ user_id,
522
+ )
523
+
524
+ # Clear DeFi metadata when intent is ready
525
+ _clear_ready_metadata(agent_name, response_metadata, user_id, conversation_id)
526
+ except Exception:
527
+ logger.exception("Failed to persist streamed response")
528
+
529
+
530
+ @app.post("/chat/stream")
531
+ async def chat_stream(request: ChatRequest):
532
+ """SSE streaming endpoint — streams thought process + tokens in real-time.
533
+
534
+ Event types:
535
+ - ``status`` : node lifecycle (step label, routing info)
536
+ - ``token`` : incremental text chunks from the final LLM response
537
+ - ``tool_io`` : tool invocation results (truncated for the wire)
538
+ - ``done`` : final metadata envelope (agent, nodes, costs)
539
+ - ``error`` : unrecoverable error
540
+ """
541
+ uid: str | None = None
542
+ cid: str | None = None
543
+ try:
544
+ uid, cid = _resolve_identity(request)
545
+ except HTTPException as exc:
546
+ # Return error as a streaming event so the client can parse it
547
+ async def _err():
548
+ yield _sse("error", {"message": exc.detail})
549
+
550
+ return StreamingResponse(_err(), media_type="text/event-stream")
551
+
552
+ user_id, conversation_id = uid, cid
553
+
554
+ wallet = request.wallet_address.strip() if request.wallet_address else None
555
+ if wallet and wallet.lower() == "default":
556
+ wallet = None
557
+ display_name = None
558
+ if isinstance(request.message.metadata, dict):
559
+ display_name = request.message.metadata.get("display_name")
560
+
561
+ # Session setup, message persistence, and history fetch (non-blocking)
562
+ await asyncio.to_thread(
563
+ chat_manager_instance.ensure_session,
564
+ user_id,
565
+ conversation_id,
566
+ wallet_address=wallet,
567
+ display_name=display_name,
568
+ )
569
+
570
+ if request.message.role == "user":
571
+ clean_content = _sanitize_user_message_content(request.message.content)
572
+ if clean_content is not None:
573
+ request.message.content = clean_content
574
+
575
+ await asyncio.to_thread(
576
+ chat_manager_instance.add_message,
577
+ request.message.dict(),
578
+ conversation_id,
579
+ user_id,
580
+ )
581
+
582
+ conversation_messages = await asyncio.to_thread(
583
+ chat_manager_instance.get_messages,
584
+ conversation_id,
585
+ user_id,
586
+ )
587
+
588
+ initial_state: Dict[str, Any] = {
589
+ "messages": conversation_messages,
590
+ "user_id": user_id,
591
+ "conversation_id": conversation_id,
592
+ "wallet_address": wallet,
593
+ }
594
+
595
+ async def event_generator():
596
+ """Yields SSE events from LangGraph astream_events."""
597
+ cost_tracker = Config.get_cost_tracker()
598
+ cost_snapshot = cost_tracker.get_snapshot()
599
+
600
+ final_response_chunks: List[str] = []
601
+ response_agent = "supervisor"
602
+ response_metadata: Dict[str, Any] = {}
603
+ nodes_executed: List[str] = []
604
+ # Track which node is the "final agent" so we only stream its tokens
605
+ current_agent_node: str | None = None
606
+ streaming_tokens = False
607
+
608
+ try:
609
+ async for event in graph.astream_events(
610
+ initial_state, version="v2"
611
+ ):
612
+ kind = event["event"]
613
+ name = event.get("name", "")
614
+
615
+ # ── Node starts ──
616
+ if kind == "on_chain_start" and name in _NODE_LABELS:
617
+ nodes_executed.append(name)
618
+ # Track agent node for token attribution
619
+ if name.endswith("_agent_node"):
620
+ current_agent_node = name
621
+ yield _sse("status", {
622
+ "step": name,
623
+ "label": _NODE_LABELS[name],
624
+ "ts": time.time(),
625
+ })
626
+
627
+ # ── Semantic router result ──
628
+ elif kind == "on_chain_end" and name == "semantic_router_node":
629
+ output = event.get("data", {}).get("output", {})
630
+ if isinstance(output, dict):
631
+ yield _sse("status", {
632
+ "step": "routed",
633
+ "agent": output.get("route_agent", "unknown"),
634
+ "confidence": output.get("route_confidence", 0),
635
+ "ts": time.time(),
636
+ })
637
+
638
+ # ── Tool invocations ──
639
+ elif kind == "on_tool_start":
640
+ yield _sse("status", {
641
+ "step": "tool",
642
+ "tool": name,
643
+ "label": f"Using {name}...",
644
+ "ts": time.time(),
645
+ })
646
+
647
+ elif kind == "on_tool_end":
648
+ tool_output = event.get("data", {}).get("output", "")
649
+ preview = str(tool_output)[:200]
650
+ yield _sse("tool_io", {
651
+ "tool": name,
652
+ "output": preview,
653
+ "ts": time.time(),
654
+ })
655
+
656
+ # ── LLM token streaming ──
657
+ elif kind == "on_chat_model_stream":
658
+ chunk = event.get("data", {}).get("chunk")
659
+ if chunk and hasattr(chunk, "content") and chunk.content:
660
+ text = chunk.content if isinstance(chunk.content, str) else ""
661
+ if text:
662
+ # Only stream tokens from agent nodes, not from
663
+ # router/formatter internal calls
664
+ parent_tags = event.get("tags", [])
665
+ is_formatter = "formatter" in name.lower() or any(
666
+ "formatter" in t for t in parent_tags
667
+ )
668
+ if not is_formatter and current_agent_node:
669
+ if not streaming_tokens:
670
+ streaming_tokens = True
671
+ yield _sse("status", {
672
+ "step": "generating",
673
+ "label": "Generating response...",
674
+ "ts": time.time(),
675
+ })
676
+ final_response_chunks.append(text)
677
+ yield _sse("token", {"t": text})
678
+
679
+ # ── Node ends — capture graph output ──
680
+ elif kind == "on_chain_end" and name == "LangGraph":
681
+ output = event.get("data", {}).get("output", {})
682
+ if isinstance(output, dict):
683
+ response_agent = output.get(
684
+ "response_agent", response_agent
685
+ )
686
+ response_metadata = output.get(
687
+ "response_metadata", response_metadata
688
+ )
689
+ # If we didn't stream tokens (e.g. formatter rewrote),
690
+ # use the final_response from the graph
691
+ if not final_response_chunks:
692
+ graph_response = output.get("final_response", "")
693
+ if graph_response:
694
+ final_response_chunks.append(graph_response)
695
+
696
+ except Exception as exc:
697
+ logger.exception("Stream error for user=%s conversation=%s", user_id, conversation_id)
698
+ yield _sse("error", {"message": str(exc)})
699
+ return
700
+
701
+ # ── Build final metadata ──
702
+ full_response = "".join(final_response_chunks)
703
+ cost_delta = cost_tracker.calculate_delta(cost_snapshot)
704
+
705
+ agent_name = _map_agent_type(response_agent)
706
+
707
+ # Enrich metadata (same logic as _build_response_payload)
708
+ if not response_metadata:
709
+ if agent_name == "token swap":
710
+ swap_meta = metadata.get_swap_agent(
711
+ user_id=user_id, conversation_id=conversation_id
712
+ )
713
+ if swap_meta:
714
+ response_metadata = swap_meta
715
+ elif agent_name == "lending":
716
+ lending_meta = metadata.get_lending_agent(
717
+ user_id=user_id, conversation_id=conversation_id
718
+ )
719
+ if lending_meta:
720
+ response_metadata = lending_meta
721
+ elif agent_name == "staking":
722
+ staking_meta = metadata.get_staking_agent(
723
+ user_id=user_id, conversation_id=conversation_id
724
+ )
725
+ if staking_meta:
726
+ response_metadata = staking_meta
727
+
728
+ yield _sse("done", {
729
+ "agent": agent_name,
730
+ "nodes": nodes_executed,
731
+ "metadata": response_metadata,
732
+ "response": full_response,
733
+ "costs": {
734
+ "total_usd": cost_delta.get("cost", 0),
735
+ },
736
+ })
737
+
738
+ # ── Background: persist response + costs ──
739
+ asyncio.create_task(
740
+ _persist_response_bg(
741
+ full_response,
742
+ response_agent,
743
+ response_metadata,
744
+ user_id,
745
+ conversation_id,
746
+ cost_delta,
747
+ )
748
+ )
749
+
750
+ return StreamingResponse(
751
+ event_generator(),
752
+ media_type="text/event-stream",
753
+ headers={
754
+ "Cache-Control": "no-cache",
755
+ "Connection": "keep-alive",
756
+ "X-Accel-Buffering": "no",
757
+ },
758
+ )
759
+
760
+
761
  # Supported audio MIME types
762
  AUDIO_MIME_TYPES = {
763
  ".mp3": "audio/mpeg",
 
798
  {"transcription": "<exact transcription>", "intent": "<category>", "confidence": <0.0-1.0>}
799
  """
800
 
801
+ _AUDIO_TRANSCRIBE_ONLY_PROMPT = """\
802
+ You will receive an audio clip. Transcribe exactly what is being said.
803
+ Return ONLY the transcription text, nothing else. No JSON, no markdown, no labels.
804
+ """
805
+
806
  # Maps audio classification intents to agent runtime names
807
  _AUDIO_INTENT_AGENT_MAP: Dict[str, str] = {
808
  "swap": "swap_agent",
 
810
  "staking": "staking_agent",
811
  "dca": "dca_agent",
812
  "market_data": "crypto_agent",
813
+ "portfolio": "portfolio_advisor",
814
  "search": "search_agent",
815
  "education": "default_agent",
816
  "general": "default_agent",
 
1020
  conversation_messages,
1021
  request_user_id,
1022
  request_conversation_id,
1023
+ wallet_address=wallet,
1024
  pre_classified=pre_classified,
1025
  )
1026
 
 
1066
  raise HTTPException(status_code=500, detail=str(e))
1067
 
1068
 
1069
+ @app.post("/transcribe")
1070
+ async def transcribe_audio(
1071
+ audio: UploadFile = File(..., description="Audio file (mp3, wav, flac, ogg, webm, m4a)"),
1072
+ ):
1073
+ """Transcribe audio to text without invoking the agent pipeline.
1074
+
1075
+ Stateless endpoint — no user_id, conversation_id, or persistence.
1076
+ Returns ``{"text": "<transcription>"}``.
1077
+ """
1078
+ try:
1079
+ audio_content = await audio.read()
1080
+ if len(audio_content) > MAX_AUDIO_SIZE:
1081
+ raise HTTPException(
1082
+ status_code=413,
1083
+ detail=f"Audio file too large. Maximum size is {MAX_AUDIO_SIZE // (1024 * 1024)}MB.",
1084
+ )
1085
+ if len(audio_content) == 0:
1086
+ raise HTTPException(status_code=400, detail="Audio file is empty.")
1087
+
1088
+ mime_type = _get_audio_mime_type(audio.filename or "", audio.content_type)
1089
+ encoded_audio = base64.b64encode(audio_content).decode("utf-8")
1090
+
1091
+ from src.llm.tiers import ModelTier
1092
+ llm = Config.get_llm(model=ModelTier.TRANSCRIPTION, with_cost_tracking=True)
1093
+
1094
+ message = HumanMessage(
1095
+ content=[
1096
+ {"type": "text", "text": _AUDIO_TRANSCRIBE_ONLY_PROMPT},
1097
+ {"type": "media", "data": encoded_audio, "mime_type": mime_type},
1098
+ ]
1099
+ )
1100
+
1101
+ response = await asyncio.to_thread(llm.invoke, [message])
1102
+
1103
+ raw_content = response.content
1104
+ if isinstance(raw_content, list):
1105
+ text_parts = []
1106
+ for part in raw_content:
1107
+ if isinstance(part, dict) and part.get("text"):
1108
+ text_parts.append(part["text"])
1109
+ elif isinstance(part, str):
1110
+ text_parts.append(part)
1111
+ raw_content = " ".join(text_parts).strip()
1112
+
1113
+ text = (raw_content or "").strip()
1114
+ if not text:
1115
+ raise HTTPException(
1116
+ status_code=400,
1117
+ detail="Could not transcribe the audio. Please try again with a clearer recording.",
1118
+ )
1119
+
1120
+ return {"text": text}
1121
+
1122
+ except HTTPException:
1123
+ raise
1124
+ except Exception as e:
1125
+ logger.exception("Transcribe endpoint failed")
1126
+ raise HTTPException(status_code=500, detail=str(e))
1127
+
1128
+
1129
  # Include chat manager router
1130
  app.include_router(chat_manager_router)
src/graphs/edges.py CHANGED
@@ -27,6 +27,7 @@ _INTENT_TO_NODE = {
27
  IntentCategory.STAKING.value: "staking_agent_node",
28
  IntentCategory.DCA.value: "dca_agent_node",
29
  IntentCategory.MARKET_DATA.value: "crypto_agent_node",
 
30
  IntentCategory.SEARCH.value: "search_agent_node",
31
  IntentCategory.EDUCATION.value: "default_agent_node",
32
  IntentCategory.GENERAL.value: "default_agent_node",
@@ -41,6 +42,7 @@ _AGENT_NAME_TO_NODE = {
41
  "search_agent": "search_agent_node",
42
  "default_agent": "default_agent_node",
43
  "database_agent": "database_agent_node",
 
44
  }
45
 
46
  # DeFi state key → node mapping
 
27
  IntentCategory.STAKING.value: "staking_agent_node",
28
  IntentCategory.DCA.value: "dca_agent_node",
29
  IntentCategory.MARKET_DATA.value: "crypto_agent_node",
30
+ IntentCategory.PORTFOLIO.value: "portfolio_advisor_node",
31
  IntentCategory.SEARCH.value: "search_agent_node",
32
  IntentCategory.EDUCATION.value: "default_agent_node",
33
  IntentCategory.GENERAL.value: "default_agent_node",
 
42
  "search_agent": "search_agent_node",
43
  "default_agent": "default_agent_node",
44
  "database_agent": "database_agent_node",
45
+ "portfolio_advisor": "portfolio_advisor_node",
46
  }
47
 
48
  # DeFi state key → node mapping
src/graphs/factory.py CHANGED
@@ -24,6 +24,7 @@ from src.graphs.nodes import (
24
  search_agent_node,
25
  default_agent_node,
26
  database_agent_node,
 
27
  )
28
  from src.graphs.edges import decide_route, after_llm_router
29
  from src.agents.formatter.node import formatter_node
@@ -40,6 +41,7 @@ _AGENT_NODES = [
40
  "search_agent_node",
41
  "default_agent_node",
42
  "database_agent_node",
 
43
  ]
44
 
45
 
@@ -70,6 +72,7 @@ def build_graph() -> StateGraph:
70
  graph.add_node("search_agent_node", search_agent_node)
71
  graph.add_node("default_agent_node", default_agent_node)
72
  graph.add_node("database_agent_node", database_agent_node)
 
73
 
74
  # --- Entry point ---
75
  graph.set_entry_point("entry_node")
@@ -92,6 +95,7 @@ def build_graph() -> StateGraph:
92
  "search_agent_node": "search_agent_node",
93
  "default_agent_node": "default_agent_node",
94
  "database_agent_node": "database_agent_node",
 
95
  },
96
  )
97
 
@@ -108,6 +112,7 @@ def build_graph() -> StateGraph:
108
  "search_agent_node": "search_agent_node",
109
  "default_agent_node": "default_agent_node",
110
  "database_agent_node": "database_agent_node",
 
111
  },
112
  )
113
 
 
24
  search_agent_node,
25
  default_agent_node,
26
  database_agent_node,
27
+ portfolio_advisor_node,
28
  )
29
  from src.graphs.edges import decide_route, after_llm_router
30
  from src.agents.formatter.node import formatter_node
 
41
  "search_agent_node",
42
  "default_agent_node",
43
  "database_agent_node",
44
+ "portfolio_advisor_node",
45
  ]
46
 
47
 
 
72
  graph.add_node("search_agent_node", search_agent_node)
73
  graph.add_node("default_agent_node", default_agent_node)
74
  graph.add_node("database_agent_node", database_agent_node)
75
+ graph.add_node("portfolio_advisor_node", portfolio_advisor_node)
76
 
77
  # --- Entry point ---
78
  graph.set_entry_point("entry_node")
 
95
  "search_agent_node": "search_agent_node",
96
  "default_agent_node": "default_agent_node",
97
  "database_agent_node": "database_agent_node",
98
+ "portfolio_advisor_node": "portfolio_advisor_node",
99
  },
100
  )
101
 
 
112
  "search_agent_node": "search_agent_node",
113
  "default_agent_node": "default_agent_node",
114
  "database_agent_node": "database_agent_node",
115
+ "portfolio_advisor_node": "portfolio_advisor_node",
116
  },
117
  )
118
 
src/graphs/nodes.py CHANGED
@@ -48,6 +48,9 @@ from src.agents.staking.agent import StakingAgent
48
  from src.agents.staking.tools import staking_session
49
  from src.agents.staking.prompt import STAKING_AGENT_SYSTEM_PROMPT
50
  from src.agents.search.agent import SearchAgent
 
 
 
51
  from src.agents.database.client import is_database_available
52
 
53
  logger = logging.getLogger(__name__)
@@ -90,6 +93,8 @@ def initialize_agents() -> None:
90
  _agents["lending_agent"] = LendingAgent(llm).agent
91
  _agents["staking_agent"] = StakingAgent(llm).agent
92
 
 
 
93
  if is_database_available():
94
  _agents["database_agent"] = DatabaseAgent(llm)
95
  else:
@@ -261,6 +266,7 @@ Available agents:
261
  - dca_agent: Dollar-cost averaging strategies.
262
  - lending_agent: Lending operations (supply, borrow, repay, withdraw).
263
  - staking_agent: Staking operations (stake ETH, unstake stETH via Lido).
 
264
  - search_agent: Web search for current events and factual lookups.
265
  - database_agent: Database queries and data analysis.
266
  - default_agent: General conversation, education, greetings.
@@ -287,7 +293,8 @@ def llm_router_node(state: AgentState) -> dict:
287
  # Validate
288
  valid_agents = {
289
  "crypto_agent", "swap_agent", "dca_agent", "lending_agent",
290
- "staking_agent", "search_agent", "database_agent", "default_agent",
 
291
  }
292
  if chosen not in valid_agents:
293
  chosen = "default_agent"
@@ -464,5 +471,53 @@ def default_agent_node(state: AgentState, config: RunnableConfig | None = None)
464
  return _invoke_simple_agent("default_agent", state, config)
465
 
466
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  def database_agent_node(state: AgentState, config: RunnableConfig | None = None) -> dict:
468
  return _invoke_simple_agent("database_agent", state, config)
 
48
  from src.agents.staking.tools import staking_session
49
  from src.agents.staking.prompt import STAKING_AGENT_SYSTEM_PROMPT
50
  from src.agents.search.agent import SearchAgent
51
+ from src.agents.portfolio.agent import PortfolioAdvisorAgent
52
+ from src.agents.portfolio.tools import portfolio_session
53
+ from src.agents.portfolio.prompt import PORTFOLIO_ADVISOR_SYSTEM_PROMPT
54
  from src.agents.database.client import is_database_available
55
 
56
  logger = logging.getLogger(__name__)
 
93
  _agents["lending_agent"] = LendingAgent(llm).agent
94
  _agents["staking_agent"] = StakingAgent(llm).agent
95
 
96
+ _agents["portfolio_advisor"] = PortfolioAdvisorAgent(llm).agent
97
+
98
  if is_database_available():
99
  _agents["database_agent"] = DatabaseAgent(llm)
100
  else:
 
266
  - dca_agent: Dollar-cost averaging strategies.
267
  - lending_agent: Lending operations (supply, borrow, repay, withdraw).
268
  - staking_agent: Staking operations (stake ETH, unstake stETH via Lido).
269
+ - portfolio_advisor: Portfolio analysis, risk assessment, wallet holdings, rebalancing advice.
270
  - search_agent: Web search for current events and factual lookups.
271
  - database_agent: Database queries and data analysis.
272
  - default_agent: General conversation, education, greetings.
 
293
  # Validate
294
  valid_agents = {
295
  "crypto_agent", "swap_agent", "dca_agent", "lending_agent",
296
+ "staking_agent", "portfolio_advisor", "search_agent",
297
+ "database_agent", "default_agent",
298
  }
299
  if chosen not in valid_agents:
300
  chosen = "default_agent"
 
471
  return _invoke_simple_agent("default_agent", state, config)
472
 
473
 
474
+ def portfolio_advisor_node(state: AgentState, config: RunnableConfig | None = None) -> dict:
475
+ """Invoke the portfolio advisor with wallet_address session context."""
476
+ user_id = state.get("user_id")
477
+ conversation_id = state.get("conversation_id")
478
+ wallet_address = state.get("wallet_address")
479
+ langchain_messages = list(state.get("langchain_messages", []))
480
+ nodes = list(state.get("nodes_executed", []))
481
+ nodes.append("portfolio_advisor_node")
482
+
483
+ agent = _agents.get("portfolio_advisor")
484
+ if not agent:
485
+ return {
486
+ "final_response": "Portfolio advisor is not available.",
487
+ "response_agent": "portfolio_advisor",
488
+ "response_metadata": {},
489
+ "raw_agent_messages": [],
490
+ "nodes_executed": nodes,
491
+ }
492
+
493
+ # Inject system prompt
494
+ scoped_messages = [SystemMessage(content=PORTFOLIO_ADVISOR_SYSTEM_PROMPT)]
495
+ scoped_messages.extend(langchain_messages)
496
+
497
+ try:
498
+ with portfolio_session(user_id=user_id, conversation_id=conversation_id, wallet_address=wallet_address):
499
+ response = agent.invoke({"messages": scoped_messages}, config=config)
500
+ except Exception:
501
+ logger.exception("Error invoking portfolio_advisor")
502
+ return {
503
+ "final_response": "Sorry, an error occurred while analyzing your portfolio.",
504
+ "response_agent": "portfolio_advisor",
505
+ "response_metadata": {},
506
+ "raw_agent_messages": [],
507
+ "nodes_executed": nodes,
508
+ }
509
+
510
+ agent_name, text, messages_out = extract_response_from_graph(response)
511
+ meta = build_metadata(agent_name or "portfolio_advisor", user_id, conversation_id, messages_out)
512
+
513
+ return {
514
+ "final_response": text,
515
+ "response_agent": agent_name or "portfolio_advisor",
516
+ "response_metadata": meta,
517
+ "raw_agent_messages": messages_out,
518
+ "nodes_executed": nodes,
519
+ }
520
+
521
+
522
  def database_agent_node(state: AgentState, config: RunnableConfig | None = None) -> dict:
523
  return _invoke_simple_agent("database_agent", state, config)
src/graphs/state.py CHANGED
@@ -14,6 +14,7 @@ class AgentState(TypedDict, total=False):
14
  messages: List[Dict[str, Any]] # Raw conversation messages from the gateway
15
  user_id: str
16
  conversation_id: str
 
17
 
18
  # --- Windowed context ---
19
  windowed_messages: List[Dict[str, Any]] # After context windowing
 
14
  messages: List[Dict[str, Any]] # Raw conversation messages from the gateway
15
  user_id: str
16
  conversation_id: str
17
+ wallet_address: Optional[str] # EVM wallet address (from HTTP request)
18
 
19
  # --- Windowed context ---
20
  windowed_messages: List[Dict[str, Any]] # After context windowing