container lifecycle management

This commit is contained in:
2026-03-12 15:13:38 -04:00
parent e99ef5d2dd
commit b9cc397e05
61 changed files with 6880 additions and 31 deletions

9
gateway/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.env
.env.*
!.env.example
*.log
.git
.gitignore
README.md

39
gateway/.env.example Normal file
View File

@@ -0,0 +1,39 @@
# Server configuration
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
CORS_ORIGIN=*
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dexorder
# LLM Provider API Keys (configure at least one)
# Anthropic Claude
ANTHROPIC_API_KEY=sk-ant-xxxxx
# OpenAI GPT
OPENAI_API_KEY=sk-xxxxx
# Google Gemini
GOOGLE_API_KEY=xxxxx
# OpenRouter (access to 300+ models with one key)
OPENROUTER_API_KEY=sk-or-xxxxx
# Default model (if user has no preference)
DEFAULT_MODEL_PROVIDER=anthropic
DEFAULT_MODEL=claude-3-5-sonnet-20241022
# Telegram (optional)
TELEGRAM_BOT_TOKEN=
# Kubernetes configuration
KUBERNETES_NAMESPACE=dexorder-agents
KUBERNETES_IN_CLUSTER=false
KUBERNETES_CONTEXT=minikube
AGENT_IMAGE=ghcr.io/dexorder/agent:latest
SIDECAR_IMAGE=ghcr.io/dexorder/lifecycle-sidecar:latest
AGENT_STORAGE_CLASS=standard
# Redis (for session management - future)
# REDIS_URL=redis://localhost:6379

6
gateway/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
*.log
.DS_Store

313
gateway/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,313 @@
# Gateway Architecture: LangChain.js + LangGraph
## Why LangChain.js (Not Vercel AI SDK or Direct Anthropic SDK)?
### The Decision
After evaluating Vercel AI SDK and LangChain.js, we chose **LangChain.js + LangGraph** for these reasons:
1. **Multi-model support**: 300+ models via OpenRouter, plus direct integrations
2. **Complex workflows**: LangGraph for stateful trading analysis pipelines
3. **No vendor lock-in**: Switch between Anthropic, OpenAI, Google with one line
4. **Streaming**: Same as Vercel AI SDK (`.stream()` method)
5. **Tool calling**: Unified across all providers
6. **Trading-specific**: State management, conditional branching, human-in-the-loop
**We don't need Vercel AI SDK because:**
- ❌ We use Vue (not React) - don't need React hooks
- ❌ We have Node.js servers (not edge) - don't need edge runtime
-**DO need** complex workflows (strategy analysis, backtesting, approvals)
-**DO need** stateful execution (resume from failures)
---
## Architecture Layers
### Layer 1: Model Abstraction (`src/llm/`)
**Provider Factory** (`provider.ts`)
```typescript
const factory = new LLMProviderFactory(config, logger);
// Create any model
const claude = factory.createModel({
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
});
const gpt4 = factory.createModel({
provider: 'openai',
model: 'gpt-4o',
});
```
**Model Router** (`router.ts`)
```typescript
const router = new ModelRouter(factory, logger);
// Intelligently route based on:
// - User license (free → Gemini Flash, pro → GPT-4, enterprise → Claude)
// - Query complexity (simple → cheap, complex → smart)
// - User preference (if set in license.preferredModel)
// - Cost optimization (always use cheapest)
const model = await router.route(
message.content,
userLicense,
RoutingStrategy.COMPLEXITY
);
```
---
### Layer 2: Agent Harness (`src/harness/`)
**Stateless Orchestrator**
The harness has **ZERO conversation state**. Everything lives in user's MCP container.
**Flow:**
```typescript
async handleMessage(message: InboundMessage) {
// 1. Fetch context from user's MCP (resources, not tools)
const resources = await mcpClient.listResources();
const context = await Promise.all([
mcpClient.readResource('context://user-profile'), // Trading style
mcpClient.readResource('context://conversation-summary'), // RAG summary
mcpClient.readResource('context://workspace-state'), // Current chart
mcpClient.readResource('context://system-prompt'), // Custom instructions
]);
// 2. Route to appropriate model
const model = await modelRouter.route(message, license);
// 3. Build messages with embedded context
const messages = buildLangChainMessages(systemPrompt, context);
// 4. Call LLM
const response = await model.invoke(messages);
// 5. Save to user's MCP (tool call)
await mcpClient.callTool('save_message', { role: 'user', content: message });
await mcpClient.callTool('save_message', { role: 'assistant', content: response });
return response;
}
```
**Streaming variant:**
```typescript
async *streamMessage(message: InboundMessage) {
const model = await modelRouter.route(message, license);
const messages = buildMessages(context, message);
const stream = await model.stream(messages);
let fullResponse = '';
for await (const chunk of stream) {
fullResponse += chunk.content;
yield chunk.content; // Stream to WebSocket/Telegram
}
// Save after streaming completes
await mcpClient.callTool('save_message', { /* ... */ });
}
```
---
### Layer 3: Workflows (`src/workflows/`)
**LangGraph for Complex Trading Analysis**
```typescript
// Example: Strategy Analysis Pipeline
const workflow = new StateGraph(StrategyAnalysisState)
.addNode('code_review', async (state) => {
const model = new ChatAnthropic({ model: 'claude-3-opus' });
const review = await model.invoke(`Review: ${state.strategyCode}`);
return { codeReview: review.content };
})
.addNode('backtest', async (state) => {
// Call user's MCP backtest tool
const results = await mcpClient.callTool('run_backtest', {
strategy: state.strategyCode,
ticker: state.ticker,
});
return { backtestResults: results };
})
.addNode('risk_assessment', async (state) => {
const model = new ChatAnthropic({ model: 'claude-3-5-sonnet' });
const assessment = await model.invoke(
`Analyze risk: ${JSON.stringify(state.backtestResults)}`
);
return { riskAssessment: assessment.content };
})
.addNode('human_approval', async (state) => {
// Pause for user review (human-in-the-loop)
return { humanApproved: await waitForUserApproval(state) };
})
.addConditionalEdges('human_approval', (state) => {
return state.humanApproved ? 'deploy' : 'reject';
})
.compile();
// Execute
const result = await workflow.invoke({
strategyCode: userCode,
ticker: 'BTC/USDT',
timeframe: '1h',
});
```
**Benefits:**
- **Stateful**: Resume if server crashes mid-analysis
- **Conditional**: Route based on results (if Sharpe > 2 → deploy, else → reject)
- **Human-in-the-loop**: Pause for user approval
- **Multi-step**: Each node can use different models
---
## User Context Architecture
### MCP Resources (Not Tools)
**User's MCP server exposes resources** (read-only context):
```
context://user-profile → Trading style, preferences
context://conversation-summary → RAG-generated summary
context://workspace-state → Current chart, positions
context://system-prompt → User's custom AI instructions
```
**Gateway fetches and embeds in LLM call:**
```typescript
const userProfile = await mcpClient.readResource('context://user-profile');
const conversationSummary = await mcpClient.readResource('context://conversation-summary');
// User's MCP server runs RAG search and returns summary
// Gateway embeds this in Claude/GPT prompt
```
**Why resources, not tools?**
- Resources = context injection (read-only)
- Tools = actions (write operations)
- Context should be fetched **before** LLM call, not during
---
## Model Routing Strategies
### 1. User Preference
```typescript
// User's license has preferred model
{
"preferredModel": {
"provider": "anthropic",
"model": "claude-3-5-sonnet-20241022"
}
}
// Router uses this if set
```
### 2. Complexity-Based
```typescript
const isComplex = message.includes('backtest') || message.length > 200;
if (isComplex) {
return { provider: 'anthropic', model: 'claude-3-opus' }; // Smart
} else {
return { provider: 'openai', model: 'gpt-4o-mini' }; // Fast
}
```
### 3. License Tier
```typescript
switch (license.licenseType) {
case 'free':
return { provider: 'google', model: 'gemini-2.0-flash-exp' }; // Cheap
case 'pro':
return { provider: 'openai', model: 'gpt-4o' }; // Balanced
case 'enterprise':
return { provider: 'anthropic', model: 'claude-3-5-sonnet' }; // Premium
}
```
### 4. Cost-Optimized
```typescript
return { provider: 'google', model: 'gemini-2.0-flash-exp' }; // Always cheapest
```
---
## When to Use What
### Simple Chat → Agent Harness
```typescript
// User: "What's the RSI on BTC?"
// → Fast streaming response via harness.streamMessage()
```
### Complex Analysis → LangGraph Workflow
```typescript
// User: "Analyze this strategy and backtest it"
// → Multi-step workflow: code review → backtest → risk → approval
```
### Direct Tool Call → MCP Client
```typescript
// User: "Get my watchlist"
// → Direct MCP tool call, no LLM needed
```
---
## Data Flow
```
User Message ("Analyze my strategy")
Gateway → Route to workflow (not harness)
LangGraph Workflow:
├─ Node 1: Code Review (Claude Opus)
│ └─ Analyzes strategy code
├─ Node 2: Backtest (MCP tool call)
│ └─ User's container runs backtest
├─ Node 3: Risk Assessment (Claude Sonnet)
│ └─ Evaluates results
├─ Node 4: Human Approval (pause)
│ └─ User reviews in UI
└─ Node 5: Recommendation (GPT-4o-mini)
└─ Final decision
Result → Return to user
```
---
## Benefits Summary
| Feature | LangChain.js | Vercel AI SDK | Direct Anthropic SDK |
|---------|--------------|---------------|----------------------|
| Multi-model | ✅ 300+ models | ✅ 100+ models | ❌ Anthropic only |
| Streaming | ✅ `.stream()` | ✅ `streamText()` | ✅ `.stream()` |
| Tool calling | ✅ Unified | ✅ Unified | ✅ Anthropic format |
| Complex workflows | ✅ LangGraph | ❌ Limited | ❌ DIY |
| Stateful agents | ✅ LangGraph | ❌ No | ❌ No |
| Human-in-the-loop | ✅ LangGraph | ❌ No | ❌ No |
| React hooks | ❌ N/A | ✅ `useChat()` | ❌ N/A |
| Bundle size | Large (101kb) | Small (30kb) | Medium (60kb) |
| **Dexorder needs** | **✅ Perfect fit** | **❌ Missing workflows** | **❌ Vendor lock-in** |
---
## Next Steps
1. **Implement tool calling** in agent harness (bind MCP tools to LangChain)
2. **Add state persistence** for LangGraph (PostgreSQL checkpointer)
3. **Build more workflows**: market scanner, portfolio optimizer
4. **Add monitoring**: Track model usage, costs, latency
5. **User container**: Implement Python MCP server with resources

40
gateway/Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY tsconfig.json ./
# Install dependencies
RUN npm ci
# Copy source
COPY src ./src
# Build
RUN npm run build
# Production image
FROM node:22-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --omit=dev
# Copy built application
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
USER nodejs
EXPOSE 3000
CMD ["node", "dist/main.js"]

212
gateway/README.md Normal file
View File

