client-py connected
This commit is contained in:
1
.idea/ai.iml
generated
1
.idea/ai.iml
generated
@@ -16,6 +16,7 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/ingestor/src/proto" />
|
<excludeFolder url="file://$MODULE_DIR$/ingestor/src/proto" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/relay/protobuf" />
|
<excludeFolder url="file://$MODULE_DIR$/relay/protobuf" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/relay/target" />
|
<excludeFolder url="file://$MODULE_DIR$/relay/target" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/doc/competition" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.12 (ai)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.12 (ai)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
7
bin/dev
7
bin/dev
@@ -151,14 +151,12 @@ rebuild_images() {
|
|||||||
if [ "$service" == "all" ] || [ "$service" == "gateway" ]; then
|
if [ "$service" == "all" ] || [ "$service" == "gateway" ]; then
|
||||||
echo -e "${GREEN}→${NC} Building gateway..."
|
echo -e "${GREEN}→${NC} Building gateway..."
|
||||||
GATEWAY_TAG=$(build_and_get_tag gateway) || exit 1
|
GATEWAY_TAG=$(build_and_get_tag gateway) || exit 1
|
||||||
docker tag "dexorder/ai-gateway:$GATEWAY_TAG" "dexorder/gateway:$GATEWAY_TAG"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build lifecycle-sidecar (Go binary)
|
# Build lifecycle-sidecar (Go binary)
|
||||||
if [ "$service" == "all" ] || [ "$service" == "lifecycle-sidecar" ] || [ "$service" == "sidecar" ]; then
|
if [ "$service" == "all" ] || [ "$service" == "lifecycle-sidecar" ] || [ "$service" == "sidecar" ]; then
|
||||||
echo -e "${GREEN}→${NC} Building lifecycle-sidecar..."
|
echo -e "${GREEN}→${NC} Building lifecycle-sidecar..."
|
||||||
SIDECAR_TAG=$(build_and_get_tag lifecycle-sidecar) || exit 1
|
SIDECAR_TAG=$(build_and_get_tag lifecycle-sidecar) || exit 1
|
||||||
docker tag "dexorder/ai-lifecycle-sidecar:$SIDECAR_TAG" "lifecycle-sidecar:$SIDECAR_TAG"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build web (Vue.js application)
|
# Build web (Vue.js application)
|
||||||
@@ -399,7 +397,7 @@ create_dev_user() {
|
|||||||
'http://localhost:8080/mcp',
|
'http://localhost:8080/mcp',
|
||||||
'{\"maxIndicators\":50,\"maxStrategies\":20,\"maxBacktestDays\":365,\"realtimeData\":true,\"customExecutors\":true,\"apiAccess\":true}',
|
'{\"maxIndicators\":50,\"maxStrategies\":20,\"maxBacktestDays\":365,\"realtimeData\":true,\"customExecutors\":true,\"apiAccess\":true}',
|
||||||
'{\"maxConcurrentSessions\":5,\"maxMessagesPerDay\":1000,\"maxTokensPerMessage\":8192,\"rateLimitPerMinute\":60}',
|
'{\"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
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
license_type = EXCLUDED.license_type,
|
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
|
kubectl wait --for=delete pod -l app=qdrant --timeout=60s 2>/dev/null || true
|
||||||
# Now delete PVCs
|
# Now delete PVCs
|
||||||
delete_pvcs all
|
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
|
minikube stop
|
||||||
echo -e "${GREEN}✓ Minikube stopped and PVCs deleted${NC}"
|
echo -e "${GREEN}✓ Minikube stopped and PVCs deleted${NC}"
|
||||||
echo -e "${YELLOW}Tip: Use 'bin/dev stop --keep-data' to preserve PVCs${NC}"
|
echo -e "${YELLOW}Tip: Use 'bin/dev stop --keep-data' to preserve PVCs${NC}"
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
ZMQ_XPUB_PORT=5570 \
|
ZMQ_XPUB_PORT=5570 \
|
||||||
ZMQ_GATEWAY_ENDPOINT=tcp://gateway:5571 \
|
ZMQ_GATEWAY_ENDPOINT=tcp://gateway:5571 \
|
||||||
MCP_SERVER_NAME=dexorder-user \
|
MCP_SERVER_NAME=dexorder-user \
|
||||||
|
MCP_TRANSPORT=sse \
|
||||||
|
MCP_HTTP_PORT=3000 \
|
||||||
|
MCP_HTTP_HOST=0.0.0.0 \
|
||||||
IDLE_TIMEOUT_MINUTES=15 \
|
IDLE_TIMEOUT_MINUTES=15 \
|
||||||
ENABLE_IDLE_SHUTDOWN=true
|
ENABLE_IDLE_SHUTDOWN=true
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Provides high-level APIs for:
|
|||||||
|
|
||||||
__version__ = "0.1.0"
|
__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 .ohlc_client import OHLCClient
|
||||||
from .iceberg_client import IcebergClient
|
from .iceberg_client import IcebergClient
|
||||||
from .history_client import HistoryClient
|
from .history_client import HistoryClient
|
||||||
|
|||||||
559
client-py/dexorder/api/category_tools.py
Normal file
559
client-py/dexorder/api/category_tools.py
Normal file
@@ -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
|
||||||
@@ -16,12 +16,11 @@ import struct
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Import protobuf messages (assuming they're generated in ../protobuf)
|
# Import protobuf messages from generated package
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../protobuf'))
|
|
||||||
try:
|
try:
|
||||||
from ingestor_pb2 import SubmitHistoricalRequest, SubmitResponse, HistoryReadyNotification
|
from dexorder.generated.ingestor_pb2 import SubmitHistoricalRequest, SubmitResponse, HistoryReadyNotification
|
||||||
except ImportError:
|
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
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -205,26 +205,36 @@ class LifecycleManager:
|
|||||||
_lifecycle_manager: Optional[LifecycleManager] = None
|
_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."""
|
"""Get or create the global lifecycle manager instance."""
|
||||||
global _lifecycle_manager
|
global _lifecycle_manager
|
||||||
if _lifecycle_manager is None:
|
if _lifecycle_manager is None:
|
||||||
# Load configuration from environment
|
# Load configuration from environment or use provided values
|
||||||
idle_timeout = int(os.environ.get("IDLE_TIMEOUT_MINUTES", "15"))
|
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"))
|
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(
|
_lifecycle_manager = LifecycleManager(
|
||||||
idle_timeout_minutes=idle_timeout,
|
idle_timeout_minutes=idle_timeout,
|
||||||
check_interval_seconds=check_interval,
|
check_interval_seconds=check_interval,
|
||||||
enable_shutdown=enable_shutdown,
|
enable_shutdown=enable,
|
||||||
)
|
)
|
||||||
return _lifecycle_manager
|
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."""
|
"""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()
|
manager.setup_signal_handlers()
|
||||||
await manager.start()
|
await manager.start()
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
import uvicorn
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.server.stdio import stdio_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 import EventPublisher, start_lifecycle_manager, get_lifecycle_manager
|
||||||
from dexorder.events import EventType, UserEvent, DeliverySpec
|
from dexorder.events import EventType, UserEvent, DeliverySpec
|
||||||
from dexorder.api.workspace_tools import get_workspace_store
|
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
|
# MCP server settings
|
||||||
self.mcp_server_name: str = os.getenv("MCP_SERVER_NAME", "dexorder-user")
|
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
|
# Lifecycle settings
|
||||||
self.idle_timeout_minutes: int = int(os.getenv("IDLE_TIMEOUT_MINUTES", "15"))
|
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)
|
workspace_store = get_workspace_store(config.workspace_dir)
|
||||||
logging.info(f"Workspace store initialized at {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()
|
@server.list_resources()
|
||||||
async def list_resources():
|
async def list_resources():
|
||||||
"""List available resources"""
|
"""List available resources"""
|
||||||
@@ -143,6 +158,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
|||||||
"name": "Hello World",
|
"name": "Hello World",
|
||||||
"description": "A simple hello world resource",
|
"description": "A simple hello world resource",
|
||||||
"mimeType": "text/plain",
|
"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()
|
@server.list_tools()
|
||||||
async def list_tools():
|
async def list_tools():
|
||||||
"""List available tools including workspace tools"""
|
"""List available tools including workspace and category tools"""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "workspace_read",
|
"name": "workspace_read",
|
||||||
@@ -184,6 +202,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["store_name"]
|
"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"]
|
"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"]
|
"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()
|
@server.call_tool()
|
||||||
async def handle_tool_call(name: str, arguments: dict):
|
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":
|
if name == "workspace_read":
|
||||||
return workspace_store.read(arguments.get("store_name", ""))
|
return workspace_store.read(arguments.get("store_name", ""))
|
||||||
elif name == "workspace_write":
|
elif name == "workspace_write":
|
||||||
@@ -247,13 +382,78 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
|||||||
arguments.get("store_name", ""),
|
arguments.get("store_name", ""),
|
||||||
arguments.get("patch", [])
|
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:
|
else:
|
||||||
raise ValueError(f"Unknown tool: {name}")
|
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
|
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
|
# Main Application
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -347,17 +547,34 @@ class UserContainer:
|
|||||||
logging.info("User container stopped")
|
logging.info("User container stopped")
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Run the MCP server via stdio"""
|
"""Run the MCP server with configured transport"""
|
||||||
await self.start()
|
await self.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run MCP server on stdio
|
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):
|
async with stdio_server() as (read_stream, write_stream):
|
||||||
await self.mcp_server.run(
|
await self.mcp_server.run(
|
||||||
read_stream,
|
read_stream,
|
||||||
write_stream,
|
write_stream,
|
||||||
self.mcp_server.create_initialization_options()
|
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:
|
finally:
|
||||||
await self.stop()
|
await self.stop()
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ setup(
|
|||||||
"protobuf>=4.25.0",
|
"protobuf>=4.25.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"aiofiles>=23.0.0",
|
"aiofiles>=23.0.0",
|
||||||
"mcp>=0.9.0",
|
"mcp>=1.0.0",
|
||||||
"jsonpatch>=1.33",
|
"jsonpatch>=1.33",
|
||||||
|
"starlette>=0.27.0",
|
||||||
|
"uvicorn>=0.27.0",
|
||||||
|
"sse-starlette>=1.6.0",
|
||||||
|
"matplotlib>=3.7.0",
|
||||||
],
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
"dev": [
|
"dev": [
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ spec:
|
|||||||
memory: "2Gi"
|
memory: "2Gi"
|
||||||
cpu: "2000m"
|
cpu: "2000m"
|
||||||
min:
|
min:
|
||||||
memory: "64Mi"
|
memory: "32Mi"
|
||||||
cpu: "50m"
|
cpu: "10m"
|
||||||
# PVC size limits
|
# PVC size limits
|
||||||
- type: PersistentVolumeClaim
|
- type: PersistentVolumeClaim
|
||||||
max:
|
max:
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ spec:
|
|||||||
c.image.startsWith('localhost:5000/dexorder/agent') ||
|
c.image.startsWith('localhost:5000/dexorder/agent') ||
|
||||||
c.image.startsWith('dexorder/agent') ||
|
c.image.startsWith('dexorder/agent') ||
|
||||||
c.image.startsWith('dexorder/ai-client-py') ||
|
c.image.startsWith('dexorder/ai-client-py') ||
|
||||||
c.image.startsWith('ai-client-py') ||
|
c.image.startsWith('dexorder/ai-lifecycle-sidecar'))
|
||||||
c.image.startsWith('lifecycle-sidecar'))
|
|
||||||
message: "Only approved dexorder agent images are allowed"
|
message: "Only approved dexorder agent images are allowed"
|
||||||
reason: Forbidden
|
reason: Forbidden
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,40 @@ data:
|
|||||||
# Default model (if user has no preference)
|
# Default model (if user has no preference)
|
||||||
defaults:
|
defaults:
|
||||||
model_provider: anthropic
|
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 configuration
|
||||||
kubernetes:
|
kubernetes:
|
||||||
namespace: dexorder-agents
|
namespace: dexorder-agents
|
||||||
in_cluster: true
|
in_cluster: true
|
||||||
agent_image: ai-client-py:latest
|
agent_image: dexorder/ai-client-py:latest
|
||||||
sidecar_image: lifecycle-sidecar:latest
|
sidecar_image: dexorder/ai-lifecycle-sidecar:latest
|
||||||
storage_class: standard
|
storage_class: standard
|
||||||
|
image_pull_policy: Never # For minikube dev - use local images
|
||||||
|
|
||||||
# DragonflyDB (Redis-compatible, for hot storage and session management)
|
# DragonflyDB (Redis-compatible, for hot storage and session management)
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: gateway
|
- name: gateway
|
||||||
image: dexorder/gateway:latest
|
image: dexorder/ai-gateway:latest
|
||||||
imagePullPolicy: Never
|
imagePullPolicy: Never
|
||||||
env:
|
env:
|
||||||
- name: NODE_OPTIONS
|
- name: NODE_OPTIONS
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ generatorOptions:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,16 @@ data:
|
|||||||
# Default model (if user has no preference)
|
# Default model (if user has no preference)
|
||||||
defaults:
|
defaults:
|
||||||
model_provider: anthropic
|
model_provider: anthropic
|
||||||
model: claude-3-5-sonnet-20241022
|
model: claude-sonnet-4-6
|
||||||
|
|
||||||
# Kubernetes configuration
|
# Kubernetes configuration
|
||||||
kubernetes:
|
kubernetes:
|
||||||
namespace: dexorder-agents
|
namespace: dexorder-agents
|
||||||
in_cluster: true
|
in_cluster: true
|
||||||
agent_image: ghcr.io/dexorder/agent:latest
|
agent_image: dexorder/ai-client-py:latest
|
||||||
sidecar_image: ghcr.io/dexorder/lifecycle-sidecar:latest
|
sidecar_image: dexorder/ai-lifecycle-sidecar:latest
|
||||||
storage_class: standard
|
storage_class: standard
|
||||||
|
image_pull_policy: Always # For production - always pull from registry
|
||||||
|
|
||||||
# DragonflyDB (Redis-compatible, for hot storage and session management)
|
# DragonflyDB (Redis-compatible, for hot storage and session management)
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
52
doc/competition/mmt.gg/companion_capabilities.md
Normal file
52
doc/competition/mmt.gg/companion_capabilities.md
Normal file
@@ -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.
|
||||||
@@ -210,7 +210,7 @@ async handleMessage(message: InboundMessage): Promise<OutboundMessage> {
|
|||||||
|
|
||||||
// 5. Call Claude with embedded context
|
// 5. Call Claude with embedded context
|
||||||
const response = await anthropic.messages.create({
|
const response = await anthropic.messages.create({
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
model: 'claude-sonnet-4-6',
|
||||||
system: systemPrompt, // <-- User profile + workspace + custom prompt
|
system: systemPrompt, // <-- User profile + workspace + custom prompt
|
||||||
messages, // <-- Conversation summary from RAG
|
messages, // <-- Conversation summary from RAG
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ OPENROUTER_API_KEY=sk-or-xxxxx
|
|||||||
|
|
||||||
# Default model (if user has no preference)
|
# Default model (if user has no preference)
|
||||||
DEFAULT_MODEL_PROVIDER=anthropic
|
DEFAULT_MODEL_PROVIDER=anthropic
|
||||||
DEFAULT_MODEL=claude-3-5-sonnet-20241022
|
DEFAULT_MODEL=claude-sonnet-4-6
|
||||||
|
|
||||||
# Telegram (optional)
|
# Telegram (optional)
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const factory = new LLMProviderFactory(config, logger);
|
|||||||
// Create any model
|
// Create any model
|
||||||
const claude = factory.createModel({
|
const claude = factory.createModel({
|
||||||
provider: 'anthropic',
|
provider: 'anthropic',
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
model: 'claude-sonnet-4-6',
|
||||||
});
|
});
|
||||||
|
|
||||||
const gpt4 = factory.createModel({
|
const gpt4 = factory.createModel({
|
||||||
@@ -205,7 +205,7 @@ const conversationSummary = await mcpClient.readResource('context://conversation
|
|||||||
{
|
{
|
||||||
"preferredModel": {
|
"preferredModel": {
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"model": "claude-3-5-sonnet-20241022"
|
"model": "claude-sonnet-4-6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ ANTHROPIC_API_KEY=sk-ant-xxxxx
|
|||||||
|
|
||||||
# Optional: Set default model
|
# Optional: Set default model
|
||||||
DEFAULT_MODEL_PROVIDER=anthropic
|
DEFAULT_MODEL_PROVIDER=anthropic
|
||||||
DEFAULT_MODEL=claude-3-5-sonnet-20241022
|
DEFAULT_MODEL=claude-sonnet-4-6
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Start Ollama and pull embedding model:
|
4. Start Ollama and pull embedding model:
|
||||||
|
|||||||
@@ -19,7 +19,31 @@ database:
|
|||||||
# Default model (if user has no preference)
|
# Default model (if user has no preference)
|
||||||
defaults:
|
defaults:
|
||||||
model_provider: anthropic
|
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 configuration
|
||||||
kubernetes:
|
kubernetes:
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS user_licenses (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
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)
|
CREATE INDEX idx_user_licenses_expires_at ON user_licenses(expires_at)
|
||||||
WHERE expires_at IS NOT NULL;
|
WHERE expires_at IS NOT NULL;
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export class TelegramHandler {
|
|||||||
userId: authContext.userId,
|
userId: authContext.userId,
|
||||||
sessionId: authContext.sessionId,
|
sessionId: authContext.sessionId,
|
||||||
content: text,
|
content: text,
|
||||||
|
attachments: [], // TODO: Add image support for Telegram
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -158,21 +158,25 @@ export class WebSocketHandler {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create agent harness
|
// Declare harness outside try block so it's available in catch
|
||||||
const harness = new AgentHarness({
|
let harness: AgentHarness | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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,
|
userId: authContext.userId,
|
||||||
sessionId: authContext.sessionId,
|
sessionId: authContext.sessionId,
|
||||||
license: authContext.license,
|
license: authContext.license,
|
||||||
providerConfig: this.config.providerConfig,
|
providerConfig: this.config.providerConfig,
|
||||||
logger,
|
logger,
|
||||||
|
workspaceManager: workspace,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
// Initialize workspace and harness
|
|
||||||
await workspace.initialize();
|
|
||||||
workspace.setAdapter(wsAdapter);
|
|
||||||
this.workspaces.set(authContext.sessionId, workspace);
|
|
||||||
|
|
||||||
await harness.initialize();
|
await harness.initialize();
|
||||||
this.harnesses.set(authContext.sessionId, harness);
|
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');
|
logger.info({ type: payload.type, request_id: payload.request_id }, 'WebSocket message parsed');
|
||||||
|
|
||||||
// Route based on message type
|
// Route based on message type
|
||||||
if (payload.type === 'message') {
|
if (payload.type === 'message' || payload.type === 'agent_user_message') {
|
||||||
// Chat message - send to agent harness
|
// Chat message - send to agent harness with streaming
|
||||||
const inboundMessage: InboundMessage = {
|
const inboundMessage: InboundMessage = {
|
||||||
messageId: randomUUID(),
|
messageId: randomUUID(),
|
||||||
userId: authContext.userId,
|
userId: authContext.userId,
|
||||||
@@ -231,14 +235,41 @@ export class WebSocketHandler {
|
|||||||
timestamp: new Date(),
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream response chunks to client
|
||||||
|
try {
|
||||||
|
for await (const chunk of harness.streamMessage(inboundMessage)) {
|
||||||
socket.send(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'message',
|
type: 'agent_chunk',
|
||||||
...response,
|
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') {
|
} else if (payload.type === 'hello') {
|
||||||
// Workspace sync: hello message
|
// Workspace sync: hello message
|
||||||
logger.debug({ seqs: payload.seqs }, 'Handling workspace hello');
|
logger.debug({ seqs: payload.seqs }, 'Handling workspace hello');
|
||||||
@@ -280,8 +311,10 @@ export class WebSocketHandler {
|
|||||||
this.workspaces.delete(authContext.sessionId);
|
this.workspaces.delete(authContext.sessionId);
|
||||||
|
|
||||||
// Cleanup harness
|
// Cleanup harness
|
||||||
|
if (harness) {
|
||||||
await harness.cleanup();
|
await harness.cleanup();
|
||||||
this.harnesses.delete(authContext.sessionId);
|
this.harnesses.delete(authContext.sessionId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (error: any) => {
|
socket.on('error', (error: any) => {
|
||||||
@@ -292,9 +325,11 @@ export class WebSocketHandler {
|
|||||||
socket.close(1011, 'Internal server error');
|
socket.close(1011, 'Internal server error');
|
||||||
await workspace.shutdown();
|
await workspace.shutdown();
|
||||||
this.workspaces.delete(authContext.sessionId);
|
this.workspaces.delete(authContext.sessionId);
|
||||||
|
if (harness) {
|
||||||
await harness.cleanup();
|
await harness.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll for container readiness in the background
|
* Poll for container readiness in the background
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export class ZMQRelayClient {
|
|||||||
relayNotificationEndpoint: config.relayNotificationEndpoint,
|
relayNotificationEndpoint: config.relayNotificationEndpoint,
|
||||||
clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`,
|
clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`,
|
||||||
requestTimeout: config.requestTimeout || 30000,
|
requestTimeout: config.requestTimeout || 30000,
|
||||||
|
onMetadataUpdate: config.onMetadataUpdate || (async () => {}),
|
||||||
};
|
};
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.notificationTopic = `RESPONSE:${this.config.clientId}`;
|
this.notificationTopic = `RESPONSE:${this.config.clientId}`;
|
||||||
|
|||||||
@@ -101,9 +101,15 @@ See individual workflow READMEs for details.
|
|||||||
|
|
||||||
YAML-based configuration:
|
YAML-based configuration:
|
||||||
|
|
||||||
- `models.yaml`: LLM providers, routing, rate limits
|
|
||||||
- `subagent-routing.yaml`: When to use which subagent
|
- `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
|
## User Context
|
||||||
|
|
||||||
Enhanced session context with channel awareness for multi-channel support:
|
Enhanced session context with channel awareness for multi-channel support:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MCPClientConnector } from './mcp-client.js';
|
|||||||
import { CONTEXT_URIS, type ResourceContent } from '../types/resources.js';
|
import { CONTEXT_URIS, type ResourceContent } from '../types/resources.js';
|
||||||
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
|
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
|
||||||
import { ModelRouter, RoutingStrategy } from '../llm/router.js';
|
import { ModelRouter, RoutingStrategy } from '../llm/router.js';
|
||||||
|
import type { WorkspaceManager } from '../workspace/workspace-manager.js';
|
||||||
|
|
||||||
export interface AgentHarnessConfig {
|
export interface AgentHarnessConfig {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -15,6 +16,7 @@ export interface AgentHarnessConfig {
|
|||||||
license: UserLicense;
|
license: UserLicense;
|
||||||
providerConfig: ProviderConfig;
|
providerConfig: ProviderConfig;
|
||||||
logger: FastifyBaseLogger;
|
logger: FastifyBaseLogger;
|
||||||
|
workspaceManager?: WorkspaceManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,9 +35,13 @@ export class AgentHarness {
|
|||||||
private modelFactory: LLMProviderFactory;
|
private modelFactory: LLMProviderFactory;
|
||||||
private modelRouter: ModelRouter;
|
private modelRouter: ModelRouter;
|
||||||
private mcpClient: MCPClientConnector;
|
private mcpClient: MCPClientConnector;
|
||||||
|
private workspaceManager?: WorkspaceManager;
|
||||||
|
private lastWorkspaceSeq: number = 0;
|
||||||
|
private isFirstMessage: boolean = true;
|
||||||
|
|
||||||
constructor(config: AgentHarnessConfig) {
|
constructor(config: AgentHarnessConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.workspaceManager = config.workspaceManager;
|
||||||
|
|
||||||
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
|
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
|
||||||
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
|
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
|
||||||
@@ -102,18 +108,14 @@ export class AgentHarness {
|
|||||||
// 7. Extract text response (tool handling TODO)
|
// 7. Extract text response (tool handling TODO)
|
||||||
const assistantMessage = response.content as string;
|
const assistantMessage = response.content as string;
|
||||||
|
|
||||||
// 8. Save messages to user's MCP server
|
// TODO: Save messages to Iceberg conversation table instead of MCP
|
||||||
this.config.logger.debug('Saving messages to MCP');
|
// Should batch-insert periodically or on session end to avoid many small Parquet files
|
||||||
await this.mcpClient.callTool('save_message', {
|
// await icebergConversationStore.appendMessages([...]);
|
||||||
role: 'user',
|
|
||||||
content: message.content,
|
// Mark first message as processed
|
||||||
timestamp: message.timestamp.toISOString(),
|
if (this.isFirstMessage) {
|
||||||
});
|
this.isFirstMessage = false;
|
||||||
await this.mcpClient.callTool('save_message', {
|
}
|
||||||
role: 'assistant',
|
|
||||||
content: assistantMessage,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageId: `msg_${Date.now()}`,
|
messageId: `msg_${Date.now()}`,
|
||||||
@@ -157,17 +159,17 @@ export class AgentHarness {
|
|||||||
yield content;
|
yield content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save after streaming completes
|
// TODO: Save messages to Iceberg conversation table instead of MCP
|
||||||
await this.mcpClient.callTool('save_message', {
|
// Should batch-insert periodically or on session end to avoid many small Parquet files
|
||||||
role: 'user',
|
// await icebergConversationStore.appendMessages([
|
||||||
content: message.content,
|
// { role: 'user', content: message.content, timestamp: message.timestamp },
|
||||||
timestamp: message.timestamp.toISOString(),
|
// { role: 'assistant', content: fullResponse, timestamp: new Date() }
|
||||||
});
|
// ]);
|
||||||
await this.mcpClient.callTool('save_message', {
|
|
||||||
role: 'assistant',
|
// Mark first message as processed
|
||||||
content: fullResponse,
|
if (this.isFirstMessage) {
|
||||||
timestamp: new Date().toISOString(),
|
this.isFirstMessage = false;
|
||||||
});
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.config.logger.error({ error }, 'Error streaming message');
|
this.config.logger.error({ error }, 'Error streaming message');
|
||||||
throw error;
|
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
|
// Add current user message
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -273,9 +284,18 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
|
|||||||
prompt += `\n\n# User Profile\n${userProfile.text}`;
|
prompt += `\n\n# User Profile\n${userProfile.text}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add workspace context
|
// Add workspace context from MCP resource (if available)
|
||||||
if (workspaceState?.text) {
|
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)
|
// Add user's custom instructions (highest priority)
|
||||||
@@ -286,6 +306,30 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
|
|||||||
return prompt;
|
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\`\`\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export interface MCPClientConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP client connector for user's container
|
* 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 {
|
export class MCPClientConnector {
|
||||||
private client: Client | null = null;
|
private client: Client | null = null;
|
||||||
@@ -23,8 +24,7 @@ export class MCPClientConnector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to user's MCP server
|
* Connect to user's MCP server via SSE transport
|
||||||
* TODO: Implement HTTP/SSE transport instead of stdio for container communication
|
|
||||||
*/
|
*/
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.connected) {
|
if (this.connected) {
|
||||||
@@ -34,7 +34,7 @@ export class MCPClientConnector {
|
|||||||
try {
|
try {
|
||||||
this.config.logger.info(
|
this.config.logger.info(
|
||||||
{ userId: this.config.userId, url: this.config.mcpServerUrl },
|
{ userId: this.config.userId, url: this.config.mcpServerUrl },
|
||||||
'Connecting to user MCP server'
|
'Connecting to user MCP server via SSE'
|
||||||
);
|
);
|
||||||
|
|
||||||
this.client = new Client(
|
this.client = new Client(
|
||||||
@@ -46,24 +46,18 @@ export class MCPClientConnector {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
sampling: {},
|
sampling: {},
|
||||||
},
|
},
|
||||||
} as any
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Replace with HTTP transport when user containers are ready
|
// Create SSE transport for HTTP connection to user container
|
||||||
// For now, this is a placeholder structure
|
const transport = new SSEClientTransport(
|
||||||
// const transport = new HTTPTransport(this.config.mcpServerUrl, {
|
new URL(`${this.config.mcpServerUrl}/sse`)
|
||||||
// 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'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.client.connect(transport);
|
||||||
|
|
||||||
this.connected = true;
|
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) {
|
} catch (error) {
|
||||||
this.config.logger.error(
|
this.config.logger.error(
|
||||||
{ error, userId: this.config.userId },
|
{ error, userId: this.config.userId },
|
||||||
@@ -84,12 +78,8 @@ export class MCPClientConnector {
|
|||||||
try {
|
try {
|
||||||
this.config.logger.debug({ tool: name, args }, 'Calling MCP tool');
|
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 });
|
||||||
// const result = await this.client.callTool({ name, arguments: args });
|
return result;
|
||||||
// return result;
|
|
||||||
|
|
||||||
// Placeholder response
|
|
||||||
return { success: true, message: 'MCP tool call placeholder' };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.config.logger.error({ error, tool: name }, 'MCP tool call failed');
|
this.config.logger.error({ error, tool: name }, 'MCP tool call failed');
|
||||||
throw error;
|
throw error;
|
||||||
@@ -98,27 +88,35 @@ export class MCPClientConnector {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* List available tools from user's MCP server
|
* List available tools from user's MCP server
|
||||||
|
* Filters to only return tools marked as agent_accessible
|
||||||
*/
|
*/
|
||||||
async listTools(): Promise<Array<{ name: string; description?: string }>> {
|
async listTools(): Promise<Array<{ name: string; description?: string; inputSchema?: any }>> {
|
||||||
if (!this.client || !this.connected) {
|
if (!this.client || !this.connected) {
|
||||||
throw new Error('MCP client not connected');
|
throw new Error('MCP client not connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Implement when MCP client is connected
|
const response = await this.client.listTools();
|
||||||
// const tools = await this.client.listTools();
|
|
||||||
// return tools;
|
|
||||||
|
|
||||||
// Placeholder tools (actions only, not context)
|
// Filter tools to only include agent-accessible ones
|
||||||
return [
|
const tools = response.tools
|
||||||
{ name: 'save_message', description: 'Save message to conversation history' },
|
.filter((tool: any) => {
|
||||||
{ name: 'list_strategies', description: 'List user strategies' },
|
// Check if tool has agent_accessible annotation
|
||||||
{ name: 'read_strategy', description: 'Read strategy code' },
|
const annotations = tool.annotations || {};
|
||||||
{ name: 'write_strategy', description: 'Write strategy code' },
|
return annotations.agent_accessible === true;
|
||||||
{ name: 'run_backtest', description: 'Run backtest on strategy' },
|
})
|
||||||
{ name: 'get_watchlist', description: 'Get user watchlist' },
|
.map((tool: any) => ({
|
||||||
{ name: 'execute_trade', description: 'Execute trade' },
|
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) {
|
} catch (error) {
|
||||||
this.config.logger.error({ error }, 'Failed to list MCP tools');
|
this.config.logger.error({ error }, 'Failed to list MCP tools');
|
||||||
throw error;
|
throw error;
|
||||||
@@ -127,6 +125,7 @@ export class MCPClientConnector {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* List available resources from user's MCP server
|
* List available resources from user's MCP server
|
||||||
|
* Filters to only return resources marked as agent_accessible
|
||||||
*/
|
*/
|
||||||
async listResources(): Promise<Array<{ uri: string; name: string; description?: string; mimeType?: string }>> {
|
async listResources(): Promise<Array<{ uri: string; name: string; description?: string; mimeType?: string }>> {
|
||||||
if (!this.client || !this.connected) {
|
if (!this.client || !this.connected) {
|
||||||
@@ -134,37 +133,28 @@ export class MCPClientConnector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Implement when MCP client is connected
|
const response = await this.client.listResources();
|
||||||
// const resources = await this.client.listResources();
|
|
||||||
// return resources;
|
|
||||||
|
|
||||||
// Placeholder resources for user context
|
// Filter resources to only include agent-accessible ones
|
||||||
return [
|
const resources = response.resources
|
||||||
{
|
.filter((resource: any) => {
|
||||||
uri: 'context://user-profile',
|
// Check if resource has agent_accessible annotation
|
||||||
name: 'User Profile',
|
const annotations = resource.annotations || {};
|
||||||
description: 'User trading style, preferences, and background',
|
return annotations.agent_accessible === true;
|
||||||
mimeType: 'text/plain',
|
})
|
||||||
},
|
.map((resource: any) => ({
|
||||||
{
|
uri: resource.uri,
|
||||||
uri: 'context://conversation-summary',
|
name: resource.name,
|
||||||
name: 'Conversation Summary',
|
description: resource.description,
|
||||||
description: 'Semantic summary of recent conversation history with RAG',
|
mimeType: resource.mimeType,
|
||||||
mimeType: 'text/plain',
|
}));
|
||||||
},
|
|
||||||
{
|
this.config.logger.debug(
|
||||||
uri: 'context://workspace-state',
|
{ totalResources: response.resources.length, agentAccessibleResources: resources.length },
|
||||||
name: 'Workspace State',
|
'Listed MCP resources with filtering'
|
||||||
description: 'Current chart, watchlist, and open positions',
|
);
|
||||||
mimeType: 'application/json',
|
|
||||||
},
|
return resources;
|
||||||
{
|
|
||||||
uri: 'context://system-prompt',
|
|
||||||
name: 'Custom System Prompt',
|
|
||||||
description: 'User custom instructions for the assistant',
|
|
||||||
mimeType: 'text/plain',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.config.logger.error({ error }, 'Failed to list MCP resources');
|
this.config.logger.error({ error }, 'Failed to list MCP resources');
|
||||||
throw error;
|
throw error;
|
||||||
@@ -182,55 +172,18 @@ export class MCPClientConnector {
|
|||||||
try {
|
try {
|
||||||
this.config.logger.debug({ uri }, 'Reading MCP resource');
|
this.config.logger.debug({ uri }, 'Reading MCP resource');
|
||||||
|
|
||||||
// TODO: Implement when MCP client is connected
|
const response = await this.client.readResource({ uri });
|
||||||
// const resource = await this.client.readResource({ uri });
|
|
||||||
// return resource;
|
|
||||||
|
|
||||||
// Placeholder resource content
|
// Extract the first content item (MCP returns array of contents)
|
||||||
if (uri === 'context://user-profile') {
|
const content = response.contents[0];
|
||||||
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]
|
|
||||||
|
|
||||||
User recently discussed:
|
// Handle union type: content is either TextContent or BlobContent
|
||||||
- Moving average crossover strategies
|
|
||||||
- Backtesting on BTC/USDT
|
|
||||||
- Risk management techniques`,
|
|
||||||
};
|
|
||||||
} else if (uri === 'context://workspace-state') {
|
|
||||||
return {
|
return {
|
||||||
uri,
|
uri: content.uri,
|
||||||
mimeType: 'application/json',
|
mimeType: content.mimeType,
|
||||||
text: JSON.stringify({
|
text: 'text' in content ? content.text : undefined,
|
||||||
currentChart: { ticker: 'BINANCE:BTC/USDT', timeframe: '1h' },
|
blob: 'blob' in content ? content.blob : undefined,
|
||||||
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: '' };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.config.logger.error({ error, uri }, 'MCP resource read failed');
|
this.config.logger.error({ error, uri }, 'MCP resource read failed');
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ name: my-subagent
|
|||||||
description: What it does
|
description: What it does
|
||||||
|
|
||||||
# Model override (optional)
|
# Model override (optional)
|
||||||
model: claude-3-5-sonnet-20241022
|
model: claude-sonnet-4-6
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
maxTokens: 4096
|
maxTokens: 4096
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ name: code-reviewer
|
|||||||
description: Reviews trading strategy code for bugs, performance issues, and best practices
|
description: Reviews trading strategy code for bugs, performance issues, and best practices
|
||||||
|
|
||||||
# Model configuration (optional override)
|
# Model configuration (optional override)
|
||||||
model: claude-3-5-sonnet-20241022
|
model: claude-sonnet-4-6
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
maxTokens: 4096
|
maxTokens: 4096
|
||||||
|
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ timeout: 60000
|
|||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
requiresApproval: false
|
requiresApproval: false
|
||||||
|
|
||||||
model: claude-3-5-sonnet-20241022
|
model: claude-sonnet-4-6
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Add Factory Function
|
### 6. Add Factory Function
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ maxValidationRetries: 3 # Max times to retry fixing errors
|
|||||||
minBacktestScore: 0.5 # Minimum Sharpe ratio to pass
|
minBacktestScore: 0.5 # Minimum Sharpe ratio to pass
|
||||||
|
|
||||||
# Model override (optional)
|
# Model override (optional)
|
||||||
model: claude-3-5-sonnet-20241022
|
model: claude-sonnet-4-6
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ maxPositionPercent: 0.05 # 5% of portfolio max
|
|||||||
minRiskRewardRatio: 2.0 # Minimum 2:1 risk/reward
|
minRiskRewardRatio: 2.0 # Minimum 2:1 risk/reward
|
||||||
|
|
||||||
# Model override (optional)
|
# Model override (optional)
|
||||||
model: claude-3-5-sonnet-20241022
|
model: claude-sonnet-4-6
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface DeploymentSpec {
|
|||||||
agentImage: string;
|
agentImage: string;
|
||||||
sidecarImage: string;
|
sidecarImage: string;
|
||||||
storageClass: string;
|
storageClass: string;
|
||||||
|
imagePullPolicy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +133,8 @@ export class KubernetesClient {
|
|||||||
.replace(/\{\{pvcName\}\}/g, pvcName)
|
.replace(/\{\{pvcName\}\}/g, pvcName)
|
||||||
.replace(/\{\{agentImage\}\}/g, spec.agentImage)
|
.replace(/\{\{agentImage\}\}/g, spec.agentImage)
|
||||||
.replace(/\{\{sidecarImage\}\}/g, spec.sidecarImage)
|
.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)
|
// Parse YAML documents (deployment, pvc, service)
|
||||||
const documents = yaml.loadAll(rendered) as any[];
|
const documents = yaml.loadAll(rendered) as any[];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface ContainerManagerConfig {
|
|||||||
agentImage: string;
|
agentImage: string;
|
||||||
sidecarImage: string;
|
sidecarImage: string;
|
||||||
storageClass: string;
|
storageClass: string;
|
||||||
|
imagePullPolicy?: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
logger: FastifyBaseLogger;
|
logger: FastifyBaseLogger;
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,7 @@ export class ContainerManager {
|
|||||||
agentImage: this.config.agentImage,
|
agentImage: this.config.agentImage,
|
||||||
sidecarImage: this.config.sidecarImage,
|
sidecarImage: this.config.sidecarImage,
|
||||||
storageClass: this.config.storageClass,
|
storageClass: this.config.storageClass,
|
||||||
|
imagePullPolicy: this.config.imagePullPolicy,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.config.k8sClient.createAgentDeployment(spec);
|
await this.config.k8sClient.createAgentDeployment(spec);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: agent
|
- name: agent
|
||||||
image: {{agentImage}}
|
image: {{agentImage}}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: {{imagePullPolicy}}
|
||||||
|
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@@ -72,6 +72,8 @@ spec:
|
|||||||
value: "3000"
|
value: "3000"
|
||||||
- name: ZMQ_CONTROL_PORT
|
- name: ZMQ_CONTROL_PORT
|
||||||
value: "5555"
|
value: "5555"
|
||||||
|
- name: ZMQ_GATEWAY_ENDPOINT
|
||||||
|
value: "tcp://gateway.default.svc.cluster.local:5571"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- name: mcp
|
- name: mcp
|
||||||
@@ -102,14 +104,14 @@ spec:
|
|||||||
|
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /ready
|
path: /health
|
||||||
port: mcp
|
port: mcp
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
|
||||||
- name: lifecycle-sidecar
|
- name: lifecycle-sidecar
|
||||||
image: {{sidecarImage}}
|
image: {{sidecarImage}}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: {{imagePullPolicy}}
|
||||||
|
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: agent
|
- name: agent
|
||||||
image: {{agentImage}}
|
image: {{agentImage}}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: {{imagePullPolicy}}
|
||||||
|
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@@ -71,6 +71,8 @@ spec:
|
|||||||
value: "3000"
|
value: "3000"
|
||||||
- name: ZMQ_CONTROL_PORT
|
- name: ZMQ_CONTROL_PORT
|
||||||
value: "5555"
|
value: "5555"
|
||||||
|
- name: ZMQ_GATEWAY_ENDPOINT
|
||||||
|
value: "tcp://gateway.default.svc.cluster.local:5571"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- name: mcp
|
- name: mcp
|
||||||
@@ -101,14 +103,14 @@ spec:
|
|||||||
|
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /ready
|
path: /health
|
||||||
port: mcp
|
port: mcp
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
|
||||||
- name: lifecycle-sidecar
|
- name: lifecycle-sidecar
|
||||||
image: {{sidecarImage}}
|
image: {{sidecarImage}}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: {{imagePullPolicy}}
|
||||||
|
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: agent
|
- name: agent
|
||||||
image: {{agentImage}}
|
image: {{agentImage}}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: {{imagePullPolicy}}
|
||||||
|
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@@ -71,6 +71,8 @@ spec:
|
|||||||
value: "3000"
|
value: "3000"
|
||||||
- name: ZMQ_CONTROL_PORT
|
- name: ZMQ_CONTROL_PORT
|
||||||
value: "5555"
|
value: "5555"
|
||||||
|
- name: ZMQ_GATEWAY_ENDPOINT
|
||||||
|
value: "tcp://gateway.default.svc.cluster.local:5571"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- name: mcp
|
- name: mcp
|
||||||
@@ -101,14 +103,14 @@ spec:
|
|||||||
|
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /ready
|
path: /health
|
||||||
port: mcp
|
port: mcp
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
|
||||||
- name: lifecycle-sidecar
|
- name: lifecycle-sidecar
|
||||||
image: {{sidecarImage}}
|
image: {{sidecarImage}}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: {{imagePullPolicy}}
|
||||||
|
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -19,11 +19,33 @@ export interface ModelConfig {
|
|||||||
maxTokens?: number;
|
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
|
* Provider configuration with API keys
|
||||||
*/
|
*/
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
anthropicApiKey?: string;
|
anthropicApiKey?: string;
|
||||||
|
defaultModel?: ModelConfig;
|
||||||
|
licenseModels?: LicenseModelsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,15 +99,26 @@ export class LLMProviderFactory {
|
|||||||
* Get default model based on environment
|
* Get default model based on environment
|
||||||
*/
|
*/
|
||||||
getDefaultModel(): ModelConfig {
|
getDefaultModel(): ModelConfig {
|
||||||
|
if (this.config.defaultModel) {
|
||||||
|
return this.config.defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.config.anthropicApiKey) {
|
if (!this.config.anthropicApiKey) {
|
||||||
throw new Error('Anthropic API key not configured');
|
throw new Error('Anthropic API key not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: LLMProvider.ANTHROPIC,
|
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 = {
|
export const MODELS = {
|
||||||
CLAUDE_SONNET: {
|
CLAUDE_SONNET: {
|
||||||
provider: LLMProvider.ANTHROPIC,
|
provider: LLMProvider.ANTHROPIC,
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
model: 'claude-sonnet-4-6',
|
||||||
},
|
},
|
||||||
CLAUDE_HAIKU: {
|
CLAUDE_HAIKU: {
|
||||||
provider: LLMProvider.ANTHROPIC,
|
provider: LLMProvider.ANTHROPIC,
|
||||||
model: 'claude-3-5-haiku-20241022',
|
model: 'claude-haiku-4-5-20251001',
|
||||||
},
|
},
|
||||||
CLAUDE_OPUS: {
|
CLAUDE_OPUS: {
|
||||||
provider: LLMProvider.ANTHROPIC,
|
provider: LLMProvider.ANTHROPIC,
|
||||||
model: 'claude-3-opus-20240229',
|
model: 'claude-opus-4-6',
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, ModelConfig>;
|
} as const satisfies Record<string, ModelConfig>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
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';
|
import type { UserLicense } from '../types/user.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,11 +25,13 @@ export class ModelRouter {
|
|||||||
private factory: LLMProviderFactory;
|
private factory: LLMProviderFactory;
|
||||||
private logger: FastifyBaseLogger;
|
private logger: FastifyBaseLogger;
|
||||||
private defaultModel: ModelConfig;
|
private defaultModel: ModelConfig;
|
||||||
|
private licenseModels?: LicenseModelsConfig;
|
||||||
|
|
||||||
constructor(factory: LLMProviderFactory, logger: FastifyBaseLogger) {
|
constructor(factory: LLMProviderFactory, logger: FastifyBaseLogger) {
|
||||||
this.factory = factory;
|
this.factory = factory;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.defaultModel = factory.getDefaultModel();
|
this.defaultModel = factory.getDefaultModel();
|
||||||
|
this.licenseModels = factory.getLicenseModelsConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,37 +99,53 @@ export class ModelRouter {
|
|||||||
private routeByComplexity(message: string, license: UserLicense): ModelConfig {
|
private routeByComplexity(message: string, license: UserLicense): ModelConfig {
|
||||||
const isComplex = this.isComplexQuery(message);
|
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') {
|
if (license.licenseType === 'enterprise') {
|
||||||
// Enterprise users get best models for complex queries
|
|
||||||
return isComplex
|
return isComplex
|
||||||
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-opus-20240229' }
|
? { provider: LLMProvider.ANTHROPIC, model: 'claude-opus-4-6' }
|
||||||
: { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
|
: { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.licenseType === 'pro') {
|
if (license.licenseType === 'pro') {
|
||||||
// Pro users get good models
|
|
||||||
return isComplex
|
return isComplex
|
||||||
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }
|
? { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' }
|
||||||
: { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
|
: { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free users get efficient models
|
return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
|
||||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route based on license tier
|
* Route based on license tier
|
||||||
*/
|
*/
|
||||||
private routeByLicenseTier(license: UserLicense): ModelConfig {
|
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) {
|
switch (license.licenseType) {
|
||||||
case 'enterprise':
|
case 'enterprise':
|
||||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
|
return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
|
||||||
|
|
||||||
case 'pro':
|
case 'pro':
|
||||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
|
return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
|
||||||
|
|
||||||
case 'free':
|
case 'free':
|
||||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
|
return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return this.defaultModel;
|
return this.defaultModel;
|
||||||
@@ -137,24 +155,50 @@ export class ModelRouter {
|
|||||||
/**
|
/**
|
||||||
* Route to cheapest available model
|
* Route to cheapest available model
|
||||||
*/
|
*/
|
||||||
private routeByCost(_license: UserLicense): ModelConfig {
|
private routeByCost(license: UserLicense): ModelConfig {
|
||||||
// All tiers: use Haiku for cost efficiency
|
// Use configuration if available
|
||||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
|
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
|
* Check if model is allowed for user's license
|
||||||
*/
|
*/
|
||||||
private isModelAllowed(model: ModelConfig, license: UserLicense): boolean {
|
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') {
|
if (license.licenseType === 'free') {
|
||||||
const allowedModels = ['claude-3-5-haiku-20241022'];
|
const allowedModels = ['claude-haiku-4-5-20251001'];
|
||||||
return allowedModels.includes(model.model);
|
return allowedModels.includes(model.model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pro: all except Opus
|
|
||||||
if (license.licenseType === 'pro') {
|
if (license.licenseType === 'pro') {
|
||||||
const blockedModels = ['claude-3-opus-20240229'];
|
const blockedModels = ['claude-opus-4-6'];
|
||||||
return !blockedModels.includes(model.model);
|
return !blockedModels.includes(model.model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,12 +85,35 @@ function loadConfig() {
|
|||||||
// Authentication configuration
|
// Authentication configuration
|
||||||
authSecret: secretsData.auth?.secret || process.env.AUTH_SECRET || 'change-me-in-production',
|
authSecret: secretsData.auth?.secret || process.env.AUTH_SECRET || 'change-me-in-production',
|
||||||
|
|
||||||
// LLM provider API keys
|
// LLM provider API keys and model configuration
|
||||||
providerConfig: {
|
providerConfig: {
|
||||||
anthropicApiKey: secretsData.llm_providers?.anthropic_api_key || process.env.ANTHROPIC_API_KEY,
|
anthropicApiKey: secretsData.llm_providers?.anthropic_api_key || process.env.ANTHROPIC_API_KEY,
|
||||||
openaiApiKey: secretsData.llm_providers?.openai_api_key || process.env.OPENAI_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,
|
googleApiKey: secretsData.llm_providers?.google_api_key || process.env.GOOGLE_API_KEY,
|
||||||
openrouterApiKey: secretsData.llm_providers?.openrouter_api_key || process.env.OPENROUTER_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 || '',
|
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',
|
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',
|
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',
|
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,
|
agentImage: config.kubernetes.agentImage,
|
||||||
sidecarImage: config.kubernetes.sidecarImage,
|
sidecarImage: config.kubernetes.sidecarImage,
|
||||||
storageClass: config.kubernetes.storageClass,
|
storageClass: config.kubernetes.storageClass,
|
||||||
|
imagePullPolicy: config.kubernetes.imagePullPolicy,
|
||||||
namespace: config.kubernetes.namespace,
|
namespace: config.kubernetes.namespace,
|
||||||
logger: app.log,
|
logger: app.log,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export type {
|
|||||||
PathTriggerHandler,
|
PathTriggerHandler,
|
||||||
PathTriggerContext,
|
PathTriggerContext,
|
||||||
ChartState,
|
ChartState,
|
||||||
ChartStore,
|
ShapesStore,
|
||||||
|
IndicatorsStore,
|
||||||
ChannelState,
|
ChannelState,
|
||||||
ChannelInfo,
|
ChannelInfo,
|
||||||
WorkspaceStores,
|
WorkspaceStores,
|
||||||
|
|||||||
@@ -404,4 +404,28 @@ export class SyncRegistry {
|
|||||||
}
|
}
|
||||||
return states;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,17 +84,19 @@ export const DEFAULT_STORES: StoreConfig[] = [
|
|||||||
symbol: 'BINANCE:BTC/USDT',
|
symbol: 'BINANCE:BTC/USDT',
|
||||||
start_time: null,
|
start_time: null,
|
||||||
end_time: null,
|
end_time: null,
|
||||||
interval: '15',
|
period: '15',
|
||||||
selected_shapes: [],
|
selected_shapes: [],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'chartStore',
|
name: 'shapes',
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initialState: () => ({
|
initialState: () => ({}),
|
||||||
drawings: {},
|
},
|
||||||
templates: {},
|
{
|
||||||
}),
|
name: 'indicators',
|
||||||
|
persistent: true,
|
||||||
|
initialState: () => ({}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'channelState',
|
name: 'channelState',
|
||||||
@@ -198,22 +200,70 @@ export interface PathTrigger {
|
|||||||
*/
|
*/
|
||||||
export interface ChartState {
|
export interface ChartState {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
start_time: number | null;
|
start_time: number | null; // unix timestamp
|
||||||
end_time: number | null;
|
end_time: number | null; // unix timestamp
|
||||||
interval: string;
|
period: string; // OHLC duration (e.g., '15' for 15 minutes)
|
||||||
selected_shapes: string[];
|
selected_shapes: string[]; // list of shape ID's
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chart store - persistent, stores drawings and templates.
|
* Control point for shapes (drawings/annotations).
|
||||||
*/
|
*/
|
||||||
export interface ChartStore {
|
export interface ControlPoint {
|
||||||
drawings: Record<string, unknown>;
|
time: number; // unix timestamp
|
||||||
templates: Record<string, unknown>;
|
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<string, any>;
|
||||||
|
symbol?: string;
|
||||||
|
created_at?: number;
|
||||||
|
modified_at?: number;
|
||||||
|
original_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes store - persistent, stores TradingView drawings and annotations.
|
||||||
|
*/
|
||||||
|
export type ShapesStore = Record<string, Shape>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicator instance on TradingView chart.
|
||||||
|
*/
|
||||||
|
export interface IndicatorInstance {
|
||||||
|
id: string;
|
||||||
|
talib_name: string;
|
||||||
|
instance_name: string;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
tv_study_id?: string;
|
||||||
|
tv_indicator_name?: string;
|
||||||
|
tv_inputs?: Record<string, any>;
|
||||||
|
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<string, IndicatorInstance>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel state - transient, tracks connected channels.
|
* Channel state - transient, tracks connected channels.
|
||||||
|
* NOTE: This store is gateway-only and should NOT be synced to web clients.
|
||||||
*/
|
*/
|
||||||
export interface ChannelState {
|
export interface ChannelState {
|
||||||
connected: Record<string, ChannelInfo>;
|
connected: Record<string, ChannelInfo>;
|
||||||
@@ -233,7 +283,8 @@ export interface ChannelInfo {
|
|||||||
*/
|
*/
|
||||||
export interface WorkspaceStores {
|
export interface WorkspaceStores {
|
||||||
chartState: ChartState;
|
chartState: ChartState;
|
||||||
chartStore: ChartStore;
|
shapes: ShapesStore;
|
||||||
|
indicators: IndicatorsStore;
|
||||||
channelState: ChannelState;
|
channelState: ChannelState;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,6 +271,53 @@ export class WorkspaceManager {
|
|||||||
return this.registry.getStoreNames();
|
return this.registry.getStoreNames();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize entire workspace state as JSON.
|
||||||
|
*/
|
||||||
|
serializeState(): string {
|
||||||
|
const state: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
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<string, JsonPatchOp[]> {
|
||||||
|
const changes: Record<string, JsonPatchOp[]> = {};
|
||||||
|
|
||||||
|
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
|
// Path Triggers
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import SplitterPanel from 'primevue/splitterpanel'
|
|||||||
import ChartView from './components/ChartView.vue'
|
import ChartView from './components/ChartView.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
import LoginScreen from './components/LoginScreen.vue'
|
import LoginScreen from './components/LoginScreen.vue'
|
||||||
import { useOrderStore } from './stores/orders'
|
|
||||||
import { useChartStore } from './stores/chart'
|
import { useChartStore } from './stores/chart'
|
||||||
import { useShapeStore } from './stores/shapes'
|
import { useShapeStore } from './stores/shapes'
|
||||||
import { useIndicatorStore } from './stores/indicators'
|
import { useIndicatorStore } from './stores/indicators'
|
||||||
|
import { useChannelStore } from './stores/channel'
|
||||||
import { useStateSync } from './composables/useStateSync'
|
import { useStateSync } from './composables/useStateSync'
|
||||||
import { wsManager } from './composables/useWebSocket'
|
import { wsManager } from './composables/useWebSocket'
|
||||||
import { authService } from './composables/useAuth'
|
import { authService } from './composables/useAuth'
|
||||||
@@ -30,10 +30,10 @@ const chartStore = useChartStore()
|
|||||||
watch(isMobile, (mobile) => {
|
watch(isMobile, (mobile) => {
|
||||||
if (mobile) {
|
if (mobile) {
|
||||||
// Set all chart state to null when chart is hidden
|
// Set all chart state to null when chart is hidden
|
||||||
chartStore.chart_state.symbol = null as any
|
chartStore.symbol = null as any
|
||||||
chartStore.chart_state.start_time = null
|
chartStore.start_time = null
|
||||||
chartStore.chart_state.end_time = null
|
chartStore.end_time = null
|
||||||
chartStore.chart_state.interval = null as any
|
chartStore.period = null as any
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,15 +90,15 @@ const handleAuthenticate = async (email: string, password: string) => {
|
|||||||
|
|
||||||
const initializeApp = async () => {
|
const initializeApp = async () => {
|
||||||
// Initialize state sync after successful authentication
|
// Initialize state sync after successful authentication
|
||||||
const orderStore = useOrderStore()
|
|
||||||
const chartStore = useChartStore()
|
const chartStore = useChartStore()
|
||||||
const shapeStore = useShapeStore()
|
const shapeStore = useShapeStore()
|
||||||
const indicatorStore = useIndicatorStore()
|
const indicatorStore = useIndicatorStore()
|
||||||
|
const channelStore = useChannelStore()
|
||||||
const stateSync = useStateSync({
|
const stateSync = useStateSync({
|
||||||
OrderStore: orderStore,
|
chartState: chartStore,
|
||||||
ChartStore: chartStore,
|
shapes: shapeStore,
|
||||||
ShapeStore: shapeStore,
|
indicators: indicatorStore,
|
||||||
IndicatorStore: indicatorStore
|
channelState: channelStore
|
||||||
})
|
})
|
||||||
stateSyncCleanup = stateSync.cleanup
|
stateSyncCleanup = stateSync.cleanup
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ onMounted(() => {
|
|||||||
datafeed = createTradingViewDatafeed()
|
datafeed = createTradingViewDatafeed()
|
||||||
|
|
||||||
tvWidget = new window.TradingView.widget({
|
tvWidget = new window.TradingView.widget({
|
||||||
symbol: chartStore.chart_state.symbol, // Use symbol from store
|
symbol: chartStore.symbol, // Use symbol from store
|
||||||
datafeed: datafeed,
|
datafeed: datafeed,
|
||||||
interval: chartStore.chart_state.interval as any,
|
interval: chartStore.period as any,
|
||||||
container: chartContainer.value!,
|
container: chartContainer.value!,
|
||||||
library_path: '/charting_library/',
|
library_path: '/charting_library/',
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
@@ -167,8 +167,8 @@ function initializeVisibleRange() {
|
|||||||
to: new Date(endTime * 1000).toISOString()
|
to: new Date(endTime * 1000).toISOString()
|
||||||
})
|
})
|
||||||
|
|
||||||
chartStore.chart_state.start_time = startTime
|
chartStore.start_time = startTime
|
||||||
chartStore.chart_state.end_time = endTime
|
chartStore.end_time = endTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,16 +183,16 @@ function setupChartListeners() {
|
|||||||
if (symbolInfo && symbolInfo.ticker) {
|
if (symbolInfo && symbolInfo.ticker) {
|
||||||
console.log('[ChartView] Symbol changed to:', symbolInfo.ticker)
|
console.log('[ChartView] Symbol changed to:', symbolInfo.ticker)
|
||||||
isUpdatingFromChart = true
|
isUpdatingFromChart = true
|
||||||
chartStore.chart_state.symbol = symbolInfo.ticker
|
chartStore.symbol = symbolInfo.ticker
|
||||||
isUpdatingFromChart = false
|
isUpdatingFromChart = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen for interval changes
|
// Listen for period changes
|
||||||
chart.onIntervalChanged().subscribe(null, (interval: string) => {
|
chart.onIntervalChanged().subscribe(null, (interval: string) => {
|
||||||
console.log('[ChartView] Interval changed to:', interval)
|
console.log('[ChartView] Period changed to:', interval)
|
||||||
isUpdatingFromChart = true
|
isUpdatingFromChart = true
|
||||||
chartStore.chart_state.interval = interval
|
chartStore.period = interval
|
||||||
isUpdatingFromChart = false
|
isUpdatingFromChart = false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,8 +210,8 @@ function setupChartListeners() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
isUpdatingFromChart = true
|
isUpdatingFromChart = true
|
||||||
chartStore.chart_state.start_time = startTime
|
chartStore.start_time = startTime
|
||||||
chartStore.chart_state.end_time = endTime
|
chartStore.end_time = endTime
|
||||||
isUpdatingFromChart = false
|
isUpdatingFromChart = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -224,7 +224,7 @@ function setupStoreWatchers() {
|
|||||||
|
|
||||||
// Watch for external changes to symbol (e.g., from backend/agent)
|
// Watch for external changes to symbol (e.g., from backend/agent)
|
||||||
watch(
|
watch(
|
||||||
() => chartStore.chart_state.symbol,
|
() => chartStore.symbol,
|
||||||
(newSymbol) => {
|
(newSymbol) => {
|
||||||
if (isUpdatingFromChart) return // Ignore updates that came from the chart itself
|
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(
|
watch(
|
||||||
() => chartStore.chart_state.interval,
|
() => chartStore.period,
|
||||||
(newInterval) => {
|
(newPeriod) => {
|
||||||
if (isUpdatingFromChart) return
|
if (isUpdatingFromChart) return
|
||||||
|
|
||||||
console.log('[ChartView] Store interval changed externally to:', newInterval)
|
console.log('[ChartView] Store period changed externally to:', newPeriod)
|
||||||
if (chart.resolution() !== newInterval) {
|
if (chart.resolution() !== newPeriod) {
|
||||||
chart.setResolution(newInterval, () => {
|
chart.setResolution(newPeriod, () => {
|
||||||
console.log('[ChartView] Chart interval updated to:', newInterval)
|
console.log('[ChartView] Chart period updated to:', newPeriod)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
|
|
||||||
// If studyId is actually an object, extract the real values
|
// If studyId is actually an object, extract the real values
|
||||||
let actualStudyId = studyId
|
let actualStudyId = studyId
|
||||||
@@ -539,7 +539,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
|
|
||||||
if (studyId && typeof studyId === 'string') {
|
if (studyId && typeof studyId === 'string') {
|
||||||
console.log('[Indicators] Study properties changed for ID:', studyId)
|
console.log('[Indicators] Study properties changed for ID:', studyId)
|
||||||
@@ -671,7 +671,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
|
|||||||
const chart = tvWidget.activeChart()
|
const chart = tvWidget.activeChart()
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
const allStudies = chart.getAllStudies()
|
const allStudies = chart.getAllStudies()
|
||||||
|
|
||||||
if (!allStudies) return
|
if (!allStudies) return
|
||||||
@@ -755,7 +755,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
|
|
||||||
// Find added indicators
|
// Find added indicators
|
||||||
for (const [id, indicator] of Object.entries(newIndicators)) {
|
for (const [id, indicator] of Object.entries(newIndicators)) {
|
||||||
@@ -815,7 +815,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
|
|||||||
const chart = tvWidget.activeChart()
|
const chart = tvWidget.activeChart()
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
const tvName = ALL_BACKEND_TO_TV_NAMES[indicator.talib_name]
|
const tvName = ALL_BACKEND_TO_TV_NAMES[indicator.talib_name]
|
||||||
|
|
||||||
if (!tvName) {
|
if (!tvName) {
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
|
|||||||
const chart = tvWidget.activeChart()
|
const chart = tvWidget.activeChart()
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
|
|
||||||
if (eventType === 'remove') {
|
if (eventType === 'remove') {
|
||||||
isUpdatingStore = true
|
isUpdatingStore = true
|
||||||
@@ -394,7 +394,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
|
|||||||
|
|
||||||
if (!allShapes) return
|
if (!allShapes) return
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
|
|
||||||
// Track which shape IDs we've seen
|
// Track which shape IDs we've seen
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
@@ -471,7 +471,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
|
|||||||
const chart = tvWidget.activeChart()
|
const chart = tvWidget.activeChart()
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
|
|
||||||
// Find added shapes
|
// Find added shapes
|
||||||
for (const [id, shape] of Object.entries(newShapes)) {
|
for (const [id, shape] of Object.entries(newShapes)) {
|
||||||
@@ -530,11 +530,11 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
|
|||||||
const chart = tvWidget.activeChart()
|
const chart = tvWidget.activeChart()
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
|
|
||||||
const currentSymbol = chartStore.chart_state.symbol
|
const currentSymbol = chartStore.symbol
|
||||||
|
|
||||||
// Get current chart interval and convert to seconds for timestamp canonicalization
|
// Get current chart period and convert to seconds for timestamp canonicalization
|
||||||
const interval = chartStore.chart_state.interval
|
const period = chartStore.period
|
||||||
const intervalSeconds = intervalToSeconds(interval)
|
const intervalSeconds = intervalToSeconds(period)
|
||||||
|
|
||||||
// Convert points to TradingView format and canonicalize timestamps to candle boundaries
|
// Convert points to TradingView format and canonicalize timestamps to candle boundaries
|
||||||
const tvPoints = shape.points.map(p => {
|
const tvPoints = shape.points.map(p => {
|
||||||
@@ -715,7 +715,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
|
|||||||
.map((obj: any) => obj.id)
|
.map((obj: any) => obj.id)
|
||||||
|
|
||||||
console.log('[TradingView Shapes] Selection changed:', selectedShapeIds)
|
console.log('[TradingView Shapes] Selection changed:', selectedShapeIds)
|
||||||
chartStore.chart_state.selected_shapes = selectedShapeIds
|
chartStore.selected_shapes = selectedShapeIds
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ class WebSocketManager {
|
|||||||
public isConnected = ref(false)
|
public isConnected = ref(false)
|
||||||
public isAuthenticated = ref(false)
|
public isAuthenticated = ref(false)
|
||||||
private token: string | null = null
|
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
|
* Connect to WebSocket with JWT token for authentication
|
||||||
@@ -63,6 +68,7 @@ class WebSocketManager {
|
|||||||
console.log('[WebSocket] Connected successfully')
|
console.log('[WebSocket] Connected successfully')
|
||||||
this.isConnected.value = true
|
this.isConnected.value = true
|
||||||
this.isAuthenticated.value = false // Wait for 'connected' message from server
|
this.isAuthenticated.value = false // Wait for 'connected' message from server
|
||||||
|
this.reconnectAttempts = 0 // Reset reconnection counter
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +82,8 @@ class WebSocketManager {
|
|||||||
if (message.type === 'connected') {
|
if (message.type === 'connected') {
|
||||||
console.log('[WebSocket] Received connected message, marking as authenticated')
|
console.log('[WebSocket] Received connected message, marking as authenticated')
|
||||||
this.isAuthenticated.value = true
|
this.isAuthenticated.value = true
|
||||||
|
// Flush any queued messages now that we're authenticated
|
||||||
|
this.flushMessageQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass to all handlers
|
// Pass to all handlers
|
||||||
@@ -96,6 +104,11 @@ class WebSocketManager {
|
|||||||
this.isConnected.value = false
|
this.isConnected.value = false
|
||||||
this.isAuthenticated.value = false
|
this.isAuthenticated.value = false
|
||||||
console.log('WebSocket disconnected:', event.code, event.reason)
|
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
|
// Connection timeout
|
||||||
@@ -117,19 +130,69 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
send(message: WebSocketMessage) {
|
send(message: WebSocketMessage) {
|
||||||
console.log('[WebSocket] Attempting to send message:', message.type, 'readyState:', this.ws?.readyState)
|
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
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))
|
this.ws.send(JSON.stringify(message))
|
||||||
} else {
|
} 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() {
|
disconnect() {
|
||||||
if (this.reconnectTimeout) {
|
if (this.reconnectTimeout) {
|
||||||
clearTimeout(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) {
|
if (this.ws) {
|
||||||
this.ws.close()
|
this.ws.close()
|
||||||
this.ws = null
|
this.ws = null
|
||||||
|
|||||||
24
web/src/stores/channel.ts
Normal file
24
web/src/stores/channel.ts
Normal file
@@ -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<string, ChannelInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChannelStore = defineStore('channelState', () => {
|
||||||
|
const connected = ref<Record<string, ChannelInfo>>({})
|
||||||
|
|
||||||
|
return { connected }
|
||||||
|
})
|
||||||
@@ -5,18 +5,16 @@ export interface ChartState {
|
|||||||
symbol: string
|
symbol: string
|
||||||
start_time: number | null
|
start_time: number | null
|
||||||
end_time: number | null
|
end_time: number | null
|
||||||
interval: string
|
period: string
|
||||||
selected_shapes: string[]
|
selected_shapes: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChartStore = defineStore('ChartStore', () => {
|
export const useChartStore = defineStore('chartState', () => {
|
||||||
const chart_state = ref<ChartState>({
|
const symbol = ref<string>('BINANCE:BTC/USDT')
|
||||||
symbol: 'BINANCE:BTC/USDT',
|
const start_time = ref<number | null>(null)
|
||||||
start_time: null,
|
const end_time = ref<number | null>(null)
|
||||||
end_time: null,
|
const period = ref<string>('15')
|
||||||
interval: '15',
|
const selected_shapes = ref<string[]>([])
|
||||||
selected_shapes: []
|
|
||||||
})
|
|
||||||
|
|
||||||
return { chart_state }
|
return { symbol, start_time, end_time, period, selected_shapes }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface IndicatorInstance {
|
|||||||
original_id?: string
|
original_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useIndicatorStore = defineStore('IndicatorStore', () => {
|
export const useIndicatorStore = defineStore('indicators', () => {
|
||||||
const indicators = ref<Record<string, IndicatorInstance>>({})
|
const indicators = ref<Record<string, IndicatorInstance>>({})
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface Shape {
|
|||||||
original_id?: string // Original ID from backend/agent before TradingView assigns its own ID
|
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<Record<string, Shape>>({})
|
const shapes = ref<Record<string, Shape>>({})
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
|
|||||||
Reference in New Issue
Block a user