diff --git a/.idea/ai.iml b/.idea/ai.iml
index 69bfc2fc..bb280a92 100644
--- a/.idea/ai.iml
+++ b/.idea/ai.iml
@@ -16,6 +16,7 @@
+
diff --git a/bin/dev b/bin/dev
index 78ed5b81..b545e84c 100755
--- a/bin/dev
+++ b/bin/dev
@@ -151,14 +151,12 @@ rebuild_images() {
if [ "$service" == "all" ] || [ "$service" == "gateway" ]; then
echo -e "${GREEN}→${NC} Building gateway..."
GATEWAY_TAG=$(build_and_get_tag gateway) || exit 1
- docker tag "dexorder/ai-gateway:$GATEWAY_TAG" "dexorder/gateway:$GATEWAY_TAG"
fi
# Build lifecycle-sidecar (Go binary)
if [ "$service" == "all" ] || [ "$service" == "lifecycle-sidecar" ] || [ "$service" == "sidecar" ]; then
echo -e "${GREEN}→${NC} Building lifecycle-sidecar..."
SIDECAR_TAG=$(build_and_get_tag lifecycle-sidecar) || exit 1
- docker tag "dexorder/ai-lifecycle-sidecar:$SIDECAR_TAG" "lifecycle-sidecar:$SIDECAR_TAG"
fi
# Build web (Vue.js application)
@@ -399,7 +397,7 @@ create_dev_user() {
'http://localhost:8080/mcp',
'{\"maxIndicators\":50,\"maxStrategies\":20,\"maxBacktestDays\":365,\"realtimeData\":true,\"customExecutors\":true,\"apiAccess\":true}',
'{\"maxConcurrentSessions\":5,\"maxMessagesPerDay\":1000,\"maxTokensPerMessage\":8192,\"rateLimitPerMinute\":60}',
- '{\"provider\":\"anthropic\",\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":0.7}'
+ '{\"provider\":\"anthropic\",\"model\":\"claude-sonnet-4-6\",\"temperature\":0.7}'
)
ON CONFLICT (user_id) DO UPDATE SET
license_type = EXCLUDED.license_type,
@@ -651,6 +649,9 @@ case "$COMMAND" in
kubectl wait --for=delete pod -l app=qdrant --timeout=60s 2>/dev/null || true
# Now delete PVCs
delete_pvcs all
+ # Delete dexorder-agents namespace
+ echo -e "${GREEN}→${NC} Deleting dexorder-agents namespace..."
+ kubectl delete namespace dexorder-agents 2>/dev/null || true
minikube stop
echo -e "${GREEN}✓ Minikube stopped and PVCs deleted${NC}"
echo -e "${YELLOW}Tip: Use 'bin/dev stop --keep-data' to preserve PVCs${NC}"
diff --git a/client-py/Dockerfile b/client-py/Dockerfile
index 2eb18c24..37714c61 100644
--- a/client-py/Dockerfile
+++ b/client-py/Dockerfile
@@ -68,6 +68,9 @@ ENV PYTHONUNBUFFERED=1 \
ZMQ_XPUB_PORT=5570 \
ZMQ_GATEWAY_ENDPOINT=tcp://gateway:5571 \
MCP_SERVER_NAME=dexorder-user \
+ MCP_TRANSPORT=sse \
+ MCP_HTTP_PORT=3000 \
+ MCP_HTTP_HOST=0.0.0.0 \
IDLE_TIMEOUT_MINUTES=15 \
ENABLE_IDLE_SHUTDOWN=true
diff --git a/client-py/dexorder/__init__.py b/client-py/dexorder/__init__.py
index cdf13a8d..1a04c556 100644
--- a/client-py/dexorder/__init__.py
+++ b/client-py/dexorder/__init__.py
@@ -11,6 +11,10 @@ Provides high-level APIs for:
__version__ = "0.1.0"
+# Suppress the protobuf warning - it's handled at import time
+import warnings
+warnings.filterwarnings('ignore', message='Warning: Protobuf files not found')
+
from .ohlc_client import OHLCClient
from .iceberg_client import IcebergClient
from .history_client import HistoryClient
diff --git a/client-py/dexorder/api/category_tools.py b/client-py/dexorder/api/category_tools.py
new file mode 100644
index 00000000..796fefa5
--- /dev/null
+++ b/client-py/dexorder/api/category_tools.py
@@ -0,0 +1,559 @@
+"""
+Category-based File Management Tools for MCP Server
+
+Provides write/edit/read/list tools for categorized Python scripts with automatic
+directory structure, metadata management, and validation harnesses.
+
+Categories:
+- strategy: Trading strategies with specific data feed requirements
+- indicator: Technical indicators with configurable parameters
+- research: Research scripts with pyplot visualization support
+
+File Structure:
+{DATA_DIR}/{category}/{sanitized_name}/
+ - implementation.py # Python implementation
+ - metadata.json # Category-specific metadata (includes name and description)
+
+After write/edit operations, a category-specific test harness runs to validate
+the code and capture errors/output for agent feedback.
+"""
+
+import json
+import logging
+import re
+import subprocess
+import sys
+from dataclasses import dataclass, asdict
+from enum import Enum
+from pathlib import Path
+from typing import Any, Optional
+
+log = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Categories and Metadata
+# =============================================================================
+
+class Category(str, Enum):
+ """Supported file categories."""
+ STRATEGY = "strategy"
+ INDICATOR = "indicator"
+ RESEARCH = "research"
+
+
+@dataclass
+class BaseMetadata:
+ """Base metadata for all categories."""
+ name: str # Display name (can have special chars)
+ description: str # LLM-generated description
+
+
+@dataclass
+class StrategyMetadata(BaseMetadata):
+ """Metadata for trading strategies."""
+ data_feeds: list[str] = None # Required data feeds (e.g., ["BTC/USD", "ETH/USD"])
+
+ def __post_init__(self):
+ if self.data_feeds is None:
+ self.data_feeds = []
+
+
+@dataclass
+class IndicatorMetadata(BaseMetadata):
+ """Metadata for technical indicators."""
+ default_length: int = 14 # Default period/length parameter
+
+
+@dataclass
+class ResearchMetadata(BaseMetadata):
+ """Metadata for research scripts."""
+ # Future: data_sources, dependencies, etc.
+ pass
+
+
+# Metadata class registry
+METADATA_CLASSES = {
+ Category.STRATEGY: StrategyMetadata,
+ Category.INDICATOR: IndicatorMetadata,
+ Category.RESEARCH: ResearchMetadata,
+}
+
+
+# =============================================================================
+# Utilities
+# =============================================================================
+
+def sanitize_name(name: str) -> str:
+ """
+ Sanitize a name for use as a directory name.
+
+ Converts special characters to underscores and preserves alphanumerics.
+ Examples:
+ "Tim's 5/13 EMA+" -> "Tims_5_13_EMA"
+ "My Strategy (v2)" -> "My_Strategy_v2"
+ """
+ # Replace non-alphanumeric chars (except spaces/hyphens) with underscores
+ sanitized = re.sub(r'[^\w\s\-]', '_', name)
+ # Replace spaces and hyphens with underscores
+ sanitized = re.sub(r'[\s\-]+', '_', sanitized)
+ # Remove duplicate underscores
+ sanitized = re.sub(r'_+', '_', sanitized)
+ # Strip leading/trailing underscores
+ sanitized = sanitized.strip('_')
+ return sanitized
+
+
+def get_category_path(data_dir: Path, category: Category, name: str) -> Path:
+ """
+ Get the directory path for a category item.
+
+ Args:
+ data_dir: Base data directory
+ category: Category enum
+ name: Display name (will be sanitized)
+
+ Returns:
+ Path to the category item directory
+ """
+ safe_name = sanitize_name(name)
+ return data_dir / category.value / safe_name
+
+
+# =============================================================================
+# Category File Manager
+# =============================================================================
+
+class CategoryFileManager:
+ """
+ Manages category-based file operations with validation.
+ """
+
+ def __init__(self, data_dir: Path):
+ self.data_dir = data_dir
+
+ # Ensure category directories exist
+ for category in Category:
+ (data_dir / category.value).mkdir(parents=True, exist_ok=True)
+
+ def write(
+ self,
+ category: str,
+ name: str,
+ description: str,
+ code: str,
+ metadata: Optional[dict] = None
+ ) -> dict[str, Any]:
+ """
+ Write a new category file with metadata.
+
+ Args:
+ category: Category name (strategy, indicator, research)
+ name: Display name for the item
+ description: LLM-generated description (required)
+ code: Python implementation code
+ metadata: Additional category-specific metadata fields
+
+ Returns:
+ dict with:
+ - success: bool
+ - path: str - path to the implementation file
+ - validation: dict - results from test harness
+ - error: str (if any)
+ """
+ try:
+ cat = Category(category)
+ except ValueError:
+ return {
+ "success": False,
+ "error": f"Invalid category '{category}'. Must be one of: {', '.join(c.value for c in Category)}"
+ }
+
+ # Get item directory
+ item_dir = get_category_path(self.data_dir, cat, name)
+ item_dir.mkdir(parents=True, exist_ok=True)
+
+ # Write implementation
+ impl_path = item_dir / "implementation.py"
+ try:
+ impl_path.write_text(code)
+ log.info(f"Wrote {cat.value} implementation: {impl_path}")
+ except Exception as e:
+ return {"success": False, "error": f"Failed to write implementation: {e}"}
+
+ # Build metadata
+ meta_dict = metadata or {}
+ meta_dict["name"] = name
+ meta_dict["description"] = description
+
+ # Validate and write metadata
+ try:
+ metadata_class = METADATA_CLASSES[cat]
+ meta_obj = metadata_class(**meta_dict)
+ validated_meta = asdict(meta_obj)
+
+ meta_path = item_dir / "metadata.json"
+ meta_path.write_text(json.dumps(validated_meta, indent=2))
+ log.info(f"Wrote metadata: {meta_path}")
+ except Exception as e:
+ return {"success": False, "error": f"Failed to write metadata: {e}"}
+
+ # Run validation harness
+ validation = self._validate(cat, item_dir)
+
+ return {
+ "success": validation["success"],
+ "path": str(impl_path),
+ "validation": validation,
+ }
+
+ def edit(
+ self,
+ category: str,
+ name: str,
+ code: Optional[str] = None,
+ description: Optional[str] = None,
+ metadata: Optional[dict] = None
+ ) -> dict[str, Any]:
+ """
+ Edit an existing category file.
+
+ Args:
+ category: Category name
+ name: Display name for the item
+ code: Python implementation code (optional, omit to keep existing)
+ description: Updated description (optional, omit to keep existing)
+ metadata: Additional metadata updates (optional)
+
+ Returns:
+ dict with:
+ - success: bool
+ - path: str - path to the implementation file
+ - validation: dict - results from test harness (if code updated)
+ - error: str (if any)
+ """
+ try:
+ cat = Category(category)
+ except ValueError:
+ return {"success": False, "error": f"Invalid category '{category}'"}
+
+ item_dir = get_category_path(self.data_dir, cat, name)
+
+ if not item_dir.exists():
+ return {"success": False, "error": f"Item '{name}' does not exist in category '{category}'"}
+
+ impl_path = item_dir / "implementation.py"
+ meta_path = item_dir / "metadata.json"
+
+ # Load existing metadata
+ try:
+ existing_meta = {}
+ if meta_path.exists():
+ existing_meta = json.loads(meta_path.read_text())
+ except Exception as e:
+ return {"success": False, "error": f"Failed to read existing metadata: {e}"}
+
+ # Update code if provided
+ if code is not None:
+ try:
+ impl_path.write_text(code)
+ log.info(f"Updated {cat.value} implementation: {impl_path}")
+ except Exception as e:
+ return {"success": False, "error": f"Failed to write implementation: {e}"}
+
+ # Update metadata
+ updated_meta = existing_meta.copy()
+ if description is not None:
+ updated_meta["description"] = description
+ if metadata:
+ updated_meta.update(metadata)
+
+ # Validate and write metadata
+ try:
+ metadata_class = METADATA_CLASSES[cat]
+ meta_obj = metadata_class(**updated_meta)
+ validated_meta = asdict(meta_obj)
+
+ meta_path.write_text(json.dumps(validated_meta, indent=2))
+ log.info(f"Updated metadata: {meta_path}")
+ except Exception as e:
+ return {"success": False, "error": f"Failed to write metadata: {e}"}
+
+ # Run validation harness if code was updated
+ validation = None
+ if code is not None:
+ validation = self._validate(cat, item_dir)
+
+ result = {
+ "success": True,
+ "path": str(impl_path),
+ }
+ if validation:
+ result["validation"] = validation
+ result["success"] = validation["success"]
+
+ return result
+
+ def read(
+ self,
+ category: str,
+ name: str
+ ) -> dict[str, Any]:
+ """
+ Read a category file and its metadata.
+
+ Returns:
+ dict with:
+ - exists: bool
+ - code: str - implementation code
+ - metadata: dict - metadata
+ - error: str (if any)
+ """
+ try:
+ cat = Category(category)
+ except ValueError:
+ return {"exists": False, "error": f"Invalid category '{category}'"}
+
+ item_dir = get_category_path(self.data_dir, cat, name)
+
+ if not item_dir.exists():
+ return {"exists": False}
+
+ impl_path = item_dir / "implementation.py"
+ meta_path = item_dir / "metadata.json"
+
+ try:
+ code = impl_path.read_text() if impl_path.exists() else ""
+ metadata = {}
+ if meta_path.exists():
+ metadata = json.loads(meta_path.read_text())
+
+ return {
+ "exists": True,
+ "code": code,
+ "metadata": metadata,
+ }
+ except Exception as e:
+ return {"exists": False, "error": str(e)}
+
+ def list_items(self, category: str) -> dict[str, Any]:
+ """
+ List all items in a category with names and descriptions.
+
+ Returns:
+ dict with:
+ - items: list of dicts with name, description, and full metadata
+ - error: str (if any)
+ """
+ try:
+ cat = Category(category)
+ except ValueError:
+ return {"error": f"Invalid category '{category}'"}
+
+ cat_dir = self.data_dir / cat.value
+ items = []
+
+ for item_dir in cat_dir.iterdir():
+ if not item_dir.is_dir():
+ continue
+
+ meta_path = item_dir / "metadata.json"
+ if meta_path.exists():
+ try:
+ metadata = json.loads(meta_path.read_text())
+ items.append({
+ "name": metadata.get("name", item_dir.name),
+ "description": metadata.get("description", ""),
+ "metadata": metadata,
+ })
+ except Exception as e:
+ log.error(f"Failed to read metadata for {item_dir}: {e}")
+
+ return {"items": items}
+
+ def _validate(self, category: Category, item_dir: Path) -> dict[str, Any]:
+ """
+ Run category-specific validation harness.
+
+ Returns:
+ dict with:
+ - success: bool
+ - output: str - stdout/stderr from validation
+ - images: list[dict] - base64-encoded images (for research)
+ - error: str (if any)
+ """
+ impl_path = item_dir / "implementation.py"
+
+ if category == Category.STRATEGY:
+ return self._validate_strategy(impl_path)
+ elif category == Category.INDICATOR:
+ return self._validate_indicator(impl_path)
+ elif category == Category.RESEARCH:
+ return self._validate_research(impl_path, item_dir)
+ else:
+ return {"success": False, "error": f"No validator for category {category}"}
+
+ def _validate_strategy(self, impl_path: Path) -> dict[str, Any]:
+ """
+ Validate a strategy implementation.
+
+ Runs basic syntax check and imports.
+ """
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "py_compile", str(impl_path)],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+
+ if result.returncode == 0:
+ return {
+ "success": True,
+ "output": "Strategy syntax valid",
+ }
+ else:
+ return {
+ "success": False,
+ "output": result.stderr,
+ "error": "Syntax error in strategy",
+ }
+ except subprocess.TimeoutExpired:
+ return {"success": False, "error": "Validation timeout"}
+ except Exception as e:
+ return {"success": False, "error": f"Validation failed: {e}"}
+
+ def _validate_indicator(self, impl_path: Path) -> dict[str, Any]:
+ """
+ Validate an indicator implementation.
+
+ Runs basic syntax check and imports.
+ """
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "py_compile", str(impl_path)],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+
+ if result.returncode == 0:
+ return {
+ "success": True,
+ "output": "Indicator syntax valid",
+ }
+ else:
+ return {
+ "success": False,
+ "output": result.stderr,
+ "error": "Syntax error in indicator",
+ }
+ except subprocess.TimeoutExpired:
+ return {"success": False, "error": "Validation timeout"}
+ except Exception as e:
+ return {"success": False, "error": f"Validation failed: {e}"}
+
+ def _validate_research(self, impl_path: Path, item_dir: Path) -> dict[str, Any]:
+ """
+ Validate a research script.
+
+ Runs the script and captures output + pyplot images.
+ """
+ # Create a wrapper script that captures pyplot figures
+ wrapper_code = f"""
+import sys
+import io
+import base64
+import json
+from pathlib import Path
+import matplotlib
+matplotlib.use('Agg') # Non-interactive backend
+import matplotlib.pyplot as plt
+
+# Capture stdout
+old_stdout = sys.stdout
+sys.stdout = io.StringIO()
+
+# Run user code
+user_code_path = Path(r"{impl_path}")
+try:
+ exec(compile(user_code_path.read_text(), user_code_path, 'exec'), {{}})
+except Exception as e:
+ print(f"ERROR: {{e}}", file=sys.stderr)
+ sys.exit(1)
+
+# Get stdout
+output = sys.stdout.getvalue()
+sys.stdout = old_stdout
+
+# Capture all pyplot figures as base64 PNGs
+images = []
+for fig_num in plt.get_fignums():
+ fig = plt.figure(fig_num)
+ buf = io.BytesIO()
+ fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
+ buf.seek(0)
+ img_b64 = base64.b64encode(buf.read()).decode('utf-8')
+ images.append({{"format": "png", "data": img_b64}})
+ buf.close()
+
+plt.close('all')
+
+# Output results as JSON
+result = {{
+ "output": output,
+ "images": images,
+}}
+print(json.dumps(result))
+"""
+
+ try:
+ result = subprocess.run(
+ [sys.executable, "-c", wrapper_code],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ cwd=str(item_dir),
+ )
+
+ if result.returncode == 0:
+ try:
+ data = json.loads(result.stdout)
+ return {
+ "success": True,
+ "output": data["output"],
+ "images": data["images"],
+ }
+ except json.JSONDecodeError:
+ return {
+ "success": True,
+ "output": result.stdout,
+ "images": [],
+ }
+ else:
+ return {
+ "success": False,
+ "output": result.stderr,
+ "error": "Research script execution failed",
+ }
+ except subprocess.TimeoutExpired:
+ return {"success": False, "error": "Research script timeout"}
+ except Exception as e:
+ return {"success": False, "error": f"Validation failed: {e}"}
+
+
+# =============================================================================
+# Global Manager Instance
+# =============================================================================
+
+_category_manager: Optional[CategoryFileManager] = None
+
+
+def get_category_manager(data_dir: Optional[Path] = None) -> CategoryFileManager:
+ """Get or create the global category file manager."""
+ global _category_manager
+ if _category_manager is None:
+ if data_dir is None:
+ raise ValueError("data_dir required for first initialization")
+ _category_manager = CategoryFileManager(data_dir)
+ return _category_manager
diff --git a/client-py/dexorder/history_client.py b/client-py/dexorder/history_client.py
index 5c30ba69..d0bb5047 100644
--- a/client-py/dexorder/history_client.py
+++ b/client-py/dexorder/history_client.py
@@ -16,12 +16,11 @@ import struct
import sys
import os
-# Import protobuf messages (assuming they're generated in ../protobuf)
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../protobuf'))
+# Import protobuf messages from generated package
try:
- from ingestor_pb2 import SubmitHistoricalRequest, SubmitResponse, HistoryReadyNotification
+ from dexorder.generated.ingestor_pb2 import SubmitHistoricalRequest, SubmitResponse, HistoryReadyNotification
except ImportError:
- print("Warning: Protobuf files not found. Run: protoc -I ../protobuf --python_out=../protobuf ../protobuf/*.proto")
+ print("Warning: Protobuf files not found. Run: protoc --python_out=dexorder/generated --proto_path=protobuf protobuf/*.proto")
raise
diff --git a/client-py/dexorder/lifecycle_manager.py b/client-py/dexorder/lifecycle_manager.py
index 882d865a..a1ec96c3 100644
--- a/client-py/dexorder/lifecycle_manager.py
+++ b/client-py/dexorder/lifecycle_manager.py
@@ -205,26 +205,36 @@ class LifecycleManager:
_lifecycle_manager: Optional[LifecycleManager] = None
-def get_lifecycle_manager() -> LifecycleManager:
+def get_lifecycle_manager(
+ idle_timeout_minutes: Optional[int] = None,
+ enable_shutdown: Optional[bool] = None,
+) -> LifecycleManager:
"""Get or create the global lifecycle manager instance."""
global _lifecycle_manager
if _lifecycle_manager is None:
- # Load configuration from environment
- idle_timeout = int(os.environ.get("IDLE_TIMEOUT_MINUTES", "15"))
+ # Load configuration from environment or use provided values
+ idle_timeout = idle_timeout_minutes if idle_timeout_minutes is not None else int(os.environ.get("IDLE_TIMEOUT_MINUTES", "15"))
check_interval = int(os.environ.get("IDLE_CHECK_INTERVAL_SECONDS", "60"))
- enable_shutdown = os.environ.get("ENABLE_IDLE_SHUTDOWN", "true").lower() == "true"
+ enable = enable_shutdown if enable_shutdown is not None else os.environ.get("ENABLE_IDLE_SHUTDOWN", "true").lower() == "true"
_lifecycle_manager = LifecycleManager(
idle_timeout_minutes=idle_timeout,
check_interval_seconds=check_interval,
- enable_shutdown=enable_shutdown,
+ enable_shutdown=enable,
)
return _lifecycle_manager
-async def start_lifecycle_manager() -> LifecycleManager:
+async def start_lifecycle_manager(
+ user_id: Optional[str] = None,
+ idle_timeout_minutes: Optional[int] = None,
+ enable_idle_shutdown: Optional[bool] = None,
+) -> LifecycleManager:
"""Initialize and start the lifecycle manager."""
- manager = get_lifecycle_manager()
+ manager = get_lifecycle_manager(
+ idle_timeout_minutes=idle_timeout_minutes,
+ enable_shutdown=enable_idle_shutdown,
+ )
manager.setup_signal_handlers()
await manager.start()
return manager
diff --git a/client-py/main.py b/client-py/main.py
index 73b977d3..1df1202c 100644
--- a/client-py/main.py
+++ b/client-py/main.py
@@ -18,12 +18,20 @@ from pathlib import Path
from typing import Optional
import yaml
+import uvicorn
from mcp.server import Server
from mcp.server.stdio import stdio_server
+from mcp.server.sse import SseServerTransport
+from starlette.applications import Starlette
+from starlette.routing import Route, Mount
+from starlette.requests import Request
+from starlette.responses import Response
+from sse_starlette import EventSourceResponse
from dexorder import EventPublisher, start_lifecycle_manager, get_lifecycle_manager
from dexorder.events import EventType, UserEvent, DeliverySpec
from dexorder.api.workspace_tools import get_workspace_store
+from dexorder.api.category_tools import get_category_manager
# =============================================================================
@@ -68,6 +76,9 @@ class Config:
# MCP server settings
self.mcp_server_name: str = os.getenv("MCP_SERVER_NAME", "dexorder-user")
+ self.mcp_transport: str = os.getenv("MCP_TRANSPORT", "sse") # "stdio" or "sse"
+ self.mcp_http_port: int = int(os.getenv("MCP_HTTP_PORT", "3000"))
+ self.mcp_http_host: str = os.getenv("MCP_HTTP_HOST", "0.0.0.0")
# Lifecycle settings
self.idle_timeout_minutes: int = int(os.getenv("IDLE_TIMEOUT_MINUTES", "15"))
@@ -134,6 +145,10 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
workspace_store = get_workspace_store(config.workspace_dir)
logging.info(f"Workspace store initialized at {config.workspace_dir}")
+ # Initialize category file manager
+ category_manager = get_category_manager(config.data_dir)
+ logging.info(f"Category manager initialized at {config.data_dir}")
+
@server.list_resources()
async def list_resources():
"""List available resources"""
@@ -143,6 +158,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
"name": "Hello World",
"description": "A simple hello world resource",
"mimeType": "text/plain",
+ "annotations": {
+ "agent_accessible": True, # Available to agent for ad-hoc queries
+ }
}
]
@@ -170,7 +188,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
@server.list_tools()
async def list_tools():
- """List available tools including workspace tools"""
+ """List available tools including workspace and category tools"""
return [
{
"name": "workspace_read",
@@ -184,6 +202,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
}
},
"required": ["store_name"]
+ },
+ "annotations": {
+ "agent_accessible": True, # Agent can read workspace stores
}
},
{
@@ -201,6 +222,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
}
},
"required": ["store_name", "data"]
+ },
+ "annotations": {
+ "agent_accessible": True, # Agent can write workspace stores
}
},
{
@@ -228,13 +252,124 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
}
},
"required": ["store_name", "patch"]
+ },
+ "annotations": {
+ "agent_accessible": True, # Agent can patch workspace stores
+ }
+ },
+ {
+ "name": "category_write",
+ "description": "Write a new strategy, indicator, or research script with validation",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "enum": ["strategy", "indicator", "research"],
+ "description": "Category of the script"
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name (can contain special characters)"
+ },
+ "description": {
+ "type": "string",
+ "description": "LLM-generated description of what this does (required)"
+ },
+ "code": {
+ "type": "string",
+ "description": "Python implementation code"
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Optional category-specific metadata (e.g., default_length for indicators, data_feeds for strategies)"
+ }
+ },
+ "required": ["category", "name", "description", "code"]
+ },
+ "annotations": {
+ "agent_accessible": True,
+ }
+ },
+ {
+ "name": "category_edit",
+ "description": "Edit an existing category script (updates code, description, or metadata)",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "enum": ["strategy", "indicator", "research"],
+ "description": "Category of the script"
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name of the existing item"
+ },
+ "code": {
+ "type": "string",
+ "description": "Updated Python code (optional, omit to keep existing)"
+ },
+ "description": {
+ "type": "string",
+ "description": "Updated description (optional, omit to keep existing)"
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Updated metadata fields (optional)"
+ }
+ },
+ "required": ["category", "name"]
+ },
+ "annotations": {
+ "agent_accessible": True,
+ }
+ },
+ {
+ "name": "category_read",
+ "description": "Read a category script and its metadata",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "enum": ["strategy", "indicator", "research"],
+ "description": "Category of the script"
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name of the item"
+ }
+ },
+ "required": ["category", "name"]
+ },
+ "annotations": {
+ "agent_accessible": True,
+ }
+ },
+ {
+ "name": "category_list",
+ "description": "List all items in a category with names and descriptions",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "enum": ["strategy", "indicator", "research"],
+ "description": "Category to list"
+ }
+ },
+ "required": ["category"]
+ },
+ "annotations": {
+ "agent_accessible": True,
}
}
]
@server.call_tool()
async def handle_tool_call(name: str, arguments: dict):
- """Handle tool calls including workspace tools"""
+ """Handle tool calls including workspace and category tools"""
if name == "workspace_read":
return workspace_store.read(arguments.get("store_name", ""))
elif name == "workspace_write":
@@ -247,13 +382,78 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
arguments.get("store_name", ""),
arguments.get("patch", [])
)
+ elif name == "category_write":
+ return category_manager.write(
+ category=arguments.get("category", ""),
+ name=arguments.get("name", ""),
+ description=arguments.get("description", ""),
+ code=arguments.get("code", ""),
+ metadata=arguments.get("metadata")
+ )
+ elif name == "category_edit":
+ return category_manager.edit(
+ category=arguments.get("category", ""),
+ name=arguments.get("name", ""),
+ code=arguments.get("code"),
+ description=arguments.get("description"),
+ metadata=arguments.get("metadata")
+ )
+ elif name == "category_read":
+ return category_manager.read(
+ category=arguments.get("category", ""),
+ name=arguments.get("name", "")
+ )
+ elif name == "category_list":
+ return category_manager.list_items(
+ category=arguments.get("category", "")
+ )
else:
raise ValueError(f"Unknown tool: {name}")
- logging.info(f"MCP server '{config.mcp_server_name}' created with workspace tools")
+ logging.info(f"MCP server '{config.mcp_server_name}' created with workspace and category tools")
return server
+# =============================================================================
+# SSE Transport Setup
+# =============================================================================
+
+def create_sse_app(mcp_server: Server) -> Starlette:
+ """Create Starlette app with SSE endpoint for MCP"""
+
+ # Create SSE transport instance
+ sse = SseServerTransport("/messages/")
+
+ async def handle_sse(request: Request) -> Response:
+ """Handle SSE connections for MCP"""
+ async with sse.connect_sse(
+ request.scope, request.receive, request._send
+ ) as streams:
+ await mcp_server.run(
+ streams[0],
+ streams[1],
+ mcp_server.create_initialization_options()
+ )
+ return Response()
+
+ async def handle_health(request: Request) -> Response:
+ """Health check endpoint for k8s probes and gateway readiness checks"""
+ return Response(
+ content='{"status":"ok"}',
+ media_type="application/json"
+ )
+
+ app = Starlette(
+ routes=[
+ Route("/sse", handle_sse),
+ Mount("/messages/", app=sse.handle_post_message),
+ Route("/health", handle_health),
+ ]
+ )
+
+ return app
+
+
# =============================================================================
# Main Application
# =============================================================================
@@ -347,17 +547,34 @@ class UserContainer:
logging.info("User container stopped")
async def run(self) -> None:
- """Run the MCP server via stdio"""
+ """Run the MCP server with configured transport"""
await self.start()
try:
- # Run MCP server on stdio
- async with stdio_server() as (read_stream, write_stream):
- await self.mcp_server.run(
- read_stream,
- write_stream,
- self.mcp_server.create_initialization_options()
+ if self.config.mcp_transport == "stdio":
+ # Run MCP server on stdio (for dev/testing)
+ logging.info("Starting MCP server with stdio transport")
+ async with stdio_server() as (read_stream, write_stream):
+ await self.mcp_server.run(
+ read_stream,
+ write_stream,
+ self.mcp_server.create_initialization_options()
+ )
+ elif self.config.mcp_transport == "sse":
+ # Run MCP server via HTTP/SSE (for production)
+ logging.info(f"Starting MCP server with SSE transport on {self.config.mcp_http_host}:{self.config.mcp_http_port}")
+ app = create_sse_app(self.mcp_server)
+ config = uvicorn.Config(
+ app,
+ host=self.config.mcp_http_host,
+ port=self.config.mcp_http_port,
+ log_level=os.getenv("LOG_LEVEL", "info").lower(),
+ access_log=True,
)
+ server = uvicorn.Server(config)
+ await server.serve()
+ else:
+ raise ValueError(f"Unknown MCP transport: {self.config.mcp_transport}")
finally:
await self.stop()
diff --git a/client-py/setup.py b/client-py/setup.py
index 3d5a0176..83da81ca 100644
--- a/client-py/setup.py
+++ b/client-py/setup.py
@@ -14,8 +14,12 @@ setup(
"protobuf>=4.25.0",
"pyyaml>=6.0",
"aiofiles>=23.0.0",
- "mcp>=0.9.0",
+ "mcp>=1.0.0",
"jsonpatch>=1.33",
+ "starlette>=0.27.0",
+ "uvicorn>=0.27.0",
+ "sse-starlette>=1.6.0",
+ "matplotlib>=3.7.0",
],
extras_require={
"dev": [
diff --git a/deploy/k8s/base/agent-quotas.yaml b/deploy/k8s/base/agent-quotas.yaml
index 660d8dbf..2ce28dfc 100644
--- a/deploy/k8s/base/agent-quotas.yaml
+++ b/deploy/k8s/base/agent-quotas.yaml
@@ -22,8 +22,8 @@ spec:
memory: "2Gi"
cpu: "2000m"
min:
- memory: "64Mi"
- cpu: "50m"
+ memory: "32Mi"
+ cpu: "10m"
# PVC size limits
- type: PersistentVolumeClaim
max:
diff --git a/deploy/k8s/dev/admission-policy-patch.yaml b/deploy/k8s/dev/admission-policy-patch.yaml
index 0c702efb..79c9ed12 100644
--- a/deploy/k8s/dev/admission-policy-patch.yaml
+++ b/deploy/k8s/dev/admission-policy-patch.yaml
@@ -25,8 +25,7 @@ spec:
c.image.startsWith('localhost:5000/dexorder/agent') ||
c.image.startsWith('dexorder/agent') ||
c.image.startsWith('dexorder/ai-client-py') ||
- c.image.startsWith('ai-client-py') ||
- c.image.startsWith('lifecycle-sidecar'))
+ c.image.startsWith('dexorder/ai-lifecycle-sidecar'))
message: "Only approved dexorder agent images are allowed"
reason: Forbidden
diff --git a/deploy/k8s/dev/configs/gateway-config.yaml b/deploy/k8s/dev/configs/gateway-config.yaml
index 79a40e0c..a6dc251d 100644
--- a/deploy/k8s/dev/configs/gateway-config.yaml
+++ b/deploy/k8s/dev/configs/gateway-config.yaml
@@ -25,15 +25,40 @@ data:
# Default model (if user has no preference)
defaults:
model_provider: anthropic
- model: claude-3-5-sonnet-20241022
+ model: claude-sonnet-4-6
+
+ # License tier model configuration
+ license_models:
+ # Free tier models
+ free:
+ default: claude-haiku-4-5-20251001
+ cost_optimized: claude-haiku-4-5-20251001
+ complex: claude-haiku-4-5-20251001
+ allowed_models:
+ - claude-haiku-4-5-20251001
+
+ # Pro tier models
+ pro:
+ default: claude-sonnet-4-6
+ cost_optimized: claude-haiku-4-5-20251001
+ complex: claude-sonnet-4-6
+ blocked_models:
+ - claude-opus-4-6
+
+ # Enterprise tier models
+ enterprise:
+ default: claude-sonnet-4-6
+ cost_optimized: claude-haiku-4-5-20251001
+ complex: claude-opus-4-6
# Kubernetes configuration
kubernetes:
namespace: dexorder-agents
in_cluster: true
- agent_image: ai-client-py:latest
- sidecar_image: lifecycle-sidecar:latest
+ agent_image: dexorder/ai-client-py:latest
+ sidecar_image: dexorder/ai-lifecycle-sidecar:latest
storage_class: standard
+ image_pull_policy: Never # For minikube dev - use local images
# DragonflyDB (Redis-compatible, for hot storage and session management)
redis:
diff --git a/deploy/k8s/dev/gateway-dev-patch.yaml b/deploy/k8s/dev/gateway-dev-patch.yaml
index af73df5c..6756bbcd 100644
--- a/deploy/k8s/dev/gateway-dev-patch.yaml
+++ b/deploy/k8s/dev/gateway-dev-patch.yaml
@@ -8,7 +8,7 @@ spec:
spec:
containers:
- name: gateway
- image: dexorder/gateway:latest
+ image: dexorder/ai-gateway:latest
imagePullPolicy: Never
env:
- name: NODE_OPTIONS
diff --git a/deploy/k8s/dev/kustomization.yaml b/deploy/k8s/dev/kustomization.yaml
index 299ef5c3..f92dba28 100644
--- a/deploy/k8s/dev/kustomization.yaml
+++ b/deploy/k8s/dev/kustomization.yaml
@@ -132,6 +132,29 @@ generatorOptions:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/k8s/prod/configs/gateway-config.yaml b/deploy/k8s/prod/configs/gateway-config.yaml
index 0e7f77c4..fd91e637 100644
--- a/deploy/k8s/prod/configs/gateway-config.yaml
+++ b/deploy/k8s/prod/configs/gateway-config.yaml
@@ -24,15 +24,16 @@ data:
# Default model (if user has no preference)
defaults:
model_provider: anthropic
- model: claude-3-5-sonnet-20241022
+ model: claude-sonnet-4-6
# Kubernetes configuration
kubernetes:
namespace: dexorder-agents
in_cluster: true
- agent_image: ghcr.io/dexorder/agent:latest
- sidecar_image: ghcr.io/dexorder/lifecycle-sidecar:latest
+ agent_image: dexorder/ai-client-py:latest
+ sidecar_image: dexorder/ai-lifecycle-sidecar:latest
storage_class: standard
+ image_pull_policy: Always # For production - always pull from registry
# DragonflyDB (Redis-compatible, for hot storage and session management)
redis:
diff --git a/doc/competition/mmt.gg/companion_capabilities.md b/doc/competition/mmt.gg/companion_capabilities.md
new file mode 100644
index 00000000..4fd81004
--- /dev/null
+++ b/doc/competition/mmt.gg/companion_capabilities.md
@@ -0,0 +1,52 @@
+Tell me about yourself and your capabilities
+
+
+I'm **MMT Companion**, a crypto market assistant embedded directly in your MMT trading terminal.
+
+Here's what I can do:
+
+### Market Analysis
+- Fetch and analyze **OHLCV candle data** across any timeframe
+- Summarize **price action, structure, and key levels**
+- Provide **market overviews** with funding rates, liquidations, and trade flow
+
+### Order Flow & Depth
+- Read **live orderbooks** and identify bid/ask walls
+- Analyze **orderbook heatmaps** to track liquidity shifts over time
+- Break down **cumulative volume delta (CVD)** by trade size bucket
+
+### Positioning & Derivatives
+- Track **open interest** trends and leverage buildup
+- Monitor **funding rates** and liquidation clusters
+
+### Volume & Profiling
+- Build **volume profiles** to find POC, value area, and volume gaps
+
+### Chart Drawing
+- Draw **horizontal levels, rectangles, trendlines, and rays** directly on your chart
+
+### Scripting
+- Write, edit, and deploy **MMT scripts** in the built-in editor
+
+---
+
+I always work from your **live chart context** — currently `BTC/USD` on Binance Futures at `5m`. All data I pull is real-time and grounded in actual market feeds, never fabricated.
+
+What would you like to analyze?
+
+
+Are you able to write automated strategies and execute them? How about backtesting?
+
+### Scripting — Yes. Execution & Backtesting — No.
+
+**What I can do:**
+- Write and edit **MMT scripts** — indicators, signals, overlays, alerts, and visual tools using the MMT scripting language
+- Deploy scripts directly into your **MMT script editor** in the terminal
+
+**What I cannot do:**
+- **Automated execution** — I cannot place, manage, or cancel orders on your behalf. MMT Companion has no order routing capability.
+- **Backtesting** — There is no backtesting engine available through MMT scripting or this assistant at this time.
+
+---
+
+In short, I can build powerful **analytical and signal scripts** that run on your live chart, but acting on those signals (trading) remains manual on your end. If you have a strategy idea you want visualized or signaled in real-time, I can script that for you.
diff --git a/doc/user_mcp_resources.md b/doc/user_mcp_resources.md
index 9decf89e..d507466f 100644
--- a/doc/user_mcp_resources.md
+++ b/doc/user_mcp_resources.md
@@ -210,7 +210,7 @@ async handleMessage(message: InboundMessage): Promise {
// 5. Call Claude with embedded context
const response = await anthropic.messages.create({
- model: 'claude-3-5-sonnet-20241022',
+ model: 'claude-sonnet-4-6',
system: systemPrompt, // <-- User profile + workspace + custom prompt
messages, // <-- Conversation summary from RAG
tools,
diff --git a/gateway/.env.example b/gateway/.env.example
index bdbde2a4..9364515f 100644
--- a/gateway/.env.example
+++ b/gateway/.env.example
@@ -22,7 +22,7 @@ OPENROUTER_API_KEY=sk-or-xxxxx
# Default model (if user has no preference)
DEFAULT_MODEL_PROVIDER=anthropic
-DEFAULT_MODEL=claude-3-5-sonnet-20241022
+DEFAULT_MODEL=claude-sonnet-4-6
# Telegram (optional)
TELEGRAM_BOT_TOKEN=
diff --git a/gateway/ARCHITECTURE.md b/gateway/ARCHITECTURE.md
index 1a7a77b5..367f16cc 100644
--- a/gateway/ARCHITECTURE.md
+++ b/gateway/ARCHITECTURE.md
@@ -32,7 +32,7 @@ const factory = new LLMProviderFactory(config, logger);
// Create any model
const claude = factory.createModel({
provider: 'anthropic',
- model: 'claude-3-5-sonnet-20241022',
+ model: 'claude-sonnet-4-6',
});
const gpt4 = factory.createModel({
@@ -205,7 +205,7 @@ const conversationSummary = await mcpClient.readResource('context://conversation
{
"preferredModel": {
"provider": "anthropic",
- "model": "claude-3-5-sonnet-20241022"
+ "model": "claude-sonnet-4-6"
}
}
diff --git a/gateway/README.md b/gateway/README.md
index 204360b5..9a1d497d 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -120,7 +120,7 @@ ANTHROPIC_API_KEY=sk-ant-xxxxx
# Optional: Set default model
DEFAULT_MODEL_PROVIDER=anthropic
-DEFAULT_MODEL=claude-3-5-sonnet-20241022
+DEFAULT_MODEL=claude-sonnet-4-6
```
4. Start Ollama and pull embedding model:
diff --git a/gateway/config.example.yaml b/gateway/config.example.yaml
index f5b6f348..e9d95431 100644
--- a/gateway/config.example.yaml
+++ b/gateway/config.example.yaml
@@ -19,7 +19,31 @@ database:
# Default model (if user has no preference)
defaults:
model_provider: anthropic
- model: claude-3-5-sonnet-20241022
+ model: claude-sonnet-4-6
+
+# License tier model configuration
+license_models:
+ # Free tier models
+ free:
+ default: claude-haiku-4-5-20251001
+ cost_optimized: claude-haiku-4-5-20251001
+ complex: claude-haiku-4-5-20251001
+ allowed_models:
+ - claude-haiku-4-5-20251001
+
+ # Pro tier models
+ pro:
+ default: claude-sonnet-4-6
+ cost_optimized: claude-haiku-4-5-20251001
+ complex: claude-sonnet-4-6
+ blocked_models:
+ - claude-opus-4-6
+
+ # Enterprise tier models
+ enterprise:
+ default: claude-sonnet-4-6
+ cost_optimized: claude-haiku-4-5-20251001
+ complex: claude-opus-4-6
# Kubernetes configuration
kubernetes:
diff --git a/gateway/schema.sql b/gateway/schema.sql
index 102a74c6..744399d3 100644
--- a/gateway/schema.sql
+++ b/gateway/schema.sql
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS user_licenses (
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-COMMENT ON COLUMN user_licenses.preferred_model IS 'Optional model preference: {"provider": "anthropic", "model": "claude-3-5-sonnet-20241022", "temperature": 0.7}';
+COMMENT ON COLUMN user_licenses.preferred_model IS 'Optional model preference: {"provider": "anthropic", "model": "claude-sonnet-4-6", "temperature": 0.7}';
CREATE INDEX idx_user_licenses_expires_at ON user_licenses(expires_at)
WHERE expires_at IS NOT NULL;
diff --git a/gateway/src/channels/telegram-handler.ts b/gateway/src/channels/telegram-handler.ts
index f154daf0..b2fa2906 100644
--- a/gateway/src/channels/telegram-handler.ts
+++ b/gateway/src/channels/telegram-handler.ts
@@ -110,6 +110,7 @@ export class TelegramHandler {
userId: authContext.userId,
sessionId: authContext.sessionId,
content: text,
+ attachments: [], // TODO: Add image support for Telegram
timestamp: new Date(),
};
diff --git a/gateway/src/channels/websocket-handler.ts b/gateway/src/channels/websocket-handler.ts
index 305c934a..b8ae6534 100644
--- a/gateway/src/channels/websocket-handler.ts
+++ b/gateway/src/channels/websocket-handler.ts
@@ -158,21 +158,25 @@ export class WebSocketHandler {
}),
};
- // Create agent harness
- const harness = new AgentHarness({
- userId: authContext.userId,
- sessionId: authContext.sessionId,
- license: authContext.license,
- providerConfig: this.config.providerConfig,
- logger,
- });
+ // Declare harness outside try block so it's available in catch
+ let harness: AgentHarness | undefined;
try {
- // Initialize workspace and harness
+ // Initialize workspace first
await workspace.initialize();
workspace.setAdapter(wsAdapter);
this.workspaces.set(authContext.sessionId, workspace);
+ // Create agent harness with workspace manager
+ harness = new AgentHarness({
+ userId: authContext.userId,
+ sessionId: authContext.sessionId,
+ license: authContext.license,
+ providerConfig: this.config.providerConfig,
+ logger,
+ workspaceManager: workspace,
+ });
+
await harness.initialize();
this.harnesses.set(authContext.sessionId, harness);
@@ -220,8 +224,8 @@ export class WebSocketHandler {
logger.info({ type: payload.type, request_id: payload.request_id }, 'WebSocket message parsed');
// Route based on message type
- if (payload.type === 'message') {
- // Chat message - send to agent harness
+ if (payload.type === 'message' || payload.type === 'agent_user_message') {
+ // Chat message - send to agent harness with streaming
const inboundMessage: InboundMessage = {
messageId: randomUUID(),
userId: authContext.userId,
@@ -231,14 +235,41 @@ export class WebSocketHandler {
timestamp: new Date(),
};
- const response = await harness.handleMessage(inboundMessage);
+ if (!harness) {
+ logger.error('Harness not initialized');
+ socket.send(JSON.stringify({ type: 'error', message: 'Session not ready' }));
+ return;
+ }
- socket.send(
- JSON.stringify({
- type: 'message',
- ...response,
- })
- );
+ // Stream response chunks to client
+ try {
+ for await (const chunk of harness.streamMessage(inboundMessage)) {
+ socket.send(
+ JSON.stringify({
+ type: 'agent_chunk',
+ content: chunk,
+ done: false,
+ })
+ );
+ }
+
+ // Send final chunk with done flag
+ socket.send(
+ JSON.stringify({
+ type: 'agent_chunk',
+ content: '',
+ done: true,
+ })
+ );
+ } catch (error) {
+ logger.error({ error }, 'Error streaming response');
+ socket.send(
+ JSON.stringify({
+ type: 'error',
+ message: 'Failed to generate response',
+ })
+ );
+ }
} else if (payload.type === 'hello') {
// Workspace sync: hello message
logger.debug({ seqs: payload.seqs }, 'Handling workspace hello');
@@ -280,8 +311,10 @@ export class WebSocketHandler {
this.workspaces.delete(authContext.sessionId);
// Cleanup harness
- await harness.cleanup();
- this.harnesses.delete(authContext.sessionId);
+ if (harness) {
+ await harness.cleanup();
+ this.harnesses.delete(authContext.sessionId);
+ }
});
socket.on('error', (error: any) => {
@@ -292,7 +325,9 @@ export class WebSocketHandler {
socket.close(1011, 'Internal server error');
await workspace.shutdown();
this.workspaces.delete(authContext.sessionId);
- await harness.cleanup();
+ if (harness) {
+ await harness.cleanup();
+ }
}
}
diff --git a/gateway/src/clients/zmq-relay-client.ts b/gateway/src/clients/zmq-relay-client.ts
index 034afc03..a7870f49 100644
--- a/gateway/src/clients/zmq-relay-client.ts
+++ b/gateway/src/clients/zmq-relay-client.ts
@@ -66,6 +66,7 @@ export class ZMQRelayClient {
relayNotificationEndpoint: config.relayNotificationEndpoint,
clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`,
requestTimeout: config.requestTimeout || 30000,
+ onMetadataUpdate: config.onMetadataUpdate || (async () => {}),
};
this.logger = logger;
this.notificationTopic = `RESPONSE:${this.config.clientId}`;
diff --git a/gateway/src/harness/README.md b/gateway/src/harness/README.md
index 1e1f2c34..1d7bf6b0 100644
--- a/gateway/src/harness/README.md
+++ b/gateway/src/harness/README.md
@@ -101,9 +101,15 @@ See individual workflow READMEs for details.
YAML-based configuration:
-- `models.yaml`: LLM providers, routing, rate limits
- `subagent-routing.yaml`: When to use which subagent
+Model configuration is centralized in the main gateway config (`/config/config.yaml`), including:
+- Default models and providers
+- License tier model assignments (free, pro, enterprise)
+- Model routing rules (complexity, cost-optimized)
+
+Resource limits (token limits, rate limits) are stored in the database per user.
+
## User Context
Enhanced session context with channel awareness for multi-channel support:
diff --git a/gateway/src/harness/agent-harness.ts b/gateway/src/harness/agent-harness.ts
index 6cf6688f..53251cfb 100644
--- a/gateway/src/harness/agent-harness.ts
+++ b/gateway/src/harness/agent-harness.ts
@@ -8,6 +8,7 @@ import { MCPClientConnector } from './mcp-client.js';
import { CONTEXT_URIS, type ResourceContent } from '../types/resources.js';
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
import { ModelRouter, RoutingStrategy } from '../llm/router.js';
+import type { WorkspaceManager } from '../workspace/workspace-manager.js';
export interface AgentHarnessConfig {
userId: string;
@@ -15,6 +16,7 @@ export interface AgentHarnessConfig {
license: UserLicense;
providerConfig: ProviderConfig;
logger: FastifyBaseLogger;
+ workspaceManager?: WorkspaceManager;
}
/**
@@ -33,9 +35,13 @@ export class AgentHarness {
private modelFactory: LLMProviderFactory;
private modelRouter: ModelRouter;
private mcpClient: MCPClientConnector;
+ private workspaceManager?: WorkspaceManager;
+ private lastWorkspaceSeq: number = 0;
+ private isFirstMessage: boolean = true;
constructor(config: AgentHarnessConfig) {
this.config = config;
+ this.workspaceManager = config.workspaceManager;
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
@@ -102,18 +108,14 @@ export class AgentHarness {
// 7. Extract text response (tool handling TODO)
const assistantMessage = response.content as string;
- // 8. Save messages to user's MCP server
- this.config.logger.debug('Saving messages to MCP');
- await this.mcpClient.callTool('save_message', {
- role: 'user',
- content: message.content,
- timestamp: message.timestamp.toISOString(),
- });
- await this.mcpClient.callTool('save_message', {
- role: 'assistant',
- content: assistantMessage,
- timestamp: new Date().toISOString(),
- });
+ // TODO: Save messages to Iceberg conversation table instead of MCP
+ // Should batch-insert periodically or on session end to avoid many small Parquet files
+ // await icebergConversationStore.appendMessages([...]);
+
+ // Mark first message as processed
+ if (this.isFirstMessage) {
+ this.isFirstMessage = false;
+ }
return {
messageId: `msg_${Date.now()}`,
@@ -157,17 +159,17 @@ export class AgentHarness {
yield content;
}
- // Save after streaming completes
- await this.mcpClient.callTool('save_message', {
- role: 'user',
- content: message.content,
- timestamp: message.timestamp.toISOString(),
- });
- await this.mcpClient.callTool('save_message', {
- role: 'assistant',
- content: fullResponse,
- timestamp: new Date().toISOString(),
- });
+ // TODO: Save messages to Iceberg conversation table instead of MCP
+ // Should batch-insert periodically or on session end to avoid many small Parquet files
+ // await icebergConversationStore.appendMessages([
+ // { role: 'user', content: message.content, timestamp: message.timestamp },
+ // { role: 'assistant', content: fullResponse, timestamp: new Date() }
+ // ]);
+
+ // Mark first message as processed
+ if (this.isFirstMessage) {
+ this.isFirstMessage = false;
+ }
} catch (error) {
this.config.logger.error({ error }, 'Error streaming message');
throw error;
@@ -224,6 +226,15 @@ export class AgentHarness {
});
}
+ // Add workspace delta (for subsequent turns)
+ const workspaceDelta = this.buildWorkspaceDelta();
+ if (workspaceDelta) {
+ messages.push({
+ role: 'user',
+ content: workspaceDelta,
+ });
+ }
+
// Add current user message
messages.push({
role: 'user',
@@ -273,9 +284,18 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
prompt += `\n\n# User Profile\n${userProfile.text}`;
}
- // Add workspace context
+ // Add workspace context from MCP resource (if available)
if (workspaceState?.text) {
- prompt += `\n\n# Current Workspace\n${workspaceState.text}`;
+ prompt += `\n\n# Current Workspace (from MCP)\n${workspaceState.text}`;
+ }
+
+ // Add full workspace state from WorkspaceManager (first message only)
+ if (this.isFirstMessage && this.workspaceManager) {
+ const workspaceJSON = this.workspaceManager.serializeState();
+ prompt += `\n\n# Workspace State (JSON)\n\`\`\`json\n${workspaceJSON}\n\`\`\``;
+
+ // Record current workspace sequence for delta tracking
+ this.lastWorkspaceSeq = this.workspaceManager.getCurrentSeq();
}
// Add user's custom instructions (highest priority)
@@ -286,6 +306,30 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
return prompt;
}
+ /**
+ * Build workspace delta message for subsequent turns.
+ * Returns null if no changes since last message.
+ */
+ private buildWorkspaceDelta(): string | null {
+ if (!this.workspaceManager || this.isFirstMessage) {
+ return null;
+ }
+
+ const changes = this.workspaceManager.getChangesSince(this.lastWorkspaceSeq);
+
+ if (Object.keys(changes).length === 0) {
+ return null;
+ }
+
+ // Format changes as JSON
+ const deltaJSON = JSON.stringify(changes, null, 2);
+
+ // Update sequence marker
+ this.lastWorkspaceSeq = this.workspaceManager.getCurrentSeq();
+
+ return `[Workspace Changes Since Last Turn]\n\`\`\`json\n${deltaJSON}\n\`\`\``;
+ }
+
/**
diff --git a/gateway/src/harness/config/models.yaml b/gateway/src/harness/config/models.yaml
deleted file mode 100644
index e1463840..00000000
--- a/gateway/src/harness/config/models.yaml
+++ /dev/null
@@ -1,110 +0,0 @@
-# Default LLM Model Configuration
-
-# Default model for general agent tasks
-default:
- provider: anthropic
- model: claude-3-5-sonnet-20241022
- temperature: 0.7
- maxTokens: 4096
-
-# Model overrides for specific use cases
-models:
- # Fast model for simple tasks (routing, classification)
- fast:
- provider: anthropic
- model: claude-3-haiku-20240307
- temperature: 0.3
- maxTokens: 1024
-
- # Reasoning model for complex analysis
- reasoning:
- provider: anthropic
- model: claude-3-5-sonnet-20241022
- temperature: 0.5
- maxTokens: 8192
-
- # Precise model for code generation/review
- code:
- provider: anthropic
- model: claude-3-5-sonnet-20241022
- temperature: 0.2
- maxTokens: 8192
-
- # Creative model for strategy brainstorming
- creative:
- provider: anthropic
- model: claude-3-5-sonnet-20241022
- temperature: 0.9
- maxTokens: 4096
-
-# Embedding model configuration
-embeddings:
- provider: openai
- model: text-embedding-3-small
- dimensions: 1536
-
-# Model routing rules (complexity-based)
-routing:
- # Simple queries → fast model
- simple:
- keywords:
- - "what is"
- - "define"
- - "list"
- - "show me"
- maxTokens: 100
- model: fast
-
- # Code-related → code model
- code:
- keywords:
- - "code"
- - "function"
- - "implement"
- - "debug"
- - "review"
- model: code
-
- # Analysis tasks → reasoning model
- analysis:
- keywords:
- - "analyze"
- - "compare"
- - "evaluate"
- - "assess"
- model: reasoning
-
- # Everything else → default
- default:
- model: default
-
-# Cost optimization settings
-costControl:
- # Cache system prompts (Anthropic prompt caching)
- cacheSystemPrompts: true
-
- # Token limits per license type
- tokenLimits:
- free:
- maxTokensPerMessage: 2048
- maxTokensPerDay: 50000
- pro:
- maxTokensPerMessage: 8192
- maxTokensPerDay: 500000
- enterprise:
- maxTokensPerMessage: 16384
- maxTokensPerDay: -1 # unlimited
-
-# Rate limiting
-rateLimits:
- # Requests per minute by license
- requestsPerMinute:
- free: 10
- pro: 60
- enterprise: 120
-
- # Concurrent requests
- concurrentRequests:
- free: 1
- pro: 3
- enterprise: 10
diff --git a/gateway/src/harness/mcp-client.ts b/gateway/src/harness/mcp-client.ts
index 5856f607..4f8be213 100644
--- a/gateway/src/harness/mcp-client.ts
+++ b/gateway/src/harness/mcp-client.ts
@@ -1,4 +1,5 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import type { FastifyBaseLogger } from 'fastify';
@@ -11,7 +12,7 @@ export interface MCPClientConfig {
/**
* MCP client connector for user's container
- * Manages connection to user-specific MCP server
+ * Manages connection to user-specific MCP server via SSE transport
*/
export class MCPClientConnector {
private client: Client | null = null;
@@ -23,8 +24,7 @@ export class MCPClientConnector {
}
/**
- * Connect to user's MCP server
- * TODO: Implement HTTP/SSE transport instead of stdio for container communication
+ * Connect to user's MCP server via SSE transport
*/
async connect(): Promise {
if (this.connected) {
@@ -34,7 +34,7 @@ export class MCPClientConnector {
try {
this.config.logger.info(
{ userId: this.config.userId, url: this.config.mcpServerUrl },
- 'Connecting to user MCP server'
+ 'Connecting to user MCP server via SSE'
);
this.client = new Client(
@@ -46,24 +46,18 @@ export class MCPClientConnector {
capabilities: {
sampling: {},
},
- } as any
+ }
);
- // TODO: Replace with HTTP transport when user containers are ready
- // For now, this is a placeholder structure
- // const transport = new HTTPTransport(this.config.mcpServerUrl, {
- // headers: {
- // 'Authorization': `Bearer ${this.config.platformJWT}`
- // }
- // });
-
- // Placeholder: will be replaced with actual container transport
- this.config.logger.warn(
- 'MCP transport not yet implemented - using placeholder'
+ // Create SSE transport for HTTP connection to user container
+ const transport = new SSEClientTransport(
+ new URL(`${this.config.mcpServerUrl}/sse`)
);
+ await this.client.connect(transport);
+
this.connected = true;
- this.config.logger.info('Connected to user MCP server');
+ this.config.logger.info('Connected to user MCP server via SSE');
} catch (error) {
this.config.logger.error(
{ error, userId: this.config.userId },
@@ -84,12 +78,8 @@ export class MCPClientConnector {
try {
this.config.logger.debug({ tool: name, args }, 'Calling MCP tool');
- // TODO: Implement when MCP client is connected
- // const result = await this.client.callTool({ name, arguments: args });
- // return result;
-
- // Placeholder response
- return { success: true, message: 'MCP tool call placeholder' };
+ const result = await this.client.callTool({ name, arguments: args });
+ return result;
} catch (error) {
this.config.logger.error({ error, tool: name }, 'MCP tool call failed');
throw error;
@@ -98,27 +88,35 @@ export class MCPClientConnector {
/**
* List available tools from user's MCP server
+ * Filters to only return tools marked as agent_accessible
*/
- async listTools(): Promise> {
+ async listTools(): Promise> {
if (!this.client || !this.connected) {
throw new Error('MCP client not connected');
}
try {
- // TODO: Implement when MCP client is connected
- // const tools = await this.client.listTools();
- // return tools;
+ const response = await this.client.listTools();
- // Placeholder tools (actions only, not context)
- return [
- { name: 'save_message', description: 'Save message to conversation history' },
- { name: 'list_strategies', description: 'List user strategies' },
- { name: 'read_strategy', description: 'Read strategy code' },
- { name: 'write_strategy', description: 'Write strategy code' },
- { name: 'run_backtest', description: 'Run backtest on strategy' },
- { name: 'get_watchlist', description: 'Get user watchlist' },
- { name: 'execute_trade', description: 'Execute trade' },
- ];
+ // Filter tools to only include agent-accessible ones
+ const tools = response.tools
+ .filter((tool: any) => {
+ // Check if tool has agent_accessible annotation
+ const annotations = tool.annotations || {};
+ return annotations.agent_accessible === true;
+ })
+ .map((tool: any) => ({
+ name: tool.name,
+ description: tool.description,
+ inputSchema: tool.inputSchema,
+ }));
+
+ this.config.logger.debug(
+ { totalTools: response.tools.length, agentAccessibleTools: tools.length },
+ 'Listed MCP tools with filtering'
+ );
+
+ return tools;
} catch (error) {
this.config.logger.error({ error }, 'Failed to list MCP tools');
throw error;
@@ -127,6 +125,7 @@ export class MCPClientConnector {
/**
* List available resources from user's MCP server
+ * Filters to only return resources marked as agent_accessible
*/
async listResources(): Promise> {
if (!this.client || !this.connected) {
@@ -134,37 +133,28 @@ export class MCPClientConnector {
}
try {
- // TODO: Implement when MCP client is connected
- // const resources = await this.client.listResources();
- // return resources;
+ const response = await this.client.listResources();
- // Placeholder resources for user context
- return [
- {
- uri: 'context://user-profile',
- name: 'User Profile',
- description: 'User trading style, preferences, and background',
- mimeType: 'text/plain',
- },
- {
- uri: 'context://conversation-summary',
- name: 'Conversation Summary',
- description: 'Semantic summary of recent conversation history with RAG',
- mimeType: 'text/plain',
- },
- {
- uri: 'context://workspace-state',
- name: 'Workspace State',
- description: 'Current chart, watchlist, and open positions',
- mimeType: 'application/json',
- },
- {
- uri: 'context://system-prompt',
- name: 'Custom System Prompt',
- description: 'User custom instructions for the assistant',
- mimeType: 'text/plain',
- },
- ];
+ // Filter resources to only include agent-accessible ones
+ const resources = response.resources
+ .filter((resource: any) => {
+ // Check if resource has agent_accessible annotation
+ const annotations = resource.annotations || {};
+ return annotations.agent_accessible === true;
+ })
+ .map((resource: any) => ({
+ uri: resource.uri,
+ name: resource.name,
+ description: resource.description,
+ mimeType: resource.mimeType,
+ }));
+
+ this.config.logger.debug(
+ { totalResources: response.resources.length, agentAccessibleResources: resources.length },
+ 'Listed MCP resources with filtering'
+ );
+
+ return resources;
} catch (error) {
this.config.logger.error({ error }, 'Failed to list MCP resources');
throw error;
@@ -182,55 +172,18 @@ export class MCPClientConnector {
try {
this.config.logger.debug({ uri }, 'Reading MCP resource');
- // TODO: Implement when MCP client is connected
- // const resource = await this.client.readResource({ uri });
- // return resource;
+ const response = await this.client.readResource({ uri });
- // Placeholder resource content
- if (uri === 'context://user-profile') {
- return {
- uri,
- mimeType: 'text/plain',
- text: `User Profile:
-- Trading experience: Intermediate
-- Preferred timeframes: 1h, 4h, 1d
-- Risk tolerance: Medium
-- Focus: Swing trading with technical indicators`,
- };
- } else if (uri === 'context://conversation-summary') {
- return {
- uri,
- mimeType: 'text/plain',
- text: `Recent Conversation Summary:
-[RAG-generated summary would go here]
+ // Extract the first content item (MCP returns array of contents)
+ const content = response.contents[0];
-User recently discussed:
-- Moving average crossover strategies
-- Backtesting on BTC/USDT
-- Risk management techniques`,
- };
- } else if (uri === 'context://workspace-state') {
- return {
- uri,
- mimeType: 'application/json',
- text: JSON.stringify({
- currentChart: { ticker: 'BINANCE:BTC/USDT', timeframe: '1h' },
- watchlist: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'],
- openPositions: [],
- }, null, 2),
- };
- } else if (uri === 'context://system-prompt') {
- return {
- uri,
- mimeType: 'text/plain',
- text: `Custom Instructions:
-- Be concise and data-driven
-- Always show risk/reward ratios
-- Prefer simple strategies over complex ones`,
- };
- }
-
- return { uri, text: '' };
+ // Handle union type: content is either TextContent or BlobContent
+ return {
+ uri: content.uri,
+ mimeType: content.mimeType,
+ text: 'text' in content ? content.text : undefined,
+ blob: 'blob' in content ? content.blob : undefined,
+ };
} catch (error) {
this.config.logger.error({ error, uri }, 'MCP resource read failed');
throw error;
diff --git a/gateway/src/harness/subagents/README.md b/gateway/src/harness/subagents/README.md
index 2abcdf97..e5ab834d 100644
--- a/gateway/src/harness/subagents/README.md
+++ b/gateway/src/harness/subagents/README.md
@@ -42,7 +42,7 @@ name: my-subagent
description: What it does
# Model override (optional)
-model: claude-3-5-sonnet-20241022
+model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 4096
diff --git a/gateway/src/harness/subagents/code-reviewer/config.yaml b/gateway/src/harness/subagents/code-reviewer/config.yaml
index d47c2652..1b041f12 100644
--- a/gateway/src/harness/subagents/code-reviewer/config.yaml
+++ b/gateway/src/harness/subagents/code-reviewer/config.yaml
@@ -4,7 +4,7 @@ name: code-reviewer
description: Reviews trading strategy code for bugs, performance issues, and best practices
# Model configuration (optional override)
-model: claude-3-5-sonnet-20241022
+model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 4096
diff --git a/gateway/src/harness/workflows/README.md b/gateway/src/harness/workflows/README.md
index 18ea047a..66eef7a9 100644
--- a/gateway/src/harness/workflows/README.md
+++ b/gateway/src/harness/workflows/README.md
@@ -334,7 +334,7 @@ timeout: 60000
maxRetries: 3
requiresApproval: false
-model: claude-3-5-sonnet-20241022
+model: claude-sonnet-4-6
```
### 6. Add Factory Function
diff --git a/gateway/src/harness/workflows/strategy-validation/config.yaml b/gateway/src/harness/workflows/strategy-validation/config.yaml
index ec8b5fe2..c423e21c 100644
--- a/gateway/src/harness/workflows/strategy-validation/config.yaml
+++ b/gateway/src/harness/workflows/strategy-validation/config.yaml
@@ -15,5 +15,5 @@ maxValidationRetries: 3 # Max times to retry fixing errors
minBacktestScore: 0.5 # Minimum Sharpe ratio to pass
# Model override (optional)
-model: claude-3-5-sonnet-20241022
+model: claude-sonnet-4-6
temperature: 0.3
diff --git a/gateway/src/harness/workflows/trading-request/config.yaml b/gateway/src/harness/workflows/trading-request/config.yaml
index 2030c8fc..0db9500f 100644
--- a/gateway/src/harness/workflows/trading-request/config.yaml
+++ b/gateway/src/harness/workflows/trading-request/config.yaml
@@ -15,5 +15,5 @@ maxPositionPercent: 0.05 # 5% of portfolio max
minRiskRewardRatio: 2.0 # Minimum 2:1 risk/reward
# Model override (optional)
-model: claude-3-5-sonnet-20241022
+model: claude-sonnet-4-6
temperature: 0.2
diff --git a/gateway/src/k8s/client.ts b/gateway/src/k8s/client.ts
index d4170cea..ec259cc2 100644
--- a/gateway/src/k8s/client.ts
+++ b/gateway/src/k8s/client.ts
@@ -21,6 +21,7 @@ export interface DeploymentSpec {
agentImage: string;
sidecarImage: string;
storageClass: string;
+ imagePullPolicy?: string;
}
/**
@@ -132,7 +133,8 @@ export class KubernetesClient {
.replace(/\{\{pvcName\}\}/g, pvcName)
.replace(/\{\{agentImage\}\}/g, spec.agentImage)
.replace(/\{\{sidecarImage\}\}/g, spec.sidecarImage)
- .replace(/\{\{storageClass\}\}/g, spec.storageClass);
+ .replace(/\{\{storageClass\}\}/g, spec.storageClass)
+ .replace(/\{\{imagePullPolicy\}\}/g, spec.imagePullPolicy || 'Always');
// Parse YAML documents (deployment, pvc, service)
const documents = yaml.loadAll(rendered) as any[];
diff --git a/gateway/src/k8s/container-manager.ts b/gateway/src/k8s/container-manager.ts
index 5d983202..52e944a8 100644
--- a/gateway/src/k8s/container-manager.ts
+++ b/gateway/src/k8s/container-manager.ts
@@ -7,6 +7,7 @@ export interface ContainerManagerConfig {
agentImage: string;
sidecarImage: string;
storageClass: string;
+ imagePullPolicy?: string;
namespace: string;
logger: FastifyBaseLogger;
}
@@ -82,6 +83,7 @@ export class ContainerManager {
agentImage: this.config.agentImage,
sidecarImage: this.config.sidecarImage,
storageClass: this.config.storageClass,
+ imagePullPolicy: this.config.imagePullPolicy,
};
await this.config.k8sClient.createAgentDeployment(spec);
diff --git a/gateway/src/k8s/templates/enterprise-tier.yaml b/gateway/src/k8s/templates/enterprise-tier.yaml
index 6d1d1a49..b42abc3a 100644
--- a/gateway/src/k8s/templates/enterprise-tier.yaml
+++ b/gateway/src/k8s/templates/enterprise-tier.yaml
@@ -40,7 +40,7 @@ spec:
containers:
- name: agent
image: {{agentImage}}
- imagePullPolicy: Always
+ imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
@@ -72,6 +72,8 @@ spec:
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
+ - name: ZMQ_GATEWAY_ENDPOINT
+ value: "tcp://gateway.default.svc.cluster.local:5571"
ports:
- name: mcp
@@ -102,14 +104,14 @@ spec:
readinessProbe:
httpGet:
- path: /ready
+ path: /health
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
- imagePullPolicy: Always
+ imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
diff --git a/gateway/src/k8s/templates/free-tier.yaml b/gateway/src/k8s/templates/free-tier.yaml
index c75c2621..6f4b1b1e 100644
--- a/gateway/src/k8s/templates/free-tier.yaml
+++ b/gateway/src/k8s/templates/free-tier.yaml
@@ -39,7 +39,7 @@ spec:
containers:
- name: agent
image: {{agentImage}}
- imagePullPolicy: Always
+ imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
@@ -71,7 +71,9 @@ spec:
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
-
+ - name: ZMQ_GATEWAY_ENDPOINT
+ value: "tcp://gateway.default.svc.cluster.local:5571"
+
ports:
- name: mcp
containerPort: 3000
@@ -101,14 +103,14 @@ spec:
readinessProbe:
httpGet:
- path: /ready
+ path: /health
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
- imagePullPolicy: Always
+ imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
diff --git a/gateway/src/k8s/templates/pro-tier.yaml b/gateway/src/k8s/templates/pro-tier.yaml
index 76cbe9bb..135d3534 100644
--- a/gateway/src/k8s/templates/pro-tier.yaml
+++ b/gateway/src/k8s/templates/pro-tier.yaml
@@ -39,7 +39,7 @@ spec:
containers:
- name: agent
image: {{agentImage}}
- imagePullPolicy: Always
+ imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
@@ -71,6 +71,8 @@ spec:
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
+ - name: ZMQ_GATEWAY_ENDPOINT
+ value: "tcp://gateway.default.svc.cluster.local:5571"
ports:
- name: mcp
@@ -101,14 +103,14 @@ spec:
readinessProbe:
httpGet:
- path: /ready
+ path: /health
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
- imagePullPolicy: Always
+ imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
diff --git a/gateway/src/llm/provider.ts b/gateway/src/llm/provider.ts
index ace2b4d6..20f98049 100644
--- a/gateway/src/llm/provider.ts
+++ b/gateway/src/llm/provider.ts
@@ -19,11 +19,33 @@ export interface ModelConfig {
maxTokens?: number;
}
+/**
+ * License tier model configuration
+ */
+export interface LicenseTierModels {
+ default: string;
+ cost_optimized: string;
+ complex: string;
+ allowed_models?: string[];
+ blocked_models?: string[];
+}
+
+/**
+ * License models configuration
+ */
+export interface LicenseModelsConfig {
+ free: LicenseTierModels;
+ pro: LicenseTierModels;
+ enterprise: LicenseTierModels;
+}
+
/**
* Provider configuration with API keys
*/
export interface ProviderConfig {
anthropicApiKey?: string;
+ defaultModel?: ModelConfig;
+ licenseModels?: LicenseModelsConfig;
}
/**
@@ -77,15 +99,26 @@ export class LLMProviderFactory {
* Get default model based on environment
*/
getDefaultModel(): ModelConfig {
+ if (this.config.defaultModel) {
+ return this.config.defaultModel;
+ }
+
if (!this.config.anthropicApiKey) {
throw new Error('Anthropic API key not configured');
}
return {
provider: LLMProvider.ANTHROPIC,
- model: 'claude-3-5-sonnet-20241022',
+ model: 'claude-sonnet-4-6',
};
}
+
+ /**
+ * Get license models configuration
+ */
+ getLicenseModelsConfig(): LicenseModelsConfig | undefined {
+ return this.config.licenseModels;
+ }
}
/**
@@ -94,14 +127,14 @@ export class LLMProviderFactory {
export const MODELS = {
CLAUDE_SONNET: {
provider: LLMProvider.ANTHROPIC,
- model: 'claude-3-5-sonnet-20241022',
+ model: 'claude-sonnet-4-6',
},
CLAUDE_HAIKU: {
provider: LLMProvider.ANTHROPIC,
- model: 'claude-3-5-haiku-20241022',
+ model: 'claude-haiku-4-5-20251001',
},
CLAUDE_OPUS: {
provider: LLMProvider.ANTHROPIC,
- model: 'claude-3-opus-20240229',
+ model: 'claude-opus-4-6',
},
} as const satisfies Record;
diff --git a/gateway/src/llm/router.ts b/gateway/src/llm/router.ts
index 247093b0..a9c72605 100644
--- a/gateway/src/llm/router.ts
+++ b/gateway/src/llm/router.ts
@@ -1,6 +1,6 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { FastifyBaseLogger } from 'fastify';
-import { LLMProviderFactory, type ModelConfig, LLMProvider } from './provider.js';
+import { LLMProviderFactory, type ModelConfig, LLMProvider, type LicenseModelsConfig } from './provider.js';
import type { UserLicense } from '../types/user.js';
/**
@@ -25,11 +25,13 @@ export class ModelRouter {
private factory: LLMProviderFactory;
private logger: FastifyBaseLogger;
private defaultModel: ModelConfig;
+ private licenseModels?: LicenseModelsConfig;
constructor(factory: LLMProviderFactory, logger: FastifyBaseLogger) {
this.factory = factory;
this.logger = logger;
this.defaultModel = factory.getDefaultModel();
+ this.licenseModels = factory.getLicenseModelsConfig();
}
/**
@@ -97,37 +99,53 @@ export class ModelRouter {
private routeByComplexity(message: string, license: UserLicense): ModelConfig {
const isComplex = this.isComplexQuery(message);
+ // Use configuration if available
+ if (this.licenseModels) {
+ const tierConfig = this.licenseModels[license.licenseType];
+ if (tierConfig) {
+ const model = isComplex ? tierConfig.complex : tierConfig.default;
+ return { provider: this.defaultModel.provider as LLMProvider, model };
+ }
+ }
+
+ // Fallback to hardcoded defaults
if (license.licenseType === 'enterprise') {
- // Enterprise users get best models for complex queries
return isComplex
- ? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-opus-20240229' }
- : { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
+ ? { provider: LLMProvider.ANTHROPIC, model: 'claude-opus-4-6' }
+ : { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
}
if (license.licenseType === 'pro') {
- // Pro users get good models
return isComplex
- ? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }
- : { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
+ ? { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' }
+ : { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
}
- // Free users get efficient models
- return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
+ return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
}
/**
* Route based on license tier
*/
private routeByLicenseTier(license: UserLicense): ModelConfig {
+ // Use configuration if available
+ if (this.licenseModels) {
+ const tierConfig = this.licenseModels[license.licenseType];
+ if (tierConfig) {
+ return { provider: this.defaultModel.provider as LLMProvider, model: tierConfig.default };
+ }
+ }
+
+ // Fallback to hardcoded defaults
switch (license.licenseType) {
case 'enterprise':
- return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
+ return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
case 'pro':
- return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
+ return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
case 'free':
- return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
+ return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
default:
return this.defaultModel;
@@ -137,24 +155,50 @@ export class ModelRouter {
/**
* Route to cheapest available model
*/
- private routeByCost(_license: UserLicense): ModelConfig {
- // All tiers: use Haiku for cost efficiency
- return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
+ private routeByCost(license: UserLicense): ModelConfig {
+ // Use configuration if available
+ if (this.licenseModels) {
+ const tierConfig = this.licenseModels[license.licenseType];
+ if (tierConfig) {
+ return { provider: this.defaultModel.provider as LLMProvider, model: tierConfig.cost_optimized };
+ }
+ }
+
+ // Fallback: use Haiku for cost efficiency
+ return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
}
/**
* Check if model is allowed for user's license
*/
private isModelAllowed(model: ModelConfig, license: UserLicense): boolean {
- // Free tier: only Haiku
+ // Use configuration if available
+ if (this.licenseModels) {
+ const tierConfig = this.licenseModels[license.licenseType];
+ if (tierConfig) {
+ // Check allowed_models list if defined
+ if (tierConfig.allowed_models && tierConfig.allowed_models.length > 0) {
+ return tierConfig.allowed_models.includes(model.model);
+ }
+
+ // Check blocked_models list if defined
+ if (tierConfig.blocked_models && tierConfig.blocked_models.length > 0) {
+ return !tierConfig.blocked_models.includes(model.model);
+ }
+
+ // No restrictions if neither list is defined
+ return true;
+ }
+ }
+
+ // Fallback to hardcoded defaults
if (license.licenseType === 'free') {
- const allowedModels = ['claude-3-5-haiku-20241022'];
+ const allowedModels = ['claude-haiku-4-5-20251001'];
return allowedModels.includes(model.model);
}
- // Pro: all except Opus
if (license.licenseType === 'pro') {
- const blockedModels = ['claude-3-opus-20240229'];
+ const blockedModels = ['claude-opus-4-6'];
return !blockedModels.includes(model.model);
}
diff --git a/gateway/src/main.ts b/gateway/src/main.ts
index 27023744..08194571 100644
--- a/gateway/src/main.ts
+++ b/gateway/src/main.ts
@@ -85,12 +85,35 @@ function loadConfig() {
// Authentication configuration
authSecret: secretsData.auth?.secret || process.env.AUTH_SECRET || 'change-me-in-production',
- // LLM provider API keys
+ // LLM provider API keys and model configuration
providerConfig: {
anthropicApiKey: secretsData.llm_providers?.anthropic_api_key || process.env.ANTHROPIC_API_KEY,
openaiApiKey: secretsData.llm_providers?.openai_api_key || process.env.OPENAI_API_KEY,
googleApiKey: secretsData.llm_providers?.google_api_key || process.env.GOOGLE_API_KEY,
openrouterApiKey: secretsData.llm_providers?.openrouter_api_key || process.env.OPENROUTER_API_KEY,
+ defaultModel: {
+ provider: configData.defaults?.model_provider || 'anthropic',
+ model: configData.defaults?.model || 'claude-sonnet-4-6',
+ },
+ licenseModels: {
+ free: {
+ default: configData.license_models?.free?.default || 'claude-haiku-4-5-20251001',
+ cost_optimized: configData.license_models?.free?.cost_optimized || 'claude-haiku-4-5-20251001',
+ complex: configData.license_models?.free?.complex || 'claude-haiku-4-5-20251001',
+ allowed_models: configData.license_models?.free?.allowed_models || ['claude-haiku-4-5-20251001'],
+ },
+ pro: {
+ default: configData.license_models?.pro?.default || 'claude-sonnet-4-6',
+ cost_optimized: configData.license_models?.pro?.cost_optimized || 'claude-haiku-4-5-20251001',
+ complex: configData.license_models?.pro?.complex || 'claude-sonnet-4-6',
+ blocked_models: configData.license_models?.pro?.blocked_models || ['claude-opus-4-6'],
+ },
+ enterprise: {
+ default: configData.license_models?.enterprise?.default || 'claude-sonnet-4-6',
+ cost_optimized: configData.license_models?.enterprise?.cost_optimized || 'claude-haiku-4-5-20251001',
+ complex: configData.license_models?.enterprise?.complex || 'claude-opus-4-6',
+ },
+ },
},
telegramBotToken: secretsData.telegram?.bot_token || process.env.TELEGRAM_BOT_TOKEN || '',
@@ -148,6 +171,7 @@ function loadConfig() {
agentImage: configData.kubernetes?.agent_image || process.env.AGENT_IMAGE || 'ghcr.io/dexorder/agent:latest',
sidecarImage: configData.kubernetes?.sidecar_image || process.env.SIDECAR_IMAGE || 'ghcr.io/dexorder/lifecycle-sidecar:latest',
storageClass: configData.kubernetes?.storage_class || process.env.AGENT_STORAGE_CLASS || 'standard',
+ imagePullPolicy: configData.kubernetes?.image_pull_policy || process.env.IMAGE_PULL_POLICY || 'Always',
},
};
}
@@ -265,6 +289,7 @@ const containerManager = new ContainerManager({
agentImage: config.kubernetes.agentImage,
sidecarImage: config.kubernetes.sidecarImage,
storageClass: config.kubernetes.storageClass,
+ imagePullPolicy: config.kubernetes.imagePullPolicy,
namespace: config.kubernetes.namespace,
logger: app.log,
});
diff --git a/gateway/src/workspace/index.ts b/gateway/src/workspace/index.ts
index da54ed0d..440e4310 100644
--- a/gateway/src/workspace/index.ts
+++ b/gateway/src/workspace/index.ts
@@ -66,7 +66,8 @@ export type {
PathTriggerHandler,
PathTriggerContext,
ChartState,
- ChartStore,
+ ShapesStore,
+ IndicatorsStore,
ChannelState,
ChannelInfo,
WorkspaceStores,
diff --git a/gateway/src/workspace/sync-registry.ts b/gateway/src/workspace/sync-registry.ts
index 642617c3..c7a872dd 100644
--- a/gateway/src/workspace/sync-registry.ts
+++ b/gateway/src/workspace/sync-registry.ts
@@ -404,4 +404,28 @@ export class SyncRegistry {
}
return states;
}
+
+ /**
+ * Get patches since a given sequence number for a store.
+ * Returns empty array if no patches available since that sequence.
+ */
+ getPatchesSince(storeName: string, sinceSeq: number): JsonPatchOp[] | null {
+ const entry = this.entries.get(storeName);
+ if (!entry) {
+ return null;
+ }
+
+ const catchupPatches = entry.getCatchupPatches(sinceSeq);
+ if (catchupPatches === null) {
+ return null; // History not available
+ }
+
+ // Flatten all patches into a single array
+ const allPatches: JsonPatchOp[] = [];
+ for (const { patch } of catchupPatches) {
+ allPatches.push(...patch);
+ }
+
+ return allPatches;
+ }
}
diff --git a/gateway/src/workspace/types.ts b/gateway/src/workspace/types.ts
index ee541385..fe95185d 100644
--- a/gateway/src/workspace/types.ts
+++ b/gateway/src/workspace/types.ts
@@ -84,17 +84,19 @@ export const DEFAULT_STORES: StoreConfig[] = [
symbol: 'BINANCE:BTC/USDT',
start_time: null,
end_time: null,
- interval: '15',
+ period: '15',
selected_shapes: [],
}),
},
{
- name: 'chartStore',
+ name: 'shapes',
persistent: true,
- initialState: () => ({
- drawings: {},
- templates: {},
- }),
+ initialState: () => ({}),
+ },
+ {
+ name: 'indicators',
+ persistent: true,
+ initialState: () => ({}),
},
{
name: 'channelState',
@@ -198,22 +200,70 @@ export interface PathTrigger {
*/
export interface ChartState {
symbol: string;
- start_time: number | null;
- end_time: number | null;
- interval: string;
- selected_shapes: string[];
+ start_time: number | null; // unix timestamp
+ end_time: number | null; // unix timestamp
+ period: string; // OHLC duration (e.g., '15' for 15 minutes)
+ selected_shapes: string[]; // list of shape ID's
}
/**
- * Chart store - persistent, stores drawings and templates.
+ * Control point for shapes (drawings/annotations).
*/
-export interface ChartStore {
- drawings: Record;
- templates: Record;
+export interface ControlPoint {
+ time: number; // unix timestamp
+ price: number;
+ channel?: string;
}
+/**
+ * Shape (drawing/annotation) on TradingView chart.
+ */
+export interface Shape {
+ id: string;
+ type: string;
+ points: ControlPoint[];
+ color?: string;
+ line_width?: number;
+ line_style?: string;
+ properties?: Record;
+ symbol?: string;
+ created_at?: number;
+ modified_at?: number;
+ original_id?: string;
+}
+
+/**
+ * Shapes store - persistent, stores TradingView drawings and annotations.
+ */
+export type ShapesStore = Record;
+
+/**
+ * Indicator instance on TradingView chart.
+ */
+export interface IndicatorInstance {
+ id: string;
+ talib_name: string;
+ instance_name: string;
+ parameters: Record;
+ tv_study_id?: string;
+ tv_indicator_name?: string;
+ tv_inputs?: Record;
+ visible: boolean;
+ pane: string;
+ symbol?: string;
+ created_at?: number;
+ modified_at?: number;
+ original_id?: string;
+}
+
+/**
+ * Indicators store - persistent, stores TradingView studies/indicators.
+ */
+export type IndicatorsStore = Record;
+
/**
* Channel state - transient, tracks connected channels.
+ * NOTE: This store is gateway-only and should NOT be synced to web clients.
*/
export interface ChannelState {
connected: Record;
@@ -233,7 +283,8 @@ export interface ChannelInfo {
*/
export interface WorkspaceStores {
chartState: ChartState;
- chartStore: ChartStore;
+ shapes: ShapesStore;
+ indicators: IndicatorsStore;
channelState: ChannelState;
[key: string]: unknown;
}
diff --git a/gateway/src/workspace/workspace-manager.ts b/gateway/src/workspace/workspace-manager.ts
index 98885baa..4c346af8 100644
--- a/gateway/src/workspace/workspace-manager.ts
+++ b/gateway/src/workspace/workspace-manager.ts
@@ -271,6 +271,53 @@ export class WorkspaceManager {
return this.registry.getStoreNames();
}
+ /**
+ * Serialize entire workspace state as JSON.
+ */
+ serializeState(): string {
+ const state: Record = {};
+
+ for (const storeConfig of this.stores) {
+ const storeState = this.registry.getState(storeConfig.name);
+ if (storeState !== undefined) {
+ state[storeConfig.name] = storeState;
+ }
+ }
+
+ return JSON.stringify(state, null, 2);
+ }
+
+ /**
+ * Get the highest sequence number across all stores.
+ */
+ getCurrentSeq(): number {
+ let maxSeq = 0;
+ for (const storeName of this.registry.getStoreNames()) {
+ const seq = this.registry.getSeq(storeName);
+ if (seq > maxSeq) {
+ maxSeq = seq;
+ }
+ }
+ return maxSeq;
+ }
+
+ /**
+ * Get all patches since a given sequence number across all stores.
+ * Returns patches grouped by store name.
+ */
+ getChangesSince(sinceSeq: number): Record {
+ const changes: Record = {};
+
+ for (const storeConfig of this.stores) {
+ const patches = this.registry.getPatchesSince(storeConfig.name, sinceSeq);
+ if (patches && patches.length > 0) {
+ changes[storeConfig.name] = patches;
+ }
+ }
+
+ return changes;
+ }
+
// ===========================================================================
// Path Triggers
// ===========================================================================
diff --git a/web/src/App.vue b/web/src/App.vue
index a7d3a594..997d43e8 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -5,10 +5,10 @@ import SplitterPanel from 'primevue/splitterpanel'
import ChartView from './components/ChartView.vue'
import ChatPanel from './components/ChatPanel.vue'
import LoginScreen from './components/LoginScreen.vue'
-import { useOrderStore } from './stores/orders'
import { useChartStore } from './stores/chart'
import { useShapeStore } from './stores/shapes'
import { useIndicatorStore } from './stores/indicators'
+import { useChannelStore } from './stores/channel'
import { useStateSync } from './composables/useStateSync'
import { wsManager } from './composables/useWebSocket'
import { authService } from './composables/useAuth'
@@ -30,10 +30,10 @@ const chartStore = useChartStore()
watch(isMobile, (mobile) => {
if (mobile) {
// Set all chart state to null when chart is hidden
- chartStore.chart_state.symbol = null as any
- chartStore.chart_state.start_time = null
- chartStore.chart_state.end_time = null
- chartStore.chart_state.interval = null as any
+ chartStore.symbol = null as any
+ chartStore.start_time = null
+ chartStore.end_time = null
+ chartStore.period = null as any
}
})
@@ -90,15 +90,15 @@ const handleAuthenticate = async (email: string, password: string) => {
const initializeApp = async () => {
// Initialize state sync after successful authentication
- const orderStore = useOrderStore()
const chartStore = useChartStore()
const shapeStore = useShapeStore()
const indicatorStore = useIndicatorStore()
+ const channelStore = useChannelStore()
const stateSync = useStateSync({
- OrderStore: orderStore,
- ChartStore: chartStore,
- ShapeStore: shapeStore,
- IndicatorStore: indicatorStore
+ chartState: chartStore,
+ shapes: shapeStore,
+ indicators: indicatorStore,
+ channelState: channelStore
})
stateSyncCleanup = stateSync.cleanup
}
diff --git a/web/src/components/ChartView.vue b/web/src/components/ChartView.vue
index 28ce3ce2..032f6b18 100644
--- a/web/src/components/ChartView.vue
+++ b/web/src/components/ChartView.vue
@@ -29,9 +29,9 @@ onMounted(() => {
datafeed = createTradingViewDatafeed()
tvWidget = new window.TradingView.widget({
- symbol: chartStore.chart_state.symbol, // Use symbol from store
+ symbol: chartStore.symbol, // Use symbol from store
datafeed: datafeed,
- interval: chartStore.chart_state.interval as any,
+ interval: chartStore.period as any,
container: chartContainer.value!,
library_path: '/charting_library/',
locale: 'en',
@@ -167,8 +167,8 @@ function initializeVisibleRange() {
to: new Date(endTime * 1000).toISOString()
})
- chartStore.chart_state.start_time = startTime
- chartStore.chart_state.end_time = endTime
+ chartStore.start_time = startTime
+ chartStore.end_time = endTime
}
}
@@ -183,16 +183,16 @@ function setupChartListeners() {
if (symbolInfo && symbolInfo.ticker) {
console.log('[ChartView] Symbol changed to:', symbolInfo.ticker)
isUpdatingFromChart = true
- chartStore.chart_state.symbol = symbolInfo.ticker
+ chartStore.symbol = symbolInfo.ticker
isUpdatingFromChart = false
}
})
- // Listen for interval changes
+ // Listen for period changes
chart.onIntervalChanged().subscribe(null, (interval: string) => {
- console.log('[ChartView] Interval changed to:', interval)
+ console.log('[ChartView] Period changed to:', interval)
isUpdatingFromChart = true
- chartStore.chart_state.interval = interval
+ chartStore.period = interval
isUpdatingFromChart = false
})
@@ -210,8 +210,8 @@ function setupChartListeners() {
})
isUpdatingFromChart = true
- chartStore.chart_state.start_time = startTime
- chartStore.chart_state.end_time = endTime
+ chartStore.start_time = startTime
+ chartStore.end_time = endTime
isUpdatingFromChart = false
}
})
@@ -224,7 +224,7 @@ function setupStoreWatchers() {
// Watch for external changes to symbol (e.g., from backend/agent)
watch(
- () => chartStore.chart_state.symbol,
+ () => chartStore.symbol,
(newSymbol) => {
if (isUpdatingFromChart) return // Ignore updates that came from the chart itself
@@ -238,16 +238,16 @@ function setupStoreWatchers() {
}
)
- // Watch for external changes to interval
+ // Watch for external changes to period
watch(
- () => chartStore.chart_state.interval,
- (newInterval) => {
+ () => chartStore.period,
+ (newPeriod) => {
if (isUpdatingFromChart) return
- console.log('[ChartView] Store interval changed externally to:', newInterval)
- if (chart.resolution() !== newInterval) {
- chart.setResolution(newInterval, () => {
- console.log('[ChartView] Chart interval updated to:', newInterval)
+ console.log('[ChartView] Store period changed externally to:', newPeriod)
+ if (chart.resolution() !== newPeriod) {
+ chart.setResolution(newPeriod, () => {
+ console.log('[ChartView] Chart period updated to:', newPeriod)
})
}
}
diff --git a/web/src/composables/useTradingViewIndicators.ts b/web/src/composables/useTradingViewIndicators.ts
index 196d99d1..9dc25d39 100644
--- a/web/src/composables/useTradingViewIndicators.ts
+++ b/web/src/composables/useTradingViewIndicators.ts
@@ -412,7 +412,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
return
}
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
// If studyId is actually an object, extract the real values
let actualStudyId = studyId
@@ -539,7 +539,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
return
}
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
if (studyId && typeof studyId === 'string') {
console.log('[Indicators] Study properties changed for ID:', studyId)
@@ -671,7 +671,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
const chart = tvWidget.activeChart()
if (!chart) return
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
const allStudies = chart.getAllStudies()
if (!allStudies) return
@@ -755,7 +755,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
return
}
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
// Find added indicators
for (const [id, indicator] of Object.entries(newIndicators)) {
@@ -815,7 +815,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
const chart = tvWidget.activeChart()
if (!chart) return
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
const tvName = ALL_BACKEND_TO_TV_NAMES[indicator.talib_name]
if (!tvName) {
diff --git a/web/src/composables/useTradingViewShapes.ts b/web/src/composables/useTradingViewShapes.ts
index 4cd4a5e3..4821bc8e 100644
--- a/web/src/composables/useTradingViewShapes.ts
+++ b/web/src/composables/useTradingViewShapes.ts
@@ -207,7 +207,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
const chart = tvWidget.activeChart()
if (!chart) return
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
if (eventType === 'remove') {
isUpdatingStore = true
@@ -394,7 +394,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
if (!allShapes) return
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
// Track which shape IDs we've seen
const seenIds = new Set()
@@ -471,7 +471,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
const chart = tvWidget.activeChart()
if (!chart) return
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
// Find added shapes
for (const [id, shape] of Object.entries(newShapes)) {
@@ -530,11 +530,11 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
const chart = tvWidget.activeChart()
if (!chart) return
- const currentSymbol = chartStore.chart_state.symbol
+ const currentSymbol = chartStore.symbol
- // Get current chart interval and convert to seconds for timestamp canonicalization
- const interval = chartStore.chart_state.interval
- const intervalSeconds = intervalToSeconds(interval)
+ // Get current chart period and convert to seconds for timestamp canonicalization
+ const period = chartStore.period
+ const intervalSeconds = intervalToSeconds(period)
// Convert points to TradingView format and canonicalize timestamps to candle boundaries
const tvPoints = shape.points.map(p => {
@@ -715,7 +715,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
.map((obj: any) => obj.id)
console.log('[TradingView Shapes] Selection changed:', selectedShapeIds)
- chartStore.chart_state.selected_shapes = selectedShapeIds
+ chartStore.selected_shapes = selectedShapeIds
})
} catch (error) {
diff --git a/web/src/composables/useWebSocket.ts b/web/src/composables/useWebSocket.ts
index bc959973..09548cbb 100644
--- a/web/src/composables/useWebSocket.ts
+++ b/web/src/composables/useWebSocket.ts
@@ -14,6 +14,11 @@ class WebSocketManager {
public isConnected = ref(false)
public isAuthenticated = ref(false)
private token: string | null = null
+ private messageQueue: WebSocketMessage[] = []
+ private reconnectAttempts = 0
+ private maxReconnectAttempts = Infinity // Keep trying indefinitely
+ private reconnectDelay = 1000 // Start with 1 second
+ private maxReconnectDelay = 15000 // Max 15 seconds
/**
* Connect to WebSocket with JWT token for authentication
@@ -63,6 +68,7 @@ class WebSocketManager {
console.log('[WebSocket] Connected successfully')
this.isConnected.value = true
this.isAuthenticated.value = false // Wait for 'connected' message from server
+ this.reconnectAttempts = 0 // Reset reconnection counter
resolve()
}
@@ -76,6 +82,8 @@ class WebSocketManager {
if (message.type === 'connected') {
console.log('[WebSocket] Received connected message, marking as authenticated')
this.isAuthenticated.value = true
+ // Flush any queued messages now that we're authenticated
+ this.flushMessageQueue()
}
// Pass to all handlers
@@ -96,6 +104,11 @@ class WebSocketManager {
this.isConnected.value = false
this.isAuthenticated.value = false
console.log('WebSocket disconnected:', event.code, event.reason)
+
+ // Attempt to reconnect if we have a token
+ if (this.token && !event.wasClean) {
+ this.scheduleReconnect()
+ }
}
// Connection timeout
@@ -117,19 +130,69 @@ class WebSocketManager {
}
send(message: WebSocketMessage) {
- console.log('[WebSocket] Attempting to send message:', message.type, 'readyState:', this.ws?.readyState)
if (this.ws?.readyState === WebSocket.OPEN) {
- console.log('[WebSocket] Sending message:', JSON.stringify(message))
+ console.log('[WebSocket] Sending message:', message.type)
this.ws.send(JSON.stringify(message))
} else {
- console.error('[WebSocket] Cannot send message - WebSocket not open. State:', this.ws?.readyState)
+ console.log('[WebSocket] Queuing message (not connected yet):', message.type, '- Queue size:', this.messageQueue.length + 1)
+ this.messageQueue.push(message)
+ // Trigger reconnection if not already in progress
+ if (this.token && !this.reconnectTimeout) {
+ this.scheduleReconnect()
+ }
+ }
+ }
+
+ private scheduleReconnect() {
+ if (this.reconnectTimeout) {
+ return // Reconnection already scheduled
+ }
+
+ // Exponential backoff with max delay
+ const delay = Math.min(
+ this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
+ this.maxReconnectDelay
+ )
+
+ console.log(`[WebSocket] Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`)
+
+ this.reconnectTimeout = window.setTimeout(() => {
+ this.reconnectTimeout = null
+ this.reconnectAttempts++
+
+ if (this.token) {
+ console.log('[WebSocket] Attempting to reconnect...')
+ this.connect(this.token)
+ .then(() => {
+ console.log('[WebSocket] Reconnected successfully')
+ this.reconnectAttempts = 0 // Reset counter on success
+ this.flushMessageQueue()
+ })
+ .catch(err => {
+ console.error('[WebSocket] Reconnection failed:', err)
+ // scheduleReconnect will be called again via onclose
+ })
+ }
+ }, delay)
+ }
+
+ private flushMessageQueue() {
+ if (this.messageQueue.length > 0) {
+ console.log(`[WebSocket] Flushing ${this.messageQueue.length} queued messages`)
+ const queue = [...this.messageQueue]
+ this.messageQueue = []
+ queue.forEach(msg => this.send(msg))
}
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
+ this.reconnectTimeout = null
}
+ this.token = null // Clear token to prevent auto-reconnect
+ this.messageQueue = [] // Clear message queue
+ this.reconnectAttempts = 0
if (this.ws) {
this.ws.close()
this.ws = null
diff --git a/web/src/stores/channel.ts b/web/src/stores/channel.ts
new file mode 100644
index 00000000..b30e3d8a
--- /dev/null
+++ b/web/src/stores/channel.ts
@@ -0,0 +1,24 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+export interface ChannelInfo {
+ type: string
+ connectedAt: number
+ capabilities: {
+ supportsSync: boolean
+ supportsImages: boolean
+ supportsMarkdown: boolean
+ supportsStreaming: boolean
+ supportsTradingViewEmbed: boolean
+ }
+}
+
+export interface ChannelState {
+ connected: Record
+}
+
+export const useChannelStore = defineStore('channelState', () => {
+ const connected = ref>({})
+
+ return { connected }
+})
diff --git a/web/src/stores/chart.ts b/web/src/stores/chart.ts
index a0e8db58..b6f77662 100644
--- a/web/src/stores/chart.ts
+++ b/web/src/stores/chart.ts
@@ -5,18 +5,16 @@ export interface ChartState {
symbol: string
start_time: number | null
end_time: number | null
- interval: string
+ period: string
selected_shapes: string[]
}
-export const useChartStore = defineStore('ChartStore', () => {
- const chart_state = ref({
- symbol: 'BINANCE:BTC/USDT',
- start_time: null,
- end_time: null,
- interval: '15',
- selected_shapes: []
- })
+export const useChartStore = defineStore('chartState', () => {
+ const symbol = ref('BINANCE:BTC/USDT')
+ const start_time = ref(null)
+ const end_time = ref(null)
+ const period = ref('15')
+ const selected_shapes = ref([])
- return { chart_state }
+ return { symbol, start_time, end_time, period, selected_shapes }
})
diff --git a/web/src/stores/indicators.ts b/web/src/stores/indicators.ts
index 1366c3ff..a51fef14 100644
--- a/web/src/stores/indicators.ts
+++ b/web/src/stores/indicators.ts
@@ -17,7 +17,7 @@ export interface IndicatorInstance {
original_id?: string
}
-export const useIndicatorStore = defineStore('IndicatorStore', () => {
+export const useIndicatorStore = defineStore('indicators', () => {
const indicators = ref>({})
// Helper methods
diff --git a/web/src/stores/shapes.ts b/web/src/stores/shapes.ts
index 1e796da7..dee30007 100644
--- a/web/src/stores/shapes.ts
+++ b/web/src/stores/shapes.ts
@@ -21,7 +21,7 @@ export interface Shape {
original_id?: string // Original ID from backend/agent before TradingView assigns its own ID
}
-export const useShapeStore = defineStore('ShapeStore', () => {
+export const useShapeStore = defineStore('shapes', () => {
const shapes = ref>({})
// Helper methods