From 97c7696af3bf085835a33bd03adff995005a387d Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Mon, 8 Sep 2025 18:43:54 +0800 Subject: [PATCH 01/21] feat: refactor AI Hedge Fund Agent structure and add remote agent support --- .../configs/agent_cards/hedge_fund_agent.json | 5 + .../ai-hedge-fund/adapter/__main__.py | 75 ++-------- .../ai-hedge-fund/adapter/agent_executor.py | 28 ---- .../adapter/hedge_fund_agent.json | 27 ---- .../ai-hedge-fund/adapter/test_client.py | 35 ++--- .../ai-hedge-fund/launch_adapter.sh | 6 + python/valuecell/core/agent/connect.py | 135 +++++++++++++++--- python/valuecell/core/agent/types.py | 7 +- 8 files changed, 164 insertions(+), 154 deletions(-) create mode 100644 python/configs/agent_cards/hedge_fund_agent.json delete mode 100644 python/third_party/ai-hedge-fund/adapter/agent_executor.py delete mode 100644 python/third_party/ai-hedge-fund/adapter/hedge_fund_agent.json create mode 100644 python/third_party/ai-hedge-fund/launch_adapter.sh diff --git a/python/configs/agent_cards/hedge_fund_agent.json b/python/configs/agent_cards/hedge_fund_agent.json new file mode 100644 index 000000000..2031bb854 --- /dev/null +++ b/python/configs/agent_cards/hedge_fund_agent.json @@ -0,0 +1,5 @@ +{ + "name": "AIHedgeFundAgent", + "url": "http://localhost:10001/", + "enabled": true +} \ No newline at end of file diff --git a/python/third_party/ai-hedge-fund/adapter/__main__.py b/python/third_party/ai-hedge-fund/adapter/__main__.py index 00c14dd40..a768c1a3e 100644 --- a/python/third_party/ai-hedge-fund/adapter/__main__.py +++ b/python/third_party/ai-hedge-fund/adapter/__main__.py @@ -1,67 +1,22 @@ +import asyncio import json -import logging -import sys -from pathlib import Path -import click -import httpx -import uvicorn -from a2a.server.apps import A2AStarletteApplication -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import ( - BasePushNotificationSender, - InMemoryPushNotificationConfigStore, - InMemoryTaskStore, -) -from a2a.types import AgentCard -from adapter.agent_executor import VanillaAgentExecutor +from valuecell.core.agent.decorator import serve +from valuecell.core.agent.types import BaseAgent -logger = logging.getLogger(__name__) +from src.main import run_hedge_fund -@click.command() -@click.option('--host', 'host', default='localhost') -@click.option('--port', 'port', default=10001) -@click.option('--agent-card', 'agent_card_file') -def main(host, port, agent_card_file): - """Starts an Agent server.""" - try: - if not agent_card_file: - raise ValueError('Agent card is required') - with Path.open(agent_card_file) as file: - data = json.load(file) - agent_card = AgentCard(**data) +class AIHedgeFundAgent(BaseAgent): + async def stream(self, query, session_id, task_id): + query = json.loads(query) + result = run_hedge_fund(**query) + yield { + "content": json.dumps(result), + "is_task_complete": True, + } - client = httpx.AsyncClient() - push_notification_config_store = InMemoryPushNotificationConfigStore() - push_notification_sender = BasePushNotificationSender( - client, config_store=push_notification_config_store - ) - request_handler = DefaultRequestHandler( - agent_executor=VanillaAgentExecutor(), - task_store=InMemoryTaskStore(), - push_config_store=push_notification_config_store, - push_sender=push_notification_sender, - ) - - server = A2AStarletteApplication( - agent_card=agent_card, http_handler=request_handler - ) - - logger.info(f'Starting server on {host}:{port}') - - uvicorn.run(server.build(), host=host, port=port) - except FileNotFoundError: - logger.error(f"Error: File '{agent_card_file}' not found.") - sys.exit(1) - except json.JSONDecodeError: - logger.error(f"Error: File '{agent_card_file}' contains invalid JSON.") - sys.exit(1) - except Exception as e: - logger.error(f'An error occurred during server startup: {e}') - sys.exit(1) - - -if __name__ == '__main__': - main() +if __name__ == "__main__": + agent = serve(port=10001)(AIHedgeFundAgent)() + asyncio.run(agent.serve()) diff --git a/python/third_party/ai-hedge-fund/adapter/agent_executor.py b/python/third_party/ai-hedge-fund/adapter/agent_executor.py deleted file mode 100644 index 429f673ee..000000000 --- a/python/third_party/ai-hedge-fund/adapter/agent_executor.py +++ /dev/null @@ -1,28 +0,0 @@ -import json - -from a2a.server.agent_execution import AgentExecutor, RequestContext -from a2a.server.events import EventQueue -from a2a.types import Task, UnsupportedOperationError -from a2a.utils import new_agent_text_message -from a2a.utils.errors import ServerError -from src.main import run_hedge_fund - - -class VanillaAgent: - async def invoke(self, message: dict) -> str: - result = run_hedge_fund(**message) - return json.dumps(result) - - -class VanillaAgentExecutor(AgentExecutor): - def __init__(self): - self.agent = VanillaAgent() - - async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: - result = await self.agent.invoke(json.loads(context.get_user_input())) - await event_queue.enqueue_event(new_agent_text_message(result)) - - async def cancel( - self, request: RequestContext, event_queue: EventQueue - ) -> Task | None: - raise ServerError(error=UnsupportedOperationError()) diff --git a/python/third_party/ai-hedge-fund/adapter/hedge_fund_agent.json b/python/third_party/ai-hedge-fund/adapter/hedge_fund_agent.json deleted file mode 100644 index 161698b67..000000000 --- a/python/third_party/ai-hedge-fund/adapter/hedge_fund_agent.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "AI Hedge Fund Agent", - "description": "Proof-of-concept multi-agent system that analyzes equities and generates research-focused trading signals. For educational and research use only — not intended for real trading or investment.", - "url": "http://localhost:10001/", - "version": "1.0.0", - "capabilities": { - "streaming": false, - "pushNotifications": false, - "stateTransitionHistory": false - }, - "defaultInputModes": [ - "text", - "text/plain" - ], - "defaultOutputModes": [ - "text", - "text/plain" - ], - "skills": [ - { - "id": "run_hedge_fund", - "name": "Run Hedge Fund", - "description": "Execute the multi-agent analysis pipeline for specified tickers and date range. Returns proposed orders, position sizing and rationale from contributing agents.", - "tags": ["analysis"] - } - ] -} \ No newline at end of file diff --git a/python/third_party/ai-hedge-fund/adapter/test_client.py b/python/third_party/ai-hedge-fund/adapter/test_client.py index 70e2481a0..f1ac809b6 100644 --- a/python/third_party/ai-hedge-fund/adapter/test_client.py +++ b/python/third_party/ai-hedge-fund/adapter/test_client.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) # Get a logger instance -async def _main(tickers, initial_cash, model_provider, model_name): +async def _main(tickers, initial_cash, model_provider, model_name, selected_analysts): base_url = "http://localhost:10001" print(f"{tickers=}") @@ -27,21 +27,14 @@ async def _main(tickers, initial_cash, model_provider, model_name): resolver = A2ACardResolver(httpx_client=httpx_client, base_url=base_url) try: - logger.info( - "Attempting to fetch public agent card from: " - f"{base_url}{AGENT_CARD_WELL_KNOWN_PATH}" - ) + logger.info("Attempting to fetch public agent card from: " f"{base_url}{AGENT_CARD_WELL_KNOWN_PATH}") agent_card = await resolver.get_agent_card() logger.info("Successfully fetched public agent card:") # logger.info(agent_card.model_dump_json(indent=2, exclude_none=True)) except Exception as e: - logger.error( - f"Critical error fetching public agent card: {e}", exc_info=True - ) - raise RuntimeError( - "Failed to fetch the public agent card. Cannot continue." - ) from e + logger.error(f"Critical error fetching public agent card: {e}", exc_info=True) + raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e client = A2AClient(httpx_client=httpx_client, agent_card=agent_card) logger.info("A2AClient initialized.") @@ -78,7 +71,7 @@ async def _main(tickers, initial_cash, model_provider, model_name): "portfolio": portfolio, "model_name": model_name, "model_provider": model_provider, - "selected_analysts": [value for _, value in ANALYST_ORDER], + "selected_analysts": selected_analysts, } payload = { @@ -89,9 +82,7 @@ async def _main(tickers, initial_cash, model_provider, model_name): "contextId": uuid4().hex, }, } - request = SendMessageRequest( - id=str(uuid4()), params=MessageSendParams(**payload) - ) + request = SendMessageRequest(id=str(uuid4()), params=MessageSendParams(**payload)) response = await client.send_message(request, http_kwargs={"timeout": None}) print(response.model_dump(mode="json", exclude_none=True)) @@ -102,9 +93,19 @@ async def _main(tickers, initial_cash, model_provider, model_name): @click.option("--initial-cash", "initial_cash", default=10000.00) @click.option("--model-provider", "model_provider", default="OpenRouter") @click.option("--model-name", "model_name", default="openai/gpt-4o-mini") -def main(tickers, initial_cash, model_provider, model_name) -> None: +@click.option("--analysts", "analysts", default="all") +def main(tickers, initial_cash, model_provider, model_name, analysts) -> None: + supported_analysts = [name for name, _ in sorted(ANALYST_ORDER.items(), key=lambda x: x[1])] tickers = [ticker.strip().upper() for ticker in tickers.split(",")] - asyncio.run(_main(tickers, initial_cash, model_provider, model_name)) + if analysts == "all": + analysts = [name for name, _ in supported_analysts] + else: + analysts = [name.strip() for name in analysts.split(",")] + for name in analysts: + if name not in supported_analysts: + raise ValueError(f"Unknown analyst name: {name}. Supported analysts: {supported_analysts}") + + asyncio.run(_main(tickers, initial_cash, model_provider, model_name, analysts)) if __name__ == "__main__": diff --git a/python/third_party/ai-hedge-fund/launch_adapter.sh b/python/third_party/ai-hedge-fund/launch_adapter.sh new file mode 100644 index 000000000..4c3395bdd --- /dev/null +++ b/python/third_party/ai-hedge-fund/launch_adapter.sh @@ -0,0 +1,6 @@ +echo "Starting AI Hedge Fund Agent..." +echo "Python Environment Overview:" +echo "uv: $(which uv)" +echo "python: $(which python)" + +python -m adapter diff --git a/python/valuecell/core/agent/connect.py b/python/valuecell/core/agent/connect.py index 0c2a05f6d..6fbeae2e5 100644 --- a/python/valuecell/core/agent/connect.py +++ b/python/valuecell/core/agent/connect.py @@ -1,10 +1,15 @@ import asyncio +import json import logging +from pathlib import Path from typing import Dict, List +from a2a.client import A2ACardResolver +from a2a.types import AgentCard +import httpx from valuecell.core.agent.client import AgentClient -from valuecell.core.agent.registry import AgentRegistry from valuecell.core.agent.listener import NotificationListener +from valuecell.core.agent.registry import AgentRegistry from valuecell.utils import get_next_available_port logger = logging.getLogger(__name__) @@ -19,6 +24,75 @@ def __init__(self): self._agent_instances: Dict[str, object] = {} self._listeners: Dict[str, asyncio.Task] = {} self._listener_urls: Dict[str, str] = {} + # Remote agent cards loaded from config files + self._remote_agent_cards: Dict[str, AgentCard] = {} + + async def load_remote_agents(self, config_dir: str = None) -> None: + """Load remote agent cards from configuration directory.""" + if config_dir is None: + # Default to python/configs/agent_cards relative to current file + current_file = Path(__file__) + config_dir = ( + current_file.parent.parent.parent.parent / "configs" / "agent_cards" + ) + else: + config_dir = Path(config_dir) + + if not config_dir.exists(): + logger.warning(f"Remote agent config directory not found: {config_dir}") + return + + async with httpx.AsyncClient() as httpx_client: + loaded_count = 0 + for json_file in config_dir.glob("*.json"): + try: + with open(json_file, "r", encoding="utf-8") as f: + card_data = json.load(f) + + agent_name = card_data.get("name") + if not agent_name: + logger.warning(f"No 'name' field in {json_file}, skipping") + continue + + # Validate required fields + required_fields = ["name", "url"] + if not all(field in card_data for field in required_fields): + logger.warning( + f"Missing required fields in {json_file}, skipping" + ) + continue + + resolver = A2ACardResolver( + httpx_client=httpx_client, base_url=card_data["url"] + ) + self._remote_agent_cards[ + agent_name + ] = await resolver.get_agent_card() + loaded_count += 1 + logger.info( + f"Loaded remote agent card: {agent_name} from {json_file.name}" + ) + + except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: + logger.error( + f"Failed to load remote agent card from {json_file}: {e}" + ) + + logger.info(f"Loaded {loaded_count} remote agent cards from {config_dir}") + + async def connect_remote_agent(self, agent_name: str) -> str: + """Connect to a remote agent (no lifecycle management).""" + if agent_name not in self._remote_agent_cards: + raise ValueError(f"Remote agent '{agent_name}' not found in loaded cards") + + card_data = self._remote_agent_cards[agent_name] + agent_url = card_data.url + + # Create client connection for remote agent + self._connections[agent_name] = AgentClient(agent_url) + + logger.info(f"Connected to remote agent '{agent_name}' at {agent_url}") + return agent_url async def start_agent( self, @@ -156,7 +230,11 @@ async def _cleanup_agent(self, agent_name: str): async def get_client(self, agent_name: str) -> AgentClient: """Get Agent client connection""" if agent_name not in self._connections: - await self.start_agent(agent_name) + # Try to connect to remote agent first, then fallback to local + if agent_name in self._remote_agent_cards: + await self.connect_remote_agent(agent_name) + else: + await self.start_agent(agent_name) return self._connections[agent_name] @@ -170,8 +248,10 @@ def list_running_agents(self) -> List[str]: return list(self._running_agents.keys()) def list_available_agents(self) -> List[str]: - """List all available agents from registry""" - return AgentRegistry.list_agents() + """List all available agents from registry and remote cards""" + local_agents = AgentRegistry.list_agents() + remote_agents = list(self._remote_agent_cards.keys()) + return local_agents + remote_agents async def stop_all(self): """Stop all running agents""" @@ -180,15 +260,38 @@ async def stop_all(self): def get_agent_info(self, agent_name: str) -> dict: """Get agent information including listener info""" - if agent_name not in self._agent_instances: - return None - - agent_instance = self._agent_instances[agent_name] - return { - "name": agent_name, - "url": agent_instance.agent_card.url, - "listener_url": self._listener_urls.get(agent_name), - "card": agent_instance.agent_card.model_dump(exclude_none=True), - "running": agent_name in self._running_agents, - "has_listener": agent_name in self._listeners, - } + # Check if it's a local agent + if agent_name in self._agent_instances: + agent_instance = self._agent_instances[agent_name] + return { + "name": agent_name, + "type": "local", + "url": agent_instance.agent_card.url, + "listener_url": self._listener_urls.get(agent_name), + "card": agent_instance.agent_card.model_dump(exclude_none=True), + "running": agent_name in self._running_agents, + "has_listener": agent_name in self._listeners, + } + + # Check if it's a remote agent + if agent_name in self._remote_agent_cards: + card_data = self._remote_agent_cards[agent_name] + return { + "name": agent_name, + "type": "remote", + "url": card_data.url, + "card": card_data, + "connected": agent_name in self._connections, + "running": False, # Remote agents are not managed by us + "has_listener": False, + } + + return None + + def list_remote_agents(self) -> List[str]: + """List remote agents loaded from config files""" + return list(self._remote_agent_cards.keys()) + + def get_remote_agent_card(self, agent_name: str) -> dict: + """Get remote agent card data""" + return self._remote_agent_cards.get(agent_name) diff --git a/python/valuecell/core/agent/types.py b/python/valuecell/core/agent/types.py index bc5198ae2..0510ee997 100644 --- a/python/valuecell/core/agent/types.py +++ b/python/valuecell/core/agent/types.py @@ -16,16 +16,11 @@ class StreamResponse(BaseModel): ) -class BaseAgent(ABC, BaseModel): +class BaseAgent(ABC): """ Abstract base class for all agents. """ - agent_name: str = Field(..., description="Unique name of the agent") - description: str = Field( - ..., description="Description of the agent's purpose and functionality" - ) - @abstractmethod async def stream(self, query, session_id, task_id) -> StreamResponse: """ From f0615a7b38603efe6895a290bb50478efc5c30d6 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:11:55 +0800 Subject: [PATCH 02/21] fix: enhance error handling in GenericAgentExecutor and improve logging --- python/valuecell/core/agent/decorator.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/python/valuecell/core/agent/decorator.py b/python/valuecell/core/agent/decorator.py index ba1baff86..34a6d1450 100644 --- a/python/valuecell/core/agent/decorator.py +++ b/python/valuecell/core/agent/decorator.py @@ -22,7 +22,7 @@ TextPart, UnsupportedOperationError, ) -from a2a.utils import new_task +from a2a.utils import new_task, new_agent_text_message from a2a.utils.errors import ServerError from valuecell.core.agent.registry import AgentRegistry from valuecell.core.agent.types import BaseAgent @@ -186,8 +186,15 @@ async def _add_chunk(content: str, last: bool = False): await updater.complete() break except Exception as e: - # Convert unexpected errors into server errors so callers can handle them uniformly - raise ServerError(error=e) from e + message = ( + f"Error during {self.agent.__class__.__name__} agent execution : {e}" + ) + logger.error(message) + await updater.update_status( + TaskState.failed, + message=new_agent_text_message(message, task.context_id, task.id), + final=True, + ) async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: # Default cancel operation From 63a16cdaafa5405dc0ade8461706e876c6731cf4 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:27:20 +0800 Subject: [PATCH 03/21] feat: implement remote agent configuration loading and connection handling --- python/valuecell/core/agent/connect.py | 108 +++++++++++++++++++++---- 1 file changed, 93 insertions(+), 15 deletions(-) diff --git a/python/valuecell/core/agent/connect.py b/python/valuecell/core/agent/connect.py index 6fbeae2e5..eabd95ea8 100644 --- a/python/valuecell/core/agent/connect.py +++ b/python/valuecell/core/agent/connect.py @@ -26,6 +26,41 @@ def __init__(self): self._listener_urls: Dict[str, str] = {} # Remote agent cards loaded from config files self._remote_agent_cards: Dict[str, AgentCard] = {} + # Remote agent configs (JSON data from config files) + self._remote_agent_configs: Dict[str, dict] = {} + + def _load_remote_agent_configs(self, config_dir: str = None) -> None: + """Load remote agent configs from JSON files (sync operation).""" + if config_dir is None: + # Default to python/configs/agent_cards relative to current file + current_file = Path(__file__) + config_dir = ( + current_file.parent.parent.parent.parent / "configs" / "agent_cards" + ) + else: + config_dir = Path(config_dir) + + if not config_dir.exists(): + return + + for json_file in config_dir.glob("*.json"): + try: + with open(json_file, "r", encoding="utf-8") as f: + config_data = json.load(f) + + agent_name = config_data.get("name") + if not agent_name: + continue + + # Validate required fields + required_fields = ["name", "url"] + if not all(field in config_data for field in required_fields): + continue + + self._remote_agent_configs[agent_name] = config_data + + except (json.JSONDecodeError, FileNotFoundError, KeyError): + continue async def load_remote_agents(self, config_dir: str = None) -> None: """Load remote agent cards from configuration directory.""" @@ -82,11 +117,15 @@ async def load_remote_agents(self, config_dir: str = None) -> None: async def connect_remote_agent(self, agent_name: str) -> str: """Connect to a remote agent (no lifecycle management).""" - if agent_name not in self._remote_agent_cards: + if agent_name not in self._remote_agent_configs: + # Auto-load configs if not found + self._load_remote_agent_configs() + + if agent_name not in self._remote_agent_configs: raise ValueError(f"Remote agent '{agent_name}' not found in loaded cards") - card_data = self._remote_agent_cards[agent_name] - agent_url = card_data.url + config_data = self._remote_agent_configs[agent_name] + agent_url = config_data["url"] # Create client connection for remote agent self._connections[agent_name] = AgentClient(agent_url) @@ -103,6 +142,11 @@ async def start_agent( notification_callback: callable = None, ) -> str: """Start an agent, optionally with a notification listener.""" + # Check if it's a remote agent first + if agent_name in self._remote_agent_configs: + return await self._handle_remote_agent(agent_name) + + # Handle local agent agent_class = AgentRegistry.get_agent(agent_name) if not agent_class: raise ValueError(f"Agent '{agent_name}' not found in registry") @@ -143,6 +187,31 @@ async def start_agent( return agent_url + async def _handle_remote_agent(self, agent_name: str) -> str: + """Handle remote agent connection and card loading.""" + config_data = self._remote_agent_configs[agent_name] + agent_url = config_data["url"] + + # Load actual agent card using A2ACardResolver + async with httpx.AsyncClient() as httpx_client: + try: + resolver = A2ACardResolver( + httpx_client=httpx_client, base_url=agent_url + ) + agent_card = await resolver.get_agent_card() + self._remote_agent_cards[agent_name] = agent_card + logger.info(f"Loaded agent card for remote agent: {agent_name}") + except Exception as e: + logger.error(f"Failed to get agent card for {agent_name}: {e}") + # Fallback: create basic card from config + agent_card = None + + # Create client connection + self._connections[agent_name] = AgentClient(agent_url) + logger.info(f"Connected to remote agent '{agent_name}' at {agent_url}") + + return agent_url + async def _start_listener_for_agent( self, agent_name: str, @@ -230,11 +299,7 @@ async def _cleanup_agent(self, agent_name: str): async def get_client(self, agent_name: str) -> AgentClient: """Get Agent client connection""" if agent_name not in self._connections: - # Try to connect to remote agent first, then fallback to local - if agent_name in self._remote_agent_cards: - await self.connect_remote_agent(agent_name) - else: - await self.start_agent(agent_name) + await self.start_agent(agent_name) return self._connections[agent_name] @@ -249,8 +314,12 @@ def list_running_agents(self) -> List[str]: def list_available_agents(self) -> List[str]: """List all available agents from registry and remote cards""" + # Auto-load remote agent configs if not already loaded + if not self._remote_agent_configs: + self._load_remote_agent_configs() + local_agents = AgentRegistry.list_agents() - remote_agents = list(self._remote_agent_cards.keys()) + remote_agents = list(self._remote_agent_configs.keys()) return local_agents + remote_agents async def stop_all(self): @@ -274,13 +343,16 @@ def get_agent_info(self, agent_name: str) -> dict: } # Check if it's a remote agent - if agent_name in self._remote_agent_cards: - card_data = self._remote_agent_cards[agent_name] + if agent_name in self._remote_agent_configs: + config_data = self._remote_agent_configs[agent_name] + agent_card = self._remote_agent_cards.get(agent_name) return { "name": agent_name, "type": "remote", - "url": card_data.url, - "card": card_data, + "url": config_data.get("url"), + "card": agent_card.model_dump(exclude_none=True) + if agent_card + else config_data, "connected": agent_name in self._connections, "running": False, # Remote agents are not managed by us "has_listener": False, @@ -290,8 +362,14 @@ def get_agent_info(self, agent_name: str) -> dict: def list_remote_agents(self) -> List[str]: """List remote agents loaded from config files""" - return list(self._remote_agent_cards.keys()) + # Auto-load remote agent configs if not already loaded + if not self._remote_agent_configs: + self._load_remote_agent_configs() + return list(self._remote_agent_configs.keys()) def get_remote_agent_card(self, agent_name: str) -> dict: """Get remote agent card data""" - return self._remote_agent_cards.get(agent_name) + # Return actual AgentCard if available, otherwise config data + if agent_name in self._remote_agent_cards: + return self._remote_agent_cards[agent_name] + return self._remote_agent_configs.get(agent_name) From 5f1539bee603d39bdaf08ba41ed5737cf3762ce0 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:33:31 +0800 Subject: [PATCH 04/21] feat: add example script for using RemoteConnections with remote agents --- .../agent/tests/test_remote_agent_demo.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 python/valuecell/core/agent/tests/test_remote_agent_demo.py diff --git a/python/valuecell/core/agent/tests/test_remote_agent_demo.py b/python/valuecell/core/agent/tests/test_remote_agent_demo.py new file mode 100644 index 000000000..1efefd564 --- /dev/null +++ b/python/valuecell/core/agent/tests/test_remote_agent_demo.py @@ -0,0 +1,72 @@ +""" +Example usage of RemoteConnections with remote agents. + +This script demonstrates how to: +1. Load remote agent cards from configuration files +2. Connect to remote agents +3. Get agent information +""" + +import asyncio +import json +import logging + +from valuecell.core.agent.connect import RemoteConnections + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main(): + """Main example function.""" + # Create RemoteConnections instance + connections = RemoteConnections() + + # List all available agents (local + remote) + available_agents = connections.list_available_agents() + logger.info(f"Available agents: {available_agents}") + + # List only remote agents + remote_agents = connections.list_remote_agents() + logger.info(f"Remote agents: {remote_agents}") + + # Get info for each remote agent + for agent_name in remote_agents: + agent_info = connections.get_agent_info(agent_name) + logger.info(f"Agent info for '{agent_name}': {agent_info}") + + # Get raw card data + card_data = connections.get_remote_agent_card(agent_name) + logger.info(f"Card data for '{agent_name}': {card_data}") + + # Try to start/connect to a remote agent (if any) + if remote_agents: + agent_name = remote_agents[0] + logger.info(f"Attempting to start remote agent: {agent_name}") + try: + agent_url = await connections.start_agent(agent_name) + logger.info(f"Successfully started {agent_name} at {agent_url}") + + # Get client connection + client = await connections.get_client(agent_name) + logger.info(f"Got client for {agent_name}: {client}") + + # Check updated agent info + updated_info = connections.get_agent_info(agent_name) + logger.info(f"Updated info for '{agent_name}': {updated_info}") + + async for resp in await client.send_message( + json.dumps( + {"text": "Hello from remote agent example!"}, + ), + exhaustive=True, + ): + logger.info(f"Response from {agent_name}: {resp}") + + except Exception as e: + logger.error(f"Failed to start {agent_name}: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) From 400f030b323173da2c4e71ca2fed14d4b00e96e4 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Sep 2025 18:19:40 +0800 Subject: [PATCH 05/21] feat: add agno as dependency --- python/pyproject.toml | 1 + python/uv.lock | 213 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/python/pyproject.toml b/python/pyproject.toml index d3df20f08..2baa258a4 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "tushare>=1.4.24", "requests>=2.32.5", "akshare>=1.17.44", + "agno>=1.8.2", ] [project.optional-dependencies] diff --git a/python/uv.lock b/python/uv.lock index aeb283add..f7bb4e96f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -29,6 +29,30 @@ http-server = [ { name = "starlette" }, ] +[[package]] +name = "agno" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "gitpython" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/44/b7aa76f7b2e8268bcd986be432ffad35e081c7b1d3c67e19b2ee2748c4b2/agno-1.8.2.tar.gz", hash = "sha256:f8dab74a91cb56d5329d449b236361f1e39d0f1573182cc20912e16db25b6012", size = 754571, upload-time = "2025-09-08T19:29:50.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/7d/756bc6cb78c5f98f06a1f2be2b354fa115dca9be64bf73b0bbada75d20d1/agno-1.8.2-py3-none-any.whl", hash = "sha256:2a55a80848bcfe448c09ba1eed41c299a84c2f882e7e3aed145d887cbba88de1", size = 997067, upload-time = "2025-09-08T19:29:48.043Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -404,6 +428,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + [[package]] name = "et-xmlfile" version = "2.0.0" @@ -498,6 +531,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + [[package]] name = "google-api-core" version = "2.25.1" @@ -685,6 +742,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901, upload-time = "2025-08-22T10:34:45.799Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mini-racer" version = "0.12.4" @@ -1095,6 +1173,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1158,6 +1250,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -1167,6 +1277,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1182,6 +1318,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -1220,6 +1369,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "simplejson" version = "3.20.1" @@ -1264,6 +1422,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1316,6 +1483,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1346,6 +1542,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/75/63810958023595b460f2a5ef6baf5a60ffd8166e5fc06a3c2f22e9ca7b34/tushare-1.4.24-py3-none-any.whl", hash = "sha256:778e3128262747cb0cdadac2e5a5e6cd1a520c239b4ffbde2776652424451b08", size = 143587, upload-time = "2025-08-25T02:02:03.554Z" }, ] +[[package]] +name = "typer" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1404,6 +1615,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "a2a-sdk", extra = ["http-server"] }, + { name = "agno" }, { name = "akshare" }, { name = "fastapi" }, { name = "pydantic" }, @@ -1426,6 +1638,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3.4" }, + { name = "agno", specifier = ">=1.8.2" }, { name = "akshare", specifier = ">=1.17.44" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "pydantic", specifier = ">=2.0.0" }, From ecb987f54b51026695522cbd2ec8a66569615452 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Sep 2025 18:21:52 +0800 Subject: [PATCH 06/21] feat: enhance remote agent demo with validated natural language to base model conversion --- .../ai-hedge-fund/adapter/__main__.py | 98 +++++++++++++++++-- .../agent/tests/test_remote_agent_demo.py | 4 +- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/python/third_party/ai-hedge-fund/adapter/__main__.py b/python/third_party/ai-hedge-fund/adapter/__main__.py index a768c1a3e..5fbec56a9 100644 --- a/python/third_party/ai-hedge-fund/adapter/__main__.py +++ b/python/third_party/ai-hedge-fund/adapter/__main__.py @@ -1,20 +1,106 @@ import asyncio import json +from datetime import datetime +from typing import List +from agno.agent import Agent +from agno.models.openrouter import OpenRouter +from dateutil.relativedelta import relativedelta +from pydantic import BaseModel, Field, field_validator from valuecell.core.agent.decorator import serve from valuecell.core.agent.types import BaseAgent from src.main import run_hedge_fund +from src.utils.analysts import ANALYST_ORDER + +allowed_analysts = set(key for display_name, key in sorted(ANALYST_ORDER, key=lambda x: x[1])) + + +class HedgeFundRequest(BaseModel): + tickers: List[str] = Field(..., description="List of stock tickers to analyze. Must be from: [AAPL, GOOGL, MSFT, NVDA, TSLA]") + selected_analysts: List[str] = Field(default=[], description=f"List of analysts to use for analysis. If empty, all analysts will be used. Must be from {allowed_analysts}") + + @field_validator("tickers") + @classmethod + def validate_tickers(cls, v): + allowed_tickers = {"AAPL", "GOOGL", "MSFT", "NVDA", "TSLA"} + invalid_tickers = set(v) - allowed_tickers + if invalid_tickers: + raise ValueError(f"Invalid tickers: {invalid_tickers}. Allowed: {allowed_tickers}") + return v + + @field_validator("selected_analysts") + @classmethod + def validate_analysts(cls, v): + if v: # Only validate if not empty + invalid_analysts = set(v) - allowed_analysts + if invalid_analysts: + raise ValueError(f"Invalid analysts: {invalid_analysts}. Allowed: {allowed_analysts}") + return v class AIHedgeFundAgent(BaseAgent): + def __init__(self): + super().__init__() + self.agno_agent = Agent( + model=OpenRouter(id="openai/gpt-4o-mini"), + response_model=HedgeFundRequest, + markdown=True, + ) + async def stream(self, query, session_id, task_id): - query = json.loads(query) - result = run_hedge_fund(**query) - yield { - "content": json.dumps(result), - "is_task_complete": True, - } + try: + run_response = self.agno_agent.run(f"Parse the following hedge fund analysis request and extract the parameters: {query}") + hedge_fund_request = run_response.content + + end_date = datetime.now().strftime("%Y-%m-%d") + end_date_obj = datetime.strptime(end_date, "%Y-%m-%d") + start_date = (end_date_obj - relativedelta(months=3)).strftime("%Y-%m-%d") + + initial_cash = 10000.00 + portfolio = { + "cash": initial_cash, + "margin_requirement": 0, + "margin_used": 0.0, + "positions": { + ticker: { + "long": 0, + "short": 0, + "long_cost_basis": 0.0, + "short_cost_basis": 0.0, + "short_margin_used": 0.0, + } + for ticker in hedge_fund_request.tickers + }, + "realized_gains": { + ticker: { + "long": 0.0, + "short": 0.0, + } + for ticker in hedge_fund_request.tickers + }, + } + + result = run_hedge_fund( + tickers=hedge_fund_request.tickers, + start_date=start_date, + end_date=end_date, + portfolio=portfolio, + model_name="openai/gpt-4o-mini", + model_provider="OpenRouter", + selected_analysts=hedge_fund_request.selected_analysts, + ) + + yield { + "content": json.dumps(result), + "is_task_complete": True, + } + + except Exception as e: + yield { + "content": json.dumps({"error": str(e)}), + "is_task_complete": True, + } if __name__ == "__main__": diff --git a/python/valuecell/core/agent/tests/test_remote_agent_demo.py b/python/valuecell/core/agent/tests/test_remote_agent_demo.py index 1efefd564..7ad4657e5 100644 --- a/python/valuecell/core/agent/tests/test_remote_agent_demo.py +++ b/python/valuecell/core/agent/tests/test_remote_agent_demo.py @@ -57,9 +57,7 @@ async def main(): logger.info(f"Updated info for '{agent_name}': {updated_info}") async for resp in await client.send_message( - json.dumps( - {"text": "Hello from remote agent example!"}, - ), + "analyze apple stock with buffett and damodaran", exhaustive=True, ): logger.info(f"Response from {agent_name}: {resp}") From 8e37c909f954acf72805c4bf69ac0b321e2de039 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Sep 2025 18:30:25 +0800 Subject: [PATCH 07/21] fix lint and format --- .../ai-hedge-fund/adapter/__main__.py | 26 ++++++++++++++----- .../agent/tests/test_remote_agent_demo.py | 1 - 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/python/third_party/ai-hedge-fund/adapter/__main__.py b/python/third_party/ai-hedge-fund/adapter/__main__.py index 5fbec56a9..cfac38c0b 100644 --- a/python/third_party/ai-hedge-fund/adapter/__main__.py +++ b/python/third_party/ai-hedge-fund/adapter/__main__.py @@ -13,12 +13,20 @@ from src.main import run_hedge_fund from src.utils.analysts import ANALYST_ORDER -allowed_analysts = set(key for display_name, key in sorted(ANALYST_ORDER, key=lambda x: x[1])) +allowed_analysts = set( + key for display_name, key in sorted(ANALYST_ORDER, key=lambda x: x[1]) +) class HedgeFundRequest(BaseModel): - tickers: List[str] = Field(..., description="List of stock tickers to analyze. Must be from: [AAPL, GOOGL, MSFT, NVDA, TSLA]") - selected_analysts: List[str] = Field(default=[], description=f"List of analysts to use for analysis. If empty, all analysts will be used. Must be from {allowed_analysts}") + tickers: List[str] = Field( + ..., + description="List of stock tickers to analyze. Must be from: [AAPL, GOOGL, MSFT, NVDA, TSLA]", + ) + selected_analysts: List[str] = Field( + default=[], + description=f"List of analysts to use for analysis. If empty, all analysts will be used. Must be from {allowed_analysts}", + ) @field_validator("tickers") @classmethod @@ -26,7 +34,9 @@ def validate_tickers(cls, v): allowed_tickers = {"AAPL", "GOOGL", "MSFT", "NVDA", "TSLA"} invalid_tickers = set(v) - allowed_tickers if invalid_tickers: - raise ValueError(f"Invalid tickers: {invalid_tickers}. Allowed: {allowed_tickers}") + raise ValueError( + f"Invalid tickers: {invalid_tickers}. Allowed: {allowed_tickers}" + ) return v @field_validator("selected_analysts") @@ -35,7 +45,9 @@ def validate_analysts(cls, v): if v: # Only validate if not empty invalid_analysts = set(v) - allowed_analysts if invalid_analysts: - raise ValueError(f"Invalid analysts: {invalid_analysts}. Allowed: {allowed_analysts}") + raise ValueError( + f"Invalid analysts: {invalid_analysts}. Allowed: {allowed_analysts}" + ) return v @@ -50,7 +62,9 @@ def __init__(self): async def stream(self, query, session_id, task_id): try: - run_response = self.agno_agent.run(f"Parse the following hedge fund analysis request and extract the parameters: {query}") + run_response = self.agno_agent.run( + f"Parse the following hedge fund analysis request and extract the parameters: {query}" + ) hedge_fund_request = run_response.content end_date = datetime.now().strftime("%Y-%m-%d") diff --git a/python/valuecell/core/agent/tests/test_remote_agent_demo.py b/python/valuecell/core/agent/tests/test_remote_agent_demo.py index 7ad4657e5..cdba0f7c8 100644 --- a/python/valuecell/core/agent/tests/test_remote_agent_demo.py +++ b/python/valuecell/core/agent/tests/test_remote_agent_demo.py @@ -8,7 +8,6 @@ """ import asyncio -import json import logging from valuecell.core.agent.connect import RemoteConnections From ec5a7e7f8dbe51a3e6c48f21766a518226973411 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Tue, 9 Sep 2025 18:39:15 +0800 Subject: [PATCH 08/21] feat: remove test_client.py as it is no longer needed --- .../ai-hedge-fund/adapter/test_client.py | 112 ------------------ 1 file changed, 112 deletions(-) delete mode 100644 python/third_party/ai-hedge-fund/adapter/test_client.py diff --git a/python/third_party/ai-hedge-fund/adapter/test_client.py b/python/third_party/ai-hedge-fund/adapter/test_client.py deleted file mode 100644 index f1ac809b6..000000000 --- a/python/third_party/ai-hedge-fund/adapter/test_client.py +++ /dev/null @@ -1,112 +0,0 @@ -import asyncio -import json -import logging -from datetime import datetime -from uuid import uuid4 - -import click -import httpx -from a2a.client import A2ACardResolver, A2AClient -from a2a.types import ( - MessageSendParams, - SendMessageRequest, -) -from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH -from dateutil.relativedelta import relativedelta -from src.utils.analysts import ANALYST_ORDER - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) # Get a logger instance - - -async def _main(tickers, initial_cash, model_provider, model_name, selected_analysts): - base_url = "http://localhost:10001" - print(f"{tickers=}") - - async with httpx.AsyncClient() as httpx_client: - resolver = A2ACardResolver(httpx_client=httpx_client, base_url=base_url) - - try: - logger.info("Attempting to fetch public agent card from: " f"{base_url}{AGENT_CARD_WELL_KNOWN_PATH}") - agent_card = await resolver.get_agent_card() - logger.info("Successfully fetched public agent card:") - # logger.info(agent_card.model_dump_json(indent=2, exclude_none=True)) - - except Exception as e: - logger.error(f"Critical error fetching public agent card: {e}", exc_info=True) - raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e - - client = A2AClient(httpx_client=httpx_client, agent_card=agent_card) - logger.info("A2AClient initialized.") - - end_date = datetime.now().strftime("%Y-%m-%d") - end_date_obj = datetime.strptime(end_date, "%Y-%m-%d") - start_date = (end_date_obj - relativedelta(months=3)).strftime("%Y-%m-%d") - portfolio = { - "cash": initial_cash, # Initial cash amount - "margin_requirement": 0, # Initial margin requirement - "margin_used": 0.0, # total margin usage across all short positions - "positions": { - ticker: { - "long": 0, # Number of shares held long - "short": 0, # Number of shares held short - "long_cost_basis": 0.0, # Average cost basis for long positions - "short_cost_basis": 0.0, # Average price at which shares were sold short - "short_margin_used": 0.0, # Dollars of margin used for this ticker's short - } - for ticker in tickers - }, - "realized_gains": { - ticker: { - "long": 0.0, # Realized gains from long positions - "short": 0.0, # Realized gains from short positions - } - for ticker in tickers - }, - } - paramas = { - "tickers": tickers, - "start_date": start_date, - "end_date": end_date, - "portfolio": portfolio, - "model_name": model_name, - "model_provider": model_provider, - "selected_analysts": selected_analysts, - } - - payload = { - "message": { - "role": "user", - "parts": [{"kind": "text", "text": json.dumps(paramas)}], - "messageId": uuid4().hex, - "contextId": uuid4().hex, - }, - } - request = SendMessageRequest(id=str(uuid4()), params=MessageSendParams(**payload)) - - response = await client.send_message(request, http_kwargs={"timeout": None}) - print(response.model_dump(mode="json", exclude_none=True)) - - -@click.command() -@click.option("--tickers", "tickers", default="AAPL") -@click.option("--initial-cash", "initial_cash", default=10000.00) -@click.option("--model-provider", "model_provider", default="OpenRouter") -@click.option("--model-name", "model_name", default="openai/gpt-4o-mini") -@click.option("--analysts", "analysts", default="all") -def main(tickers, initial_cash, model_provider, model_name, analysts) -> None: - supported_analysts = [name for name, _ in sorted(ANALYST_ORDER.items(), key=lambda x: x[1])] - tickers = [ticker.strip().upper() for ticker in tickers.split(",")] - if analysts == "all": - analysts = [name for name, _ in supported_analysts] - else: - analysts = [name.strip() for name in analysts.split(",")] - for name in analysts: - if name not in supported_analysts: - raise ValueError(f"Unknown analyst name: {name}. Supported analysts: {supported_analysts}") - - asyncio.run(_main(tickers, initial_cash, model_provider, model_name, analysts)) - - -if __name__ == "__main__": - main() From 63a781270262fc52f19020e6bd9f29f60ea0dab0 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:31:22 +0800 Subject: [PATCH 09/21] feat: disable default listener and push notifications in agent configuration --- python/valuecell/core/agent/connect.py | 2 +- python/valuecell/core/agent/decorator.py | 2 +- python/valuecell/core/agent/tests/test_e2e_demo.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/python/valuecell/core/agent/connect.py b/python/valuecell/core/agent/connect.py index eabd95ea8..318344eb4 100644 --- a/python/valuecell/core/agent/connect.py +++ b/python/valuecell/core/agent/connect.py @@ -136,7 +136,7 @@ async def connect_remote_agent(self, agent_name: str) -> str: async def start_agent( self, agent_name: str, - with_listener: bool = True, + with_listener: bool = False, listener_port: int = None, listener_host: str = "localhost", notification_callback: callable = None, diff --git a/python/valuecell/core/agent/decorator.py b/python/valuecell/core/agent/decorator.py index 34a6d1450..bc3f2f29c 100644 --- a/python/valuecell/core/agent/decorator.py +++ b/python/valuecell/core/agent/decorator.py @@ -36,7 +36,7 @@ def serve( host: str = "localhost", port: int = None, streaming: bool = True, - push_notifications: bool = True, + push_notifications: bool = False, description: str = None, version: str = "1.0.0", skills: list[AgentSkill | dict] = None, diff --git a/python/valuecell/core/agent/tests/test_e2e_demo.py b/python/valuecell/core/agent/tests/test_e2e_demo.py index 3153ac620..cfff19276 100644 --- a/python/valuecell/core/agent/tests/test_e2e_demo.py +++ b/python/valuecell/core/agent/tests/test_e2e_demo.py @@ -11,7 +11,7 @@ # Demo agents using the @serve decorator -@serve(name="Calculator Agent") +@serve(name="Calculator Agent", push_notifications=True) class CalculatorAgent: """A calculator agent that can do basic math""" @@ -46,7 +46,7 @@ async def stream(self, query, session_id, task_id): } -@serve(name="Weather Agent", port=9101, description="Provides weather information") +@serve(name="Weather Agent", port=9101, push_notifications=True, description="Provides weather information") class WeatherAgent: """A weather information agent""" @@ -83,7 +83,7 @@ async def stream(self, query, session_id, task_id): } -@serve(name="Simple Agent", streaming=False, push_notifications=False) +@serve(name="Simple Agent") class SimpleAgent: """A simple non-streaming agent""" From 09cb17e4d91ba795c6dc9316cf403a96edcc3488 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:32:29 +0800 Subject: [PATCH 10/21] feat: rename send_message parameter `exhaustive` as `streaming` --- python/valuecell/core/agent/client.py | 10 +++++++--- .../core/agent/tests/test_remote_agent_demo.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/python/valuecell/core/agent/client.py b/python/valuecell/core/agent/client.py index f9a3ce403..6e14d5e03 100644 --- a/python/valuecell/core/agent/client.py +++ b/python/valuecell/core/agent/client.py @@ -48,9 +48,13 @@ async def _setup_client(self): self._client = client_factory.create(card) async def send_message( - self, text: str, context_id: str = None, exhaustive: bool = False + self, text: str, context_id: str = None, streaming: bool = False ) -> MessageResponse | AsyncIterator[MessageResponse]: - """Send message to Agent""" + """Send message to Agent. + + If `streaming` is True, return an async iterator producing (task, event) pairs. + If `streaming` is False, return the first (task, event) pair (and close the generator). + """ await self._ensure_initialized() message = Message( @@ -61,7 +65,7 @@ async def send_message( ) generator = self._client.send_message(message) - if exhaustive: + if streaming: return generator task, event = await generator.__anext__() diff --git a/python/valuecell/core/agent/tests/test_remote_agent_demo.py b/python/valuecell/core/agent/tests/test_remote_agent_demo.py index cdba0f7c8..b95837b74 100644 --- a/python/valuecell/core/agent/tests/test_remote_agent_demo.py +++ b/python/valuecell/core/agent/tests/test_remote_agent_demo.py @@ -57,7 +57,7 @@ async def main(): async for resp in await client.send_message( "analyze apple stock with buffett and damodaran", - exhaustive=True, + streaming=True, ): logger.info(f"Response from {agent_name}: {resp}") From 0c8bbd9794538a4f0ec96acb08884a9ee44766fd Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:34:29 +0800 Subject: [PATCH 11/21] refactor: streamline error handling in AIHedgeFundAgent's stream method --- .../ai-hedge-fund/adapter/__main__.py | 99 +++++++++---------- 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/python/third_party/ai-hedge-fund/adapter/__main__.py b/python/third_party/ai-hedge-fund/adapter/__main__.py index cfac38c0b..644004c1d 100644 --- a/python/third_party/ai-hedge-fund/adapter/__main__.py +++ b/python/third_party/ai-hedge-fund/adapter/__main__.py @@ -61,60 +61,53 @@ def __init__(self): ) async def stream(self, query, session_id, task_id): - try: - run_response = self.agno_agent.run( - f"Parse the following hedge fund analysis request and extract the parameters: {query}" - ) - hedge_fund_request = run_response.content - - end_date = datetime.now().strftime("%Y-%m-%d") - end_date_obj = datetime.strptime(end_date, "%Y-%m-%d") - start_date = (end_date_obj - relativedelta(months=3)).strftime("%Y-%m-%d") - - initial_cash = 10000.00 - portfolio = { - "cash": initial_cash, - "margin_requirement": 0, - "margin_used": 0.0, - "positions": { - ticker: { - "long": 0, - "short": 0, - "long_cost_basis": 0.0, - "short_cost_basis": 0.0, - "short_margin_used": 0.0, - } - for ticker in hedge_fund_request.tickers - }, - "realized_gains": { - ticker: { - "long": 0.0, - "short": 0.0, - } - for ticker in hedge_fund_request.tickers - }, - } - - result = run_hedge_fund( - tickers=hedge_fund_request.tickers, - start_date=start_date, - end_date=end_date, - portfolio=portfolio, - model_name="openai/gpt-4o-mini", - model_provider="OpenRouter", - selected_analysts=hedge_fund_request.selected_analysts, - ) - - yield { - "content": json.dumps(result), - "is_task_complete": True, - } + run_response = self.agno_agent.run( + f"Parse the following hedge fund analysis request and extract the parameters: {query}" + ) + hedge_fund_request = run_response.content + + end_date = datetime.now().strftime("%Y-%m-%d") + end_date_obj = datetime.strptime(end_date, "%Y-%m-%d") + start_date = (end_date_obj - relativedelta(months=3)).strftime("%Y-%m-%d") + + initial_cash = 10000.00 + portfolio = { + "cash": initial_cash, + "margin_requirement": 0, + "margin_used": 0.0, + "positions": { + ticker: { + "long": 0, + "short": 0, + "long_cost_basis": 0.0, + "short_cost_basis": 0.0, + "short_margin_used": 0.0, + } + for ticker in hedge_fund_request.tickers + }, + "realized_gains": { + ticker: { + "long": 0.0, + "short": 0.0, + } + for ticker in hedge_fund_request.tickers + }, + } + + result = run_hedge_fund( + tickers=hedge_fund_request.tickers, + start_date=start_date, + end_date=end_date, + portfolio=portfolio, + model_name="openai/gpt-4o-mini", + model_provider="OpenRouter", + selected_analysts=hedge_fund_request.selected_analysts, + ) - except Exception as e: - yield { - "content": json.dumps({"error": str(e)}), - "is_task_complete": True, - } + yield { + "content": json.dumps(result), + "is_task_complete": True, + } if __name__ == "__main__": From 222de7b77d1a3e97461a3532410358250a95dd60 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:36:40 +0800 Subject: [PATCH 12/21] feat: move example scripts for core agent functionality and remote connections --- .../agent/tests/test_e2e_demo.py => examples/core_e2e_demo.py} | 0 .../core_remote_agent_demo.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename python/valuecell/{core/agent/tests/test_e2e_demo.py => examples/core_e2e_demo.py} (100%) rename python/valuecell/{core/agent/tests/test_remote_agent_demo.py => examples/core_remote_agent_demo.py} (100%) diff --git a/python/valuecell/core/agent/tests/test_e2e_demo.py b/python/valuecell/examples/core_e2e_demo.py similarity index 100% rename from python/valuecell/core/agent/tests/test_e2e_demo.py rename to python/valuecell/examples/core_e2e_demo.py diff --git a/python/valuecell/core/agent/tests/test_remote_agent_demo.py b/python/valuecell/examples/core_remote_agent_demo.py similarity index 100% rename from python/valuecell/core/agent/tests/test_remote_agent_demo.py rename to python/valuecell/examples/core_remote_agent_demo.py From 43be7f209151b59eb3d9aa58190f56c79e9e3a37 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:36:40 +0800 Subject: [PATCH 13/21] feat: exclude examples from packaging --- python/pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/pyproject.toml b/python/pyproject.toml index 2baa258a4..68695d23a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -59,4 +59,8 @@ exclude = [ "**/third_party/**", "tests/**", "**/tests/**", + "examples/**", + "**/examples/**", + "docs/**", + "**/docs/**", ] From 3ea2c392ecd621ea15f65f77292eaa5d4fdc7110 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:49:31 +0800 Subject: [PATCH 14/21] feat: add comprehensive README for ValueCell Agent Core with examples and configuration details --- python/valuecell/core/agent/README.md | 462 ++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 python/valuecell/core/agent/README.md diff --git a/python/valuecell/core/agent/README.md b/python/valuecell/core/agent/README.md new file mode 100644 index 000000000..2a6d640f6 --- /dev/null +++ b/python/valuecell/core/agent/README.md @@ -0,0 +1,462 @@ +# ValueCell Agent System + +The ValueCell Agent System is a distributed intelligent agent framework based on the Agent-to-Agent (A2A) protocol, providing a clean decorator interface and powerful connection management capabilities. + +## Core Features + +- 🎯 **Simple Decorator**: Easily create Agents using the `@serve` decorator +- 🔄 **Streaming Response**: Support for real-time streaming data processing +- 🌐 **Distributed Architecture**: Support for both local and remote Agent connections +- 📡 **Push Notifications**: Optional push notification functionality +- 🔧 **Flexible Configuration**: Support for automatic port allocation and custom configuration +- 📋 **Agent Registry**: Unified management of all Agent instances + +## Quick Start + +### 1. Create a Simple Agent + +```python +from valuecell.core.agent.decorator import serve + +@serve(name="Calculator Agent", push_notifications=True) +class CalculatorAgent: + """An agent that can perform basic math calculations""" + + def __init__(self): + self.agent_name = "CalculatorAgent" + + async def stream(self, query, session_id, task_id): + """Process math queries""" + yield {"is_task_complete": False, "content": f"🧮 Calculating: {query}"} + + # Execute calculation logic + try: + if any(op in query for op in ["+", "-", "*", "/", "(", ")"]): + result = eval(query) # Note: Use safe parsing in production + yield {"is_task_complete": True, "content": f"✅ Result: {result}"} + else: + yield { + "is_task_complete": True, + "content": "❓ Please enter a math expression, e.g., '2 + 3'" + } + except Exception as e: + yield { + "is_task_complete": True, + "content": f"❌ Calculation error: {str(e)}" + } +``` + +### 2. Use RemoteConnections to manage Agents + +```python +import asyncio +from valuecell.core.agent.connect import RemoteConnections + +async def main(): + # Create connection manager + connections = RemoteConnections() + + # List all available Agents + available = connections.list_available_agents() + print(f"Available Agents: {available}") + + # Start Agent + calc_url = await connections.start_agent("CalculatorAgent") + print(f"Calculator Agent started at: {calc_url}") + + # Get client and send message + client = await connections.get_client("CalculatorAgent") + task, event = await client.send_message("What is 15 + 27?") + print(f"Calculation result: {task.status}") + + # Clean up resources + await connections.stop_all() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Core Components + +### 1. @serve Decorator + +The `@serve` decorator is the core tool for creating Agents, providing the following parameters: + +```python +@serve( + name="My Agent", # Agent name + host="localhost", # Service host + port=9100, # Service port (optional, auto-allocated) + streaming=True, # Whether to support streaming response + push_notifications=False, # Whether to enable push notifications + description="Description", # Agent description + version="1.0.0", # Agent version + skills=[] # Agent skills list +) +``` + +### 2. RemoteConnections Class + +`RemoteConnections` is the core class for Agent connection management, providing the following functionality: + +#### Basic Operations + +```python +connections = RemoteConnections() + +# List all available Agents (local + remote) +available_agents = connections.list_available_agents() + +# List running Agents +running_agents = connections.list_running_agents() + +# Start Agent +agent_url = await connections.start_agent("AgentName") + +# Get Agent client +client = await connections.get_client("AgentName") + +# Stop specific Agent +await connections.stop_agent("AgentName") + +# Stop all Agents +await connections.stop_all() +``` + +#### Remote Agent Support + +```python +# List remote Agents +remote_agents = connections.list_remote_agents() + +# Get remote Agent configuration +card_data = connections.get_remote_agent_card("RemoteAgentName") + +# Get Agent information +agent_info = connections.get_agent_info("AgentName") +``` + +### 3. AgentClient Class + +`AgentClient` provides the interface for communicating with Agents: + +```python +client = AgentClient("http://localhost:9100/") + +# Send message (non-streaming) +task, event = await client.send_message("Hello Agent") + +# Send message (streaming) +async for response in await client.send_message("Stream query", streaming=True): + print(f"Streaming response: {response}") + +# Get Agent card information +card = await client.get_agent_card() + +# Close connection +await client.close() +``` + +### 4. Agent Registry + +`AgentRegistry` manages all registered Agents: + +```python +from valuecell.core.agent.registry import AgentRegistry + +# List all registered Agents +agents = AgentRegistry.list_agents() + +# Get specific Agent class +agent_class = AgentRegistry.get_agent("AgentName") + +# Get registry detailed information +info = AgentRegistry.get_registry_info() +``` + +## Complete Examples + +### Example 1: Multi-Agent System + +```python +import asyncio +import logging +from valuecell.core.agent.decorator import serve +from valuecell.core.agent.connect import RemoteConnections + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@serve(name="Calculator Agent", push_notifications=True) +class CalculatorAgent: + async def stream(self, query, session_id, task_id): + yield {"is_task_complete": False, "content": f"🧮 Calculating: {query}"} + await asyncio.sleep(0.5) + yield {"is_task_complete": True, "content": "✅ Calculation complete"} + +@serve(name="Weather Agent", port=9101, push_notifications=True) +class WeatherAgent: + async def stream(self, query, session_id, task_id): + yield {"is_task_complete": False, "content": f"🌤️ Checking weather: {query}"} + await asyncio.sleep(0.8) + yield {"is_task_complete": True, "content": "☀️ Today's weather: Sunny, 22°C"} + +async def demo(): + connections = RemoteConnections() + + try: + # Start multiple Agents + calc_url = await connections.start_agent("CalculatorAgent") + weather_url = await connections.start_agent("WeatherAgent") + + logger.info(f"Calculator Agent: {calc_url}") + logger.info(f"Weather Agent: {weather_url}") + + # Wait for Agents to start + await asyncio.sleep(2) + + # Test Calculator Agent + calc_client = await connections.get_client("CalculatorAgent") + task, _ = await calc_client.send_message("2 + 3") + logger.info(f"Calculator result: {task.status}") + + # Test Weather Agent + weather_client = await connections.get_client("WeatherAgent") + task, _ = await weather_client.send_message("How's the weather in Beijing?") + logger.info(f"Weather result: {task.status}") + + await asyncio.sleep(5) + + finally: + await connections.stop_all() + +if __name__ == "__main__": + asyncio.run(demo()) +``` + +### Example 2: Remote Agent Connection + +```python +import asyncio +import logging +from valuecell.core.agent.connect import RemoteConnections + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +async def remote_demo(): + connections = RemoteConnections() + + # List all available Agents (including remote ones) + available_agents = connections.list_available_agents() + logger.info(f"Available Agents: {available_agents}") + + # List remote Agents + remote_agents = connections.list_remote_agents() + logger.info(f"Remote Agents: {remote_agents}") + + # Connect to remote Agent (if any) + if remote_agents: + agent_name = remote_agents[0] + try: + agent_url = await connections.start_agent(agent_name) + logger.info(f"Successfully connected to remote Agent {agent_name}: {agent_url}") + + # Get client and send message + client = await connections.get_client(agent_name) + + # Stream process message + async for response in await client.send_message( + "Analyze Apple stock", + streaming=True + ): + logger.info(f"Remote response: {response}") + + except Exception as e: + logger.error(f"Failed to connect to remote Agent {agent_name}: {e}") + +if __name__ == "__main__": + asyncio.run(remote_demo()) +``` + +## Agent Development Guide + +### 1. Agent Interface Implementation + +All Agents must implement the `stream` method: + +```python +async def stream(self, query, session_id, task_id): + """ + Process user queries and return streaming responses + + Args: + query: User query content + session_id: Session ID + task_id: Task ID + + Yields: + dict: Dictionary containing 'content' and 'is_task_complete' + """ + pass +``` + +### 2. Response Format + +Each response should be a dictionary containing the following fields: + +```python +{ + "content": "Response content", # Required: Text content of the response + "is_task_complete": False # Required: Whether the task is complete +} +``` + +### 3. Error Handling + +It's recommended to add appropriate error handling in Agents: + +```python +async def stream(self, query, session_id, task_id): + try: + # Processing logic + yield {"is_task_complete": False, "content": "Processing..."} + # ... business logic ... + yield {"is_task_complete": True, "content": "Complete"} + except Exception as e: + yield { + "is_task_complete": True, + "content": f"❌ Processing error: {str(e)}" + } +``` + +### 4. Asynchronous Operations + +Agents can perform asynchronous operations internally: + +```python +async def stream(self, query, session_id, task_id): + yield {"is_task_complete": False, "content": "Starting process..."} + + # Asynchronous wait + await asyncio.sleep(1) + + yield {"is_task_complete": False, "content": "Intermediate step..."} + + # More asynchronous operations + result = await some_async_function() + + yield {"is_task_complete": True, "content": f"Complete: {result}"} +``` + +## Configuration + +### Remote Agent Configuration + +Create JSON configuration files in the `python/configs/agent_cards/` directory: + +```json +{ + "name": "Hedge Fund Agent", + "url": "http://localhost:8080/", + "description": "Professional hedge fund analysis Agent", + "version": "1.0.0", + "capabilities": { + "streaming": true, + "push_notifications": true + } +} +``` + +### Environment Variables + +Configuration via environment variables: + +```bash +export VALUECELL_AGENT_HOST=localhost +export VALUECELL_AGENT_PORT_RANGE_START=9100 +export VALUECELL_AGENT_PORT_RANGE_END=9200 +``` + +## Testing + +Run core functionality tests: + +```bash +cd python +python -m pytest valuecell/core/agent/tests/ -v +``` + +Run complete end-to-end tests: + +```bash +python valuecell/examples/core_e2e_demo.py +``` + +Test remote Agent connections: + +```bash +python valuecell/examples/core_remote_agent_demo.py +``` + +## Logging + +The system uses Python's standard logging library. Recommended configuration: + +```python +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) + +logger = logging.getLogger(__name__) +``` + +## Performance Considerations + +1. **Port Management**: The system automatically allocates available ports to avoid conflicts +2. **Connection Reuse**: RemoteConnections manages connection pools to avoid duplicate connections +3. **Asynchronous Processing**: Full asynchronous architecture supporting high concurrency +4. **Streaming Response**: Reduces memory usage and improves response speed +5. **Error Recovery**: Built-in error handling and recovery mechanisms + +## Troubleshooting + +### Common Issues + +1. **Port Conflicts** + + ```bash + Solution: Use automatic port allocation or specify different ports + ``` + +2. **Agent Not Registered** + + ```python + # Ensure Agent class is imported + from your_module import YourAgent + ``` + +3. **Connection Failure** + + ```python + # Check if Agent is running + running = connections.list_running_agents() + print(running) + ``` + +4. **Remote Agent Connection Issues** + + ```bash + Check configuration file format and network connectivity + ``` + +### Debug Mode + +Enable verbose logging: + +```python +logging.getLogger("valuecell.core.agent").setLevel(logging.DEBUG) +``` From a1e78e589d02ea352a39d92f47a3725b30d24afc Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:01:05 +0800 Subject: [PATCH 15/21] feat: implement get_agent_card_path utility function and update remote agent config loading --- python/valuecell/core/agent/connect.py | 10 ++----- python/valuecell/utils/__init__.py | 2 ++ python/valuecell/utils/path.py | 38 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 python/valuecell/utils/path.py diff --git a/python/valuecell/core/agent/connect.py b/python/valuecell/core/agent/connect.py index 318344eb4..a76bed891 100644 --- a/python/valuecell/core/agent/connect.py +++ b/python/valuecell/core/agent/connect.py @@ -4,13 +4,13 @@ from pathlib import Path from typing import Dict, List +import httpx from a2a.client import A2ACardResolver from a2a.types import AgentCard -import httpx from valuecell.core.agent.client import AgentClient from valuecell.core.agent.listener import NotificationListener from valuecell.core.agent.registry import AgentRegistry -from valuecell.utils import get_next_available_port +from valuecell.utils import get_agent_card_path, get_next_available_port logger = logging.getLogger(__name__) @@ -65,11 +65,7 @@ def _load_remote_agent_configs(self, config_dir: str = None) -> None: async def load_remote_agents(self, config_dir: str = None) -> None: """Load remote agent cards from configuration directory.""" if config_dir is None: - # Default to python/configs/agent_cards relative to current file - current_file = Path(__file__) - config_dir = ( - current_file.parent.parent.parent.parent / "configs" / "agent_cards" - ) + config_dir = get_agent_card_path() else: config_dir = Path(config_dir) diff --git a/python/valuecell/utils/__init__.py b/python/valuecell/utils/__init__.py index 29fd4e226..26da1aa5c 100644 --- a/python/valuecell/utils/__init__.py +++ b/python/valuecell/utils/__init__.py @@ -1,7 +1,9 @@ +from .path import get_agent_card_path from .port import get_next_available_port from .uuid import generate_uuid __all__ = [ "get_next_available_port", "generate_uuid", + "get_agent_card_path", ] diff --git a/python/valuecell/utils/path.py b/python/valuecell/utils/path.py new file mode 100644 index 000000000..57d83ba72 --- /dev/null +++ b/python/valuecell/utils/path.py @@ -0,0 +1,38 @@ +from pathlib import Path + + +def get_root_path() -> str: + """ + Returns the root directory of the current Python project (where pyproject.toml is located) + + Returns: + str: Absolute path of the project root directory + + Raises: + FileNotFoundError: If pyproject.toml file cannot be found + """ + # Start searching from the current file's directory upwards + current_path = Path(__file__).resolve() + + # Traverse upwards through parent directories to find pyproject.toml + for parent in current_path.parents: + pyproject_path = parent / "pyproject.toml" + if pyproject_path.exists(): + return str(parent) + + # If not found, raise an exception + raise FileNotFoundError( + "pyproject.toml file not found, unable to determine project root directory" + ) + + +def get_agent_card_path() -> str: + """ + Returns the path to the agent card JSON file located in the configs/agent_cards directory. + + Returns: + str: Absolute path of the agent card JSON file + """ + root_path = get_root_path() + agent_card_path = Path(root_path) / "configs" / "agent_cards" + return str(agent_card_path) From 34a950836fac891de57d1b7798dd09c6999fcd50 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:30:10 +0800 Subject: [PATCH 16/21] feat: add parse_host_port function to handle URL-like strings and update imports --- python/valuecell/utils/__init__.py | 3 +- python/valuecell/utils/port.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/python/valuecell/utils/__init__.py b/python/valuecell/utils/__init__.py index 26da1aa5c..189591d91 100644 --- a/python/valuecell/utils/__init__.py +++ b/python/valuecell/utils/__init__.py @@ -1,9 +1,10 @@ from .path import get_agent_card_path -from .port import get_next_available_port +from .port import get_next_available_port, parse_host_port from .uuid import generate_uuid __all__ = [ "get_next_available_port", "generate_uuid", "get_agent_card_path", + "parse_host_port", ] diff --git a/python/valuecell/utils/port.py b/python/valuecell/utils/port.py index b812aa242..c1f770a18 100644 --- a/python/valuecell/utils/port.py +++ b/python/valuecell/utils/port.py @@ -1,4 +1,5 @@ import socket +from urllib.parse import urlsplit def get_next_available_port(start: int = 9000, num: int = 1000) -> int: @@ -11,3 +12,46 @@ def get_next_available_port(start: int = 9000, num: int = 1000) -> int: continue raise RuntimeError("No available ports found") + + +def parse_host_port(url, default_scheme=None): + """ + Parse host and port from a URL-like string. + + Parameters + - url: a full URL like "http://localhost:10001/" or a host[:port] like "localhost:10001" or "example.com". + - default_scheme: optional "http" or "https". If provided and the input contains no explicit port, + the returned port will be the scheme's default (80 for http, 443 for https). If None, port stays None + when not explicitly present. + + Returns + - (host, port) + - host: string (hostname or IPv6 without brackets) or None if not present + - port: int or None + + Notes + - This uses urlsplit and prepends '//' when the input lacks '://', so inputs like "host:port" are parsed as netloc. + - IPv6 addresses like "[::1]:8000" are supported; returned host will be "::1". + """ + # Ensure netloc is parsed correctly when scheme is missing by prepending '//' + parsed = urlsplit(url if "://" in url else "//" + url) + + host = ( + parsed.hostname + ) # hostname with IPv6 brackets removed, and username/password stripped + port = parsed.port # explicit port if given, else None + scheme = parsed.scheme + + # If no explicit port and a default scheme is provided, fill default port for http/https + if port is None: + use_scheme = scheme or default_scheme + if use_scheme == "http": + port = 80 + elif use_scheme == "https": + port = 443 + + # Optional validation: ensure port number (if present) is within valid range + if port is not None and not 1 <= port <= 65535: + raise ValueError(f"invalid port: {port}") + + return host, port From c827b99a0a80edef8d79638e28251c8746935f12 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:30:29 +0800 Subject: [PATCH 17/21] refactor: update agent decorator usage to create_wrapped_agent and improve formatting in examples --- .../ai-hedge-fund/adapter/__main__.py | 4 +- python/valuecell/core/agent/decorator.py | 73 ++++++++++++++++++- python/valuecell/examples/core_e2e_demo.py | 7 +- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/python/third_party/ai-hedge-fund/adapter/__main__.py b/python/third_party/ai-hedge-fund/adapter/__main__.py index 644004c1d..0a0df39a6 100644 --- a/python/third_party/ai-hedge-fund/adapter/__main__.py +++ b/python/third_party/ai-hedge-fund/adapter/__main__.py @@ -7,7 +7,7 @@ from agno.models.openrouter import OpenRouter from dateutil.relativedelta import relativedelta from pydantic import BaseModel, Field, field_validator -from valuecell.core.agent.decorator import serve +from valuecell.core.agent.decorator import create_wrapped_agent from valuecell.core.agent.types import BaseAgent from src.main import run_hedge_fund @@ -111,5 +111,5 @@ async def stream(self, query, session_id, task_id): if __name__ == "__main__": - agent = serve(port=10001)(AIHedgeFundAgent)() + agent = create_wrapped_agent(AIHedgeFundAgent) asyncio.run(agent.serve()) diff --git a/python/valuecell/core/agent/decorator.py b/python/valuecell/core/agent/decorator.py index bc3f2f29c..f1f218fc3 100644 --- a/python/valuecell/core/agent/decorator.py +++ b/python/valuecell/core/agent/decorator.py @@ -1,5 +1,7 @@ +import json import logging -from typing import Type +from pathlib import Path +from typing import Dict, Optional, Type import httpx import uvicorn @@ -22,11 +24,15 @@ TextPart, UnsupportedOperationError, ) -from a2a.utils import new_task, new_agent_text_message +from a2a.utils import new_agent_text_message, new_task from a2a.utils.errors import ServerError from valuecell.core.agent.registry import AgentRegistry from valuecell.core.agent.types import BaseAgent -from valuecell.utils import get_next_available_port +from valuecell.utils import ( + get_agent_card_path, + get_next_available_port, + parse_host_port, +) logger = logging.getLogger(__name__) @@ -40,8 +46,14 @@ def serve( description: str = None, version: str = "1.0.0", skills: list[AgentSkill | dict] = None, + **extra_kwargs, ): def decorator(cls: Type) -> Type: + if extra_kwargs: + logger.warning( + f"Extra kwargs {extra_kwargs} are not used in the @serve decorator" + ) + # Build agent card (port will be assigned when server starts) agent_skills = [] if skills: @@ -203,3 +215,58 @@ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None def _create_agent_executor(agent_instance): return GenericAgentExecutor(agent_instance) + + +def _get_serve_params_by_agent_name(name: str) -> Optional[Dict]: + """ + Reads JSON files from agent_cards directory and returns the first one where name matches. + + Args: + name: The agent name to search for + + Returns: + Dict: The agent configuration dictionary if found, None otherwise + """ + agent_cards_path = Path(get_agent_card_path()) + + # Check if the agent_cards directory exists + if not agent_cards_path.exists(): + return None + + # Iterate through all JSON files in the agent_cards directory + for json_file in agent_cards_path.glob("*.json"): + try: + with open(json_file, "r", encoding="utf-8") as f: + agent_config = json.load(f) + + # Check if this agent config has the matching name + if not isinstance(agent_config, dict): + continue + if agent_config.get("name") != name: + continue + if "url" in agent_config and agent_config["url"]: + host, port = parse_host_port( + agent_config.get("url"), default_scheme="http" + ) + agent_config["host"] = host + agent_config["port"] = port + + return agent_config + + except (json.JSONDecodeError, IOError): + # Skip files that can't be read or parsed + continue + + # Return None if no matching agent is found + return None + + +def create_wrapped_agent(agent_class: Type[BaseAgent]): + # Get agent configuration from agent cards + agent_config = _get_serve_params_by_agent_name(agent_class.__name__) + if not agent_config: + raise ValueError( + f"No agent configuration found for {agent_class.__name__} in agent cards" + ) + + return serve(**agent_config)(agent_class)() diff --git a/python/valuecell/examples/core_e2e_demo.py b/python/valuecell/examples/core_e2e_demo.py index cfff19276..96f9c3cc8 100644 --- a/python/valuecell/examples/core_e2e_demo.py +++ b/python/valuecell/examples/core_e2e_demo.py @@ -46,7 +46,12 @@ async def stream(self, query, session_id, task_id): } -@serve(name="Weather Agent", port=9101, push_notifications=True, description="Provides weather information") +@serve( + name="Weather Agent", + port=9101, + push_notifications=True, + description="Provides weather information", +) class WeatherAgent: """A weather information agent""" From db90c160aec8cc22c4831a0419a8f387c7331caa Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:37:56 +0800 Subject: [PATCH 18/21] doc: optimize BaseAgent stream docstring --- python/valuecell/core/agent/types.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/python/valuecell/core/agent/types.py b/python/valuecell/core/agent/types.py index 0510ee997..565349d26 100644 --- a/python/valuecell/core/agent/types.py +++ b/python/valuecell/core/agent/types.py @@ -23,10 +23,17 @@ class BaseAgent(ABC): @abstractmethod async def stream(self, query, session_id, task_id) -> StreamResponse: - """ - Abstract method to stream the agent with the provided input data. - Must be implemented by all subclasses. - """ + """ + Process user queries and return streaming responses + + Args: + query: User query content + session_id: Session ID + task_id: Task ID + + Yields: + dict: Dictionary containing 'content' and 'is_task_complete' + """ MessageResponse = tuple[Task, Optional[TaskStatusUpdateEvent | TaskArtifactUpdateEvent]] From 1e139f6acd7ad09d7b5448a144435dc3f9d15cd3 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:45:41 +0800 Subject: [PATCH 19/21] feat: enable listener by default in start_agent method --- python/valuecell/core/agent/connect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/valuecell/core/agent/connect.py b/python/valuecell/core/agent/connect.py index a76bed891..0c946d249 100644 --- a/python/valuecell/core/agent/connect.py +++ b/python/valuecell/core/agent/connect.py @@ -132,7 +132,7 @@ async def connect_remote_agent(self, agent_name: str) -> str: async def start_agent( self, agent_name: str, - with_listener: bool = False, + with_listener: bool = True, listener_port: int = None, listener_host: str = "localhost", notification_callback: callable = None, From b062dc143bd41471e80f20e0eab6a6606d87b61c Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:47:19 +0800 Subject: [PATCH 20/21] fix format --- python/valuecell/core/agent/types.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/python/valuecell/core/agent/types.py b/python/valuecell/core/agent/types.py index 565349d26..205ba57d8 100644 --- a/python/valuecell/core/agent/types.py +++ b/python/valuecell/core/agent/types.py @@ -23,17 +23,17 @@ class BaseAgent(ABC): @abstractmethod async def stream(self, query, session_id, task_id) -> StreamResponse: - """ - Process user queries and return streaming responses + """ + Process user queries and return streaming responses - Args: - query: User query content - session_id: Session ID - task_id: Task ID + Args: + query: User query content + session_id: Session ID + task_id: Task ID - Yields: - dict: Dictionary containing 'content' and 'is_task_complete' - """ + Yields: + dict: Dictionary containing 'content' and 'is_task_complete' + """ MessageResponse = tuple[Task, Optional[TaskStatusUpdateEvent | TaskArtifactUpdateEvent]] From 92b27acd73bf618440c4c3bc0d042a468e8c7f66 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:55:19 +0800 Subject: [PATCH 21/21] fix: update tickers description to use allowed_tickers set --- python/third_party/ai-hedge-fund/adapter/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/third_party/ai-hedge-fund/adapter/__main__.py b/python/third_party/ai-hedge-fund/adapter/__main__.py index 0a0df39a6..ab269d4c4 100644 --- a/python/third_party/ai-hedge-fund/adapter/__main__.py +++ b/python/third_party/ai-hedge-fund/adapter/__main__.py @@ -16,12 +16,13 @@ allowed_analysts = set( key for display_name, key in sorted(ANALYST_ORDER, key=lambda x: x[1]) ) +allowed_tickers = {"AAPL", "GOOGL", "MSFT", "NVDA", "TSLA"} class HedgeFundRequest(BaseModel): tickers: List[str] = Field( ..., - description="List of stock tickers to analyze. Must be from: [AAPL, GOOGL, MSFT, NVDA, TSLA]", + description=f"List of stock tickers to analyze. Must be from: {allowed_tickers}", ) selected_analysts: List[str] = Field( default=[], @@ -31,7 +32,6 @@ class HedgeFundRequest(BaseModel): @field_validator("tickers") @classmethod def validate_tickers(cls, v): - allowed_tickers = {"AAPL", "GOOGL", "MSFT", "NVDA", "TSLA"} invalid_tickers = set(v) - allowed_tickers if invalid_tickers: raise ValueError(