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

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);
*/