From 030d2b38b53c528b9cff76cff4259e4eaddd9fd0 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:14:08 +0800 Subject: [PATCH 1/2] feature: implement sending actionable plan rationale to Discord webhook --- .../strategy_agent/decision/composer.py | 56 ++++++++++++++++++- .../valuecell/agents/strategy_agent/utils.py | 51 ++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index 15d9bc286..282f8229f 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -21,7 +21,7 @@ TradeSide, UserRequest, ) -from ..utils import extract_price_map +from ..utils import extract_price_map, send_discord_message from .interfaces import Composer from .system_prompt import SYSTEM_PROMPT @@ -71,6 +71,12 @@ async def compose(self, context: ComposeContext) -> List[TradeInstruction]: logger.exception("LLM invocation failed") return [] + # Optionally forward non-NOOP plan rationale to Discord webhook (env-driven) + try: + await self._send_plan_to_discord(plan) + except Exception: # do not fail compose on notification errors + logger.exception("Failed sending plan to Discord") + return self._normalize_plan(context, plan) # ------------------------------------------------------------------ @@ -258,6 +264,54 @@ async def _call_llm(self, prompt: str) -> LlmPlanProposal: logger.debug("Received LLM response {}", content) return content + async def _send_plan_to_discord(self, plan: LlmPlanProposal) -> None: + """Send plan rationale to Discord when there are actionable items. + + Behavior: + - If `plan.items` contains any item whose `action` is not `NOOP`, send + a Markdown-formatted message containing the plan-level rationale and + per-item brief rationales. + - Reads webhook from `STRATEGY_AGENT_DISCORD_WEBHOOK_URL` (handled by + `send_discord_message`). Does nothing if no actionable items exist. + """ + actionable = [it for it in plan.items if it.action != LlmDecisionAction.NOOP] + if not actionable: + return + + strategy_name = self._request.trading_config.strategy_name + parts = [f"## Strategy {strategy_name} — Actions Detected\n"] + # top-level rationale + top_r = plan.rationale + if top_r: + parts.append("**Overall rationale:**\n") + parts.append(f"{top_r}\n") + + parts.append("**Items:**\n") + for it in actionable: + action = it.action.value + instr_parts = [] + # instrument symbol if exists + instr_parts.append(f"`{it.instrument.symbol}`") + # target qty / magnitude + instr_parts.append(f"qty={it.target_qty}") + # item rationale + item_r = it.rationale + summary = " — ".join(instr_parts) if instr_parts else "" + if item_r: + parts.append(f"- **{action}** {summary} — Reasoning: {item_r}\n") + else: + parts.append(f"- **{action}** {summary}\n") + + message = "\n".join(parts) + + try: + resp = await send_discord_message(message) + logger.debug( + "Sent plan to Discord, response len={}", len(resp) if resp else 0 + ) + except Exception as exc: + logger.warning("Error sending plan to Discord, err={}", exc) + # ------------------------------------------------------------------ # Normalization / guardrails helpers diff --git a/python/valuecell/agents/strategy_agent/utils.py b/python/valuecell/agents/strategy_agent/utils.py index dee41a322..2a993e314 100644 --- a/python/valuecell/agents/strategy_agent/utils.py +++ b/python/valuecell/agents/strategy_agent/utils.py @@ -1,6 +1,8 @@ -from typing import Dict +import os +from typing import Dict, Optional import ccxt.pro as ccxtpro +import httpx from loguru import logger @@ -69,3 +71,50 @@ def get_exchange_cls(exchange_id: str): if exchange_cls is None: raise RuntimeError(f"Exchange '{exchange_id}' not found in ccxt.pro") return exchange_cls + + +async def send_discord_message( + content: str, + webhook_url: Optional[str] = None, + *, + raise_for_status: bool = True, + timeout: float = 10.0, +) -> str: + """Send a message to Discord via webhook asynchronously. + + Reads the webhook URL from the environment variable + `STRATEGY_AGENT_DISCORD_WEBHOOK_URL` when `webhook_url` is not provided. + + Args: + content: The message content to send. + webhook_url: Optional webhook URL to override the environment variable. + raise_for_status: If True, raise on non-2xx responses. + timeout: Request timeout in seconds. + + Returns: + The response body as text. + + Raises: + ValueError: If no webhook URL is provided or available in env. + ImportError: If `httpx` is not installed. + httpx.HTTPStatusError: If `raise_for_status` is True and the response is an HTTP error. + """ + if webhook_url is None: + webhook_url = os.getenv("STRATEGY_AGENT_DISCORD_WEBHOOK_URL") + + if not webhook_url: + raise ValueError( + "Discord webhook URL not provided and STRATEGY_AGENT_DISCORD_WEBHOOK_URL is not set" + ) + + headers = { + "Accept": "text", + "Content-Type": "application/json", + } + payload = {"content": content} + + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(webhook_url, headers=headers, json=payload) + if raise_for_status: + resp.raise_for_status() + return resp.text From ddbcabe110b5b8e7e4132505154474a78c0a7596 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:42:28 +0800 Subject: [PATCH 2/2] fix: improve error logging for Discord plan notifications --- python/valuecell/agents/strategy_agent/decision/composer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index 282f8229f..0d489b84b 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -74,8 +74,8 @@ async def compose(self, context: ComposeContext) -> List[TradeInstruction]: # Optionally forward non-NOOP plan rationale to Discord webhook (env-driven) try: await self._send_plan_to_discord(plan) - except Exception: # do not fail compose on notification errors - logger.exception("Failed sending plan to Discord") + except Exception as exc: # do not fail compose on notification errors + logger.error("Failed sending plan to Discord: {}", exc) return self._normalize_plan(context, plan)