@@ -0,0 +1,212 @@
# Dexorder Gateway
Multi-channel gateway with agent harness for the Dexorder AI platform.
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Platform Gateway │
│ (Node.js/Fastify) │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Channels │ │
│ │ - WebSocket (/ws/chat) │ │
│ │ - Telegram Webhook (/webhook/telegram) │ │
│ └────────────────────────────────────────────────┘ │
│ ↕ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Authenticator │ │
│ │ - JWT verification (WebSocket) │ │
│ │ - Channel linking (Telegram) │ │
│ │ - User license lookup (PostgreSQL) │ │
│ └────────────────────────────────────────────────┘ │
│ ↕ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Agent Harness (per-session) │ │
│ │ - Claude API integration │ │
│ │ - MCP client connector │ │
│ │ - Conversation state │ │
│ └────────────────────────────────────────────────┘ │
│ ↕ │
│ ┌────────────────────────────────────────────────┐ │
│ │ MCP Client │ │
│ │ - User container connection │ │
│ │ - Tool routing │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌───────────────────────────────┐
│ User MCP Server (Python) │
│ - Strategies, indicators │
│ - Memory, preferences │
│ - Backtest sandbox │
└───────────────────────────────────┘
```
## Features
- **Automatic container provisioning**: Creates user agent containers on-demand via Kubernetes
- **Multi-channel support**: WebSocket and Telegram webhooks
- **Per-channel authentication**: JWT for web, channel linking for chat apps
- **User license management**: Feature flags and resource limits from PostgreSQL
- **Container lifecycle management**: Auto-shutdown on idle (handled by container sidecar)
- **License-based resources**: Different memory/CPU/storage limits per tier
- **Multi-model LLM support**: Anthropic Claude, OpenAI GPT, Google Gemini, OpenRouter (300+ models)
- **Zero vendor lock-in**: Switch models with one line, powered by LangChain.js
- **Intelligent routing**: Auto-select models based on complexity, license tier, or user preference
- **Streaming responses**: Real-time chat with WebSocket and Telegram
- **Complex workflows**: LangGraph for stateful trading analysis (backtest → risk → approval)
- **Agent harness**: Stateless orchestrator (all context lives in user's MCP container)
- **MCP resource integration**: User's RAG, conversation history, and preferences
## Container Management
When a user authenticates, the gateway:
1. **Checks for existing container**: Queries Kubernetes for deployment
2. **Creates if missing**: Renders YAML template based on license tier
3. **Waits for ready**: Polls deployment status until healthy
4. **Returns MCP endpoint**: Computed from service name
5. **Connects to MCP server**: Proceeds with normal authentication flow
Container templates by license tier:
| Tier | Memory | CPU | Storage | Idle Timeout |
|------|--------|-----|---------|--------------|
| Free | 512Mi | 500m | 1Gi | 15min |
| Pro | 2Gi | 2000m | 10Gi | 60min |
| Enterprise | 4Gi | 4000m | 50Gi | Never |
Containers self-manage their lifecycle using the lifecycle sidecar (see `../lifecycle-sidecar/`)
## Setup
### Prerequisites
- Node.js >= 22.0.0
- PostgreSQL database
- At least one LLM provider API key:
- Anthropic Claude
- OpenAI GPT
- Google Gemini
- OpenRouter (one key for 300+ models)
### Development
1. Install dependencies:
```bash
npm install
```
2. Copy environment template:
```bash
cp .env.example .env
```
3. Configure `.env` (see `.env.example`):
```bash
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dexorder
# Configure at least one provider
ANTHROPIC_API_KEY=sk-ant-xxxxx
# OPENAI_API_KEY=sk-xxxxx
# GOOGLE_API_KEY=xxxxx
# OPENROUTER_API_KEY=sk-or-xxxxx
# Optional: Set default model
DEFAULT_MODEL_PROVIDER=anthropic
DEFAULT_MODEL=claude-3-5-sonnet-20241022
```
4. Run development server:
```bash
npm run dev
```
### Production Build
```bash
npm run build
npm start
```
### Docker
```bash
docker build -t dexorder/gateway:latest .
docker run -p 3000:3000 --env-file .env dexorder/gateway:latest
```
## Database Schema
Required PostgreSQL tables (will be documented separately):
### `user_licenses`
- `user_id` (text, primary key)
- `email` (text)
- `license_type` (text: 'free', 'pro', 'enterprise')
- `features` (jsonb)
- `resource_limits` (jsonb)
- `mcp_server_url` (text)
- `expires_at` (timestamp, nullable)
- `created_at` (timestamp)
- `updated_at` (timestamp)
### `user_channel_links`
- `id` (serial, primary key)
- `user_id` (text, foreign key)
- `channel_type` (text: 'telegram', 'slack', 'discord')
- `channel_user_id` (text)
- `created_at` (timestamp)
## API Endpoints
### WebSocket
**`GET /ws/chat`**
- WebSocket connection for web client
- Auth: Bearer token in headers
- Protocol: JSON messages
Example:
```javascript
const ws = new WebSocket('ws://localhost:3000/ws/chat', {
headers: {
'Authorization': 'Bearer your-jwt-token'
}
});
ws.on('message', (data) => {
const msg = JSON.parse(data);
console.log(msg);
});
ws.send(JSON.stringify({
type: 'message',
content: 'Hello, AI!'
}));
```
### Telegram Webhook
**`POST /webhook/telegram`**
- Telegram bot webhook endpoint
- Auth: Telegram user linked to platform user
- Automatically processes incoming messages
### Health Check
**`GET /health`**
- Returns server health status
## TODO
- [ ] Implement JWT verification with JWKS
- [ ] Implement MCP HTTP/SSE transport
- [ ] Add Redis for session persistence
- [ ] Add rate limiting per user license
- [ ] Add message usage tracking
- [ ] Add streaming responses for WebSocket
- [ ] Add Slack and Discord channel handlers
- [ ] Add session cleanup/timeout logic

42
gateway/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@dexorder/gateway",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Multi-channel gateway with agent harness for Dexorder AI platform",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/websocket": "^11.0.1",
"@kubernetes/client-node": "^0.21.0",
"@langchain/anthropic": "^0.3.8",
"@langchain/core": "^0.3.24",
"@langchain/google-genai": "^0.1.6",
"@langchain/langgraph": "^0.2.26",
"@langchain/openai": "^0.3.21",
"@langchain/openrouter": "^0.1.2",
"@modelcontextprotocol/sdk": "^1.0.4",
"fastify": "^5.2.0",
"ioredis": "^5.4.2",
"js-yaml": "^4.1.0",
"pg": "^8.13.1",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=22.0.0"
}
}

79
gateway/schema.sql Normal file
View File

@@ -0,0 +1,79 @@
-- User license and authorization schema
CREATE TABLE IF NOT EXISTS user_licenses (
user_id TEXT PRIMARY KEY,
email TEXT,
license_type TEXT NOT NULL CHECK (license_type IN ('free', 'pro', 'enterprise')),
features JSONB NOT NULL DEFAULT '{
"maxIndicators": 5,
"maxStrategies": 3,
"maxBacktestDays": 30,
"realtimeData": false,
"customExecutors": false,
"apiAccess": false
}',
resource_limits JSONB NOT NULL DEFAULT '{
"maxConcurrentSessions": 1,
"maxMessagesPerDay": 100,
"maxTokensPerMessage": 4096,
"rateLimitPerMinute": 10
}',
mcp_server_url TEXT NOT NULL,
preferred_model JSONB DEFAULT NULL,
expires_at TIMESTAMP WITH TIME ZONE,
created_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}';
CREATE INDEX idx_user_licenses_expires_at ON user_licenses(expires_at)
WHERE expires_at IS NOT NULL;
-- Channel linking for multi-channel support
CREATE TABLE IF NOT EXISTS user_channel_links (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES user_licenses(user_id) ON DELETE CASCADE,
channel_type TEXT NOT NULL CHECK (channel_type IN ('telegram', 'slack', 'discord', 'websocket')),
channel_user_id TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(channel_type, channel_user_id)
);
CREATE INDEX idx_user_channel_links_user_id ON user_channel_links(user_id);
CREATE INDEX idx_user_channel_links_channel ON user_channel_links(channel_type, channel_user_id);
-- Example data for development
INSERT INTO user_licenses (user_id, email, license_type, mcp_server_url, features, resource_limits, preferred_model)
VALUES (
'dev-user-001',
'dev@example.com',
'pro',
'http://localhost:8080/mcp',
'{
"maxIndicators": 50,
"maxStrategies": 20,
"maxBacktestDays": 365,
"realtimeData": true,
"customExecutors": true,
"apiAccess": true
}',
'{
"maxConcurrentSessions": 5,
"maxMessagesPerDay": 1000,
"maxTokensPerMessage": 8192,
"rateLimitPerMinute": 60
}',
'{
"provider": "anthropic",
"model": "claude-3-5-sonnet-20241022",
"temperature": 0.7
}'
)
ON CONFLICT (user_id) DO NOTHING;
-- Example Telegram link
INSERT INTO user_channel_links (user_id, channel_type, channel_user_id)
VALUES ('dev-user-001', 'telegram', '123456789')
ON CONFLICT (channel_type, channel_user_id) DO NOTHING;

View File

@@ -0,0 +1,146 @@
import type { FastifyRequest, FastifyBaseLogger } from 'fastify';
import { UserService } from '../db/user-service.js';
import { ChannelType, type AuthContext } from '../types/user.js';
import type { ContainerManager } from '../k8s/container-manager.js';
export interface AuthenticatorConfig {
userService: UserService;
containerManager: ContainerManager;
logger: FastifyBaseLogger;
}
/**
* Multi-channel authenticator
* Handles authentication for WebSocket, Telegram, and other channels
*/
export class Authenticator {
private config: AuthenticatorConfig;
constructor(config: AuthenticatorConfig) {
this.config = config;
}
/**
* Authenticate WebSocket connection via JWT token
* Also ensures the user's container is running
*/
async authenticateWebSocket(
request: FastifyRequest
): Promise<AuthContext | null> {
try {
const token = this.extractBearerToken(request);
if (!token) {
this.config.logger.warn('No bearer token in WebSocket connection');
return null;
}
const userId = await this.config.userService.verifyWebToken(token);
if (!userId) {
this.config.logger.warn('Invalid JWT token');
return null;
}
const license = await this.config.userService.getUserLicense(userId);
if (!license) {
this.config.logger.warn({ userId }, 'User license not found');
return null;
}
// Ensure container is running (may take time if creating new container)
this.config.logger.info({ userId }, 'Ensuring user container is running');
const { mcpEndpoint, wasCreated } = await this.config.containerManager.ensureContainerRunning(
userId,
license
);
this.config.logger.info(
{ userId, mcpEndpoint, wasCreated },
'Container is ready'
);
// Update license with actual MCP endpoint
license.mcpServerUrl = mcpEndpoint;
const sessionId = `ws_${userId}_${Date.now()}`;
return {
userId,
channelType: ChannelType.WEBSOCKET,
channelUserId: userId, // For WebSocket, same as userId
sessionId,
license,
authenticatedAt: new Date(),
};
} catch (error) {
this.config.logger.error({ error }, 'WebSocket authentication error');
return null;
}
}
/**
* Authenticate Telegram webhook
* Also ensures the user's container is running
*/
async authenticateTelegram(telegramUserId: string): Promise<AuthContext | null> {
try {
const userId = await this.config.userService.getUserIdFromChannel(
'telegram',
telegramUserId
);
if (!userId) {
this.config.logger.warn(
{ telegramUserId },
'Telegram user not linked to platform user'
);
return null;
}
const license = await this.config.userService.getUserLicense(userId);
if (!license) {
this.config.logger.warn({ userId }, 'User license not found');
return null;
}
// Ensure container is running
this.config.logger.info({ userId }, 'Ensuring user container is running');
const { mcpEndpoint, wasCreated } = await this.config.containerManager.ensureContainerRunning(
userId,
license
);
this.config.logger.info(
{ userId, mcpEndpoint, wasCreated },
'Container is ready'
);
// Update license with actual MCP endpoint
license.mcpServerUrl = mcpEndpoint;
const sessionId = `tg_${telegramUserId}_${Date.now()}`;
return {
userId,
channelType: ChannelType.TELEGRAM,
channelUserId: telegramUserId,
sessionId,
license,
authenticatedAt: new Date(),
};
} catch (error) {
this.config.logger.error({ error }, 'Telegram authentication error');
return null;
}
}
/**
* Extract bearer token from request headers
*/
private extractBearerToken(request: FastifyRequest): string | null {
const auth = request.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
return null;
}
return auth.substring(7);
}
}

View File

@@ -0,0 +1,163 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { Authenticator } from '../auth/authenticator.js';
import { AgentHarness } from '../harness/agent-harness.js';
import type { InboundMessage } from '../types/messages.js';
import { randomUUID } from 'crypto';
import type { ProviderConfig } from '../llm/provider.js';
export interface TelegramHandlerConfig {
authenticator: Authenticator;
providerConfig: ProviderConfig;
telegramBotToken: string;
}
interface TelegramUpdate {
update_id: number;
message?: {
message_id: number;
from: {
id: number;
first_name: string;
username?: string;
};
chat: {
id: number;
type: string;
};
text?: string;
photo?: Array<{
file_id: string;
file_size: number;
}>;
};
}
/**
* Telegram webhook handler
*/
export class TelegramHandler {
private config: TelegramHandlerConfig;
private sessions = new Map<string, AgentHarness>();
constructor(config: TelegramHandlerConfig) {
this.config = config;
}
/**
* Register Telegram webhook routes
*/
register(app: FastifyInstance): void {
app.post('/webhook/telegram', async (request: FastifyRequest, reply: FastifyReply) => {
await this.handleWebhook(request, reply, app);
});
}
/**
* Handle Telegram webhook
*/
private async handleWebhook(
request: FastifyRequest,
reply: FastifyReply,
app: FastifyInstance
): Promise<void> {
const logger = app.log;
try {
const update = request.body as TelegramUpdate;
if (!update.message?.text) {
// Ignore non-text messages for now
reply.code(200).send({ ok: true });
return;
}
const telegramUserId = update.message.from.id.toString();
const chatId = update.message.chat.id;
const text = update.message.text;
logger.info({ telegramUserId, chatId, text }, 'Received Telegram message');
// Authenticate
const authContext = await this.config.authenticator.authenticateTelegram(telegramUserId);
if (!authContext) {
logger.warn({ telegramUserId }, 'Telegram user not authenticated');
await this.sendTelegramMessage(
chatId,
'Please link your Telegram account to Dexorder first.'
);
reply.code(200).send({ ok: true });
return;
}
// Get or create harness
let harness = this.sessions.get(authContext.sessionId);
if (!harness) {
harness = new AgentHarness({
userId: authContext.userId,
sessionId: authContext.sessionId,
license: authContext.license,
providerConfig: this.config.providerConfig,
logger,
});
await harness.initialize();
this.sessions.set(authContext.sessionId, harness);
}
// Process message
const inboundMessage: InboundMessage = {
messageId: randomUUID(),
userId: authContext.userId,
sessionId: authContext.sessionId,
content: text,
timestamp: new Date(),
};
const response = await harness.handleMessage(inboundMessage);
// Send response back to Telegram
await this.sendTelegramMessage(chatId, response.content);
reply.code(200).send({ ok: true });
} catch (error) {
logger.error({ error }, 'Error handling Telegram webhook');
reply.code(500).send({ ok: false, error: 'Internal server error' });
}
}
/**
* Send message to Telegram chat
*/
private async sendTelegramMessage(chatId: number, text: string): Promise<void> {
const url = `https://api.telegram.org/bot${this.config.telegramBotToken}/sendMessage`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'Markdown',
}),
});
if (!response.ok) {
throw new Error(`Telegram API error: ${response.statusText}`);
}
} catch (error) {
console.error('Failed to send Telegram message:', error);
throw error;
}
}
/**
* Cleanup old sessions (call periodically)
*/
async cleanupSessions(maxAgeMs = 30 * 60 * 1000): Promise<void> {
// TODO: Track session last activity and cleanup
// For now, sessions persist until server restart
}
}

