container lifecycle management
This commit is contained in:
306
gateway/src/harness/agent-harness.ts
Normal file
306
gateway/src/harness/agent-harness.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
259
gateway/src/harness/mcp-client.ts
Normal file
259
gateway/src/harness/mcp-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user