View File

@@ -0,0 +1,161 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
import type { Authenticator } from '../auth/authenticator.js';
import { AgentHarness } from '../harness/agent-harness.js';
import type { InboundMessage } from '../types/messages.js';
import { randomUUID } from 'crypto';
import type { ProviderConfig } from '../llm/provider.js';
export interface WebSocketHandlerConfig {
authenticator: Authenticator;
providerConfig: ProviderConfig;
}
/**
* WebSocket channel handler
*/
export class WebSocketHandler {
private config: WebSocketHandlerConfig;
private sessions = new Map<string, AgentHarness>();
constructor(config: WebSocketHandlerConfig) {
this.config = config;
}
/**
* Register WebSocket routes
*/
register(app: FastifyInstance): void {
app.get(
'/ws/chat',
{ websocket: true },
async (socket: WebSocket, request: FastifyRequest) => {
await this.handleConnection(socket, request, app);
}
);
}
/**
* Handle WebSocket connection
*/
private async handleConnection(
socket: WebSocket,
request: FastifyRequest,
app: FastifyInstance
): Promise<void> {
const logger = app.log;
// Send initial connecting message
socket.send(
JSON.stringify({
type: 'status',
status: 'authenticating',
message: 'Authenticating...',
})
);
// Authenticate (this may take time if creating container)
const authContext = await this.config.authenticator.authenticateWebSocket(request);
if (!authContext) {
logger.warn('WebSocket authentication failed');
socket.send(
JSON.stringify({
type: 'error',
message: 'Authentication failed',
})
);
socket.close(1008, 'Authentication failed');
return;
}
logger.info(
{ userId: authContext.userId, sessionId: authContext.sessionId },
'WebSocket connection authenticated'
);
// Send workspace starting message
socket.send(
JSON.stringify({
type: 'status',
status: 'initializing',
message: 'Starting your workspace...',
})
);
// Create agent harness
const harness = new AgentHarness({
userId: authContext.userId,
sessionId: authContext.sessionId,
license: authContext.license,
providerConfig: this.config.providerConfig,
logger,
});
try {
await harness.initialize();
this.sessions.set(authContext.sessionId, harness);
// Send connected message
socket.send(
JSON.stringify({
type: 'connected',
sessionId: authContext.sessionId,
userId: authContext.userId,
licenseType: authContext.license.licenseType,
message: 'Connected to Dexorder AI',
})
);
// Handle messages
socket.on('message', async (data: Buffer) => {
try {
const payload = JSON.parse(data.toString());
if (payload.type === 'message') {
const inboundMessage: InboundMessage = {
messageId: randomUUID(),
userId: authContext.userId,
sessionId: authContext.sessionId,
content: payload.content,
attachments: payload.attachments,
timestamp: new Date(),
};
const response = await harness.handleMessage(inboundMessage);
socket.send(
JSON.stringify({
type: 'message',
...response,
})
);
}
} catch (error) {
logger.error({ error }, 'Error handling WebSocket message');
socket.send(
JSON.stringify({
type: 'error',
message: 'Failed to process message',
})
);
}
});
// Handle disconnection
socket.on('close', async () => {
logger.info({ sessionId: authContext.sessionId }, 'WebSocket disconnected');
await harness.cleanup();
this.sessions.delete(authContext.sessionId);
});
socket.on('error', (error) => {
logger.error({ error, sessionId: authContext.sessionId }, 'WebSocket error');
});
} catch (error) {
logger.error({ error }, 'Failed to initialize agent harness');
socket.close(1011, 'Internal server error');
await harness.cleanup();
}
}
}

View File

@@ -0,0 +1,107 @@
import { Pool, PoolClient } from 'pg';
import type { UserLicense } from '../types/user.js';
import { UserLicenseSchema } from '../types/user.js';
export class UserService {
private pool: Pool;
constructor(connectionString: string) {
this.pool = new Pool({
connectionString,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
}
/**
* Get user license by user ID
*/
async getUserLicense(userId: string): Promise<UserLicense | null> {
const client = await this.pool.connect();
try {
const result = await client.query(
`SELECT
user_id as "userId",
email,
license_type as "licenseType",
features,
resource_limits as "resourceLimits",
mcp_server_url as "mcpServerUrl",
preferred_model as "preferredModel",
expires_at as "expiresAt",
created_at as "createdAt",
updated_at as "updatedAt"
FROM user_licenses
WHERE user_id = $1
AND (expires_at IS NULL OR expires_at > NOW())`,
[userId]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
// Parse and validate
return UserLicenseSchema.parse({
userId: row.userId,
email: row.email,
licenseType: row.licenseType,
features: row.features,
resourceLimits: row.resourceLimits,
mcpServerUrl: row.mcpServerUrl,
preferredModel: row.preferredModel,
expiresAt: row.expiresAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
} finally {
client.release();
}
}
/**
* Get user ID from channel-specific identifier
*/
async getUserIdFromChannel(channelType: string, channelUserId: string): Promise<string | null> {
const client = await this.pool.connect();
try {
const result = await client.query(
`SELECT user_id
FROM user_channel_links
WHERE channel_type = $1 AND channel_user_id = $2`,
[channelType, channelUserId]
);
return result.rows.length > 0 ? result.rows[0].user_id : null;
} finally {
client.release();
}
}
/**
* Verify JWT token from web client
* TODO: Implement JWT verification with JWKS
*/
async verifyWebToken(token: string): Promise<string | null> {
// Placeholder - implement JWT verification
// For now, decode without verification (INSECURE - FOR DEV ONLY)
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString()
);
return payload.sub || null;
} catch {
return null;
}
}
/**
* Close database pool
*/
async close(): Promise<void> {
await this.pool.end();
}
}

View File

@@ -0,0 +1,306 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseMessage } from '@langchain/core/messages';
import { HumanMessage, AIMessage, SystemMessage } from '@langchain/core/messages';
import type { FastifyBaseLogger } from 'fastify';
import type { UserLicense } from '../types/user.js';
import type { InboundMessage, OutboundMessage } from '../types/messages.js';
import { MCPClientConnector } from './mcp-client.js';
import { CONTEXT_URIS, type ResourceContent } from '../types/resources.js';
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
import { ModelRouter, RoutingStrategy } from '../llm/router.js';
export interface AgentHarnessConfig {
userId: string;
sessionId: string;
license: UserLicense;
providerConfig: ProviderConfig;
logger: FastifyBaseLogger;
}
/**
* Agent harness orchestrates between LLM and user's MCP server.
*
* This is a STATELESS orchestrator - all conversation history, RAG, and context
* lives in the user's MCP server container. The harness only:
* 1. Fetches context from user's MCP resources
* 2. Routes to appropriate LLM model
* 3. Calls LLM with embedded context
* 4. Routes tool calls to user's MCP or platform tools
* 5. Saves messages back to user's MCP
*/
export class AgentHarness {
private config: AgentHarnessConfig;
private modelFactory: LLMProviderFactory;
private modelRouter: ModelRouter;
private mcpClient: MCPClientConnector;
constructor(config: AgentHarnessConfig) {
this.config = config;
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
this.mcpClient = new MCPClientConnector({
userId: config.userId,
mcpServerUrl: config.license.mcpServerUrl,
logger: config.logger,
});
}
/**
* Initialize harness and connect to user's MCP server
*/
async initialize(): Promise<void> {
this.config.logger.info(
{ userId: this.config.userId, sessionId: this.config.sessionId },
'Initializing agent harness'
);
try {
await this.mcpClient.connect();
this.config.logger.info('Agent harness initialized');
} catch (error) {
this.config.logger.error({ error }, 'Failed to initialize agent harness');
throw error;
}
}
/**
* Handle incoming message from user
*/
async handleMessage(message: InboundMessage): Promise<OutboundMessage> {
this.config.logger.info(
{ messageId: message.messageId, userId: message.userId },
'Processing user message'
);
try {
// 1. Fetch context resources from user's MCP server
this.config.logger.debug('Fetching context resources from MCP');
const contextResources = await this.fetchContextResources();
// 2. Build system prompt from resources
const systemPrompt = this.buildSystemPrompt(contextResources);
// 3. Build messages with conversation context from MCP
const messages = this.buildMessages(message, contextResources);
// 4. Route to appropriate model
const model = await this.modelRouter.route(
message.content,
this.config.license,
RoutingStrategy.COMPLEXITY
);
// 5. Build LangChain messages
const langchainMessages = this.buildLangChainMessages(systemPrompt, messages);
// 6. Call LLM with streaming
this.config.logger.debug('Invoking LLM');
const response = await model.invoke(langchainMessages);
// 7. Extract text response (tool handling TODO)
const assistantMessage = response.content as string;
// 8. Save messages to user's MCP server
this.config.logger.debug('Saving messages to MCP');
await this.mcpClient.callTool('save_message', {
role: 'user',
content: message.content,
timestamp: message.timestamp.toISOString(),
});
await this.mcpClient.callTool('save_message', {
role: 'assistant',
content: assistantMessage,
timestamp: new Date().toISOString(),
});
return {
messageId: `msg_${Date.now()}`,
sessionId: message.sessionId,
content: assistantMessage,
timestamp: new Date(),
};
} catch (error) {
this.config.logger.error({ error }, 'Error processing message');
throw error;
}
}
/**
* Stream response from LLM
*/
async *streamMessage(message: InboundMessage): AsyncGenerator<string> {
try {
// Fetch context
const contextResources = await this.fetchContextResources();
const systemPrompt = this.buildSystemPrompt(contextResources);
const messages = this.buildMessages(message, contextResources);
// Route to model
const model = await this.modelRouter.route(
message.content,
this.config.license,
RoutingStrategy.COMPLEXITY
);
// Build messages
const langchainMessages = this.buildLangChainMessages(systemPrompt, messages);
// Stream response
const stream = await model.stream(langchainMessages);
let fullResponse = '';
for await (const chunk of stream) {
const content = chunk.content as string;
fullResponse += content;
yield content;
}
// Save after streaming completes
await this.mcpClient.callTool('save_message', {
role: 'user',
content: message.content,
timestamp: message.timestamp.toISOString(),
});
await this.mcpClient.callTool('save_message', {
role: 'assistant',
content: fullResponse,
timestamp: new Date().toISOString(),
});
} catch (error) {
this.config.logger.error({ error }, 'Error streaming message');
throw error;
}
}
/**
* Fetch context resources from user's MCP server
*/
private async fetchContextResources(): Promise<ResourceContent[]> {
const contextUris = [
CONTEXT_URIS.USER_PROFILE,
CONTEXT_URIS.CONVERSATION_SUMMARY,
CONTEXT_URIS.WORKSPACE_STATE,
CONTEXT_URIS.SYSTEM_PROMPT,
];
const resources = await Promise.all(
contextUris.map(async (uri) => {
try {
return await this.mcpClient.readResource(uri);
} catch (error) {
this.config.logger.warn({ error, uri }, 'Failed to fetch resource, using empty');
return { uri, text: '' };
}
})
);
return resources;
}
/**
* Build messages array with context from resources
*/
private buildMessages(
currentMessage: InboundMessage,
contextResources: ResourceContent[]
): Array<{ role: string; content: string }> {
const conversationSummary = contextResources.find(
(r) => r.uri === CONTEXT_URIS.CONVERSATION_SUMMARY
);
const messages: Array<{ role: string; content: string }> = [];
// Add conversation context as a system-like user message
if (conversationSummary?.text) {
messages.push({
role: 'user',
content: `[Previous Conversation Context]\n${conversationSummary.text}`,
});
messages.push({
role: 'assistant',
content: 'I understand the context from our previous conversations.',
});
}
// Add current user message
messages.push({
role: 'user',
content: currentMessage.content,
});
return messages;
}
/**
* Convert to LangChain message format
*/
private buildLangChainMessages(
systemPrompt: string,
messages: Array<{ role: string; content: string }>
): BaseMessage[] {
const langchainMessages: BaseMessage[] = [new SystemMessage(systemPrompt)];
for (const msg of messages) {
if (msg.role === 'user') {
langchainMessages.push(new HumanMessage(msg.content));
} else if (msg.role === 'assistant') {
langchainMessages.push(new AIMessage(msg.content));
}
}
return langchainMessages;
}
/**
* Build system prompt from platform base + user resources
*/
private buildSystemPrompt(contextResources: ResourceContent[]): string {
const userProfile = contextResources.find((r) => r.uri === CONTEXT_URIS.USER_PROFILE);
const customPrompt = contextResources.find((r) => r.uri === CONTEXT_URIS.SYSTEM_PROMPT);
const workspaceState = contextResources.find((r) => r.uri === CONTEXT_URIS.WORKSPACE_STATE);
// Base platform prompt
let prompt = `You are a helpful AI assistant for Dexorder, an AI-first trading platform.
You help users research markets, develop indicators and strategies, and analyze trading data.
User license: ${this.config.license.licenseType}
Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
// Add user profile context
if (userProfile?.text) {
prompt += `\n\n# User Profile\n${userProfile.text}`;
}
// Add workspace context
if (workspaceState?.text) {
prompt += `\n\n# Current Workspace\n${workspaceState.text}`;
}
// Add user's custom instructions (highest priority)
if (customPrompt?.text) {
prompt += `\n\n# User Instructions\n${customPrompt.text}`;
}
return prompt;
}
/**
* Get platform tools (non-user-specific tools)
*/
private getPlatformTools(): Array<{ name: string; description?: string }> {
// Platform tools that don't need user's MCP
return [
// TODO: Add platform tools like market data queries, chart rendering, etc.
];
}
/**
* Cleanup resources
*/
async cleanup(): Promise<void> {
this.config.logger.info('Cleaning up agent harness');
await this.mcpClient.disconnect();
}
}

View File

@@ -0,0 +1,259 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import type { FastifyBaseLogger } from 'fastify';
export interface MCPClientConfig {
userId: string;
mcpServerUrl: string;
platformJWT?: string;
logger: FastifyBaseLogger;
}
/**
* MCP client connector for user's container
* Manages connection to user-specific MCP server
*/
export class MCPClientConnector {
private client: Client | null = null;
private connected = false;
private config: MCPClientConfig;
constructor(config: MCPClientConfig) {
this.config = config;
}
/**
* Connect to user's MCP server
* TODO: Implement HTTP/SSE transport instead of stdio for container communication
*/
async connect(): Promise<void> {
if (this.connected) {
return;
}
try {
this.config.logger.info(
{ userId: this.config.userId, url: this.config.mcpServerUrl },
'Connecting to user MCP server'
);
this.client = new Client(
{
name: 'dexorder-gateway',
version: '0.1.0',
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// TODO: Replace with HTTP transport when user containers are ready
// For now, this is a placeholder structure
// const transport = new HTTPTransport(this.config.mcpServerUrl, {
// headers: {
// 'Authorization': `Bearer ${this.config.platformJWT}`
// }
// });
// Placeholder: will be replaced with actual container transport
this.config.logger.warn(
'MCP transport not yet implemented - using placeholder'
);
this.connected = true;
this.config.logger.info('Connected to user MCP server');
} catch (error) {
this.config.logger.error(
{ error, userId: this.config.userId },
'Failed to connect to user MCP server'
);
throw error;
}
}
/**
* Call a tool on the user's MCP server
*/
async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
if (!this.client || !this.connected) {
throw new Error('MCP client not connected');
}
try {
this.config.logger.debug({ tool: name, args }, 'Calling MCP tool');
// TODO: Implement when MCP client is connected
// const result = await this.client.callTool({ name, arguments: args });
// return result;
// Placeholder response
return { success: true, message: 'MCP tool call placeholder' };
} catch (error) {
this.config.logger.error({ error, tool: name }, 'MCP tool call failed');
throw error;
}
}
/**
* List available tools from user's MCP server
*/
async listTools(): Promise<Array<{ name: string; description?: string }>> {
if (!this.client || !this.connected) {
throw new Error('MCP client not connected');
}
try {
// TODO: Implement when MCP client is connected
// const tools = await this.client.listTools();
// return tools;
// Placeholder tools (actions only, not context)
return [
{ name: 'save_message', description: 'Save message to conversation history' },
{ name: 'list_strategies', description: 'List user strategies' },
{ name: 'read_strategy', description: 'Read strategy code' },
{ name: 'write_strategy', description: 'Write strategy code' },
{ name: 'run_backtest', description: 'Run backtest on strategy' },
{ name: 'get_watchlist', description: 'Get user watchlist' },
{ name: 'execute_trade', description: 'Execute trade' },
];
} catch (error) {
this.config.logger.error({ error }, 'Failed to list MCP tools');
throw error;
}
}
/**
* List available resources from user's MCP server
*/
async listResources(): Promise<Array<{ uri: string; name: string; description?: string; mimeType?: string }>> {
if (!this.client || !this.connected) {
throw new Error('MCP client not connected');
}
try {
// TODO: Implement when MCP client is connected
// const resources = await this.client.listResources();
// return resources;
// Placeholder resources for user context
return [
{
uri: 'context://user-profile',
name: 'User Profile',
description: 'User trading style, preferences, and background',
mimeType: 'text/plain',
},
{
uri: 'context://conversation-summary',
name: 'Conversation Summary',
description: 'Semantic summary of recent conversation history with RAG',
mimeType: 'text/plain',
},
{
uri: 'context://workspace-state',
name: 'Workspace State',
description: 'Current chart, watchlist, and open positions',
mimeType: 'application/json',
},
{
uri: 'context://system-prompt',
name: 'Custom System Prompt',
description: 'User custom instructions for the assistant',
mimeType: 'text/plain',
},
];
} catch (error) {
this.config.logger.error({ error }, 'Failed to list MCP resources');
throw error;
}
}
/**
* Read a resource from user's MCP server
*/
async readResource(uri: string): Promise<{ uri: string; mimeType?: string; text?: string; blob?: string }> {
if (!this.client || !this.connected) {
throw new Error('MCP client not connected');
}
try {
this.config.logger.debug({ uri }, 'Reading MCP resource');
// TODO: Implement when MCP client is connected
// const resource = await this.client.readResource({ uri });
// return resource;
// Placeholder resource content
if (uri === 'context://user-profile') {
return {
uri,
mimeType: 'text/plain',
text: `User Profile:
- Trading experience: Intermediate
- Preferred timeframes: 1h, 4h, 1d
- Risk tolerance: Medium
- Focus: Swing trading with technical indicators`,
};
} else if (uri === 'context://conversation-summary') {
return {
uri,
mimeType: 'text/plain',
text: `Recent Conversation Summary:
[RAG-generated summary would go here]
User recently discussed:
- Moving average crossover strategies
- Backtesting on BTC/USDT
- Risk management techniques`,
};
} else if (uri === 'context://workspace-state') {
return {
uri,
mimeType: 'application/json',
text: JSON.stringify({
currentChart: { ticker: 'BINANCE:BTC/USDT', timeframe: '1h' },
watchlist: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'],
openPositions: [],
}, null, 2),
};
} else if (uri === 'context://system-prompt') {
return {
uri,
mimeType: 'text/plain',
text: `Custom Instructions:
- Be concise and data-driven
- Always show risk/reward ratios
- Prefer simple strategies over complex ones`,
};
}
return { uri, text: '' };
} catch (error) {
this.config.logger.error({ error, uri }, 'MCP resource read failed');
throw error;
}
}
/**
* Disconnect from MCP server
*/
async disconnect(): Promise<void> {
if (this.client && this.connected) {
try {
await this.client.close();
this.connected = false;
this.config.logger.info('Disconnected from user MCP server');
} catch (error) {
this.config.logger.error({ error }, 'Error disconnecting from MCP server');
}
}
}
isConnected(): boolean {
return this.connected;
}
}

327
gateway/src/k8s/client.ts Normal file
View File

@@ -0,0 +1,327 @@
import * as k8s from '@kubernetes/client-node';
import type { FastifyBaseLogger } from 'fastify';
import * as yaml from 'js-yaml';
import * as fs from 'fs/promises';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export interface K8sClientConfig {
namespace: string;
inCluster: boolean;
context?: string; // For local dev
logger: FastifyBaseLogger;
}
export interface DeploymentSpec {
userId: string;
licenseType: 'free' | 'pro' | 'enterprise';
agentImage: string;
sidecarImage: string;
storageClass: string;
}
/**
* Kubernetes client wrapper for managing agent deployments
*/
export class KubernetesClient {
private config: K8sClientConfig;
private k8sConfig: k8s.KubeConfig;
private appsApi: k8s.AppsV1Api;
private coreApi: k8s.CoreV1Api;
constructor(config: K8sClientConfig) {
this.config = config;
this.k8sConfig = new k8s.KubeConfig();
if (config.inCluster) {
this.k8sConfig.loadFromCluster();
this.config.logger.info('Loaded in-cluster Kubernetes config');
} else {
this.k8sConfig.loadFromDefault();
if (config.context) {
this.k8sConfig.setCurrentContext(config.context);
this.config.logger.info({ context: config.context }, 'Set Kubernetes context');
}
this.config.logger.info('Loaded Kubernetes config from default location');
}
this.appsApi = this.k8sConfig.makeApiClient(k8s.AppsV1Api);
this.coreApi = this.k8sConfig.makeApiClient(k8s.CoreV1Api);
}
/**
* Generate deployment name from user ID
*/
static getDeploymentName(userId: string): string {
// Sanitize userId to be k8s-compliant (lowercase alphanumeric + hyphens)
const sanitized = userId.toLowerCase().replace(/[^a-z0-9-]/g, '-');
return `agent-${sanitized}`;
}
/**
* Generate service name (same as deployment)
*/
static getServiceName(userId: string): string {
return this.getDeploymentName(userId);
}
/**
* Generate PVC name
*/
static getPvcName(userId: string): string {
return `${this.getDeploymentName(userId)}-data`;
}
/**
* Compute MCP endpoint URL from service name
*/
static getMcpEndpoint(userId: string, namespace: string): string {
const serviceName = this.getServiceName(userId);
return `http://${serviceName}.${namespace}.svc.cluster.local:3000`;
}
/**
* Check if deployment exists
*/
async deploymentExists(deploymentName: string): Promise<boolean> {
try {
await this.appsApi.readNamespacedDeployment(deploymentName, this.config.namespace);
return true;
} catch (error: any) {
if (error.response?.statusCode === 404) {
return false;
}
throw error;
}
}
/**
* Create agent deployment from template
*/
async createAgentDeployment(spec: DeploymentSpec): Promise<void> {
const deploymentName = KubernetesClient.getDeploymentName(spec.userId);
const serviceName = KubernetesClient.getServiceName(spec.userId);
const pvcName = KubernetesClient.getPvcName(spec.userId);
this.config.logger.info(
{ userId: spec.userId, licenseType: spec.licenseType, deploymentName },
'Creating agent deployment'
);
// Load template based on license type
const templatePath = path.join(
__dirname,
'templates',
`${spec.licenseType}-tier.yaml`
);
const templateContent = await fs.readFile(templatePath, 'utf-8');
// Substitute variables
const rendered = templateContent
.replace(/\{\{userId\}\}/g, spec.userId)
.replace(/\{\{deploymentName\}\}/g, deploymentName)
.replace(/\{\{serviceName\}\}/g, serviceName)
.replace(/\{\{pvcName\}\}/g, pvcName)
.replace(/\{\{agentImage\}\}/g, spec.agentImage)
.replace(/\{\{sidecarImage\}\}/g, spec.sidecarImage)
.replace(/\{\{storageClass\}\}/g, spec.storageClass);
// Parse YAML documents (deployment, pvc, service)
const documents = yaml.loadAll(rendered) as any[];
// Apply each resource
for (const doc of documents) {
if (!doc || !doc.kind) continue;
try {
switch (doc.kind) {
case 'Deployment':
await this.appsApi.createNamespacedDeployment(this.config.namespace, doc);
this.config.logger.info({ deploymentName }, 'Created deployment');
break;
case 'PersistentVolumeClaim':
await this.coreApi.createNamespacedPersistentVolumeClaim(
this.config.namespace,
doc
);
this.config.logger.info({ pvcName }, 'Created PVC');
break;
case 'Service':
await this.coreApi.createNamespacedService(this.config.namespace, doc);
this.config.logger.info({ serviceName }, 'Created service');
break;
default:
this.config.logger.warn({ kind: doc.kind }, 'Unknown resource kind in template');
}
} catch (error: any) {
// If resource already exists, log warning but continue
if (error.response?.statusCode === 409) {
this.config.logger.warn(
{ kind: doc.kind, name: doc.metadata?.name },
'Resource already exists, skipping'
);
} else {
throw error;
}
}
}
this.config.logger.info({ deploymentName }, 'Agent deployment created successfully');
}
/**
* Wait for deployment to be ready
*/
async waitForDeploymentReady(
deploymentName: string,
timeoutMs: number = 120000
): Promise<boolean> {
const startTime = Date.now();
const pollInterval = 2000; // 2 seconds
this.config.logger.info(
{ deploymentName, timeoutMs },
'Waiting for deployment to be ready'
);
while (Date.now() - startTime < timeoutMs) {
try {
const response = await this.appsApi.readNamespacedDeployment(
deploymentName,
this.config.namespace
);
const deployment = response.body;
const status = deployment.status;
// Check if deployment is ready
if (
status?.availableReplicas &&
status.availableReplicas > 0 &&
status.readyReplicas &&
status.readyReplicas > 0
) {
this.config.logger.info({ deploymentName }, 'Deployment is ready');
return true;
}
// Check for failure conditions
if (status?.conditions) {
const failedCondition = status.conditions.find(
(c) => c.type === 'Progressing' && c.status === 'False'
);
if (failedCondition) {
this.config.logger.error(
{ deploymentName, reason: failedCondition.reason, message: failedCondition.message },
'Deployment failed to progress'
);
return false;
}
}
this.config.logger.debug(
{
deploymentName,
replicas: status?.replicas,
ready: status?.readyReplicas,
available: status?.availableReplicas,
},
'Deployment not ready yet, waiting...'
);
await new Promise((resolve) => setTimeout(resolve, pollInterval));
} catch (error: any) {
if (error.response?.statusCode === 404) {
this.config.logger.warn({ deploymentName }, 'Deployment not found');
return false;
}
throw error;
}
}
this.config.logger.warn({ deploymentName, timeoutMs }, 'Deployment readiness timeout');
return false;
}
/**
* Get service endpoint URL
*/
async getServiceEndpoint(serviceName: string): Promise<string | null> {
try {
const response = await this.coreApi.readNamespacedService(
serviceName,
this.config.namespace
);
const service = response.body;
// For ClusterIP services, return internal DNS name
if (service.spec?.type === 'ClusterIP') {
const port = service.spec.ports?.find((p) => p.name === 'mcp')?.port || 3000;
return `http://${serviceName}.${this.config.namespace}.svc.cluster.local:${port}`;
}
// For other service types (NodePort, LoadBalancer), would need different logic
this.config.logger.warn(
{ serviceName, type: service.spec?.type },
'Unexpected service type'
);
return null;
} catch (error: any) {
if (error.response?.statusCode === 404) {
this.config.logger.warn({ serviceName }, 'Service not found');
return null;
}
throw error;
}
}
/**
* Delete deployment and associated resources
* (Used for cleanup/testing - normally handled by lifecycle sidecar)
*/
async deleteAgentDeployment(userId: string): Promise<void> {
const deploymentName = KubernetesClient.getDeploymentName(userId);
const serviceName = KubernetesClient.getServiceName(userId);
const pvcName = KubernetesClient.getPvcName(userId);
this.config.logger.info({ userId, deploymentName }, 'Deleting agent deployment');
// Delete deployment
try {
await this.appsApi.deleteNamespacedDeployment(deploymentName, this.config.namespace);
this.config.logger.info({ deploymentName }, 'Deleted deployment');
} catch (error: any) {
if (error.response?.statusCode !== 404) {
this.config.logger.warn({ deploymentName, error }, 'Failed to delete deployment');
}
}
// Delete service
try {
await this.coreApi.deleteNamespacedService(serviceName, this.config.namespace);
this.config.logger.info({ serviceName }, 'Deleted service');
} catch (error: any) {
if (error.response?.statusCode !== 404) {
this.config.logger.warn({ serviceName, error }, 'Failed to delete service');
}
}
// Delete PVC
try {
await this.coreApi.deleteNamespacedPersistentVolumeClaim(pvcName, this.config.namespace);
this.config.logger.info({ pvcName }, 'Deleted PVC');
} catch (error: any) {
if (error.response?.statusCode !== 404) {
this.config.logger.warn({ pvcName, error }, 'Failed to delete PVC');
}
}
}
}

View File

@@ -0,0 +1,118 @@
import type { FastifyBaseLogger } from 'fastify';
import { KubernetesClient, type DeploymentSpec } from './client.js';
import type { UserLicense } from '../types/user.js';
export interface ContainerManagerConfig {
k8sClient: KubernetesClient;
agentImage: string;
sidecarImage: string;
storageClass: string;
namespace: string;
logger: FastifyBaseLogger;
}
export interface ContainerStatus {
exists: boolean;
ready: boolean;
mcpEndpoint: string;
}
/**
* Container manager orchestrates agent container lifecycle
*/
export class ContainerManager {
private config: ContainerManagerConfig;
constructor(config: ContainerManagerConfig) {
this.config = config;
}
/**
* Ensure user's container is running and ready
* Returns the MCP endpoint URL
*/
async ensureContainerRunning(
userId: string,
license: UserLicense
): Promise<{ mcpEndpoint: string; wasCreated: boolean }> {
const deploymentName = KubernetesClient.getDeploymentName(userId);
const mcpEndpoint = KubernetesClient.getMcpEndpoint(userId, this.config.namespace);
this.config.logger.info(
{ userId, licenseType: license.licenseType, deploymentName },
'Ensuring container is running'
);
// Check if deployment already exists
const exists = await this.config.k8sClient.deploymentExists(deploymentName);
if (exists) {
this.config.logger.info({ userId, deploymentName }, 'Container deployment already exists');
// Wait for it to be ready (in case it's starting up)
const ready = await this.config.k8sClient.waitForDeploymentReady(deploymentName, 30000);
if (!ready) {
this.config.logger.warn(
{ userId, deploymentName },
'Existing deployment not ready within timeout'
);
// Continue anyway - might be an image pull or other transient issue
}
return { mcpEndpoint, wasCreated: false };
}
// Create new deployment
this.config.logger.info({ userId, licenseType: license.licenseType }, 'Creating new container');
const spec: DeploymentSpec = {
userId,
licenseType: license.licenseType,
agentImage: this.config.agentImage,
sidecarImage: this.config.sidecarImage,
storageClass: this.config.storageClass,
};
await this.config.k8sClient.createAgentDeployment(spec);
// Wait for deployment to be ready
const ready = await this.config.k8sClient.waitForDeploymentReady(deploymentName, 120000);
if (!ready) {
throw new Error(
`Container deployment failed to become ready within timeout: ${deploymentName}`
);
}
this.config.logger.info({ userId, mcpEndpoint }, 'Container is ready');
return { mcpEndpoint, wasCreated: true };
}
/**
* Check container status without creating it
*/
async getContainerStatus(userId: string): Promise<ContainerStatus> {
const deploymentName = KubernetesClient.getDeploymentName(userId);
const mcpEndpoint = KubernetesClient.getMcpEndpoint(userId, this.config.namespace);
const exists = await this.config.k8sClient.deploymentExists(deploymentName);
if (!exists) {
return { exists: false, ready: false, mcpEndpoint };
}
// Check if ready (with short timeout)
const ready = await this.config.k8sClient.waitForDeploymentReady(deploymentName, 5000);
return { exists: true, ready, mcpEndpoint };
}
/**
* Delete container (for cleanup/testing)
*/
async deleteContainer(userId: string): Promise<void> {
await this.config.k8sClient.deleteAgentDeployment(userId);
}
}

View File

@@ -0,0 +1,199 @@
# Enterprise tier agent deployment template
# Variables: {{userId}}, {{deploymentName}}, {{pvcName}}, {{serviceName}}
# Enterprise: No idle shutdown, larger resources
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{deploymentName}}
namespace: dexorder-agents
labels:
app.kubernetes.io/name: agent
app.kubernetes.io/component: user-agent
dexorder.io/component: agent
dexorder.io/user-id: {{userId}}
dexorder.io/deployment: {{deploymentName}}
dexorder.io/license-tier: enterprise
spec:
replicas: 1
selector:
matchLabels:
dexorder.io/user-id: {{userId}}
template:
metadata:
labels:
dexorder.io/component: agent
dexorder.io/user-id: {{userId}}
dexorder.io/deployment: {{deploymentName}}
dexorder.io/license-tier: enterprise
spec:
serviceAccountName: agent-lifecycle
shareProcessNamespace: true
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: agent
image: {{agentImage}}
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "4000m"
env:
- name: USER_ID
value: {{userId}}
- name: IDLE_TIMEOUT_MINUTES
value: "0"
- name: IDLE_CHECK_INTERVAL_SECONDS
value: "60"
- name: ENABLE_IDLE_SHUTDOWN
value: "false"
- name: MCP_SERVER_PORT
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
ports:
- name: mcp
containerPort: 3000
protocol: TCP
- name: zmq-control
containerPort: 5555
protocol: TCP
volumeMounts:
- name: agent-data
mountPath: /app/data
- name: tmp
mountPath: /tmp
- name: shared-run
mountPath: /var/run/agent
livenessProbe:
httpGet:
path: /health
port: mcp
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /ready
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "64Mi"
cpu: "50m"
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: DEPLOYMENT_NAME
valueFrom:
fieldRef:
fieldPath: metadata.labels['dexorder.io/deployment']
- name: USER_TYPE
value: "enterprise"
- name: MAIN_CONTAINER_PID
value: "1"
volumeMounts:
- name: shared-run
mountPath: /var/run/agent
readOnly: true
volumes:
- name: agent-data
persistentVolumeClaim:
claimName: {{pvcName}}
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 512Mi
- name: shared-run
emptyDir:
medium: Memory
sizeLimit: 1Mi
restartPolicy: Always
terminationGracePeriodSeconds: 30
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{pvcName}}
namespace: dexorder-agents
labels:
dexorder.io/user-id: {{userId}}
dexorder.io/license-tier: enterprise
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
storageClassName: {{storageClass}}
---
apiVersion: v1
kind: Service
metadata:
name: {{serviceName}}
namespace: dexorder-agents
labels:
dexorder.io/user-id: {{userId}}
dexorder.io/license-tier: enterprise
spec:
type: ClusterIP
selector:
dexorder.io/user-id: {{userId}}
ports:
- name: mcp
port: 3000
targetPort: mcp
protocol: TCP
- name: zmq-control
port: 5555
targetPort: zmq-control
protocol: TCP

View File

@@ -0,0 +1,198 @@
# Free tier agent deployment template
# Variables: {{userId}}, {{deploymentName}}, {{pvcName}}, {{serviceName}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{deploymentName}}
namespace: dexorder-agents
labels:
app.kubernetes.io/name: agent
app.kubernetes.io/component: user-agent
dexorder.io/component: agent
dexorder.io/user-id: {{userId}}
dexorder.io/deployment: {{deploymentName}}
dexorder.io/license-tier: free
spec:
replicas: 1
selector:
matchLabels:
dexorder.io/user-id: {{userId}}
template:
metadata:
labels:
dexorder.io/component: agent
dexorder.io/user-id: {{userId}}
dexorder.io/deployment: {{deploymentName}}
dexorder.io/license-tier: free
spec:
serviceAccountName: agent-lifecycle
shareProcessNamespace: true
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: agent
image: {{agentImage}}
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
env:
- name: USER_ID
value: {{userId}}
- name: IDLE_TIMEOUT_MINUTES
value: "15"
- name: IDLE_CHECK_INTERVAL_SECONDS
value: "60"
- name: ENABLE_IDLE_SHUTDOWN
value: "true"
- name: MCP_SERVER_PORT
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
ports:
- name: mcp
containerPort: 3000
protocol: TCP
- name: zmq-control
containerPort: 5555
protocol: TCP
volumeMounts:
- name: agent-data
mountPath: /app/data
- name: tmp
mountPath: /tmp
- name: shared-run
mountPath: /var/run/agent
livenessProbe:
httpGet:
path: /health
port: mcp
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /ready
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "64Mi"
cpu: "50m"
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: DEPLOYMENT_NAME
valueFrom:
fieldRef:
fieldPath: metadata.labels['dexorder.io/deployment']
- name: USER_TYPE
value: "free"
- name: MAIN_CONTAINER_PID
value: "1"
volumeMounts:
- name: shared-run
mountPath: /var/run/agent
readOnly: true
volumes:
- name: agent-data
persistentVolumeClaim:
claimName: {{pvcName}}
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 128Mi
- name: shared-run
emptyDir:
medium: Memory
sizeLimit: 1Mi
restartPolicy: Always
terminationGracePeriodSeconds: 30
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{pvcName}}
namespace: dexorder-agents
labels:
dexorder.io/user-id: {{userId}}
dexorder.io/license-tier: free
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: {{storageClass}}
---
apiVersion: v1
kind: Service
metadata:
name: {{serviceName}}
namespace: dexorder-agents
labels:
dexorder.io/user-id: {{userId}}
dexorder.io/license-tier: free
spec:
type: ClusterIP
selector:
dexorder.io/user-id: {{userId}}
ports:
- name: mcp
port: 3000
targetPort: mcp
protocol: TCP
- name: zmq-control
port: 5555
targetPort: zmq-control
protocol: TCP

View File

@@ -0,0 +1,198 @@
# Pro tier agent deployment template
# Variables: {{userId}}, {{deploymentName}}, {{pvcName}}, {{serviceName}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{deploymentName}}
namespace: dexorder-agents
labels:
app.kubernetes.io/name: agent
app.kubernetes.io/component: user-agent
dexorder.io/component: agent
dexorder.io/user-id: {{userId}}
dexorder.io/deployment: {{deploymentName}}
dexorder.io/license-tier: pro
spec:
replicas: 1
selector:
matchLabels:
dexorder.io/user-id: {{userId}}
template:
metadata:
labels:
dexorder.io/component: agent
dexorder.io/user-id: {{userId}}
dexorder.io/deployment: {{deploymentName}}
dexorder.io/license-tier: pro
spec:
serviceAccountName: agent-lifecycle
shareProcessNamespace: true
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: agent
image: {{agentImage}}
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "2000m"
env:
- name: USER_ID
value: {{userId}}
- name: IDLE_TIMEOUT_MINUTES
value: "60"
- name: IDLE_CHECK_INTERVAL_SECONDS
value: "60"
- name: ENABLE_IDLE_SHUTDOWN
value: "true"
- name: MCP_SERVER_PORT
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
ports:
- name: mcp
containerPort: 3000
protocol: TCP
- name: zmq-control
containerPort: 5555
protocol: TCP
volumeMounts:
- name: agent-data
mountPath: /app/data
- name: tmp
mountPath: /tmp
- name: shared-run
mountPath: /var/run/agent
livenessProbe:
httpGet:
path: /health
port: mcp
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /ready
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "64Mi"
cpu: "50m"
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: DEPLOYMENT_NAME
valueFrom:
fieldRef:
fieldPath: metadata.labels['dexorder.io/deployment']
- name: USER_TYPE
value: "pro"
- name: MAIN_CONTAINER_PID
value: "1"
volumeMounts:
- name: shared-run
mountPath: /var/run/agent
readOnly: true
volumes:
- name: agent-data
persistentVolumeClaim:
claimName: {{pvcName}}
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 256Mi
- name: shared-run
emptyDir:
medium: Memory
sizeLimit: 1Mi
restartPolicy: Always
terminationGracePeriodSeconds: 30
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{pvcName}}
namespace: dexorder-agents
labels:
dexorder.io/user-id: {{userId}}
dexorder.io/license-tier: pro
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: {{storageClass}}
---
apiVersion: v1
kind: Service
metadata:
name: {{serviceName}}
namespace: dexorder-agents
labels:
dexorder.io/user-id: {{userId}}
dexorder.io/license-tier: pro
spec:
type: ClusterIP
selector:
dexorder.io/user-id: {{userId}}
ports:
- name: mcp
port: 3000
targetPort: mcp
protocol: TCP
- name: zmq-control
port: 5555
targetPort: zmq-control
protocol: TCP

216
gateway/src/llm/provider.ts Normal file
View File

@@ -0,0 +1,216 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatAnthropic } from '@langchain/anthropic';
import { ChatOpenAI } from '@langchain/openai';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { ChatOpenRouter } from '@langchain/openrouter';
import type { FastifyBaseLogger } from 'fastify';
/**
* Supported LLM providers
*/
export enum LLMProvider {
ANTHROPIC = 'anthropic',
OPENAI = 'openai',
GOOGLE = 'google',
OPENROUTER = 'openrouter',
}
/**
* Model configuration
*/
export interface ModelConfig {
provider: LLMProvider;
model: string;
temperature?: number;
maxTokens?: number;
}
/**
* Provider configuration with API keys
*/
export interface ProviderConfig {
anthropicApiKey?: string;
openaiApiKey?: string;
googleApiKey?: string;
openrouterApiKey?: string;
}
/**
* LLM Provider factory
* Creates model instances with unified interface across providers
*/
export class LLMProviderFactory {
private config: ProviderConfig;
private logger: FastifyBaseLogger;
constructor(config: ProviderConfig, logger: FastifyBaseLogger) {
this.config = config;
this.logger = logger;
}
/**
* Create a chat model instance
*/
createModel(modelConfig: ModelConfig): BaseChatModel {
this.logger.debug(
{ provider: modelConfig.provider, model: modelConfig.model },
'Creating LLM model'
);
switch (modelConfig.provider) {
case LLMProvider.ANTHROPIC:
return this.createAnthropicModel(modelConfig);
case LLMProvider.OPENAI:
return this.createOpenAIModel(modelConfig);
case LLMProvider.GOOGLE:
return this.createGoogleModel(modelConfig);
case LLMProvider.OPENROUTER:
return this.createOpenRouterModel(modelConfig);
default:
throw new Error(`Unsupported provider: ${modelConfig.provider}`);
}
}
/**
* Create Anthropic Claude model
*/
private createAnthropicModel(config: ModelConfig): ChatAnthropic {
if (!this.config.anthropicApiKey) {
throw new Error('Anthropic API key not configured');
}
return new ChatAnthropic({
model: config.model,
temperature: config.temperature ?? 0.7,
maxTokens: config.maxTokens ?? 4096,
anthropicApiKey: this.config.anthropicApiKey,
});
}
/**
* Create OpenAI GPT model
*/
private createOpenAIModel(config: ModelConfig): ChatOpenAI {
if (!this.config.openaiApiKey) {
throw new Error('OpenAI API key not configured');
}
return new ChatOpenAI({
model: config.model,
temperature: config.temperature ?? 0.7,
maxTokens: config.maxTokens ?? 4096,
openAIApiKey: this.config.openaiApiKey,
});
}
/**
* Create Google Gemini model
*/
private createGoogleModel(config: ModelConfig): ChatGoogleGenerativeAI {
if (!this.config.googleApiKey) {
throw new Error('Google API key not configured');
}
return new ChatGoogleGenerativeAI({
model: config.model,
temperature: config.temperature ?? 0.7,
maxOutputTokens: config.maxTokens ?? 4096,
apiKey: this.config.googleApiKey,
});
}
/**
* Create OpenRouter model (access to 300+ models)
*/
private createOpenRouterModel(config: ModelConfig): ChatOpenRouter {
if (!this.config.openrouterApiKey) {
throw new Error('OpenRouter API key not configured');
}
return new ChatOpenRouter({
model: config.model,
temperature: config.temperature ?? 0.7,
maxTokens: config.maxTokens ?? 4096,
apiKey: this.config.openrouterApiKey,
});
}
/**
* Get default model based on environment
*/
getDefaultModel(): ModelConfig {
// Check which API keys are available
if (this.config.anthropicApiKey) {
return {
provider: LLMProvider.ANTHROPIC,
model: 'claude-3-5-sonnet-20241022',
};
}
if (this.config.openaiApiKey) {
return {
provider: LLMProvider.OPENAI,
model: 'gpt-4o',
};
}
if (this.config.googleApiKey) {
return {
provider: LLMProvider.GOOGLE,
model: 'gemini-2.0-flash-exp',
};
}
if (this.config.openrouterApiKey) {
return {
provider: LLMProvider.OPENROUTER,
model: 'anthropic/claude-3.5-sonnet',
};
}
throw new Error('No LLM API keys configured');
}
}
/**
* Predefined model configurations
*/
export const MODELS = {
// Anthropic
CLAUDE_SONNET: {
provider: LLMProvider.ANTHROPIC,
model: 'claude-3-5-sonnet-20241022',
},
CLAUDE_HAIKU: {
provider: LLMProvider.ANTHROPIC,
model: 'claude-3-5-haiku-20241022',
},
CLAUDE_OPUS: {
provider: LLMProvider.ANTHROPIC,
model: 'claude-3-opus-20240229',
},
// OpenAI
GPT4O: {
provider: LLMProvider.OPENAI,
model: 'gpt-4o',
},
GPT4O_MINI: {
provider: LLMProvider.OPENAI,
model: 'gpt-4o-mini',
},
// Google
GEMINI_2_FLASH: {
provider: LLMProvider.GOOGLE,
model: 'gemini-2.0-flash-exp',
},
GEMINI_PRO: {
provider: LLMProvider.GOOGLE,
model: 'gemini-1.5-pro',
},
} as const satisfies Record<string, ModelConfig>;

202
gateway/src/llm/router.ts Normal file
View File

@@ -0,0 +1,202 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { FastifyBaseLogger } from 'fastify';
import { LLMProviderFactory, type ModelConfig, LLMProvider } from './provider.js';
import type { UserLicense } from '../types/user.js';
/**
* Model routing strategies
*/
export enum RoutingStrategy {
/** Use user's preferred model from license */
USER_PREFERENCE = 'user_preference',
/** Route based on query complexity */
COMPLEXITY = 'complexity',
/** Route based on license tier */
LICENSE_TIER = 'license_tier',
/** Use cheapest available model */
COST_OPTIMIZED = 'cost_optimized',
}
/**
* Model router
* Intelligently selects which model to use based on various factors
*/
export class ModelRouter {
private factory: LLMProviderFactory;
private logger: FastifyBaseLogger;
private defaultModel: ModelConfig;
constructor(factory: LLMProviderFactory, logger: FastifyBaseLogger) {
this.factory = factory;
this.logger = logger;
this.defaultModel = factory.getDefaultModel();
}
/**
* Route to appropriate model based on context
*/
async route(
message: string,
license: UserLicense,
strategy: RoutingStrategy = RoutingStrategy.USER_PREFERENCE
): Promise<BaseChatModel> {
let modelConfig: ModelConfig;
switch (strategy) {
case RoutingStrategy.USER_PREFERENCE:
modelConfig = this.routeByUserPreference(license);
break;
case RoutingStrategy.COMPLEXITY:
modelConfig = this.routeByComplexity(message, license);
break;
case RoutingStrategy.LICENSE_TIER:
modelConfig = this.routeByLicenseTier(license);
break;
case RoutingStrategy.COST_OPTIMIZED:
modelConfig = this.routeByCost(license);
break;
default:
modelConfig = this.defaultModel;
}
this.logger.info(
{
userId: license.userId,
strategy,
provider: modelConfig.provider,
model: modelConfig.model,
},
'Routing to model'
);
return this.factory.createModel(modelConfig);
}
/**
* Route based on user's preferred model (if set in license)
*/
private routeByUserPreference(license: UserLicense): ModelConfig {
// Check if user has custom model preference
const preferredModel = (license as any).preferredModel as ModelConfig | undefined;
if (preferredModel && this.isModelAllowed(preferredModel, license)) {
return preferredModel;
}
// Fall back to license tier default
return this.routeByLicenseTier(license);
}
/**
* Route based on query complexity
*/
private routeByComplexity(message: string, license: UserLicense): ModelConfig {
const isComplex = this.isComplexQuery(message);
if (license.licenseType === 'enterprise') {
// Enterprise users get best models for complex queries
return isComplex
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-opus-20240229' }
: { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
}
if (license.licenseType === 'pro') {
// Pro users get good models
return isComplex
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }
: { provider: LLMProvider.OPENAI, model: 'gpt-4o-mini' };
}
// Free users get efficient models
return { provider: LLMProvider.GOOGLE, model: 'gemini-2.0-flash-exp' };
}
/**
* Route based on license tier
*/
private routeByLicenseTier(license: UserLicense): ModelConfig {
switch (license.licenseType) {
case 'enterprise':
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
case 'pro':
return { provider: LLMProvider.OPENAI, model: 'gpt-4o' };
case 'free':
return { provider: LLMProvider.GOOGLE, model: 'gemini-2.0-flash-exp' };
default:
return this.defaultModel;
}
}
/**
* Route to cheapest available model
*/
private routeByCost(license: UserLicense): ModelConfig {
// Free tier: use cheapest
if (license.licenseType === 'free') {
return { provider: LLMProvider.GOOGLE, model: 'gemini-2.0-flash-exp' };
}
// Paid tiers: use GPT-4o-mini for cost efficiency
return { provider: LLMProvider.OPENAI, model: 'gpt-4o-mini' };
}
/**
* Check if model is allowed for user's license
*/
private isModelAllowed(model: ModelConfig, license: UserLicense): boolean {
// Free tier: only cheap models
if (license.licenseType === 'free') {
const allowedModels = ['gemini-2.0-flash-exp', 'gpt-4o-mini', 'claude-3-5-haiku-20241022'];
return allowedModels.includes(model.model);
}
// Pro: all except Opus
if (license.licenseType === 'pro') {
const blockedModels = ['claude-3-opus-20240229'];
return !blockedModels.includes(model.model);
}
// Enterprise: all models allowed
return true;
}
/**
* Determine if query is complex
*/
private isComplexQuery(message: string): boolean {
const complexityIndicators = [
// Multi-step analysis
'backtest',
'analyze',
'compare',
'optimize',
// Code generation
'write',
'create',
'implement',
'build',
// Deep reasoning
'explain why',
'what if',
'how would',
// Long messages (> 200 chars likely complex)
message.length > 200,
];
const messageLower = message.toLowerCase();
return complexityIndicators.some((indicator) =>
typeof indicator === 'string' ? messageLower.includes(indicator) : indicator
);
}
}

154
gateway/src/main.ts Normal file
View File

@@ -0,0 +1,154 @@
import Fastify from 'fastify';
import websocket from '@fastify/websocket';
import cors from '@fastify/cors';
import { UserService } from './db/user-service.js';
import { Authenticator } from './auth/authenticator.js';
import { WebSocketHandler } from './channels/websocket-handler.js';
import { TelegramHandler } from './channels/telegram-handler.js';
import { KubernetesClient } from './k8s/client.js';
import { ContainerManager } from './k8s/container-manager.js';
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
},
},
});
// Configuration from environment
const config = {
port: parseInt(process.env.PORT || '3000'),
host: process.env.HOST || '0.0.0.0',
databaseUrl: process.env.DATABASE_URL || 'postgresql://localhost/dexorder',
// LLM provider API keys
providerConfig: {
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
openaiApiKey: process.env.OPENAI_API_KEY,
googleApiKey: process.env.GOOGLE_API_KEY,
openrouterApiKey: process.env.OPENROUTER_API_KEY,
},
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
// Kubernetes configuration
kubernetes: {
namespace: process.env.KUBERNETES_NAMESPACE || 'dexorder-agents',
inCluster: process.env.KUBERNETES_IN_CLUSTER === 'true',
context: process.env.KUBERNETES_CONTEXT,
agentImage: process.env.AGENT_IMAGE || 'ghcr.io/dexorder/agent:latest',
sidecarImage: process.env.SIDECAR_IMAGE || 'ghcr.io/dexorder/lifecycle-sidecar:latest',
storageClass: process.env.AGENT_STORAGE_CLASS || 'standard',
},
};
// Validate at least one LLM provider is configured
const hasAnyProvider = Object.values(config.providerConfig).some(key => !!key);
if (!hasAnyProvider) {
app.log.error('At least one LLM provider API key is required (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, or OPENROUTER_API_KEY)');
process.exit(1);
}
// Register plugins
await app.register(cors, {
origin: process.env.CORS_ORIGIN || '*',
});
await app.register(websocket, {
options: {
maxPayload: 1024 * 1024, // 1MB
},
});
// Initialize services
const userService = new UserService(config.databaseUrl);
// Initialize Kubernetes client and container manager
const k8sClient = new KubernetesClient({
namespace: config.kubernetes.namespace,
inCluster: config.kubernetes.inCluster,
context: config.kubernetes.context,
logger: app.log,
});
const containerManager = new ContainerManager({
k8sClient,
agentImage: config.kubernetes.agentImage,
sidecarImage: config.kubernetes.sidecarImage,
storageClass: config.kubernetes.storageClass,
namespace: config.kubernetes.namespace,
logger: app.log,
});
const authenticator = new Authenticator({
userService,
containerManager,
logger: app.log,
});
// Initialize channel handlers
const websocketHandler = new WebSocketHandler({
authenticator,
providerConfig: config.providerConfig,
});
const telegramHandler = new TelegramHandler({
authenticator,
providerConfig: config.providerConfig,
telegramBotToken: config.telegramBotToken,
});
// Register routes
websocketHandler.register(app);
telegramHandler.register(app);
// Health check
app.get('/health', async () => {
return {
status: 'ok',
timestamp: new Date().toISOString(),
};
});
// Graceful shutdown
const shutdown = async () => {
app.log.info('Shutting down gracefully...');
try {
await userService.close();
await app.close();
app.log.info('Shutdown complete');
process.exit(0);
} catch (error) {
app.log.error({ error }, 'Error during shutdown');
process.exit(1);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
// Start server
try {
await app.listen({
port: config.port,
host: config.host,
});
app.log.info(
{
port: config.port,
host: config.host,
},
'Gateway server started'
);
} catch (error) {
app.log.error({ error }, 'Failed to start server');
process.exit(1);
}

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
/**
* Inbound user message from any channel
*/
export const InboundMessageSchema = z.object({
messageId: z.string(),
userId: z.string(),
sessionId: z.string(),
content: z.string(),
attachments: z.array(z.object({
type: z.enum(['image', 'file', 'url']),
url: z.string(),
mimeType: z.string().optional(),
})).optional(),
timestamp: z.date(),
});
export type InboundMessage = z.infer<typeof InboundMessageSchema>;
/**
* Outbound response to channel
*/
export const OutboundMessageSchema = z.object({
messageId: z.string(),
sessionId: z.string(),
content: z.string(),
attachments: z.array(z.object({
type: z.enum(['image', 'chart', 'file']),
url: z.string(),
caption: z.string().optional(),
})).optional(),
metadata: z.record(z.unknown()).optional(),
timestamp: z.date(),
});
export type OutboundMessage = z.infer<typeof OutboundMessageSchema>;

View File

@@ -0,0 +1,101 @@
import { z } from 'zod';
/**
* MCP Resource types for user context
*/
/**
* Base resource structure from MCP server
*/
export const MCPResourceSchema = z.object({
uri: z.string(),
mimeType: z.string().optional(),
text: z.string().optional(),
blob: z.string().optional(), // base64 encoded
});
export type MCPResource = z.infer<typeof MCPResourceSchema>;
/**
* User profile context
*/
export const UserProfileContextSchema = z.object({
tradingExperience: z.enum(['beginner', 'intermediate', 'advanced', 'professional']),
preferredTimeframes: z.array(z.string()),
riskTolerance: z.enum(['low', 'medium', 'high']),
tradingStyle: z.string(),
favoriteIndicators: z.array(z.string()).optional(),
activeTradingPairs: z.array(z.string()).optional(),
notes: z.string().optional(),
});
export type UserProfileContext = z.infer<typeof UserProfileContextSchema>;
/**
* Workspace state (current chart, positions, etc.)
*/
export const WorkspaceStateSchema = z.object({
currentChart: z.object({
ticker: z.string(),
timeframe: z.string(),
indicators: z.array(z.string()).optional(),
}).optional(),
watchlist: z.array(z.string()),
openPositions: z.array(z.object({
ticker: z.string(),
side: z.enum(['long', 'short']),
size: z.number(),
entryPrice: z.number(),
currentPrice: z.number().optional(),
unrealizedPnL: z.number().optional(),
})),
recentAlerts: z.array(z.object({
type: z.string(),
message: z.string(),
timestamp: z.string(),
})).optional(),
});
export type WorkspaceState = z.infer<typeof WorkspaceStateSchema>;
/**
* Standard context resource URIs
*/
export const CONTEXT_URIS = {
USER_PROFILE: 'context://user-profile',
CONVERSATION_SUMMARY: 'context://conversation-summary',
WORKSPACE_STATE: 'context://workspace-state',
SYSTEM_PROMPT: 'context://system-prompt',
} as const;
/**
* Resource content interface
*/
export interface ResourceContent {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
}
/**
* Helper to parse resource content
*/
export function parseResource<T>(resource: ResourceContent, schema: z.ZodSchema<T>): T | null {
if (!resource.text) {
return null;
}
try {
// Try JSON parsing if mime type is JSON
if (resource.mimeType?.includes('json')) {
const data = JSON.parse(resource.text);
return schema.parse(data);
}
// Otherwise return as-is for text resources
return resource.text as T;
} catch {
return null;
}
}

66
gateway/src/types/user.ts Normal file
View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
/**
* Model preference configuration
*/
export const ModelPreferenceSchema = z.object({
provider: z.enum(['anthropic', 'openai', 'google', 'openrouter']),
model: z.string(),
temperature: z.number().optional(),
});
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>;
/**
* User license and feature authorization
*/
export const UserLicenseSchema = z.object({
userId: z.string(),
email: z.string().email().optional(),
licenseType: z.enum(['free', 'pro', 'enterprise']),
features: z.object({
maxIndicators: z.number(),
maxStrategies: z.number(),
maxBacktestDays: z.number(),
realtimeData: z.boolean(),
customExecutors: z.boolean(),
apiAccess: z.boolean(),
}),
resourceLimits: z.object({
maxConcurrentSessions: z.number(),
maxMessagesPerDay: z.number(),
maxTokensPerMessage: z.number(),
rateLimitPerMinute: z.number(),
}),
mcpServerUrl: z.string().url(),
preferredModel: ModelPreferenceSchema.optional(),
expiresAt: z.date().optional(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type UserLicense = z.infer<typeof UserLicenseSchema>;
/**
* Channel types for multi-channel support
*/
export enum ChannelType {
WEBSOCKET = 'websocket',
TELEGRAM = 'telegram',
SLACK = 'slack',
DISCORD = 'discord',
}
/**
* Authentication context per channel
*/
export const AuthContextSchema = z.object({
userId: z.string(),
channelType: z.nativeEnum(ChannelType),
channelUserId: z.string(), // Platform-specific ID (telegram_id, discord_id, etc)
sessionId: z.string(),
license: UserLicenseSchema,
authenticatedAt: z.date(),
});
export type AuthContext = z.infer<typeof AuthContextSchema>;

View File

@@ -0,0 +1,253 @@
# LangGraph Workflows for Trading
Complex, stateful workflows built with LangGraph for trading-specific tasks.
## Overview
LangGraph provides:
- **Stateful execution**: Workflow state persists across failures
- **Conditional branching**: Route based on market conditions, backtest results, etc.
- **Human-in-the-loop**: Pause for user approval before executing trades
- **Loops & retries**: Backtest with different parameters, retry failed operations
- **Multi-agent**: Different LLMs for different tasks (analysis, risk, execution)
## Workflows
### Strategy Analysis (`strategy-analysis.ts`)
Multi-step pipeline for analyzing trading strategies:
```typescript
import { buildStrategyAnalysisWorkflow } from './workflows/strategy-analysis.js';
const workflow = buildStrategyAnalysisWorkflow(model, logger, mcpBacktestFn);
const result = await workflow.invoke({
strategyCode: userStrategy,
ticker: 'BTC/USDT',
timeframe: '1h',
});
console.log(result.recommendation); // Go/no-go decision
```
**Steps:**
1. **Code Review** - LLM analyzes strategy code for bugs, logic errors
2. **Backtest** - Runs backtest via user's MCP server
3. **Risk Assessment** - LLM evaluates results (drawdown, Sharpe, etc.)
4. **Human Approval** - Pauses for user review
5. **Recommendation** - Final go/no-go decision
**Benefits:**
- Stateful: Can resume if server restarts
- Human-in-the-loop: User must approve before deployment
- Multi-step reasoning: Each step builds on previous
---
## Future Workflows
### Market Scanner
Scan multiple tickers for trading opportunities:
```typescript
const scanner = buildMarketScannerWorkflow(model, logger);
const result = await scanner.invoke({
tickers: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'],
strategies: ['momentum', 'mean_reversion'],
timeframe: '1h',
});
// Returns ranked opportunities
```
**Steps:**
1. **Fetch Data** - Get OHLC for all tickers
2. **Apply Strategies** - Run each strategy on each ticker (parallel)
3. **Rank Signals** - Score by confidence, risk/reward
4. **Filter** - Apply user's risk limits
5. **Return Top N** - Best opportunities
---
### Portfolio Optimization
Optimize position sizing across multiple strategies:
```typescript
const optimizer = buildPortfolioOptimizerWorkflow(model, logger);
const result = await optimizer.invoke({
strategies: [strategy1, strategy2, strategy3],
totalCapital: 100000,
maxRiskPerTrade: 0.02,
});
// Returns optimal allocation
```
**Steps:**
1. **Backtest All** - Run backtests for each strategy
2. **Correlation Analysis** - Check strategy correlation
3. **Monte Carlo** - Simulate portfolio performance
4. **Optimize** - Find optimal weights (Sharpe maximization)
5. **Risk Check** - Validate against user limits
---
### Trade Execution Monitor
Monitor trade execution and adapt to market conditions:
```typescript
const monitor = buildTradeExecutionWorkflow(model, logger, exchange);
const result = await monitor.invoke({
tradeId: 'xyz',
targetPrice: 45000,
maxSlippage: 0.001,
timeLimit: 60, // seconds
});
```
**Steps:**
1. **Place Order** - Submit order to exchange
2. **Monitor Fill** - Check fill status every second
3. **Adapt** - If not filling, adjust price (within slippage)
4. **Retry Logic** - If rejected, retry with backoff
5. **Timeout** - Cancel if time limit exceeded
6. **Report** - Final execution report
---
## Using Workflows in Gateway
### Simple Chat vs Complex Workflow
```typescript
// gateway/src/orchestrator.ts
export class MessageOrchestrator {
async handleMessage(msg: InboundMessage) {
// Route based on complexity
if (this.isSimpleQuery(msg)) {
// Use agent harness for streaming chat
return this.harness.streamMessage(msg);
}
if (this.isWorkflowRequest(msg)) {
// Use LangGraph for complex analysis
return this.executeWorkflow(msg);
}
}
async executeWorkflow(msg: InboundMessage) {
const { type, params } = this.parseWorkflowRequest(msg);
switch (type) {
case 'analyze_strategy':
const workflow = buildStrategyAnalysisWorkflow(...);
return await workflow.invoke(params);
case 'scan_market':
const scanner = buildMarketScannerWorkflow(...);
return await scanner.invoke(params);
// ... more workflows
}
}
}
```
---
## Benefits for Trading
### vs Simple LLM Calls
| Scenario | Simple LLM | LangGraph Workflow |
|----------|-----------|-------------------|
| "What's the RSI?" | ✅ Fast, streaming | ❌ Overkill |
| "Analyze this strategy" | ❌ Limited context | ✅ Multi-step analysis |
| "Backtest 10 param combos" | ❌ No loops | ✅ Conditional loops |
| "Execute if approved" | ❌ No state | ✅ Human-in-the-loop |
| Server crashes mid-analysis | ❌ Lost progress | ✅ Resume from checkpoint |
### When to Use Workflows
**Use LangGraph when:**
- Multi-step analysis (backtest → risk → approval)
- Conditional logic (if bullish → momentum, else → mean-reversion)
- Human approval required (pause workflow)
- Loops needed (try different parameters)
- Long-running (can survive restarts)
**Use Agent Harness when:**
- Simple Q&A ("What is RSI?")
- Fast response needed (streaming chat)
- Single tool call ("Get my watchlist")
- Real-time interaction (Telegram, WebSocket)
---
## Implementation Notes
### State Persistence
LangGraph can persist state to database:
```typescript
import { MemorySaver } from '@langchain/langgraph';
const checkpointer = new MemorySaver();
const workflow = graph.compile({ checkpointer });
// Resume from checkpoint
const result = await workflow.invoke(input, {
configurable: { thread_id: 'user-123-strategy-analysis' }
});
```
### Human-in-the-Loop
Pause workflow for user input:
```typescript
const workflow = graph
.addNode('human_approval', humanApprovalNode)
.interrupt('human_approval'); // Pauses here
// User reviews in UI
const approved = await getUserApproval(workflowId);
// Resume workflow
await workflow.resume(state, { approved });
```
### Multi-Agent
Use different models for different tasks:
```typescript
const analysisModel = new ChatAnthropic({ model: 'claude-3-opus' }); // Smart
const codeModel = new ChatOpenAI({ model: 'gpt-4o' }); // Good at code
const cheapModel = new ChatOpenAI({ model: 'gpt-4o-mini' }); // Fast
const workflow = graph
.addNode('analyze', (state) => analysisModel.invoke(...))
.addNode('code_review', (state) => codeModel.invoke(...))
.addNode('summarize', (state) => cheapModel.invoke(...));
```
---
## Next Steps
1. Implement remaining workflows (scanner, optimizer, execution)
2. Add state persistence (PostgreSQL checkpointer)
3. Integrate human-in-the-loop with WebSocket
4. Add workflow monitoring dashboard
5. Performance optimization (parallel execution)

View File

@@ -0,0 +1,162 @@
import { StateGraph, Annotation } from '@langchain/langgraph';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import type { FastifyBaseLogger } from 'fastify';
/**
* State for strategy analysis workflow
*/
const StrategyAnalysisState = Annotation.Root({
strategyCode: Annotation<string>(),
ticker: Annotation<string>(),
timeframe: Annotation<string>(),
// Analysis steps
codeReview: Annotation<string | null>({
default: () => null,
}),
backtestResults: Annotation<Record<string, unknown> | null>({
default: () => null,
}),
riskAssessment: Annotation<string | null>({
default: () => null,
}),
humanApproved: Annotation<boolean>({
default: () => false,
}),
// Final output
recommendation: Annotation<string | null>({
default: () => null,
}),
});
type StrategyAnalysisStateType = typeof StrategyAnalysisState.State;
/**
* Build strategy analysis workflow using LangGraph
*
* Workflow steps:
* 1. Code review (LLM analyzes strategy code)
* 2. Backtest (calls user's MCP backtest tool)
* 3. Risk assessment (LLM evaluates results)
* 4. Human approval (pause for user review)
* 5. Final recommendation
*/
export function buildStrategyAnalysisWorkflow(
model: BaseChatModel,
logger: FastifyBaseLogger,
mcpBacktestFn: (strategy: string, ticker: string, timeframe: string) => Promise<Record<string, unknown>>
) {
// Node: Code Review
const codeReviewNode = async (state: StrategyAnalysisStateType) => {
logger.info('Strategy workflow: Code review');
const systemPrompt = `You are an expert trading strategy analyst.
Review the following strategy code for potential issues, bugs, or improvements.
Focus on: logic errors, edge cases, performance, and trading best practices.`;
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(`Review this strategy:\n\n${state.strategyCode}`),
]);
return {
codeReview: response.content as string,
};
};
// Node: Backtest
const backtestNode = async (state: StrategyAnalysisStateType) => {
logger.info('Strategy workflow: Running backtest');
const results = await mcpBacktestFn(state.strategyCode, state.ticker, state.timeframe);
return {
backtestResults: results,
};
};
// Node: Risk Assessment
const riskAssessmentNode = async (state: StrategyAnalysisStateType) => {
logger.info('Strategy workflow: Risk assessment');
const systemPrompt = `You are a risk management expert for trading strategies.
Analyze the backtest results and provide a risk assessment.
Focus on: drawdown, win rate, Sharpe ratio, position sizing, and risk of ruin.`;
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(
`Code review: ${state.codeReview}\n\nBacktest results: ${JSON.stringify(state.backtestResults, null, 2)}\n\nProvide risk assessment:`
),
]);
return {
riskAssessment: response.content as string,
};
};
// Node: Human Approval (placeholder - would integrate with UI)
const humanApprovalNode = async (state: StrategyAnalysisStateType) => {
logger.info('Strategy workflow: Awaiting human approval');
// In real implementation, this would pause and wait for user input
// For now, auto-approve
return {
humanApproved: true,
};
};
// Node: Final Recommendation
const recommendationNode = async (state: StrategyAnalysisStateType) => {
logger.info('Strategy workflow: Generating recommendation');
const systemPrompt = `Provide a final recommendation on whether to deploy this trading strategy.
Summarize the code review, backtest results, and risk assessment.
Give clear go/no-go decision with reasoning.`;
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(
`Code review: ${state.codeReview}\n\nBacktest: ${JSON.stringify(state.backtestResults)}\n\nRisk: ${state.riskAssessment}\n\nApproved: ${state.humanApproved}\n\nYour recommendation:`
),
]);
return {
recommendation: response.content as string,
};
};
// Build graph
const workflow = new StateGraph(StrategyAnalysisState)
.addNode('code_review', codeReviewNode)
.addNode('backtest', backtestNode)
.addNode('risk_assessment', riskAssessmentNode)
.addNode('human_approval', humanApprovalNode)
.addNode('recommendation', recommendationNode)
.addEdge('__start__', 'code_review')
.addEdge('code_review', 'backtest')
.addEdge('backtest', 'risk_assessment')
.addEdge('risk_assessment', 'human_approval')
.addConditionalEdges('human_approval', (state) => {
return state.humanApproved ? 'recommendation' : '__end__';
})
.addEdge('recommendation', '__end__');
return workflow.compile();
}
/**
* Example usage:
*
* const workflow = buildStrategyAnalysisWorkflow(model, logger, mcpBacktestFn);
*
* const result = await workflow.invoke({
* strategyCode: "strategy code here",
* ticker: "BTC/USDT",
* timeframe: "1h",
* });
*
* console.log(result.recommendation);
*/

26
gateway/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": false,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}